mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 08:50:30 +00:00
feat: Enhance device configuration and location tracking features
- Refetch device info after updating settings in device_config_screen.dart. - Update map_tab.dart to use singleton instance of LocationTrackingService and streamline location tracking callbacks. - Modify ble_response_handler.dart to handle contact not found errors and improve error callback structure. - Enhance location_tracking_service.dart with retry logic for GPS position acquisition and initial position setting without broadcasting. - Update meshcore_ble_service.dart to track last contact for auto-recovery on errors. - Improve tile_cache_service.dart error messages and streamline tile download logic. - Add current GPS location insertion feature in direct_message_sheet.dart with permission checks. - Update pubspec.lock and pubspec.yaml to include integration_test dependency. - Add screenshot automation script for iOS and Android devices. - Create integration test driver for screenshot capturing.
This commit is contained in:
@@ -64,6 +64,11 @@ class Message {
|
||||
final DateTime? deliveredAt; // When delivery was confirmed
|
||||
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
|
||||
|
||||
// Read status tracking
|
||||
final bool isRead; // Whether message has been read by user
|
||||
|
||||
@@ -88,6 +93,9 @@ class Message {
|
||||
this.roundTripTimeMs,
|
||||
this.deliveredAt,
|
||||
this.recipientPublicKey,
|
||||
this.retryAttempt = 0,
|
||||
this.lastRetryAt,
|
||||
this.usedFloodFallback = false,
|
||||
this.isRead = false,
|
||||
});
|
||||
|
||||
@@ -172,16 +180,38 @@ class Message {
|
||||
String get deliveryStatusText {
|
||||
switch (deliveryStatus) {
|
||||
case MessageDeliveryStatus.sending:
|
||||
if (retryAttempt > 0) {
|
||||
return 'Retrying ($retryAttempt/3)...';
|
||||
}
|
||||
return 'Sending...';
|
||||
|
||||
case MessageDeliveryStatus.sent:
|
||||
if (retryAttempt > 0) {
|
||||
return 'Sent (retry $retryAttempt)';
|
||||
}
|
||||
return 'Sent';
|
||||
|
||||
case MessageDeliveryStatus.delivered:
|
||||
if (roundTripTimeMs != null) {
|
||||
return 'Delivered (${roundTripTimeMs}ms)';
|
||||
final rttText = roundTripTimeMs != null ? '${roundTripTimeMs}ms' : '';
|
||||
if (retryAttempt > 0 && rttText.isNotEmpty) {
|
||||
return 'Delivered ($rttText) [retry $retryAttempt]';
|
||||
} else if (retryAttempt > 0) {
|
||||
return 'Delivered [retry $retryAttempt]';
|
||||
} else if (rttText.isNotEmpty) {
|
||||
return 'Delivered ($rttText)';
|
||||
}
|
||||
return 'Delivered';
|
||||
|
||||
case MessageDeliveryStatus.failed:
|
||||
if (usedFloodFallback) {
|
||||
return 'Failed (tried flood)';
|
||||
}
|
||||
if (retryAttempt > 0) {
|
||||
final retryWord = retryAttempt == 1 ? 'retry' : 'retries';
|
||||
return 'Failed (after $retryAttempt $retryWord)';
|
||||
}
|
||||
return 'Failed';
|
||||
|
||||
case MessageDeliveryStatus.received:
|
||||
return '';
|
||||
}
|
||||
@@ -229,6 +259,9 @@ class Message {
|
||||
int? roundTripTimeMs,
|
||||
DateTime? deliveredAt,
|
||||
Uint8List? recipientPublicKey,
|
||||
int? retryAttempt,
|
||||
DateTime? lastRetryAt,
|
||||
bool? usedFloodFallback,
|
||||
bool? isRead,
|
||||
}) {
|
||||
return Message(
|
||||
@@ -252,6 +285,9 @@ class Message {
|
||||
roundTripTimeMs: roundTripTimeMs ?? this.roundTripTimeMs,
|
||||
deliveredAt: deliveredAt ?? this.deliveredAt,
|
||||
recipientPublicKey: recipientPublicKey ?? this.recipientPublicKey,
|
||||
retryAttempt: retryAttempt ?? this.retryAttempt,
|
||||
lastRetryAt: lastRetryAt ?? this.lastRetryAt,
|
||||
usedFloodFallback: usedFloodFallback ?? this.usedFloodFallback,
|
||||
isRead: isRead ?? this.isRead,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -198,6 +198,23 @@ class AppProvider with ChangeNotifier {
|
||||
debugPrint('✅ [AppProvider] Message delivered - ACK: $ackCode, RTT: ${roundTripTimeMs}ms');
|
||||
messagesProvider.markMessageDelivered(ackCode, roundTripTimeMs);
|
||||
};
|
||||
|
||||
// Wire up MessagesProvider's sendMessageCallback for retry logic
|
||||
messagesProvider.sendMessageCallback = ({
|
||||
required contactPublicKey,
|
||||
required text,
|
||||
required messageId,
|
||||
required contact,
|
||||
retryAttempt = 0,
|
||||
}) async {
|
||||
return await connectionProvider.sendTextMessage(
|
||||
contactPublicKey: contactPublicKey,
|
||||
text: text,
|
||||
messageId: messageId,
|
||||
contact: contact,
|
||||
retryAttempt: retryAttempt,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/// Initialize the app (load contacts, sync time, etc.)
|
||||
|
||||
@@ -11,6 +11,23 @@ import 'helpers/room_login_manager.dart';
|
||||
import 'helpers/message_delivery_tracker.dart';
|
||||
import 'helpers/ping_tracker.dart';
|
||||
|
||||
/// Pending send operation for auto-recovery
|
||||
class _PendingSendOperation {
|
||||
final Uint8List contactPublicKey;
|
||||
final String text;
|
||||
final String? messageId;
|
||||
final Contact? contact;
|
||||
final int retryAttempt;
|
||||
|
||||
_PendingSendOperation({
|
||||
required this.contactPublicKey,
|
||||
required this.text,
|
||||
this.messageId,
|
||||
this.contact,
|
||||
this.retryAttempt = 0,
|
||||
});
|
||||
}
|
||||
|
||||
/// Result of a ping (telemetry request) operation
|
||||
class PingResult {
|
||||
final bool success;
|
||||
@@ -117,6 +134,9 @@ class ConnectionProvider with ChangeNotifier {
|
||||
Function(int ackCode, int roundTripTimeMs)? onMessageDelivered;
|
||||
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
|
||||
|
||||
// Track pending send operations for auto-recovery
|
||||
final Map<String, _PendingSendOperation> _pendingSendOperations = {};
|
||||
|
||||
ConnectionProvider() {
|
||||
_initializeBleService();
|
||||
}
|
||||
@@ -147,8 +167,9 @@ class ConnectionProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
};
|
||||
|
||||
_bleService.onError = (error) {
|
||||
_bleService.onError = (error, {int? errorCode}) {
|
||||
print('⚠️ [Provider] BLE error received: $error');
|
||||
print(' Error code: ${errorCode ?? "none"}');
|
||||
print(' Current connection state: ${_deviceInfo.connectionState}');
|
||||
|
||||
_error = error;
|
||||
@@ -169,6 +190,57 @@ class ConnectionProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
};
|
||||
|
||||
_bleService.onContactNotFound = (contactPublicKey) async {
|
||||
print('🔧 [Provider] Contact not found error detected - initiating auto-recovery');
|
||||
|
||||
if (contactPublicKey == null) {
|
||||
print(' ⚠️ No contact public key available for recovery');
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate operation ID from public key
|
||||
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) {
|
||||
print(' ⚠️ No pending operation found for recovery: $operationId');
|
||||
return;
|
||||
}
|
||||
|
||||
print(' 📋 Found pending operation for: ${pendingOp.contact!.advName}');
|
||||
print(' 📤 Step 1: Adding contact to radio...');
|
||||
|
||||
try {
|
||||
// Step 1: Add the contact to the radio
|
||||
await _bleService.addOrUpdateContact(pendingOp.contact!);
|
||||
|
||||
// Small delay to ensure contact is added before retrying
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
|
||||
print(' ✅ Contact added successfully');
|
||||
print(' 🔄 Step 2: Retrying message send...');
|
||||
|
||||
// Step 2: Retry the send operation
|
||||
await _bleService.sendTextMessage(
|
||||
contactPublicKey: pendingOp.contactPublicKey,
|
||||
text: pendingOp.text,
|
||||
attempt: pendingOp.retryAttempt,
|
||||
);
|
||||
|
||||
print(' ✅ Auto-recovery completed - message resent');
|
||||
|
||||
// Clear pending operation after successful recovery
|
||||
_pendingSendOperations.remove(operationId);
|
||||
} catch (e) {
|
||||
print(' ❌ Auto-recovery failed: $e');
|
||||
_error = 'Auto-recovery failed: $e';
|
||||
notifyListeners();
|
||||
|
||||
// Clear pending operation after failed recovery
|
||||
_pendingSendOperations.remove(operationId);
|
||||
}
|
||||
};
|
||||
|
||||
_bleService.onContactReceived = (contact) {
|
||||
onContactReceived?.call(contact);
|
||||
};
|
||||
@@ -529,6 +601,7 @@ class ConnectionProvider with ChangeNotifier {
|
||||
_roomLoginManager
|
||||
.clearRoomLoginStates(); // Clear login states on disconnect
|
||||
_pingTracker.clearAll(); // Clear pending pings on disconnect
|
||||
_pendingSendOperations.clear(); // Clear pending operations on disconnect
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -582,11 +655,13 @@ class ConnectionProvider with ChangeNotifier {
|
||||
///
|
||||
/// [messageId] - optional message ID to track delivery status
|
||||
/// [contact] - optional contact object for path status logging
|
||||
/// [retryAttempt] - retry attempt number (0 = first send, 1-3 = retries)
|
||||
Future<bool> sendTextMessage({
|
||||
required Uint8List contactPublicKey,
|
||||
required String text,
|
||||
String? messageId,
|
||||
Contact? contact,
|
||||
int retryAttempt = 0,
|
||||
}) async {
|
||||
if (!_bleService.isConnected) {
|
||||
_error = 'Not connected to device';
|
||||
@@ -595,9 +670,13 @@ class ConnectionProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
try {
|
||||
// Log path status if contact info is available
|
||||
// Log path status and retry info
|
||||
if (contact != null) {
|
||||
print('📤 [ConnectionProvider] Sending message to ${contact.advName}');
|
||||
if (retryAttempt > 0) {
|
||||
print('🔄 [ConnectionProvider] Sending message to ${contact.advName} (retry $retryAttempt/3)');
|
||||
} else {
|
||||
print('📤 [ConnectionProvider] Sending message to ${contact.advName}');
|
||||
}
|
||||
print(' Type: ${contact.type.displayName}');
|
||||
print(' Path status: ${contact.pathDescription}');
|
||||
if (contact.hasPath) {
|
||||
@@ -605,6 +684,21 @@ class ConnectionProvider with ChangeNotifier {
|
||||
} else {
|
||||
print(' ⚠️ No path available - will use flood mode');
|
||||
}
|
||||
} else if (retryAttempt > 0) {
|
||||
print('🔄 [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(':');
|
||||
_pendingSendOperations[operationId] = _PendingSendOperation(
|
||||
contactPublicKey: contactPublicKey,
|
||||
text: text,
|
||||
messageId: messageId,
|
||||
contact: contact,
|
||||
retryAttempt: retryAttempt,
|
||||
);
|
||||
print(' 📝 Tracked pending operation for auto-recovery: $operationId');
|
||||
}
|
||||
|
||||
// IMPORTANT: Track pending message BEFORE sending to avoid race condition
|
||||
@@ -615,12 +709,23 @@ class ConnectionProvider with ChangeNotifier {
|
||||
print(' Added message ID to pending queue BEFORE sending: $messageId');
|
||||
}
|
||||
|
||||
// Send the message
|
||||
// Send the message with retry attempt info
|
||||
await _bleService.sendTextMessage(
|
||||
contactPublicKey: contactPublicKey,
|
||||
text: text,
|
||||
attempt: retryAttempt,
|
||||
);
|
||||
|
||||
// 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(':');
|
||||
// Use a small delay to allow error response to arrive before clearing
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
_pendingSendOperations.remove(operationId);
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
_error = 'Failed to send message: $e';
|
||||
|
||||
89
lib/providers/helpers/message_retry_manager.dart
Normal file
89
lib/providers/helpers/message_retry_manager.dart
Normal file
@@ -0,0 +1,89 @@
|
||||
import '../../models/message.dart';
|
||||
import '../../models/contact.dart';
|
||||
|
||||
/// Manages message retry state and logic
|
||||
///
|
||||
/// This helper class centralizes retry logic for direct messages, implementing
|
||||
/// a progressive timeout strategy (4s, 8s, 12s) for messages sent to contacts
|
||||
/// with learned routing paths.
|
||||
class MessageRetryManager {
|
||||
// Track retry state for each message ID
|
||||
final Map<String, int> _retryAttempts = {};
|
||||
final Map<String, DateTime> _lastRetryTimes = {};
|
||||
|
||||
// Progressive timeout values in milliseconds
|
||||
static const List<int> _timeouts = [4000, 8000, 12000];
|
||||
|
||||
/// Get timeout for a specific retry attempt (0-2)
|
||||
/// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2
|
||||
int getTimeoutForAttempt(int attempt) {
|
||||
if (attempt < 0 || attempt >= _timeouts.length) {
|
||||
return _timeouts.last; // Default to last timeout if out of range
|
||||
}
|
||||
return _timeouts[attempt];
|
||||
}
|
||||
|
||||
/// Check if a message is eligible for retry
|
||||
///
|
||||
/// Returns true if:
|
||||
/// - The message has retryAttempt < 3
|
||||
/// - The contact has a learned path (contact.hasPath == true)
|
||||
/// - The message hasn't used flood fallback yet
|
||||
///
|
||||
/// Messages to contacts without paths should NOT retry (flood mode already broadcasts)
|
||||
bool canRetry(Message message, Contact contact) {
|
||||
// Never retry if already tried flood mode
|
||||
if (message.usedFloodFallback) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Never retry beyond 3 attempts
|
||||
if (message.retryAttempt >= 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only retry if contact has a learned path
|
||||
// If no path, the device uses flood mode automatically - retrying won't help
|
||||
return contact.hasPath;
|
||||
}
|
||||
|
||||
/// Check if should fall back to flood mode
|
||||
///
|
||||
/// Returns true if:
|
||||
/// - Message has exhausted all 3 retry attempts
|
||||
/// - Contact still has no path
|
||||
/// - Hasn't already used flood fallback
|
||||
bool shouldUseFloodFallback(Message message, Contact contact) {
|
||||
return message.retryAttempt >= 3 &&
|
||||
!contact.hasPath &&
|
||||
!message.usedFloodFallback;
|
||||
}
|
||||
|
||||
/// Track a retry attempt for a message
|
||||
void trackRetry(String messageId, int attempt) {
|
||||
_retryAttempts[messageId] = attempt;
|
||||
_lastRetryTimes[messageId] = DateTime.now();
|
||||
}
|
||||
|
||||
/// Clear retry tracking for a message (on success or permanent failure)
|
||||
void clearRetry(String messageId) {
|
||||
_retryAttempts.remove(messageId);
|
||||
_lastRetryTimes.remove(messageId);
|
||||
}
|
||||
|
||||
/// Clear all retry tracking (on disconnect)
|
||||
void clearAll() {
|
||||
_retryAttempts.clear();
|
||||
_lastRetryTimes.clear();
|
||||
}
|
||||
|
||||
/// Get current retry attempt for a message (for debugging)
|
||||
int? getRetryAttempt(String messageId) {
|
||||
return _retryAttempts[messageId];
|
||||
}
|
||||
|
||||
/// Get last retry time for a message (for debugging)
|
||||
DateTime? getLastRetryTime(String messageId) {
|
||||
return _lastRetryTimes[messageId];
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,13 @@ import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/message.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/sar_marker.dart';
|
||||
import '../services/message_storage_service.dart';
|
||||
import '../services/notification_service.dart';
|
||||
import '../utils/sar_message_parser.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import 'helpers/message_retry_manager.dart';
|
||||
|
||||
/// Messages Provider - manages message history and SAR markers
|
||||
class MessagesProvider with ChangeNotifier {
|
||||
@@ -23,6 +25,21 @@ class MessagesProvider with ChangeNotifier {
|
||||
// Track timeout timers for pending messages
|
||||
final Map<int, Timer> _timeoutTimers = {};
|
||||
|
||||
// Retry management
|
||||
final MessageRetryManager _retryManager = MessageRetryManager();
|
||||
|
||||
// Track which contact each sent message was sent to (for retry logic)
|
||||
final Map<String, Contact> _messageContactMap = {};
|
||||
|
||||
// Callback to connection provider for sending messages (set by AppProvider)
|
||||
Future<bool> Function({
|
||||
required Uint8List contactPublicKey,
|
||||
required String text,
|
||||
required String messageId,
|
||||
required Contact contact,
|
||||
int retryAttempt,
|
||||
})? sendMessageCallback;
|
||||
|
||||
List<Message> get messages => List.unmodifiable(_messages);
|
||||
|
||||
List<Message> get contactMessages =>
|
||||
@@ -176,7 +193,16 @@ class MessagesProvider with ChangeNotifier {
|
||||
/// 2. Same channel index (for channel messages)
|
||||
/// 3. Same sender timestamp
|
||||
/// 4. Same text content
|
||||
///
|
||||
/// Note: Sent messages (isSentMessage=true) are NEVER duplicates
|
||||
/// because they can be retried with different message IDs
|
||||
bool _isDuplicate(Message message) {
|
||||
// Sent messages (our own messages) should never be considered duplicates
|
||||
// They can be retried multiple times with different IDs
|
||||
if (message.isSentMessage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return _messages.any((existing) {
|
||||
// Check message type matches
|
||||
if (existing.messageType != message.messageType) {
|
||||
@@ -468,7 +494,7 @@ class MessagesProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Add a sent message with initial status
|
||||
void addSentMessage(Message message) {
|
||||
void addSentMessage(Message message, {Contact? contact}) {
|
||||
print('📝 [MessagesProvider] addSentMessage called');
|
||||
print(' Message ID: ${message.id}');
|
||||
print(' Message type: ${message.messageType}');
|
||||
@@ -493,6 +519,12 @@ class MessagesProvider with ChangeNotifier {
|
||||
print(' ✅ Message added to list at index ${_messages.length - 1}');
|
||||
print(' Total messages in list: ${_messages.length}');
|
||||
|
||||
// Store contact mapping for retry logic
|
||||
if (contact != null) {
|
||||
_messageContactMap[message.id] = contact;
|
||||
print(' ✅ Stored contact mapping for retry logic');
|
||||
}
|
||||
|
||||
// If it's a SAR marker message, extract and store the marker
|
||||
if (sendingMessage.isSarMarker) {
|
||||
final marker = sendingMessage.toSarMarker();
|
||||
@@ -599,6 +631,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
// Remove from pending
|
||||
_pendingSentMessages.remove(ackCode);
|
||||
|
||||
// Clear retry tracking on successful delivery
|
||||
_retryManager.clearRetry(message.id);
|
||||
|
||||
print('✅ [MessagesProvider] Message ${message.id} delivered in ${roundTripTimeMs}ms (ACK $ackCode)');
|
||||
print(' Updated status to: ${updatedMessage.deliveryStatus}');
|
||||
print(' Calling notifyListeners() to update UI');
|
||||
@@ -640,15 +675,130 @@ class MessagesProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Update message status to failed
|
||||
/// Update message status to failed (with retry logic)
|
||||
void markMessageFailed(String messageId) {
|
||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||
if (index == -1) {
|
||||
print('⚠️ [MessagesProvider] markMessageFailed: Message not found: $messageId');
|
||||
return;
|
||||
}
|
||||
|
||||
final message = _messages[index];
|
||||
final contact = _messageContactMap[messageId];
|
||||
|
||||
print('❌ [MessagesProvider] Message $messageId timeout/failed');
|
||||
print(' Retry attempt: ${message.retryAttempt}');
|
||||
print(' Contact has path: ${contact?.hasPath ?? false}');
|
||||
print(' Used flood fallback: ${message.usedFloodFallback}');
|
||||
|
||||
// Decision tree for retry/flood/fail
|
||||
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)) {
|
||||
// FLOOD FALLBACK: After 3 retries failed, try flood once
|
||||
_sendWithFloodMode(messageId, message, contact);
|
||||
} else {
|
||||
// PERMANENTLY FAILED: No retry possible
|
||||
_markAsPermanentlyFailed(messageId, message);
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedule a retry with progressive timeout
|
||||
void _scheduleRetry(String messageId, Message message, Contact contact) {
|
||||
final nextAttempt = message.retryAttempt + 1;
|
||||
final timeout = _retryManager.getTimeoutForAttempt(message.retryAttempt);
|
||||
|
||||
print('🔄 [MessagesProvider] Scheduling retry $nextAttempt/3 for message $messageId');
|
||||
print(' Timeout: ${timeout}ms');
|
||||
|
||||
// Update message with new retry attempt
|
||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||
if (index != -1) {
|
||||
final message = _messages[index];
|
||||
final updatedMessage = message.copyWith(
|
||||
_messages[index] = message.copyWith(
|
||||
retryAttempt: nextAttempt,
|
||||
deliveryStatus: MessageDeliveryStatus.sending,
|
||||
lastRetryAt: DateTime.now(),
|
||||
);
|
||||
|
||||
// Cancel old timeout timer
|
||||
if (message.expectedAckTag != null) {
|
||||
_timeoutTimers[message.expectedAckTag]?.cancel();
|
||||
_timeoutTimers.remove(message.expectedAckTag);
|
||||
_pendingSentMessages.remove(message.expectedAckTag);
|
||||
}
|
||||
|
||||
// Track retry
|
||||
_retryManager.trackRetry(messageId, nextAttempt);
|
||||
|
||||
notifyListeners(); // Update UI to show "Retrying (X/3)..."
|
||||
|
||||
// Schedule actual retry after delay
|
||||
Timer(Duration(milliseconds: timeout), () async {
|
||||
print('⏰ [MessagesProvider] Executing retry $nextAttempt for message $messageId');
|
||||
if (sendMessageCallback != null) {
|
||||
await sendMessageCallback!(
|
||||
contactPublicKey: contact.publicKey,
|
||||
text: message.text,
|
||||
messageId: messageId,
|
||||
contact: contact,
|
||||
retryAttempt: nextAttempt,
|
||||
);
|
||||
} else {
|
||||
print('⚠️ [MessagesProvider] sendMessageCallback not set, cannot retry');
|
||||
}
|
||||
});
|
||||
|
||||
_persistMessages();
|
||||
}
|
||||
}
|
||||
|
||||
/// Send message with flood mode as last resort
|
||||
Future<void> _sendWithFloodMode(String messageId, Message message, Contact contact) async {
|
||||
print('🌊 [MessagesProvider] Trying flood mode for message $messageId');
|
||||
|
||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||
if (index != -1) {
|
||||
_messages[index] = message.copyWith(
|
||||
usedFloodFallback: true,
|
||||
deliveryStatus: MessageDeliveryStatus.sending,
|
||||
);
|
||||
|
||||
// Cancel old timeout timer
|
||||
if (message.expectedAckTag != null) {
|
||||
_timeoutTimers[message.expectedAckTag]?.cancel();
|
||||
_timeoutTimers.remove(message.expectedAckTag);
|
||||
_pendingSentMessages.remove(message.expectedAckTag);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
|
||||
// Send with flood mode (no retry after this)
|
||||
if (sendMessageCallback != null) {
|
||||
await sendMessageCallback!(
|
||||
contactPublicKey: contact.publicKey,
|
||||
text: message.text,
|
||||
messageId: messageId,
|
||||
contact: contact,
|
||||
retryAttempt: 0, // Reset attempt for flood
|
||||
);
|
||||
} else {
|
||||
print('⚠️ [MessagesProvider] sendMessageCallback not set, cannot send flood');
|
||||
}
|
||||
|
||||
_persistMessages();
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark message as permanently failed
|
||||
void _markAsPermanentlyFailed(String messageId, Message message) {
|
||||
print('❌ [MessagesProvider] Message $messageId permanently failed');
|
||||
|
||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||
if (index != -1) {
|
||||
_messages[index] = message.copyWith(
|
||||
deliveryStatus: MessageDeliveryStatus.failed,
|
||||
);
|
||||
_messages[index] = updatedMessage;
|
||||
|
||||
// Cancel timeout timer if it exists
|
||||
if (message.expectedAckTag != null) {
|
||||
@@ -657,13 +807,61 @@ class MessagesProvider with ChangeNotifier {
|
||||
_pendingSentMessages.remove(message.expectedAckTag);
|
||||
}
|
||||
|
||||
print('❌ [MessagesProvider] Message $messageId marked as failed');
|
||||
// Clear retry tracking
|
||||
_retryManager.clearRetry(messageId);
|
||||
|
||||
_persistMessages();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Resend a failed message
|
||||
Future<void> resendMessage(String messageId) async {
|
||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||
if (index == -1) {
|
||||
print('⚠️ [MessagesProvider] resendMessage: Message not found: $messageId');
|
||||
return;
|
||||
}
|
||||
|
||||
final message = _messages[index];
|
||||
final contact = _messageContactMap[messageId];
|
||||
|
||||
if (contact == null) {
|
||||
print('⚠️ [MessagesProvider] Cannot resend: Contact not found for message $messageId');
|
||||
return;
|
||||
}
|
||||
|
||||
print('🔁 [MessagesProvider] Resending message $messageId');
|
||||
|
||||
// Reset retry state
|
||||
_messages[index] = message.copyWith(
|
||||
retryAttempt: 0,
|
||||
usedFloodFallback: false,
|
||||
deliveryStatus: MessageDeliveryStatus.sending,
|
||||
lastRetryAt: DateTime.now(),
|
||||
);
|
||||
|
||||
// Clear retry tracking
|
||||
_retryManager.clearRetry(messageId);
|
||||
|
||||
notifyListeners();
|
||||
|
||||
// Send again
|
||||
if (sendMessageCallback != null) {
|
||||
await sendMessageCallback!(
|
||||
contactPublicKey: contact.publicKey,
|
||||
text: message.text,
|
||||
messageId: messageId,
|
||||
contact: contact,
|
||||
retryAttempt: 0,
|
||||
);
|
||||
} else {
|
||||
print('⚠️ [MessagesProvider] sendMessageCallback not set, cannot resend');
|
||||
}
|
||||
|
||||
_persistMessages();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Cancel all pending timeout timers
|
||||
@@ -671,6 +869,10 @@ class MessagesProvider with ChangeNotifier {
|
||||
timer.cancel();
|
||||
}
|
||||
_timeoutTimers.clear();
|
||||
|
||||
// Clear retry manager
|
||||
_retryManager.clearAll();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +194,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// Refetch device info to update UI with new settings
|
||||
await connectionProvider.refreshDeviceInfo();
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
@@ -264,6 +267,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
// Save TX power
|
||||
await connectionProvider.setTxPower(txPowerResult.value!);
|
||||
|
||||
// Refetch device info to update UI with new settings
|
||||
await connectionProvider.refreshDeviceInfo();
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
|
||||
@@ -52,7 +52,8 @@ class MapTab extends StatefulWidget {
|
||||
class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
final MapController _mapController = MapController();
|
||||
final TileCacheService _tileCache = TileCacheService();
|
||||
final LocationTrackingService _locationService = LocationTrackingService();
|
||||
// DO NOT create a new LocationTrackingService instance here
|
||||
// Use the singleton from AppProvider instead via _locationService getter
|
||||
final MapMarkerService _markerService = MapMarkerService();
|
||||
bool _isInitialized = false;
|
||||
bool _isMapReady = false; // Track when map widget is actually rendered
|
||||
@@ -90,13 +91,16 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
// Access the singleton LocationTrackingService from AppProvider
|
||||
LocationTrackingService get _locationService => LocationTrackingService();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSettings();
|
||||
_loadMbtilesLayers();
|
||||
_initializeTileCache();
|
||||
_initLocationTracking();
|
||||
_setupLocationCallbacks();
|
||||
_startCompassTracking();
|
||||
|
||||
// Listen to map provider for navigation requests
|
||||
@@ -113,16 +117,22 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _initLocationTracking() async {
|
||||
// Initialize LocationTrackingService
|
||||
final appProvider = context.read<AppProvider>();
|
||||
await _locationService.initialize(appProvider.connectionProvider.bleService);
|
||||
/// Setup location tracking callbacks for map-specific features
|
||||
/// Note: LocationTrackingService is initialized and started by AppProvider
|
||||
/// This method only adds map-specific callbacks for rotation and UI updates
|
||||
void _setupLocationCallbacks() {
|
||||
// Store the original callback from AppProvider
|
||||
final originalCallback = _locationService.onPositionUpdate;
|
||||
|
||||
// Set up callbacks
|
||||
// Add map-specific callback that chains with the original
|
||||
_locationService.onPositionUpdate = (position) {
|
||||
// Call original callback first (AppProvider's logging)
|
||||
originalCallback?.call(position);
|
||||
|
||||
// Then handle map-specific logic
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
// Position updates are now handled by the service
|
||||
// Position updates trigger UI rebuild for markers
|
||||
});
|
||||
|
||||
// Rotate map if rotation mode is enabled and heading is available
|
||||
@@ -140,16 +150,6 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_locationService.onError = (error) {
|
||||
debugPrint('Location tracking error: $error');
|
||||
};
|
||||
|
||||
// Request permissions and start tracking
|
||||
final hasPermission = await _locationService.requestPermissions();
|
||||
if (hasPermission) {
|
||||
await _locationService.startTracking(distanceThreshold: _gpsUpdateDistance);
|
||||
}
|
||||
}
|
||||
|
||||
void _startCompassTracking() {
|
||||
@@ -372,7 +372,9 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
final mapProvider = context.read<MapProvider>();
|
||||
mapProvider.removeListener(_handleMapNavigation);
|
||||
_compassStreamSubscription?.cancel();
|
||||
_locationService.stopTracking();
|
||||
// DO NOT stop location tracking - it's managed by AppProvider
|
||||
// Just clear the map-specific callback
|
||||
_locationService.onPositionUpdate = null;
|
||||
_mapController.dispose();
|
||||
_tileCache.dispose();
|
||||
super.dispose();
|
||||
|
||||
@@ -29,7 +29,8 @@ typedef OnMessageDeliveredCallback = void Function(int ackCode, int roundTripTim
|
||||
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);
|
||||
typedef OnErrorCallback = void Function(String error, {int? errorCode});
|
||||
typedef OnContactNotFoundCallback = void Function(Uint8List? contactPublicKey);
|
||||
|
||||
/// Processes incoming responses from the BLE device
|
||||
class BleResponseHandler {
|
||||
@@ -58,8 +59,12 @@ class BleResponseHandler {
|
||||
OnBinaryResponseCallback? onBinaryResponse;
|
||||
OnBatteryAndStorageCallback? onBatteryAndStorage;
|
||||
OnErrorCallback? onError;
|
||||
OnContactNotFoundCallback? onContactNotFound;
|
||||
VoidCallback? onRxActivity;
|
||||
|
||||
// Track the last command that was sent, so we can retry if it fails with ERR_CODE_NOT_FOUND
|
||||
Uint8List? _lastContactPublicKey;
|
||||
|
||||
// Getters
|
||||
int get rxPacketCount => _rxPacketCount;
|
||||
List<BlePacketLog> get packetLogs => List.unmodifiable(_packetLogs);
|
||||
@@ -574,13 +579,25 @@ class BleResponseHandler {
|
||||
if (errorCode != null) {
|
||||
final errorMsg = FrameParser.getErrorMessage(errorCode);
|
||||
print(' ❌ [Error] $errorMsg');
|
||||
onError?.call(errorMsg);
|
||||
|
||||
// Special handling for ERR_CODE_NOT_FOUND (2) - contact not in radio
|
||||
if (errorCode == 2) { // ERR_CODE_NOT_FOUND
|
||||
print(' ⚠️ [Error] Contact not found in radio - attempting auto-recovery');
|
||||
onContactNotFound?.call(_lastContactPublicKey);
|
||||
}
|
||||
|
||||
onError?.call(errorMsg, errorCode: errorCode);
|
||||
}
|
||||
} catch (e) {
|
||||
print(' ❌ [Error] Parsing error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Track the last contact public key for retry logic
|
||||
void setLastContactPublicKey(Uint8List? publicKey) {
|
||||
_lastContactPublicKey = publicKey;
|
||||
}
|
||||
|
||||
/// Log a packet
|
||||
void _logPacket(Uint8List data, PacketDirection direction, {int? responseCode}) {
|
||||
_packetLogs.add(BlePacketLog(
|
||||
|
||||
@@ -77,6 +77,9 @@ class LocationTrackingService {
|
||||
/// Whether service has been initialized with BLE service
|
||||
bool _isInitialized = false;
|
||||
|
||||
/// Whether the first stable position has been set (without broadcast)
|
||||
bool _firstPositionSet = false;
|
||||
|
||||
// ============================================================================
|
||||
// Private Properties
|
||||
// ============================================================================
|
||||
@@ -172,22 +175,52 @@ class LocationTrackingService {
|
||||
/// Get current GPS position
|
||||
///
|
||||
/// Returns null if position unavailable or permissions denied.
|
||||
Future<Position?> getCurrentPosition() async {
|
||||
try {
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
timeLimit: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
/// [timeLimit] - Maximum time to wait for position (default: 15 seconds)
|
||||
/// [retryCount] - Number of retry attempts (default: 2)
|
||||
Future<Position?> getCurrentPosition({
|
||||
Duration timeLimit = const Duration(seconds: 15),
|
||||
int retryCount = 2,
|
||||
}) async {
|
||||
for (int attempt = 0; attempt <= retryCount; attempt++) {
|
||||
try {
|
||||
if (attempt > 0) {
|
||||
debugPrint('🔄 [LocationTracking] Retry attempt $attempt/$retryCount');
|
||||
// Exponential backoff: wait 2^attempt seconds before retry
|
||||
await Future.delayed(Duration(seconds: 1 << attempt));
|
||||
}
|
||||
|
||||
currentPosition = position;
|
||||
return position;
|
||||
} catch (e) {
|
||||
debugPrint('❌ [LocationTracking] Error getting position: $e');
|
||||
onError?.call('Failed to get current position: $e');
|
||||
return null;
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
locationSettings: LocationSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
timeLimit: timeLimit,
|
||||
),
|
||||
);
|
||||
|
||||
currentPosition = position;
|
||||
if (attempt > 0) {
|
||||
debugPrint('✅ [LocationTracking] Position acquired after $attempt retries');
|
||||
}
|
||||
return position;
|
||||
} catch (e) {
|
||||
final isLastAttempt = attempt == retryCount;
|
||||
if (isLastAttempt) {
|
||||
debugPrint('❌ [LocationTracking] Failed to get position after $retryCount retries: $e');
|
||||
// Only call error callback on final failure, and make it user-friendly
|
||||
if (e.toString().contains('TimeoutException')) {
|
||||
onError?.call('GPS signal weak. Position stream will continue trying...');
|
||||
} else {
|
||||
onError?.call('Failed to get GPS position. Check device settings.');
|
||||
}
|
||||
} else {
|
||||
debugPrint('⚠️ [LocationTracking] Position attempt $attempt failed: $e');
|
||||
}
|
||||
|
||||
if (isLastAttempt) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get position stream with configurable distance filter
|
||||
@@ -211,6 +244,8 @@ class LocationTrackingService {
|
||||
/// [distanceThreshold] - GPS update distance filter
|
||||
///
|
||||
/// Returns true if successful, false otherwise.
|
||||
/// Note: This method returns immediately after starting the position stream.
|
||||
/// Initial position acquisition happens asynchronously in the background.
|
||||
Future<bool> startTracking({double? distanceThreshold}) async {
|
||||
if (!_isInitialized || _bleService == null) {
|
||||
debugPrint(
|
||||
@@ -239,17 +274,28 @@ class LocationTrackingService {
|
||||
// Save settings
|
||||
await saveSettings();
|
||||
|
||||
// Get initial position
|
||||
await getCurrentPosition();
|
||||
// Try to get initial position in background (non-blocking)
|
||||
// This will populate currentPosition but won't block tracking startup
|
||||
getCurrentPosition(
|
||||
timeLimit: const Duration(seconds: 10),
|
||||
retryCount: 1,
|
||||
).then((position) {
|
||||
if (position != null) {
|
||||
debugPrint('✅ [LocationTracking] Initial position acquired in background');
|
||||
}
|
||||
}).catchError((error) {
|
||||
debugPrint('⚠️ [LocationTracking] Background initial position failed: $error');
|
||||
// Not critical - position stream will eventually provide position
|
||||
});
|
||||
|
||||
// Start position stream
|
||||
// Start position stream immediately (don't wait for initial position)
|
||||
try {
|
||||
_positionSubscription = getPositionStream(distanceFilter: threshold)
|
||||
.listen(
|
||||
_handlePositionUpdate,
|
||||
onError: (error) {
|
||||
debugPrint('❌ [LocationTracking] Position stream error: $error');
|
||||
onError?.call('Position stream error: $error');
|
||||
onError?.call('GPS stream error. Retrying...');
|
||||
},
|
||||
);
|
||||
|
||||
@@ -259,10 +305,11 @@ class LocationTrackingService {
|
||||
debugPrint(
|
||||
'✅ [LocationTracking] Tracking started with ${threshold}m threshold',
|
||||
);
|
||||
debugPrint('📡 [LocationTracking] Waiting for GPS signal...');
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('❌ [LocationTracking] Failed to start tracking: $e');
|
||||
onError?.call('Failed to start tracking: $e');
|
||||
onError?.call('Failed to start GPS tracking: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -277,6 +324,9 @@ class LocationTrackingService {
|
||||
isTracking = false;
|
||||
onTrackingStateChanged?.call(false);
|
||||
|
||||
// Reset first position flag so next connection starts fresh
|
||||
_firstPositionSet = false;
|
||||
|
||||
// Save disabled state
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_prefKeyEnabled, false);
|
||||
@@ -316,10 +366,57 @@ class LocationTrackingService {
|
||||
// Notify listeners
|
||||
onPositionUpdate?.call(position);
|
||||
|
||||
// SPECIAL CASE: First stable position after connection
|
||||
// Set lat/lon on device WITHOUT broadcasting to mesh network
|
||||
if (!_firstPositionSet) {
|
||||
_setInitialPosition(position);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we should broadcast to mesh network
|
||||
_checkAndBroadcast(position);
|
||||
}
|
||||
|
||||
/// Set initial position on device without broadcasting
|
||||
///
|
||||
/// Called only for the first stable GPS position after connection starts.
|
||||
/// Updates the device's advertised lat/lon but does NOT send an advertisement.
|
||||
void _setInitialPosition(Position position) async {
|
||||
if (_bleService == null || !_bleService!.isConnected) {
|
||||
debugPrint('⚠️ [LocationTracking] Cannot set initial position: BLE not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('📍 [LocationTracking] Setting initial position (no broadcast)');
|
||||
|
||||
// Update device's advertised location WITHOUT sending advertisement
|
||||
await _bleService!.setAdvertLatLon(
|
||||
latitude: position.latitude,
|
||||
longitude: position.longitude,
|
||||
);
|
||||
|
||||
// Mark first position as set
|
||||
_firstPositionSet = true;
|
||||
|
||||
// Update last broadcast position to prevent immediate broadcast on next update
|
||||
_lastBroadcastPosition = position;
|
||||
_lastBroadcastTime = DateTime.now();
|
||||
|
||||
// Save to preferences
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setDouble(_prefKeyLastLat, position.latitude);
|
||||
await prefs.setDouble(_prefKeyLastLon, position.longitude);
|
||||
|
||||
debugPrint('✅ [LocationTracking] Initial position set without broadcast');
|
||||
debugPrint(' Next broadcast allowed in ${minTimeIntervalSeconds}s');
|
||||
} catch (e) {
|
||||
debugPrint('❌ [LocationTracking] Failed to set initial position: $e');
|
||||
onError?.call('Failed to set initial position: $e');
|
||||
// Don't mark as set on failure, so it will retry on next update
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if position should be broadcast based on distance and time thresholds
|
||||
void _checkAndBroadcast(Position position) {
|
||||
// If never broadcast before, do it now
|
||||
|
||||
@@ -28,7 +28,8 @@ typedef OnMessageDeliveredCallback = void Function(int ackCode, int roundTripTim
|
||||
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);
|
||||
typedef OnErrorCallback = void Function(String error, {int? errorCode});
|
||||
typedef OnContactNotFoundCallback = void Function(Uint8List? contactPublicKey);
|
||||
typedef OnConnectionStateCallback = void Function(bool isConnected);
|
||||
typedef OnReconnectionAttemptCallback = void Function(int attemptNumber, int maxAttempts);
|
||||
typedef OnRssiUpdateCallback = void Function(int rssi);
|
||||
@@ -62,6 +63,7 @@ class MeshCoreBleService {
|
||||
OnBinaryResponseCallback? onBinaryResponse;
|
||||
OnBatteryAndStorageCallback? onBatteryAndStorage;
|
||||
OnErrorCallback? onError;
|
||||
OnContactNotFoundCallback? onContactNotFound;
|
||||
|
||||
// Activity callbacks (for blinking indicators)
|
||||
VoidCallback? onRxActivity;
|
||||
@@ -149,8 +151,11 @@ class MeshCoreBleService {
|
||||
_responseHandler.onBatteryAndStorage = (millivolts, usedKb, totalKb) {
|
||||
onBatteryAndStorage?.call(millivolts, usedKb, totalKb);
|
||||
};
|
||||
_responseHandler.onError = (error) {
|
||||
onError?.call(error);
|
||||
_responseHandler.onError = (error, {int? errorCode}) {
|
||||
onError?.call(error, errorCode: errorCode);
|
||||
};
|
||||
_responseHandler.onContactNotFound = (contactPublicKey) {
|
||||
onContactNotFound?.call(contactPublicKey);
|
||||
};
|
||||
_responseHandler.onRxActivity = () {
|
||||
onRxActivity?.call();
|
||||
@@ -247,6 +252,9 @@ class MeshCoreBleService {
|
||||
throw ArgumentError('Text message exceeds 160 character limit');
|
||||
}
|
||||
|
||||
// Track the last contact for auto-recovery if contact not found
|
||||
_responseHandler.setLastContactPublicKey(contactPublicKey);
|
||||
|
||||
await _commandSender.writeData(FrameBuilder.buildSendTxtMsg(
|
||||
contactPublicKey: contactPublicKey,
|
||||
text: text,
|
||||
|
||||
@@ -47,7 +47,9 @@ class TileCacheService {
|
||||
|
||||
FMTCTileProvider getTileProvider(MapLayer layer) {
|
||||
if (!_isInitialized) {
|
||||
throw StateError('TileCacheService not initialized. Call initialize() first.');
|
||||
throw StateError(
|
||||
'TileCacheService not initialized. Call initialize() first.',
|
||||
);
|
||||
}
|
||||
return _store.getTileProvider(
|
||||
loadingStrategy: BrowseLoadingStrategy.cacheFirst,
|
||||
@@ -63,7 +65,9 @@ class TileCacheService {
|
||||
Function(double progress)? onProgress,
|
||||
}) async {
|
||||
if (!_isInitialized) {
|
||||
throw StateError('TileCacheService not initialized. Call initialize() first.');
|
||||
throw StateError(
|
||||
'TileCacheService not initialized. Call initialize() first.',
|
||||
);
|
||||
}
|
||||
|
||||
if (_isDownloading) {
|
||||
@@ -78,21 +82,19 @@ class TileCacheService {
|
||||
final downloadable = region.toDownloadable(
|
||||
minZoom: minZoom,
|
||||
maxZoom: maxZoom,
|
||||
options: TileLayer(
|
||||
urlTemplate: layer.urlTemplate,
|
||||
),
|
||||
options: TileLayer(urlTemplate: layer.urlTemplate),
|
||||
);
|
||||
|
||||
final download = _store.download.startForeground(
|
||||
region: downloadable,
|
||||
);
|
||||
final download = _store.download.startForeground(region: downloadable);
|
||||
|
||||
await for (final progress in download.downloadProgress) {
|
||||
if (onProgress != null && progress.maxTilesCount > 0) {
|
||||
// Use attemptedTilesCount instead of successfulTilesCount
|
||||
// attemptedTilesCount includes successful + buffered + skipped tiles
|
||||
final percentage = progress.percentageProgress;
|
||||
print('Download progress: ${progress.attemptedTilesCount}/${progress.maxTilesCount} = ${percentage.toStringAsFixed(1)}% (successful: ${progress.successfulTilesCount}, buffered: ${progress.bufferedTilesCount}, skipped: ${progress.skippedTilesCount})');
|
||||
print(
|
||||
'Download progress: ${progress.attemptedTilesCount}/${progress.maxTilesCount} = ${percentage.toStringAsFixed(1)}% (successful: ${progress.successfulTilesCount}, buffered: ${progress.bufferedTilesCount}, skipped: ${progress.skippedTilesCount})',
|
||||
);
|
||||
onProgress(percentage);
|
||||
}
|
||||
}
|
||||
@@ -126,7 +128,9 @@ class TileCacheService {
|
||||
|
||||
Future<List<String>> getAvailableStores() async {
|
||||
if (!_isInitialized) {
|
||||
throw StateError('TileCacheService not initialized. Call initialize() first.');
|
||||
throw StateError(
|
||||
'TileCacheService not initialized. Call initialize() first.',
|
||||
);
|
||||
}
|
||||
|
||||
final stores = await FMTCRoot.stats.storesAvailable;
|
||||
@@ -137,11 +141,11 @@ class TileCacheService {
|
||||
if (!_isInitialized) return {};
|
||||
|
||||
final length = await _store.stats.length;
|
||||
final size = await _store.stats.size;
|
||||
final size = await _store.stats.all.then((a) => a.size);
|
||||
|
||||
return {
|
||||
'tileCount': length,
|
||||
'sizeMB': size / (1024 * 1024),
|
||||
'sizeMB': size / 1024,
|
||||
'storeName': _storeName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import '../../models/contact.dart';
|
||||
import '../../models/message.dart';
|
||||
import '../../providers/connection_provider.dart';
|
||||
@@ -43,6 +44,66 @@ class _DirectMessageSheetState extends State<DirectMessageSheet> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Insert current GPS location at cursor position
|
||||
Future<void> _insertCurrentLocation() async {
|
||||
try {
|
||||
// Check location permission
|
||||
LocationPermission permission = await Geolocator.checkPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
if (!mounted) return;
|
||||
ToastLogger.error(context, 'Location permission denied');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (permission == LocationPermission.deniedForever) {
|
||||
if (!mounted) return;
|
||||
ToastLogger.error(context, 'Location permission permanently denied');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current position
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.best,
|
||||
);
|
||||
|
||||
// Format location text
|
||||
final locationText = '📍 Lat: ${position.latitude.toStringAsFixed(5)}, Lon: ${position.longitude.toStringAsFixed(5)}';
|
||||
|
||||
// Check if adding location would exceed limit
|
||||
final currentText = _textController.text;
|
||||
if (currentText.length + locationText.length > _maxCharacters) {
|
||||
if (!mounted) return;
|
||||
ToastLogger.error(context, 'Adding location would exceed 160 character limit');
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert at cursor position or append
|
||||
final selection = _textController.selection;
|
||||
final newText = currentText.replaceRange(
|
||||
selection.start >= 0 ? selection.start : currentText.length,
|
||||
selection.end >= 0 ? selection.end : currentText.length,
|
||||
locationText,
|
||||
);
|
||||
|
||||
_textController.text = newText;
|
||||
|
||||
// Move cursor to end of inserted text
|
||||
final newCursorPosition = (selection.start >= 0 ? selection.start : currentText.length) + locationText.length;
|
||||
_textController.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: newCursorPosition),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
ToastLogger.success(context, 'Location inserted');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ToastLogger.error(context, 'Failed to get location: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sendDirectMessage() async {
|
||||
final text = _textController.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
@@ -80,7 +141,8 @@ class _DirectMessageSheetState extends State<DirectMessageSheet> {
|
||||
);
|
||||
|
||||
// Add to messages list with "sending" status
|
||||
messagesProvider.addSentMessage(sentMessage);
|
||||
// Pass contact for retry logic
|
||||
messagesProvider.addSentMessage(sentMessage, contact: widget.contact);
|
||||
|
||||
// Send direct message to contact (include contact for path logging)
|
||||
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
||||
@@ -226,37 +288,64 @@ class _DirectMessageSheetState extends State<DirectMessageSheet> {
|
||||
borderSide: BorderSide(color: colorScheme.primary, width: 2),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
counterText: _characterCount >= 150
|
||||
? '$_characterCount/$_maxCharacters'
|
||||
: '',
|
||||
counterStyle: TextStyle(
|
||||
fontSize: 11,
|
||||
color: _characterCount > _maxCharacters * 0.9
|
||||
? Colors.orange
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
counterText: '', // Hide default counter
|
||||
),
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _sendDirectMessage(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _textController.text.trim().isEmpty
|
||||
? null
|
||||
: _sendDirectMessage,
|
||||
icon: const Icon(Icons.send),
|
||||
label: Text(AppLocalizations.of(context)!.sendDirectMessage),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
disabledBackgroundColor: colorScheme.surfaceContainerHighest,
|
||||
disabledForegroundColor: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
// Always-visible character counter
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'$_characterCount / $_maxCharacters',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _characterCount > 155
|
||||
? Colors.red
|
||||
: (_characterCount > 140
|
||||
? Colors.orange
|
||||
: colorScheme.onSurfaceVariant),
|
||||
fontWeight: _characterCount > 140 ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Location and Send buttons
|
||||
Row(
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: _insertCurrentLocation,
|
||||
icon: const Icon(Icons.my_location, size: 18),
|
||||
label: const Text('Location'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
side: BorderSide(color: colorScheme.outline),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _textController.text.trim().isEmpty
|
||||
? null
|
||||
: _sendDirectMessage,
|
||||
icon: const Icon(Icons.send),
|
||||
label: Text(AppLocalizations.of(context)!.sendDirectMessage),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
disabledBackgroundColor: colorScheme.surfaceContainerHighest,
|
||||
disabledForegroundColor: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user