mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Add retransmission handling
This commit is contained in:
@@ -249,7 +249,10 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
await Future.delayed(const Duration(milliseconds: 300));
|
await Future.delayed(const Duration(milliseconds: 300));
|
||||||
|
|
||||||
if (pendingOp.messageId != null) {
|
if (pendingOp.messageId != null) {
|
||||||
_messageDeliveryTracker.trackPendingMessage(pendingOp.messageId!);
|
_messageDeliveryTracker.trackPendingDirectMessage(
|
||||||
|
pendingOp.messageId!,
|
||||||
|
pendingOp.contactPublicKey,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _activeService.sendTextMessage(
|
await _activeService.sendTextMessage(
|
||||||
@@ -352,7 +355,11 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
service.onMessageSent =
|
service.onMessageSent =
|
||||||
(expectedAckTag, suggestedTimeoutMs, isFloodMode, contactPublicKey) {
|
(expectedAckTag, suggestedTimeoutMs, isFloodMode, contactPublicKey) {
|
||||||
final messageId = _messageDeliveryTracker.popPendingMessageId();
|
final messageId = contactPublicKey != null
|
||||||
|
? _messageDeliveryTracker.popPendingDirectMessageId(
|
||||||
|
contactPublicKey,
|
||||||
|
)
|
||||||
|
: _messageDeliveryTracker.popPendingMessageId();
|
||||||
if (messageId != null) {
|
if (messageId != null) {
|
||||||
_messageDeliveryTracker.mapAckTagToMessageId(
|
_messageDeliveryTracker.mapAckTagToMessageId(
|
||||||
expectedAckTag,
|
expectedAckTag,
|
||||||
@@ -1148,7 +1155,10 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
// The MessagesProvider now uses simple ACK tag → recipientPublicKey mapping.
|
// The MessagesProvider now uses simple ACK tag → recipientPublicKey mapping.
|
||||||
// We still track here for the SENT response callback to work.
|
// We still track here for the SENT response callback to work.
|
||||||
if (messageId != null) {
|
if (messageId != null) {
|
||||||
_messageDeliveryTracker.trackPendingMessage(messageId);
|
_messageDeliveryTracker.trackPendingDirectMessage(
|
||||||
|
messageId,
|
||||||
|
contactPublicKey,
|
||||||
|
);
|
||||||
debugPrint(' 📝 Tracked pending message: $messageId');
|
debugPrint(' 📝 Tracked pending message: $messageId');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
/// Message delivery tracking helper
|
/// Message delivery tracking helper
|
||||||
///
|
///
|
||||||
/// Manages message delivery tracking for sent messages, including:
|
/// Manages message delivery tracking for sent messages, including:
|
||||||
@@ -16,6 +18,9 @@ class MessageDeliveryTracker {
|
|||||||
/// Messages tracked here before sending, popped when RESP_CODE_SENT arrives
|
/// Messages tracked here before sending, popped when RESP_CODE_SENT arrives
|
||||||
final List<String> _pendingMessageIds = [];
|
final List<String> _pendingMessageIds = [];
|
||||||
|
|
||||||
|
/// Contact-scoped FIFOs for matching direct-message SENT responses.
|
||||||
|
final Map<String, List<String>> _pendingMessageIdsByContact = {};
|
||||||
|
|
||||||
/// Map of ACK tag to message ID for delivery confirmation
|
/// Map of ACK tag to message ID for delivery confirmation
|
||||||
final Map<int, String> _ackTagToMessageId = {};
|
final Map<int, String> _ackTagToMessageId = {};
|
||||||
|
|
||||||
@@ -33,6 +38,15 @@ class MessageDeliveryTracker {
|
|||||||
_pendingMessageIds.add(messageId);
|
_pendingMessageIds.add(messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Track a pending direct message ID for a specific contact.
|
||||||
|
void trackPendingDirectMessage(String messageId, Uint8List contactPublicKey) {
|
||||||
|
trackPendingMessage(messageId);
|
||||||
|
final contactKey = _contactKey(contactPublicKey);
|
||||||
|
_pendingMessageIdsByContact
|
||||||
|
.putIfAbsent(contactKey, () => [])
|
||||||
|
.add(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
/// Pop the next pending message ID from FIFO queue
|
/// Pop the next pending message ID from FIFO queue
|
||||||
///
|
///
|
||||||
/// Called when RESP_CODE_SENT arrives. Returns null if queue empty.
|
/// Called when RESP_CODE_SENT arrives. Returns null if queue empty.
|
||||||
@@ -43,6 +57,24 @@ class MessageDeliveryTracker {
|
|||||||
return _pendingMessageIds.removeAt(0);
|
return _pendingMessageIds.removeAt(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pop the next pending direct message ID for a specific contact.
|
||||||
|
///
|
||||||
|
/// Falls back to the legacy global FIFO if the contact queue is empty.
|
||||||
|
String? popPendingDirectMessageId(Uint8List contactPublicKey) {
|
||||||
|
final contactKey = _contactKey(contactPublicKey);
|
||||||
|
final queue = _pendingMessageIdsByContact[contactKey];
|
||||||
|
if (queue == null || queue.isEmpty) {
|
||||||
|
return popPendingMessageId();
|
||||||
|
}
|
||||||
|
|
||||||
|
final messageId = queue.removeAt(0);
|
||||||
|
if (queue.isEmpty) {
|
||||||
|
_pendingMessageIdsByContact.remove(contactKey);
|
||||||
|
}
|
||||||
|
_pendingMessageIds.remove(messageId);
|
||||||
|
return messageId;
|
||||||
|
}
|
||||||
|
|
||||||
/// Map ACK tag to message ID after RESP_CODE_SENT received
|
/// Map ACK tag to message ID after RESP_CODE_SENT received
|
||||||
///
|
///
|
||||||
/// Creates bidirectional mapping for efficient cleanup and tracking.
|
/// Creates bidirectional mapping for efficient cleanup and tracking.
|
||||||
@@ -86,6 +118,17 @@ class MessageDeliveryTracker {
|
|||||||
_ackTagToMessageId.remove(ackTag);
|
_ackTagToMessageId.remove(ackTag);
|
||||||
_ackTagTimestamps.remove(ackTag);
|
_ackTagTimestamps.remove(ackTag);
|
||||||
}
|
}
|
||||||
|
_pendingMessageIds.remove(messageId);
|
||||||
|
final emptyKeys = <String>[];
|
||||||
|
for (final entry in _pendingMessageIdsByContact.entries) {
|
||||||
|
entry.value.remove(messageId);
|
||||||
|
if (entry.value.isEmpty) {
|
||||||
|
emptyKeys.add(entry.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final key in emptyKeys) {
|
||||||
|
_pendingMessageIdsByContact.remove(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clean up stale ACK mappings
|
/// Clean up stale ACK mappings
|
||||||
@@ -114,6 +157,7 @@ class MessageDeliveryTracker {
|
|||||||
/// Clear all tracking state
|
/// Clear all tracking state
|
||||||
void clearTracking() {
|
void clearTracking() {
|
||||||
_pendingMessageIds.clear();
|
_pendingMessageIds.clear();
|
||||||
|
_pendingMessageIdsByContact.clear();
|
||||||
_ackTagToMessageId.clear();
|
_ackTagToMessageId.clear();
|
||||||
_messageIdToAckTag.clear();
|
_messageIdToAckTag.clear();
|
||||||
_ackTagTimestamps.clear();
|
_ackTagTimestamps.clear();
|
||||||
@@ -133,9 +177,7 @@ class MessageDeliveryTracker {
|
|||||||
/// Get oldest pending ACK timestamp (for debugging)
|
/// Get oldest pending ACK timestamp (for debugging)
|
||||||
DateTime? get oldestPendingTimestamp {
|
DateTime? get oldestPendingTimestamp {
|
||||||
if (_ackTagTimestamps.isEmpty) return null;
|
if (_ackTagTimestamps.isEmpty) return null;
|
||||||
return _ackTagTimestamps.values.reduce(
|
return _ackTagTimestamps.values.reduce((a, b) => a.isBefore(b) ? a : b);
|
||||||
(a, b) => a.isBefore(b) ? a : b,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get diagnostic info for debugging
|
/// Get diagnostic info for debugging
|
||||||
@@ -145,6 +187,15 @@ class MessageDeliveryTracker {
|
|||||||
'shouldRateLimit': shouldRateLimit,
|
'shouldRateLimit': shouldRateLimit,
|
||||||
'oldestPending': oldestPendingTimestamp?.toIso8601String(),
|
'oldestPending': oldestPendingTimestamp?.toIso8601String(),
|
||||||
'ackTags': _ackTagToMessageId.keys.toList(),
|
'ackTags': _ackTagToMessageId.keys.toList(),
|
||||||
|
'pendingByContact': _pendingMessageIdsByContact.map(
|
||||||
|
(key, value) => MapEntry(key, value.length),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _contactKey(Uint8List contactPublicKey) {
|
||||||
|
return contactPublicKey
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
// Key: message ID (not ACK tag, since multiple messages can share same ACK)
|
// Key: message ID (not ACK tag, since multiple messages can share same ACK)
|
||||||
final Map<String, Timer> _timeoutTimers = {};
|
final Map<String, Timer> _timeoutTimers = {};
|
||||||
|
|
||||||
|
// Recently completed ACKs are kept briefly to ignore duplicate confirms.
|
||||||
|
final Map<int, DateTime> _completedAckHistory = {};
|
||||||
|
|
||||||
// Retry management
|
// Retry management
|
||||||
final MessageRetryManager _retryManager = MessageRetryManager();
|
final MessageRetryManager _retryManager = MessageRetryManager();
|
||||||
|
|
||||||
@@ -906,11 +909,13 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
final (groupId, recipientPublicKey) = groupMapping;
|
final (groupId, recipientPublicKey) = groupMapping;
|
||||||
debugPrint(' ✅ This is part of a grouped message: $groupId');
|
debugPrint(' ✅ This is part of a grouped message: $groupId');
|
||||||
|
|
||||||
// Update the recipient status to "sent" in the grouped message
|
// ACK-tracked recipients stay pending until the delivery confirm arrives.
|
||||||
updateGroupedMessageRecipientStatus(
|
updateGroupedMessageRecipientStatus(
|
||||||
groupId,
|
groupId,
|
||||||
recipientPublicKey,
|
recipientPublicKey,
|
||||||
MessageDeliveryStatus.sent,
|
expectedAckTag > 0
|
||||||
|
? MessageDeliveryStatus.sending
|
||||||
|
: MessageDeliveryStatus.sent,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Track the ACK for this specific recipient
|
// Track the ACK for this specific recipient
|
||||||
@@ -1022,7 +1027,9 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final updatedMessage = message.copyWith(
|
final updatedMessage = message.copyWith(
|
||||||
deliveryStatus: MessageDeliveryStatus.sent,
|
deliveryStatus: expectedAckTag > 0
|
||||||
|
? MessageDeliveryStatus.sending
|
||||||
|
: MessageDeliveryStatus.sent,
|
||||||
expectedAckTag: expectedAckTag > 0 ? expectedAckTag : null,
|
expectedAckTag: expectedAckTag > 0 ? expectedAckTag : null,
|
||||||
suggestedTimeoutMs: suggestedTimeoutMs > 0 ? suggestedTimeoutMs : null,
|
suggestedTimeoutMs: suggestedTimeoutMs > 0 ? suggestedTimeoutMs : null,
|
||||||
);
|
);
|
||||||
@@ -1245,6 +1252,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Update message status to delivered with RTT
|
/// Update message status to delivered with RTT
|
||||||
void markMessageDelivered(int ackCode, int roundTripTimeMs) {
|
void markMessageDelivered(int ackCode, int roundTripTimeMs) {
|
||||||
|
_cleanupCompletedAckHistory();
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'🔍 [MessagesProvider] markMessageDelivered called with ACK: $ackCode, RTT: ${roundTripTimeMs}ms',
|
'🔍 [MessagesProvider] markMessageDelivered called with ACK: $ackCode, RTT: ${roundTripTimeMs}ms',
|
||||||
);
|
);
|
||||||
@@ -1296,6 +1304,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
_ackTagToRecipients.remove(ackCode);
|
_ackTagToRecipients.remove(ackCode);
|
||||||
_pendingSentMessages.remove(ackCode);
|
_pendingSentMessages.remove(ackCode);
|
||||||
|
_rememberCompletedAck(ackCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -1339,6 +1348,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Remove from pending
|
// Remove from pending
|
||||||
_pendingSentMessages.remove(ackCode);
|
_pendingSentMessages.remove(ackCode);
|
||||||
|
_rememberCompletedAck(ackCode);
|
||||||
|
|
||||||
// Clear retry tracking on successful delivery
|
// Clear retry tracking on successful delivery
|
||||||
_retryManager.clearRetry(message.id);
|
_retryManager.clearRetry(message.id);
|
||||||
@@ -1362,6 +1372,12 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if (_completedAckHistory.containsKey(ackCode)) {
|
||||||
|
debugPrint(
|
||||||
|
'ℹ️ [MessagesProvider] Duplicate/late ACK $ackCode ignored (already completed)',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [MessagesProvider] No pending message found for ACK code: $ackCode',
|
'⚠️ [MessagesProvider] No pending message found for ACK code: $ackCode',
|
||||||
);
|
);
|
||||||
@@ -1482,18 +1498,31 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
debugPrint(
|
debugPrint(
|
||||||
'⏰ [MessagesProvider] Executing retry $nextAttempt for message $messageId',
|
'⏰ [MessagesProvider] Executing retry $nextAttempt for message $messageId',
|
||||||
);
|
);
|
||||||
|
final currentIndex = _messages.indexWhere((m) => m.id == messageId);
|
||||||
|
if (currentIndex == -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final currentMessage = _messages[currentIndex];
|
||||||
|
if (currentMessage.deliveryStatus == MessageDeliveryStatus.delivered) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (sendMessageCallback != null) {
|
if (sendMessageCallback != null) {
|
||||||
await sendMessageCallback!(
|
final queued = await sendMessageCallback!(
|
||||||
contactPublicKey: contact.publicKey,
|
contactPublicKey: contact.publicKey,
|
||||||
text: message.text,
|
text: message.text,
|
||||||
messageId: messageId,
|
messageId: messageId,
|
||||||
contact: contact,
|
contact: contact,
|
||||||
retryAttempt: nextAttempt,
|
retryAttempt: nextAttempt,
|
||||||
);
|
);
|
||||||
|
if (!queued) {
|
||||||
|
_markAsPermanentlyFailed(messageId, currentMessage);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot retry',
|
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot retry',
|
||||||
);
|
);
|
||||||
|
_markAsPermanentlyFailed(messageId, currentMessage);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1529,17 +1558,21 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Send with flood mode (no retry after this)
|
// Send with flood mode (no retry after this)
|
||||||
if (sendMessageCallback != null) {
|
if (sendMessageCallback != null) {
|
||||||
await sendMessageCallback!(
|
final queued = await sendMessageCallback!(
|
||||||
contactPublicKey: contact.publicKey,
|
contactPublicKey: contact.publicKey,
|
||||||
text: message.text,
|
text: message.text,
|
||||||
messageId: messageId,
|
messageId: messageId,
|
||||||
contact: contact,
|
contact: contact,
|
||||||
retryAttempt: 0, // Reset attempt for flood
|
retryAttempt: 0, // Reset attempt for flood
|
||||||
);
|
);
|
||||||
|
if (!queued) {
|
||||||
|
_markAsPermanentlyFailed(messageId, _messages[index]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot send flood',
|
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot send flood',
|
||||||
);
|
);
|
||||||
|
_markAsPermanentlyFailed(messageId, _messages[index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
@@ -1608,17 +1641,21 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Send again
|
// Send again
|
||||||
if (sendMessageCallback != null) {
|
if (sendMessageCallback != null) {
|
||||||
await sendMessageCallback!(
|
final queued = await sendMessageCallback!(
|
||||||
contactPublicKey: contact.publicKey,
|
contactPublicKey: contact.publicKey,
|
||||||
text: message.text,
|
text: message.text,
|
||||||
messageId: messageId,
|
messageId: messageId,
|
||||||
contact: contact,
|
contact: contact,
|
||||||
retryAttempt: 0,
|
retryAttempt: 0,
|
||||||
);
|
);
|
||||||
|
if (!queued) {
|
||||||
|
_markAsPermanentlyFailed(messageId, _messages[index]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot resend',
|
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot resend',
|
||||||
);
|
);
|
||||||
|
_markAsPermanentlyFailed(messageId, _messages[index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
@@ -1631,10 +1668,29 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
timer.cancel();
|
timer.cancel();
|
||||||
}
|
}
|
||||||
_timeoutTimers.clear();
|
_timeoutTimers.clear();
|
||||||
|
_completedAckHistory.clear();
|
||||||
|
|
||||||
// Clear retry manager
|
// Clear retry manager
|
||||||
_retryManager.clearAll();
|
_retryManager.clearAll();
|
||||||
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _rememberCompletedAck(int ackCode) {
|
||||||
|
_completedAckHistory[ackCode] = DateTime.now();
|
||||||
|
_cleanupCompletedAckHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cleanupCompletedAckHistory({
|
||||||
|
Duration maxAge = const Duration(minutes: 15),
|
||||||
|
}) {
|
||||||
|
final cutoff = DateTime.now().subtract(maxAge);
|
||||||
|
final staleAcks = _completedAckHistory.entries
|
||||||
|
.where((entry) => entry.value.isBefore(cutoff))
|
||||||
|
.map((entry) => entry.key)
|
||||||
|
.toList();
|
||||||
|
for (final ack in staleAcks) {
|
||||||
|
_completedAckHistory.remove(ack);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,9 +46,9 @@ class HomeScreen extends StatefulWidget {
|
|||||||
State<HomeScreen> createState() => _HomeScreenState();
|
State<HomeScreen> createState() => _HomeScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HomeScreenState extends State<HomeScreen>
|
class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
late TabController _tabController;
|
late TabController _tabController;
|
||||||
|
late final AppProvider _appProvider;
|
||||||
int _currentIndex = 0;
|
int _currentIndex = 0;
|
||||||
bool _isMapFullscreen = false;
|
bool _isMapFullscreen = false;
|
||||||
bool _showRxTxIndicators = true;
|
bool _showRxTxIndicators = true;
|
||||||
@@ -74,9 +74,13 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_appProvider = context.read<AppProvider>();
|
||||||
|
_isMapEnabled = _appProvider.isMapEnabled;
|
||||||
|
_isContactsEnabled = _appProvider.isContactsEnabled;
|
||||||
|
_appProvider.addListener(_handleAppProviderChanged);
|
||||||
|
|
||||||
// Initialize synchronously so first build always has a valid controller.
|
// Initialize synchronously so first build always has a valid controller.
|
||||||
_initTabController();
|
_initTabController();
|
||||||
_loadTabVisibilityAndInitTabs();
|
|
||||||
_loadRxTxPreference();
|
_loadRxTxPreference();
|
||||||
|
|
||||||
// Show permission dialog after the first frame if needed
|
// Show permission dialog after the first frame if needed
|
||||||
@@ -87,19 +91,6 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadTabVisibilityAndInitTabs() async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
final mapEnabled = prefs.getBool('map_enabled') ?? true;
|
|
||||||
final contactsEnabled = prefs.getBool('contacts_enabled') ?? true;
|
|
||||||
if (!mounted) return;
|
|
||||||
if (_isMapEnabled != mapEnabled || _isContactsEnabled != contactsEnabled) {
|
|
||||||
_updateTabController(
|
|
||||||
mapEnabled: mapEnabled,
|
|
||||||
contactsEnabled: contactsEnabled,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _initTabController() {
|
void _initTabController() {
|
||||||
_tabController = TabController(length: _enabledTabs.length, vsync: this);
|
_tabController = TabController(length: _enabledTabs.length, vsync: this);
|
||||||
_tabController.addListener(_onTabChanged);
|
_tabController.addListener(_onTabChanged);
|
||||||
@@ -110,6 +101,14 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _handleAppProviderChanged() {
|
||||||
|
if (!mounted) return;
|
||||||
|
_updateTabController(
|
||||||
|
mapEnabled: _appProvider.isMapEnabled,
|
||||||
|
contactsEnabled: _appProvider.isContactsEnabled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _onTabChanged() {
|
void _onTabChanged() {
|
||||||
final previousTab = _currentTab;
|
final previousTab = _currentTab;
|
||||||
final nextIndex = _tabController.index;
|
final nextIndex = _tabController.index;
|
||||||
@@ -136,15 +135,21 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
final oldTabs = _enabledTabs;
|
final oldTabs = _enabledTabs;
|
||||||
final oldIndex = _tabController.index;
|
final oldIndex = oldTabs.isEmpty
|
||||||
|
? 0
|
||||||
|
: _tabController.index.clamp(0, oldTabs.length - 1);
|
||||||
final oldTab = oldTabs[oldIndex];
|
final oldTab = oldTabs[oldIndex];
|
||||||
|
|
||||||
final oldController = _tabController;
|
final oldController = _tabController;
|
||||||
oldController.removeListener(_onTabChanged);
|
oldController.removeListener(_onTabChanged);
|
||||||
|
oldController.dispose();
|
||||||
|
|
||||||
// Update state
|
// Update state
|
||||||
_isMapEnabled = mapEnabled;
|
_isMapEnabled = mapEnabled;
|
||||||
_isContactsEnabled = contactsEnabled;
|
_isContactsEnabled = contactsEnabled;
|
||||||
|
if (!_isMapEnabled) {
|
||||||
|
_isMapFullscreen = false;
|
||||||
|
}
|
||||||
|
|
||||||
final newTabs = _enabledTabs;
|
final newTabs = _enabledTabs;
|
||||||
final newIndex = newTabs.indexOf(oldTab);
|
final newIndex = newTabs.indexOf(oldTab);
|
||||||
@@ -157,11 +162,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
_tabController.index = _currentIndex;
|
_tabController.index = _currentIndex;
|
||||||
|
|
||||||
setState(() {});
|
setState(() {});
|
||||||
|
_handleTabActivated(_currentTab);
|
||||||
// Dispose old controller after widgets have rebound to the new controller.
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
oldController.dispose();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToTab(_HomeTab tab) {
|
void _navigateToTab(_HomeTab tab) {
|
||||||
@@ -199,6 +200,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_appProvider.removeListener(_handleAppProviderChanged);
|
||||||
_tabController.removeListener(_onTabChanged);
|
_tabController.removeListener(_onTabChanged);
|
||||||
_tabController.dispose();
|
_tabController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -343,18 +345,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
messagesProvider.setLocalizations(localizations);
|
messagesProvider.setLocalizations(localizations);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if tab visibility settings changed and update tab controller
|
context.watch<AppProvider>();
|
||||||
final appProvider = context.watch<AppProvider>();
|
|
||||||
if (_isMapEnabled != appProvider.isMapEnabled ||
|
|
||||||
_isContactsEnabled != appProvider.isContactsEnabled) {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (!mounted) return;
|
|
||||||
_updateTabController(
|
|
||||||
mapEnabled: appProvider.isMapEnabled,
|
|
||||||
contactsEnabled: appProvider.isContactsEnabled,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
final enabledTabs = _enabledTabs;
|
final enabledTabs = _enabledTabs;
|
||||||
final isMapTabActive = _currentTab == _HomeTab.map;
|
final isMapTabActive = _currentTab == _HomeTab.map;
|
||||||
|
|||||||
@@ -1349,9 +1349,6 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
// SAR marker data is automatically added by SarMessageParser.enhanceMessage in MessagesProvider
|
// SAR marker data is automatically added by SarMessageParser.enhanceMessage in MessagesProvider
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add to messages list with "sending" status
|
|
||||||
messagesProvider.addSentMessage(sentMessage);
|
|
||||||
|
|
||||||
// Look up the room contact for path logging
|
// Look up the room contact for path logging
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
final roomContact = contactsProvider.contacts.where((c) {
|
final roomContact = contactsProvider.contacts.where((c) {
|
||||||
@@ -1359,6 +1356,9 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
c.publicKey.matches(roomPublicKey);
|
c.publicKey.matches(roomPublicKey);
|
||||||
}).firstOrNull;
|
}).firstOrNull;
|
||||||
|
|
||||||
|
// Add to messages list with "sending" status
|
||||||
|
messagesProvider.addSentMessage(sentMessage, contact: roomContact);
|
||||||
|
|
||||||
// Send SAR message to selected room (persisted and immutable)
|
// Send SAR message to selected room (persisted and immutable)
|
||||||
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
||||||
contactPublicKey: roomPublicKey!,
|
contactPublicKey: roomPublicKey!,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
@@ -44,11 +45,13 @@ class MessagesTab extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _MessagesTabState extends State<MessagesTab> {
|
class _MessagesTabState extends State<MessagesTab> {
|
||||||
|
static const int _maxContactMessageBytes = 156;
|
||||||
|
static const int _maxChannelMessageBytes = 127;
|
||||||
|
|
||||||
final TextEditingController _textController = TextEditingController();
|
final TextEditingController _textController = TextEditingController();
|
||||||
final FocusNode _focusNode = FocusNode();
|
final FocusNode _focusNode = FocusNode();
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
int _characterCount = 0;
|
int _messageByteCount = 0;
|
||||||
static const int _maxCharacters = 160;
|
|
||||||
String? _highlightedMessageId;
|
String? _highlightedMessageId;
|
||||||
Timer? _highlightTimer; // Timer for clearing message highlight
|
Timer? _highlightTimer; // Timer for clearing message highlight
|
||||||
|
|
||||||
@@ -166,10 +169,44 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
|
|
||||||
void _updateCharacterCount() {
|
void _updateCharacterCount() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_characterCount = _textController.text.length;
|
_messageByteCount = utf8.encode(_textController.text).length;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int get _maxMessageBytes =>
|
||||||
|
_destinationType == MessageDestinationPreferences.destinationTypeChannel
|
||||||
|
? _maxChannelMessageBytes
|
||||||
|
: _maxContactMessageBytes;
|
||||||
|
|
||||||
|
TextInputFormatter get _messageByteLimiter =>
|
||||||
|
TextInputFormatter.withFunction((oldValue, newValue) {
|
||||||
|
if (utf8.encode(newValue.text).length <= _maxMessageBytes) {
|
||||||
|
return newValue;
|
||||||
|
}
|
||||||
|
return oldValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
void _enforceMessageByteLimit() {
|
||||||
|
final currentText = _textController.text;
|
||||||
|
if (utf8.encode(currentText).length <= _maxMessageBytes) {
|
||||||
|
_updateCharacterCount();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var truncated = currentText;
|
||||||
|
while (truncated.isNotEmpty &&
|
||||||
|
utf8.encode(truncated).length > _maxMessageBytes) {
|
||||||
|
truncated = truncated.substring(0, truncated.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
_textController.value = _textController.value.copyWith(
|
||||||
|
text: truncated,
|
||||||
|
selection: TextSelection.collapsed(offset: truncated.length),
|
||||||
|
composing: TextRange.empty,
|
||||||
|
);
|
||||||
|
_updateCharacterCount();
|
||||||
|
}
|
||||||
|
|
||||||
/// Load saved message destination from preferences
|
/// Load saved message destination from preferences
|
||||||
Future<void> _loadSavedDestination() async {
|
Future<void> _loadSavedDestination() async {
|
||||||
final savedDestination =
|
final savedDestination =
|
||||||
@@ -211,6 +248,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
await MessageDestinationPreferences.clearDestination();
|
await MessageDestinationPreferences.clearDestination();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_enforceMessageByteLimit();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show recipient selector bottom sheet
|
/// Show recipient selector bottom sheet
|
||||||
@@ -250,6 +289,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
_selectedRecipient = recipient;
|
_selectedRecipient = recipient;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_enforceMessageByteLimit();
|
||||||
|
|
||||||
// Save to preferences
|
// Save to preferences
|
||||||
await MessageDestinationPreferences.setDestination(
|
await MessageDestinationPreferences.setDestination(
|
||||||
type,
|
type,
|
||||||
@@ -383,7 +424,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Add to messages list with "sending" status
|
// Add to messages list with "sending" status
|
||||||
messagesProvider.addSentMessage(sentMessage);
|
messagesProvider.addSentMessage(sentMessage, contact: _selectedRecipient);
|
||||||
|
|
||||||
// Send to selected channel
|
// Send to selected channel
|
||||||
await connectionProvider.sendChannelMessage(
|
await connectionProvider.sendChannelMessage(
|
||||||
@@ -616,7 +657,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
deliveryStatus: MessageDeliveryStatus.sending,
|
deliveryStatus: MessageDeliveryStatus.sending,
|
||||||
recipientPublicKey: isChannel ? null : recipient?.publicKey,
|
recipientPublicKey: isChannel ? null : recipient?.publicKey,
|
||||||
);
|
);
|
||||||
messagesProvider.addSentMessage(placeholder);
|
messagesProvider.addSentMessage(placeholder, contact: recipient);
|
||||||
|
|
||||||
// Send IE1 envelope via normal message path.
|
// Send IE1 envelope via normal message path.
|
||||||
final envelopeText = envelope.encode();
|
final envelopeText = envelope.encode();
|
||||||
@@ -921,7 +962,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
channelIdx: channelIdx,
|
channelIdx: channelIdx,
|
||||||
recipientPublicKey: isChannel ? null : recipient?.publicKey,
|
recipientPublicKey: isChannel ? null : recipient?.publicKey,
|
||||||
);
|
);
|
||||||
messagesProvider.addSentMessage(sentMsg);
|
messagesProvider.addSentMessage(sentMsg, contact: recipient);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isChannel) {
|
if (isChannel) {
|
||||||
@@ -1327,9 +1368,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
// SAR marker data is automatically added by SarMessageParser.enhanceMessage in MessagesProvider
|
// SAR marker data is automatically added by SarMessageParser.enhanceMessage in MessagesProvider
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add to messages list with "sending" status
|
|
||||||
messagesProvider.addSentMessage(sentMessage);
|
|
||||||
|
|
||||||
// Look up the room contact for path logging
|
// Look up the room contact for path logging
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
final roomContact = contactsProvider.contacts.where((c) {
|
final roomContact = contactsProvider.contacts.where((c) {
|
||||||
@@ -1337,6 +1375,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
c.publicKey.matches(roomPublicKey);
|
c.publicKey.matches(roomPublicKey);
|
||||||
}).firstOrNull;
|
}).firstOrNull;
|
||||||
|
|
||||||
|
// Add to messages list with "sending" status
|
||||||
|
messagesProvider.addSentMessage(sentMessage, contact: roomContact);
|
||||||
|
|
||||||
// Send SAR message to selected room (persisted and immutable)
|
// Send SAR message to selected room (persisted and immutable)
|
||||||
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
||||||
contactPublicKey: roomPublicKey!,
|
contactPublicKey: roomPublicKey!,
|
||||||
@@ -1563,12 +1604,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surface,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
border: Border(
|
|
||||||
top: BorderSide(
|
|
||||||
color: Theme.of(context).dividerColor,
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -1576,7 +1611,28 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
SafeArea(
|
SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 12),
|
padding: const EdgeInsets.fromLTRB(10, 10, 10, 10),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceContainerLow,
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).dividerColor.withValues(alpha: 0.35),
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 18,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(10, 10, 10, 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
@@ -1584,18 +1640,23 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 46,
|
width: 42,
|
||||||
height: 46,
|
height: 42,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context)
|
color: Theme.of(
|
||||||
.colorScheme
|
context,
|
||||||
.primaryContainer
|
).colorScheme.surface,
|
||||||
.withValues(alpha: 0.95),
|
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).dividerColor.withValues(alpha: 0.35),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
_isRecording ? Icons.stop : Icons.add,
|
_isRecording ? Icons.stop : Icons.add,
|
||||||
|
size: 22,
|
||||||
),
|
),
|
||||||
tooltip: _isRecording
|
tooltip: _isRecording
|
||||||
? 'Stop recording'
|
? 'Stop recording'
|
||||||
@@ -1607,34 +1668,29 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
? Colors.red
|
? Colors.red
|
||||||
: Theme.of(
|
: Theme.of(
|
||||||
context,
|
context,
|
||||||
).colorScheme.onPrimaryContainer,
|
).colorScheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(22),
|
borderRadius: BorderRadius.circular(20),
|
||||||
onTap: _showRecipientSelector,
|
onTap: _showRecipientSelector,
|
||||||
child: Ink(
|
child: Ink(
|
||||||
height: 46,
|
height: 42,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color:
|
|
||||||
_destinationType ==
|
|
||||||
MessageDestinationPreferences
|
|
||||||
.destinationTypeChannel
|
|
||||||
? Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.surfaceContainerHighest
|
|
||||||
: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.secondaryContainer,
|
|
||||||
borderRadius: BorderRadius.circular(22),
|
|
||||||
border: Border.all(
|
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
context,
|
context,
|
||||||
).dividerColor.withValues(alpha: 0.7),
|
).colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
20,
|
||||||
|
),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.dividerColor
|
||||||
|
.withValues(alpha: 0.35),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -1645,35 +1701,33 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
_getDestinationIcon(),
|
_getDestinationIcon(),
|
||||||
size: 18,
|
size: 17,
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
_getDestinationLabel(),
|
_getDestinationLabel(),
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow:
|
||||||
|
TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight:
|
||||||
color:
|
FontWeight.w600,
|
||||||
_destinationType ==
|
color: Theme.of(
|
||||||
MessageDestinationPreferences
|
|
||||||
.destinationTypeChannel
|
|
||||||
? Theme.of(
|
|
||||||
context,
|
context,
|
||||||
).colorScheme.onSurface
|
).colorScheme.onSurface,
|
||||||
: Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.onSecondaryContainer,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Icon(
|
Icon(
|
||||||
Icons.expand_more_rounded,
|
Icons.expand_more_rounded,
|
||||||
size: 20,
|
size: 20,
|
||||||
color: Theme.of(
|
color: Theme.of(context)
|
||||||
context,
|
.colorScheme
|
||||||
).colorScheme.onSurfaceVariant,
|
.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -1684,35 +1738,49 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 180),
|
duration: const Duration(
|
||||||
|
milliseconds: 180,
|
||||||
|
),
|
||||||
constraints: const BoxConstraints(
|
constraints: const BoxConstraints(
|
||||||
minHeight: 48,
|
minHeight: 46,
|
||||||
maxHeight: 140,
|
maxHeight: 132,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
context,
|
context,
|
||||||
).colorScheme.surfaceContainerHigh,
|
).colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: BorderRadius.circular(24),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: _focusNode.hasFocus
|
color: _focusNode.hasFocus
|
||||||
? Theme.of(
|
? Theme.of(
|
||||||
context,
|
context,
|
||||||
).colorScheme.primary
|
).colorScheme.primary
|
||||||
: Theme.of(context).dividerColor
|
: Theme.of(context).dividerColor
|
||||||
.withValues(alpha: 0.6),
|
.withValues(alpha: 0.35),
|
||||||
width: _focusNode.hasFocus ? 1.5 : 1,
|
width: _focusNode.hasFocus ? 1.4 : 1,
|
||||||
),
|
),
|
||||||
|
boxShadow: _focusNode.hasFocus
|
||||||
|
? [
|
||||||
|
BoxShadow(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primary
|
||||||
|
.withValues(alpha: 0.10),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 18,
|
horizontal: 16,
|
||||||
vertical: 12,
|
vertical: 12,
|
||||||
),
|
),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
@@ -1722,9 +1790,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
maxLines: 4,
|
maxLines: 4,
|
||||||
keyboardType: TextInputType.multiline,
|
keyboardType: TextInputType.multiline,
|
||||||
inputFormatters: [
|
inputFormatters: [
|
||||||
LengthLimitingTextInputFormatter(
|
_messageByteLimiter,
|
||||||
_maxCharacters,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
style: const TextStyle(fontSize: 15),
|
style: const TextStyle(fontSize: 15),
|
||||||
textAlignVertical:
|
textAlignVertical:
|
||||||
@@ -1735,25 +1801,31 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
)!.typeYourMessage,
|
)!.typeYourMessage,
|
||||||
hintStyle: TextStyle(
|
hintStyle: TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
color: Theme.of(
|
color: Theme.of(context)
|
||||||
context,
|
.colorScheme
|
||||||
).colorScheme.onSurfaceVariant,
|
.onSurfaceVariant
|
||||||
|
.withValues(alpha: 0.9),
|
||||||
),
|
),
|
||||||
|
filled: false,
|
||||||
|
fillColor: Colors.transparent,
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
isCollapsed: true,
|
isCollapsed: true,
|
||||||
),
|
),
|
||||||
textInputAction: TextInputAction.newline,
|
textInputAction:
|
||||||
|
TextInputAction.newline,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 8),
|
||||||
Builder(
|
Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final canSendText =
|
final canSendText =
|
||||||
!_isRecording &&
|
!_isRecording &&
|
||||||
!_isSendingVoice &&
|
!_isSendingVoice &&
|
||||||
_textController.text.trim().isNotEmpty;
|
_textController.text
|
||||||
|
.trim()
|
||||||
|
.isNotEmpty;
|
||||||
final semanticsLabel = _isRecording
|
final semanticsLabel = _isRecording
|
||||||
? 'Recording... release to send voice'
|
? 'Recording... release to send voice'
|
||||||
: (_isSendingVoice
|
: (_isSendingVoice
|
||||||
@@ -1766,11 +1838,15 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
button: true,
|
button: true,
|
||||||
enabled:
|
enabled:
|
||||||
canSendText ||
|
canSendText ||
|
||||||
(_voiceSupported && !_isSendingVoice),
|
(_voiceSupported &&
|
||||||
|
!_isSendingVoice),
|
||||||
label: semanticsLabel,
|
label: semanticsLabel,
|
||||||
onTap: canSendText ? _sendMessage : null,
|
onTap: canSendText
|
||||||
|
? _sendMessage
|
||||||
|
: null,
|
||||||
onLongPress:
|
onLongPress:
|
||||||
(_voiceSupported && !_isSendingVoice)
|
(_voiceSupported &&
|
||||||
|
!_isSendingVoice)
|
||||||
? () {
|
? () {
|
||||||
if (_isRecording) {
|
if (_isRecording) {
|
||||||
_stopAndSendVoice();
|
_stopAndSendVoice();
|
||||||
@@ -1790,11 +1866,13 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
? (_) => _startVoiceRecording()
|
? (_) => _startVoiceRecording()
|
||||||
: null,
|
: null,
|
||||||
onLongPressEnd:
|
onLongPressEnd:
|
||||||
(_voiceSupported && _isRecording)
|
(_voiceSupported &&
|
||||||
|
_isRecording)
|
||||||
? (_) => _stopAndSendVoice()
|
? (_) => _stopAndSendVoice()
|
||||||
: null,
|
: null,
|
||||||
onLongPressCancel:
|
onLongPressCancel:
|
||||||
(_voiceSupported && _isRecording)
|
(_voiceSupported &&
|
||||||
|
_isRecording)
|
||||||
? () => _stopAndSendVoice()
|
? () => _stopAndSendVoice()
|
||||||
: null,
|
: null,
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -1804,31 +1882,48 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
duration: const Duration(
|
duration: const Duration(
|
||||||
milliseconds: 180,
|
milliseconds: 180,
|
||||||
),
|
),
|
||||||
width: 48,
|
width: 46,
|
||||||
height: 48,
|
height: 46,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color:
|
color:
|
||||||
canSendText || _isRecording
|
canSendText ||
|
||||||
|
_isRecording
|
||||||
? Theme.of(
|
? Theme.of(
|
||||||
context,
|
context,
|
||||||
).colorScheme.primary
|
).colorScheme.primary
|
||||||
: Theme.of(context)
|
: Theme.of(
|
||||||
.colorScheme
|
context,
|
||||||
.surfaceContainerHighest,
|
).colorScheme.surface,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color:
|
||||||
|
canSendText ||
|
||||||
|
_isRecording
|
||||||
|
? Colors.transparent
|
||||||
|
: Theme.of(context)
|
||||||
|
.dividerColor
|
||||||
|
.withValues(
|
||||||
|
alpha: 0.35,
|
||||||
|
),
|
||||||
|
),
|
||||||
boxShadow:
|
boxShadow:
|
||||||
canSendText || _isRecording
|
canSendText ||
|
||||||
|
_isRecording
|
||||||
? [
|
? [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color:
|
color:
|
||||||
Theme.of(context)
|
Theme.of(
|
||||||
|
context,
|
||||||
|
)
|
||||||
.colorScheme
|
.colorScheme
|
||||||
.primary
|
.primary
|
||||||
.withValues(
|
.withValues(
|
||||||
alpha: 0.28,
|
alpha:
|
||||||
|
0.22,
|
||||||
),
|
),
|
||||||
blurRadius: 16,
|
blurRadius: 14,
|
||||||
offset: const Offset(
|
offset:
|
||||||
|
const Offset(
|
||||||
0,
|
0,
|
||||||
6,
|
6,
|
||||||
),
|
),
|
||||||
@@ -1838,8 +1933,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
),
|
),
|
||||||
child: _isSendingVoice
|
child: _isSendingVoice
|
||||||
? Center(
|
? Center(
|
||||||
child:
|
child: CircularProgressIndicator(
|
||||||
CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
color:
|
color:
|
||||||
Theme.of(
|
Theme.of(
|
||||||
@@ -1851,32 +1945,43 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
)
|
)
|
||||||
: Icon(
|
: Icon(
|
||||||
_isRecording
|
_isRecording
|
||||||
? Icons.mic_rounded
|
? Icons
|
||||||
: Icons.send_rounded,
|
.mic_rounded
|
||||||
|
: Icons
|
||||||
|
.send_rounded,
|
||||||
|
size: 22,
|
||||||
color:
|
color:
|
||||||
canSendText ||
|
canSendText ||
|
||||||
_isRecording
|
_isRecording
|
||||||
? Theme.of(context)
|
? Theme.of(
|
||||||
|
context,
|
||||||
|
)
|
||||||
.colorScheme
|
.colorScheme
|
||||||
.onPrimary
|
.onPrimary
|
||||||
: Theme.of(context)
|
: Theme.of(
|
||||||
|
context,
|
||||||
|
)
|
||||||
.colorScheme
|
.colorScheme
|
||||||
.onSurfaceVariant,
|
.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'$_characterCount/$_maxCharacters',
|
'$_messageByteCount/$_maxMessageBytes',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w500,
|
||||||
color:
|
color:
|
||||||
_characterCount >
|
_messageByteCount >
|
||||||
_maxCharacters * 0.9
|
_maxMessageBytes *
|
||||||
|
0.9
|
||||||
? Colors.orange.shade800
|
? Colors.orange.shade800
|
||||||
: Theme.of(context)
|
: Theme.of(context)
|
||||||
.colorScheme
|
.colorScheme
|
||||||
.onSurfaceVariant,
|
.onSurfaceVariant
|
||||||
|
.withValues(
|
||||||
|
alpha: 0.9,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1892,6 +1997,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ extension MessageLocalization on Message {
|
|||||||
|
|
||||||
switch (deliveryStatus) {
|
switch (deliveryStatus) {
|
||||||
case MessageDeliveryStatus.sending:
|
case MessageDeliveryStatus.sending:
|
||||||
|
if (isContactMessage) {
|
||||||
|
return l10n.pending;
|
||||||
|
}
|
||||||
return l10n.sending;
|
return l10n.sending;
|
||||||
case MessageDeliveryStatus.sent:
|
case MessageDeliveryStatus.sent:
|
||||||
return l10n.sent;
|
return l10n.sent;
|
||||||
|
|||||||
@@ -849,6 +849,7 @@ class DrawingToolbar extends StatelessWidget {
|
|||||||
contactPublicKey: room.publicKey,
|
contactPublicKey: room.publicKey,
|
||||||
text: message,
|
text: message,
|
||||||
messageId: messageId,
|
messageId: messageId,
|
||||||
|
contact: room,
|
||||||
);
|
);
|
||||||
debugPrint(' ✅ Sent successfully');
|
debugPrint(' ✅ Sent successfully');
|
||||||
|
|
||||||
|
|||||||
@@ -118,7 +118,26 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Add retry message to provider
|
// Add retry message to provider
|
||||||
messagesProvider.addSentMessage(retryMessage);
|
Contact? roomContact;
|
||||||
|
if (failedMessage.messageType == MessageType.contact) {
|
||||||
|
if (failedMessage.recipientPublicKey == null) {
|
||||||
|
messagesProvider.markMessageFailed(retryMessageId);
|
||||||
|
ToastLogger.error(
|
||||||
|
context,
|
||||||
|
AppLocalizations.of(context)!.cannotRetryMissingRecipient,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
roomContact = contactsProvider.contacts.where((c) {
|
||||||
|
return c.publicKey.length >=
|
||||||
|
failedMessage.recipientPublicKey!.length &&
|
||||||
|
c.publicKey.matches(failedMessage.recipientPublicKey!);
|
||||||
|
}).firstOrNull;
|
||||||
|
}
|
||||||
|
|
||||||
|
messagesProvider.addSentMessage(retryMessage, contact: roomContact);
|
||||||
|
|
||||||
// Resend the message
|
// Resend the message
|
||||||
if (failedMessage.messageType == MessageType.contact) {
|
if (failedMessage.messageType == MessageType.contact) {
|
||||||
@@ -132,14 +151,6 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up the room contact for path logging
|
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
|
||||||
final roomContact = contactsProvider.contacts.where((c) {
|
|
||||||
return c.publicKey.length >=
|
|
||||||
failedMessage.recipientPublicKey!.length &&
|
|
||||||
c.publicKey.matches(failedMessage.recipientPublicKey!);
|
|
||||||
}).firstOrNull;
|
|
||||||
|
|
||||||
// Resend to the same room
|
// Resend to the same room
|
||||||
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
||||||
contactPublicKey: failedMessage.recipientPublicKey!,
|
contactPublicKey: failedMessage.recipientPublicKey!,
|
||||||
@@ -1805,7 +1816,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
? '${l10n.channel}: $channelDisplayName'
|
? '${l10n.channel}: $channelDisplayName'
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
final shouldFloatBubble = message.isChannelMessage || widget.isCompact;
|
final shouldFloatBubble = widget.isCompact;
|
||||||
final bubble = ConstrainedBox(
|
final bubble = ConstrainedBox(
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
maxWidth: shouldFloatBubble
|
maxWidth: shouldFloatBubble
|
||||||
@@ -1946,7 +1957,11 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.draw, size: 16, color: Colors.white),
|
const Icon(
|
||||||
|
Icons.draw,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
AppLocalizations.of(context)!.mapDrawing,
|
AppLocalizations.of(context)!.mapDrawing,
|
||||||
@@ -2075,7 +2090,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 2),
|
const SizedBox(width: 2),
|
||||||
Text(
|
Text(
|
||||||
message.pathLen == 0 ? 'direct' : '${message.pathLen}hop',
|
message.pathLen == 0
|
||||||
|
? 'direct'
|
||||||
|
: '${message.pathLen}hop',
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
context,
|
context,
|
||||||
@@ -2101,7 +2118,8 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
message.sarCustomEmoji ?? message.sarMarkerType!.emoji,
|
message.sarCustomEmoji ??
|
||||||
|
message.sarMarkerType!.emoji,
|
||||||
style: const TextStyle(fontSize: 32),
|
style: const TextStyle(fontSize: 32),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
@@ -2129,9 +2147,8 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
'${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}',
|
'${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}',
|
||||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
style: Theme.of(context).textTheme.labelMedium
|
||||||
fontFamily: 'monospace',
|
?.copyWith(fontFamily: 'monospace'),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -2201,7 +2218,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
colorName,
|
colorName,
|
||||||
style: Theme.of(context).textTheme.labelSmall,
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.labelSmall,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -2229,10 +2248,16 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
// Tic-Tac-Toe control message content
|
// Tic-Tac-Toe control message content
|
||||||
else if (ticTacToeEvent?.type == TicTacToeEventType.start &&
|
else if (ticTacToeEvent?.type == TicTacToeEventType.start &&
|
||||||
!widget.isCompact)
|
!widget.isCompact)
|
||||||
TicTacToeMessageBubble(message: message, isSentByMe: isOwnMessage)
|
TicTacToeMessageBubble(
|
||||||
|
message: message,
|
||||||
|
isSentByMe: isOwnMessage,
|
||||||
|
)
|
||||||
// Regular message content
|
// Regular message content
|
||||||
else if (!message.isDrawing || widget.isCompact)
|
else if (!message.isDrawing || widget.isCompact)
|
||||||
Text(message.text, style: Theme.of(context).textTheme.bodyMedium),
|
Text(
|
||||||
|
message.text,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
|
||||||
if (!widget.isCompact &&
|
if (!widget.isCompact &&
|
||||||
!isSarMarker &&
|
!isSarMarker &&
|
||||||
@@ -2319,7 +2344,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Icon(
|
Icon(
|
||||||
_isExpanded ? Icons.expand_less : Icons.expand_more,
|
_isExpanded
|
||||||
|
? Icons.expand_less
|
||||||
|
: Icons.expand_more,
|
||||||
size: 14,
|
size: 14,
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
context,
|
context,
|
||||||
@@ -2343,7 +2370,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
AppLocalizations.of(context)!.recipientDetails,
|
AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.recipientDetails,
|
||||||
style: Theme.of(context).textTheme.labelSmall
|
style: Theme.of(context).textTheme.labelSmall
|
||||||
?.copyWith(fontWeight: FontWeight.bold),
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
@@ -2469,7 +2498,10 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.orange.withValues(alpha: 0.2),
|
color: Colors.orange.withValues(alpha: 0.2),
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
border: Border.all(color: Colors.orange, width: 1),
|
border: Border.all(
|
||||||
|
color: Colors.orange,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|||||||
@@ -97,9 +97,7 @@ class TicTacToeMessageBubble extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Tic-Tac-Toe · Game ${state.gameId}',
|
'Tic-Tac-Toe · Game ${state.gameId}',
|
||||||
style: Theme.of(
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||||
context,
|
|
||||||
).textTheme.labelMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: titleColor,
|
color: titleColor,
|
||||||
),
|
),
|
||||||
@@ -167,7 +165,7 @@ class TicTacToeMessageBubble extends StatelessWidget {
|
|||||||
deliveryStatus: MessageDeliveryStatus.sending,
|
deliveryStatus: MessageDeliveryStatus.sending,
|
||||||
recipientPublicKey: opponent.publicKey,
|
recipientPublicKey: opponent.publicKey,
|
||||||
);
|
);
|
||||||
messagesProvider.addSentMessage(sentMessage);
|
messagesProvider.addSentMessage(sentMessage, contact: opponent);
|
||||||
|
|
||||||
final sent = await connectionProvider.sendTextMessage(
|
final sent = await connectionProvider.sendTextMessage(
|
||||||
contactPublicKey: opponent.publicKey,
|
contactPublicKey: opponent.publicKey,
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.3.0"
|
version: "3.3.0"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: fake_async
|
name: fake_async
|
||||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ dev_dependencies:
|
|||||||
# rules and activating additional ones.
|
# rules and activating additional ones.
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
flutter_launcher_icons: "^0.14.4"
|
flutter_launcher_icons: "^0.14.4"
|
||||||
|
fake_async: ^1.3.3
|
||||||
|
|
||||||
dependency_overrides:
|
dependency_overrides:
|
||||||
meshcore_client:
|
meshcore_client:
|
||||||
|
|||||||
37
test/providers/helpers/message_delivery_tracker_test.dart
Normal file
37
test/providers/helpers/message_delivery_tracker_test.dart
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/helpers/message_delivery_tracker.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
group('MessageDeliveryTracker', () {
|
||||||
|
test('matches pending direct messages by contact', () {
|
||||||
|
final tracker = MessageDeliveryTracker();
|
||||||
|
final alice = Uint8List.fromList(List<int>.filled(32, 0xAA));
|
||||||
|
final bob = Uint8List.fromList(List<int>.filled(32, 0xBB));
|
||||||
|
|
||||||
|
tracker.trackPendingDirectMessage('alice-1', alice);
|
||||||
|
tracker.trackPendingDirectMessage('bob-1', bob);
|
||||||
|
tracker.trackPendingDirectMessage('alice-2', alice);
|
||||||
|
|
||||||
|
expect(tracker.popPendingDirectMessageId(alice), 'alice-1');
|
||||||
|
expect(tracker.popPendingDirectMessageId(bob), 'bob-1');
|
||||||
|
expect(tracker.popPendingDirectMessageId(alice), 'alice-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removeByMessageId clears pending queue state', () {
|
||||||
|
final tracker = MessageDeliveryTracker();
|
||||||
|
final alice = Uint8List.fromList(List<int>.filled(32, 0xAA));
|
||||||
|
|
||||||
|
tracker.trackPendingDirectMessage('alice-1', alice);
|
||||||
|
tracker.mapAckTagToMessageId(42, 'alice-1');
|
||||||
|
|
||||||
|
tracker.removeByMessageId('alice-1');
|
||||||
|
|
||||||
|
expect(tracker.getMessageIdForAck(42), isNull);
|
||||||
|
expect(tracker.popPendingDirectMessageId(alice), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
131
test/providers/messages_provider_retransmission_test.dart
Normal file
131
test/providers/messages_provider_retransmission_test.dart
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:fake_async/fake_async.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/contact.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/message.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/messages_provider.dart';
|
||||||
|
|
||||||
|
Contact _buildContact() {
|
||||||
|
return Contact(
|
||||||
|
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i)),
|
||||||
|
type: ContactType.chat,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: 1,
|
||||||
|
outPath: Uint8List.fromList([1, 2, 3, 4]),
|
||||||
|
advName: 'Teammate',
|
||||||
|
lastAdvert: 1700000000,
|
||||||
|
advLat: 0,
|
||||||
|
advLon: 0,
|
||||||
|
lastMod: 1700000000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Message _buildDirectMessage(String id) {
|
||||||
|
return Message(
|
||||||
|
id: id,
|
||||||
|
messageType: MessageType.contact,
|
||||||
|
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
|
||||||
|
pathLen: 0,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000000,
|
||||||
|
text: 'hello',
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
deliveryStatus: MessageDeliveryStatus.sending,
|
||||||
|
recipientPublicKey: Uint8List.fromList(List<int>.generate(32, (i) => i)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
group('MessagesProvider retransmission', () {
|
||||||
|
test('direct messages stay pending until delivery ACK arrives', () {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
provider.addSentMessage(
|
||||||
|
_buildDirectMessage('m1'),
|
||||||
|
contact: _buildContact(),
|
||||||
|
);
|
||||||
|
|
||||||
|
provider.markMessageSent('m1', 77, 250);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
provider.messages.single.deliveryStatus,
|
||||||
|
MessageDeliveryStatus.sending,
|
||||||
|
);
|
||||||
|
expect(provider.messages.single.expectedAckTag, 77);
|
||||||
|
|
||||||
|
provider.markMessageDelivered(77, 180);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
provider.messages.single.deliveryStatus,
|
||||||
|
MessageDeliveryStatus.delivered,
|
||||||
|
);
|
||||||
|
expect(provider.messages.single.roundTripTimeMs, 180);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('channel messages are marked sent immediately', () {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
provider.addSentMessage(
|
||||||
|
Message(
|
||||||
|
id: 'c1',
|
||||||
|
messageType: MessageType.channel,
|
||||||
|
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
|
||||||
|
channelIdx: 0,
|
||||||
|
pathLen: 0,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000000,
|
||||||
|
text: 'broadcast',
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
deliveryStatus: MessageDeliveryStatus.sending,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
provider.markMessageSent('c1', 0, 0);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
provider.messages.single.deliveryStatus,
|
||||||
|
MessageDeliveryStatus.sent,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing ACK schedules a delayed retransmission', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
var retryCalls = 0;
|
||||||
|
provider.sendMessageCallback =
|
||||||
|
({
|
||||||
|
required contactPublicKey,
|
||||||
|
required text,
|
||||||
|
required messageId,
|
||||||
|
required contact,
|
||||||
|
retryAttempt = 0,
|
||||||
|
}) async {
|
||||||
|
retryCalls += 1;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
provider.addSentMessage(
|
||||||
|
_buildDirectMessage('m2'),
|
||||||
|
contact: _buildContact(),
|
||||||
|
);
|
||||||
|
provider.markMessageSent('m2', 88, 10);
|
||||||
|
|
||||||
|
async.elapse(const Duration(milliseconds: 11));
|
||||||
|
async.flushMicrotasks();
|
||||||
|
|
||||||
|
expect(provider.messages.single.retryAttempt, 1);
|
||||||
|
expect(
|
||||||
|
provider.messages.single.deliveryStatus,
|
||||||
|
MessageDeliveryStatus.sending,
|
||||||
|
);
|
||||||
|
expect(retryCalls, 0);
|
||||||
|
|
||||||
|
async.elapse(const Duration(seconds: 4));
|
||||||
|
async.flushMicrotasks();
|
||||||
|
|
||||||
|
expect(retryCalls, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user