mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 08:20:36 +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));
|
||||
|
||||
if (pendingOp.messageId != null) {
|
||||
_messageDeliveryTracker.trackPendingMessage(pendingOp.messageId!);
|
||||
_messageDeliveryTracker.trackPendingDirectMessage(
|
||||
pendingOp.messageId!,
|
||||
pendingOp.contactPublicKey,
|
||||
);
|
||||
}
|
||||
|
||||
await _activeService.sendTextMessage(
|
||||
@@ -352,7 +355,11 @@ class ConnectionProvider with ChangeNotifier {
|
||||
|
||||
service.onMessageSent =
|
||||
(expectedAckTag, suggestedTimeoutMs, isFloodMode, contactPublicKey) {
|
||||
final messageId = _messageDeliveryTracker.popPendingMessageId();
|
||||
final messageId = contactPublicKey != null
|
||||
? _messageDeliveryTracker.popPendingDirectMessageId(
|
||||
contactPublicKey,
|
||||
)
|
||||
: _messageDeliveryTracker.popPendingMessageId();
|
||||
if (messageId != null) {
|
||||
_messageDeliveryTracker.mapAckTagToMessageId(
|
||||
expectedAckTag,
|
||||
@@ -1148,7 +1155,10 @@ class ConnectionProvider with ChangeNotifier {
|
||||
// The MessagesProvider now uses simple ACK tag → recipientPublicKey mapping.
|
||||
// We still track here for the SENT response callback to work.
|
||||
if (messageId != null) {
|
||||
_messageDeliveryTracker.trackPendingMessage(messageId);
|
||||
_messageDeliveryTracker.trackPendingDirectMessage(
|
||||
messageId,
|
||||
contactPublicKey,
|
||||
);
|
||||
debugPrint(' 📝 Tracked pending message: $messageId');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Message delivery tracking helper
|
||||
///
|
||||
/// 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
|
||||
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
|
||||
final Map<int, String> _ackTagToMessageId = {};
|
||||
|
||||
@@ -33,6 +38,15 @@ class MessageDeliveryTracker {
|
||||
_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
|
||||
///
|
||||
/// Called when RESP_CODE_SENT arrives. Returns null if queue empty.
|
||||
@@ -43,6 +57,24 @@ class MessageDeliveryTracker {
|
||||
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
|
||||
///
|
||||
/// Creates bidirectional mapping for efficient cleanup and tracking.
|
||||
@@ -86,6 +118,17 @@ class MessageDeliveryTracker {
|
||||
_ackTagToMessageId.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
|
||||
@@ -114,6 +157,7 @@ class MessageDeliveryTracker {
|
||||
/// Clear all tracking state
|
||||
void clearTracking() {
|
||||
_pendingMessageIds.clear();
|
||||
_pendingMessageIdsByContact.clear();
|
||||
_ackTagToMessageId.clear();
|
||||
_messageIdToAckTag.clear();
|
||||
_ackTagTimestamps.clear();
|
||||
@@ -133,9 +177,7 @@ class MessageDeliveryTracker {
|
||||
/// Get oldest pending ACK timestamp (for debugging)
|
||||
DateTime? get oldestPendingTimestamp {
|
||||
if (_ackTagTimestamps.isEmpty) return null;
|
||||
return _ackTagTimestamps.values.reduce(
|
||||
(a, b) => a.isBefore(b) ? a : b,
|
||||
);
|
||||
return _ackTagTimestamps.values.reduce((a, b) => a.isBefore(b) ? a : b);
|
||||
}
|
||||
|
||||
/// Get diagnostic info for debugging
|
||||
@@ -145,6 +187,15 @@ class MessageDeliveryTracker {
|
||||
'shouldRateLimit': shouldRateLimit,
|
||||
'oldestPending': oldestPendingTimestamp?.toIso8601String(),
|
||||
'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)
|
||||
final Map<String, Timer> _timeoutTimers = {};
|
||||
|
||||
// Recently completed ACKs are kept briefly to ignore duplicate confirms.
|
||||
final Map<int, DateTime> _completedAckHistory = {};
|
||||
|
||||
// Retry management
|
||||
final MessageRetryManager _retryManager = MessageRetryManager();
|
||||
|
||||
@@ -906,11 +909,13 @@ class MessagesProvider with ChangeNotifier {
|
||||
final (groupId, recipientPublicKey) = groupMapping;
|
||||
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(
|
||||
groupId,
|
||||
recipientPublicKey,
|
||||
MessageDeliveryStatus.sent,
|
||||
expectedAckTag > 0
|
||||
? MessageDeliveryStatus.sending
|
||||
: MessageDeliveryStatus.sent,
|
||||
);
|
||||
|
||||
// Track the ACK for this specific recipient
|
||||
@@ -1022,7 +1027,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
);
|
||||
|
||||
final updatedMessage = message.copyWith(
|
||||
deliveryStatus: MessageDeliveryStatus.sent,
|
||||
deliveryStatus: expectedAckTag > 0
|
||||
? MessageDeliveryStatus.sending
|
||||
: MessageDeliveryStatus.sent,
|
||||
expectedAckTag: expectedAckTag > 0 ? expectedAckTag : null,
|
||||
suggestedTimeoutMs: suggestedTimeoutMs > 0 ? suggestedTimeoutMs : null,
|
||||
);
|
||||
@@ -1245,6 +1252,7 @@ class MessagesProvider with ChangeNotifier {
|
||||
|
||||
/// Update message status to delivered with RTT
|
||||
void markMessageDelivered(int ackCode, int roundTripTimeMs) {
|
||||
_cleanupCompletedAckHistory();
|
||||
debugPrint(
|
||||
'🔍 [MessagesProvider] markMessageDelivered called with ACK: $ackCode, RTT: ${roundTripTimeMs}ms',
|
||||
);
|
||||
@@ -1296,6 +1304,7 @@ class MessagesProvider with ChangeNotifier {
|
||||
);
|
||||
_ackTagToRecipients.remove(ackCode);
|
||||
_pendingSentMessages.remove(ackCode);
|
||||
_rememberCompletedAck(ackCode);
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
@@ -1339,6 +1348,7 @@ class MessagesProvider with ChangeNotifier {
|
||||
|
||||
// Remove from pending
|
||||
_pendingSentMessages.remove(ackCode);
|
||||
_rememberCompletedAck(ackCode);
|
||||
|
||||
// Clear retry tracking on successful delivery
|
||||
_retryManager.clearRetry(message.id);
|
||||
@@ -1362,6 +1372,12 @@ class MessagesProvider with ChangeNotifier {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (_completedAckHistory.containsKey(ackCode)) {
|
||||
debugPrint(
|
||||
'ℹ️ [MessagesProvider] Duplicate/late ACK $ackCode ignored (already completed)',
|
||||
);
|
||||
return;
|
||||
}
|
||||
debugPrint(
|
||||
'⚠️ [MessagesProvider] No pending message found for ACK code: $ackCode',
|
||||
);
|
||||
@@ -1482,18 +1498,31 @@ class MessagesProvider with ChangeNotifier {
|
||||
debugPrint(
|
||||
'⏰ [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) {
|
||||
await sendMessageCallback!(
|
||||
final queued = await sendMessageCallback!(
|
||||
contactPublicKey: contact.publicKey,
|
||||
text: message.text,
|
||||
messageId: messageId,
|
||||
contact: contact,
|
||||
retryAttempt: nextAttempt,
|
||||
);
|
||||
if (!queued) {
|
||||
_markAsPermanentlyFailed(messageId, currentMessage);
|
||||
}
|
||||
} else {
|
||||
debugPrint(
|
||||
'⚠️ [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)
|
||||
if (sendMessageCallback != null) {
|
||||
await sendMessageCallback!(
|
||||
final queued = await sendMessageCallback!(
|
||||
contactPublicKey: contact.publicKey,
|
||||
text: message.text,
|
||||
messageId: messageId,
|
||||
contact: contact,
|
||||
retryAttempt: 0, // Reset attempt for flood
|
||||
);
|
||||
if (!queued) {
|
||||
_markAsPermanentlyFailed(messageId, _messages[index]);
|
||||
}
|
||||
} else {
|
||||
debugPrint(
|
||||
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot send flood',
|
||||
);
|
||||
_markAsPermanentlyFailed(messageId, _messages[index]);
|
||||
}
|
||||
|
||||
_persistMessages();
|
||||
@@ -1608,17 +1641,21 @@ class MessagesProvider with ChangeNotifier {
|
||||
|
||||
// Send again
|
||||
if (sendMessageCallback != null) {
|
||||
await sendMessageCallback!(
|
||||
final queued = await sendMessageCallback!(
|
||||
contactPublicKey: contact.publicKey,
|
||||
text: message.text,
|
||||
messageId: messageId,
|
||||
contact: contact,
|
||||
retryAttempt: 0,
|
||||
);
|
||||
if (!queued) {
|
||||
_markAsPermanentlyFailed(messageId, _messages[index]);
|
||||
}
|
||||
} else {
|
||||
debugPrint(
|
||||
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot resend',
|
||||
);
|
||||
_markAsPermanentlyFailed(messageId, _messages[index]);
|
||||
}
|
||||
|
||||
_persistMessages();
|
||||
@@ -1631,10 +1668,29 @@ class MessagesProvider with ChangeNotifier {
|
||||
timer.cancel();
|
||||
}
|
||||
_timeoutTimers.clear();
|
||||
_completedAckHistory.clear();
|
||||
|
||||
// Clear retry manager
|
||||
_retryManager.clearAll();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
late final AppProvider _appProvider;
|
||||
int _currentIndex = 0;
|
||||
bool _isMapFullscreen = false;
|
||||
bool _showRxTxIndicators = true;
|
||||
@@ -74,9 +74,13 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
@override
|
||||
void 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.
|
||||
_initTabController();
|
||||
_loadTabVisibilityAndInitTabs();
|
||||
_loadRxTxPreference();
|
||||
|
||||
// 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() {
|
||||
_tabController = TabController(length: _enabledTabs.length, vsync: this);
|
||||
_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() {
|
||||
final previousTab = _currentTab;
|
||||
final nextIndex = _tabController.index;
|
||||
@@ -136,15 +135,21 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
}
|
||||
|
||||
final oldTabs = _enabledTabs;
|
||||
final oldIndex = _tabController.index;
|
||||
final oldIndex = oldTabs.isEmpty
|
||||
? 0
|
||||
: _tabController.index.clamp(0, oldTabs.length - 1);
|
||||
final oldTab = oldTabs[oldIndex];
|
||||
|
||||
final oldController = _tabController;
|
||||
oldController.removeListener(_onTabChanged);
|
||||
oldController.dispose();
|
||||
|
||||
// Update state
|
||||
_isMapEnabled = mapEnabled;
|
||||
_isContactsEnabled = contactsEnabled;
|
||||
if (!_isMapEnabled) {
|
||||
_isMapFullscreen = false;
|
||||
}
|
||||
|
||||
final newTabs = _enabledTabs;
|
||||
final newIndex = newTabs.indexOf(oldTab);
|
||||
@@ -157,11 +162,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
_tabController.index = _currentIndex;
|
||||
|
||||
setState(() {});
|
||||
|
||||
// Dispose old controller after widgets have rebound to the new controller.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
oldController.dispose();
|
||||
});
|
||||
_handleTabActivated(_currentTab);
|
||||
}
|
||||
|
||||
void _navigateToTab(_HomeTab tab) {
|
||||
@@ -199,6 +200,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_appProvider.removeListener(_handleAppProviderChanged);
|
||||
_tabController.removeListener(_onTabChanged);
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
@@ -343,18 +345,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
messagesProvider.setLocalizations(localizations);
|
||||
}
|
||||
|
||||
// Check if tab visibility settings changed and update tab controller
|
||||
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,
|
||||
);
|
||||
});
|
||||
}
|
||||
context.watch<AppProvider>();
|
||||
|
||||
final enabledTabs = _enabledTabs;
|
||||
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
|
||||
);
|
||||
|
||||
// Add to messages list with "sending" status
|
||||
messagesProvider.addSentMessage(sentMessage);
|
||||
|
||||
// Look up the room contact for path logging
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final roomContact = contactsProvider.contacts.where((c) {
|
||||
@@ -1359,6 +1356,9 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
c.publicKey.matches(roomPublicKey);
|
||||
}).firstOrNull;
|
||||
|
||||
// Add to messages list with "sending" status
|
||||
messagesProvider.addSentMessage(sentMessage, contact: roomContact);
|
||||
|
||||
// Send SAR message to selected room (persisted and immutable)
|
||||
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
||||
contactPublicKey: roomPublicKey!,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io' show Platform;
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'dart:math' as math;
|
||||
@@ -44,11 +45,13 @@ class MessagesTab extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _MessagesTabState extends State<MessagesTab> {
|
||||
static const int _maxContactMessageBytes = 156;
|
||||
static const int _maxChannelMessageBytes = 127;
|
||||
|
||||
final TextEditingController _textController = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
int _characterCount = 0;
|
||||
static const int _maxCharacters = 160;
|
||||
int _messageByteCount = 0;
|
||||
String? _highlightedMessageId;
|
||||
Timer? _highlightTimer; // Timer for clearing message highlight
|
||||
|
||||
@@ -166,10 +169,44 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
|
||||
void _updateCharacterCount() {
|
||||
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
|
||||
Future<void> _loadSavedDestination() async {
|
||||
final savedDestination =
|
||||
@@ -211,6 +248,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
await MessageDestinationPreferences.clearDestination();
|
||||
}
|
||||
}
|
||||
|
||||
_enforceMessageByteLimit();
|
||||
}
|
||||
|
||||
/// Show recipient selector bottom sheet
|
||||
@@ -250,6 +289,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
_selectedRecipient = recipient;
|
||||
});
|
||||
|
||||
_enforceMessageByteLimit();
|
||||
|
||||
// Save to preferences
|
||||
await MessageDestinationPreferences.setDestination(
|
||||
type,
|
||||
@@ -383,7 +424,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
);
|
||||
|
||||
// Add to messages list with "sending" status
|
||||
messagesProvider.addSentMessage(sentMessage);
|
||||
messagesProvider.addSentMessage(sentMessage, contact: _selectedRecipient);
|
||||
|
||||
// Send to selected channel
|
||||
await connectionProvider.sendChannelMessage(
|
||||
@@ -616,7 +657,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
deliveryStatus: MessageDeliveryStatus.sending,
|
||||
recipientPublicKey: isChannel ? null : recipient?.publicKey,
|
||||
);
|
||||
messagesProvider.addSentMessage(placeholder);
|
||||
messagesProvider.addSentMessage(placeholder, contact: recipient);
|
||||
|
||||
// Send IE1 envelope via normal message path.
|
||||
final envelopeText = envelope.encode();
|
||||
@@ -921,7 +962,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
channelIdx: channelIdx,
|
||||
recipientPublicKey: isChannel ? null : recipient?.publicKey,
|
||||
);
|
||||
messagesProvider.addSentMessage(sentMsg);
|
||||
messagesProvider.addSentMessage(sentMsg, contact: recipient);
|
||||
|
||||
try {
|
||||
if (isChannel) {
|
||||
@@ -1327,9 +1368,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
// 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
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final roomContact = contactsProvider.contacts.where((c) {
|
||||
@@ -1337,6 +1375,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
c.publicKey.matches(roomPublicKey);
|
||||
}).firstOrNull;
|
||||
|
||||
// Add to messages list with "sending" status
|
||||
messagesProvider.addSentMessage(sentMessage, contact: roomContact);
|
||||
|
||||
// Send SAR message to selected room (persisted and immutable)
|
||||
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
||||
contactPublicKey: roomPublicKey!,
|
||||
@@ -1563,12 +1604,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -1576,7 +1611,28 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
SafeArea(
|
||||
top: false,
|
||||
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(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -1584,18 +1640,23 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 46,
|
||||
height: 46,
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primaryContainer
|
||||
.withValues(alpha: 0.95),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surface,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).dividerColor.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
_isRecording ? Icons.stop : Icons.add,
|
||||
size: 22,
|
||||
),
|
||||
tooltip: _isRecording
|
||||
? 'Stop recording'
|
||||
@@ -1607,34 +1668,29 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
? Colors.red
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.onPrimaryContainer,
|
||||
).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: _showRecipientSelector,
|
||||
child: Ink(
|
||||
height: 46,
|
||||
height: 42,
|
||||
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(
|
||||
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(
|
||||
@@ -1645,35 +1701,33 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
children: [
|
||||
Icon(
|
||||
_getDestinationIcon(),
|
||||
size: 18,
|
||||
size: 17,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_getDestinationLabel(),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
overflow:
|
||||
TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color:
|
||||
_destinationType ==
|
||||
MessageDestinationPreferences
|
||||
.destinationTypeChannel
|
||||
? Theme.of(
|
||||
fontWeight:
|
||||
FontWeight.w600,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface
|
||||
: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSecondaryContainer,
|
||||
).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.expand_more_rounded,
|
||||
size: 20,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1684,35 +1738,49 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
duration: const Duration(
|
||||
milliseconds: 180,
|
||||
),
|
||||
constraints: const BoxConstraints(
|
||||
minHeight: 48,
|
||||
maxHeight: 140,
|
||||
minHeight: 46,
|
||||
maxHeight: 132,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: _focusNode.hasFocus
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.primary
|
||||
: Theme.of(context).dividerColor
|
||||
.withValues(alpha: 0.6),
|
||||
width: _focusNode.hasFocus ? 1.5 : 1,
|
||||
.withValues(alpha: 0.35),
|
||||
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(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 18,
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
child: TextField(
|
||||
@@ -1722,9 +1790,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
maxLines: 4,
|
||||
keyboardType: TextInputType.multiline,
|
||||
inputFormatters: [
|
||||
LengthLimitingTextInputFormatter(
|
||||
_maxCharacters,
|
||||
),
|
||||
_messageByteLimiter,
|
||||
],
|
||||
style: const TextStyle(fontSize: 15),
|
||||
textAlignVertical:
|
||||
@@ -1735,25 +1801,31 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
)!.typeYourMessage,
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 15,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant
|
||||
.withValues(alpha: 0.9),
|
||||
),
|
||||
filled: false,
|
||||
fillColor: Colors.transparent,
|
||||
border: InputBorder.none,
|
||||
isCollapsed: true,
|
||||
),
|
||||
textInputAction: TextInputAction.newline,
|
||||
textInputAction:
|
||||
TextInputAction.newline,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const SizedBox(width: 8),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final canSendText =
|
||||
!_isRecording &&
|
||||
!_isSendingVoice &&
|
||||
_textController.text.trim().isNotEmpty;
|
||||
_textController.text
|
||||
.trim()
|
||||
.isNotEmpty;
|
||||
final semanticsLabel = _isRecording
|
||||
? 'Recording... release to send voice'
|
||||
: (_isSendingVoice
|
||||
@@ -1766,11 +1838,15 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
button: true,
|
||||
enabled:
|
||||
canSendText ||
|
||||
(_voiceSupported && !_isSendingVoice),
|
||||
(_voiceSupported &&
|
||||
!_isSendingVoice),
|
||||
label: semanticsLabel,
|
||||
onTap: canSendText ? _sendMessage : null,
|
||||
onTap: canSendText
|
||||
? _sendMessage
|
||||
: null,
|
||||
onLongPress:
|
||||
(_voiceSupported && !_isSendingVoice)
|
||||
(_voiceSupported &&
|
||||
!_isSendingVoice)
|
||||
? () {
|
||||
if (_isRecording) {
|
||||
_stopAndSendVoice();
|
||||
@@ -1790,11 +1866,13 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
? (_) => _startVoiceRecording()
|
||||
: null,
|
||||
onLongPressEnd:
|
||||
(_voiceSupported && _isRecording)
|
||||
(_voiceSupported &&
|
||||
_isRecording)
|
||||
? (_) => _stopAndSendVoice()
|
||||
: null,
|
||||
onLongPressCancel:
|
||||
(_voiceSupported && _isRecording)
|
||||
(_voiceSupported &&
|
||||
_isRecording)
|
||||
? () => _stopAndSendVoice()
|
||||
: null,
|
||||
child: Column(
|
||||
@@ -1804,31 +1882,48 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
duration: const Duration(
|
||||
milliseconds: 180,
|
||||
),
|
||||
width: 48,
|
||||
height: 48,
|
||||
width: 46,
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
canSendText || _isRecording
|
||||
canSendText ||
|
||||
_isRecording
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.primary
|
||||
: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.surface,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color:
|
||||
canSendText ||
|
||||
_isRecording
|
||||
? Colors.transparent
|
||||
: Theme.of(context)
|
||||
.dividerColor
|
||||
.withValues(
|
||||
alpha: 0.35,
|
||||
),
|
||||
),
|
||||
boxShadow:
|
||||
canSendText || _isRecording
|
||||
canSendText ||
|
||||
_isRecording
|
||||
? [
|
||||
BoxShadow(
|
||||
color:
|
||||
Theme.of(context)
|
||||
Theme.of(
|
||||
context,
|
||||
)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(
|
||||
alpha: 0.28,
|
||||
alpha:
|
||||
0.22,
|
||||
),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(
|
||||
blurRadius: 14,
|
||||
offset:
|
||||
const Offset(
|
||||
0,
|
||||
6,
|
||||
),
|
||||
@@ -1838,8 +1933,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
),
|
||||
child: _isSendingVoice
|
||||
? Center(
|
||||
child:
|
||||
CircularProgressIndicator(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color:
|
||||
Theme.of(
|
||||
@@ -1851,32 +1945,43 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
)
|
||||
: Icon(
|
||||
_isRecording
|
||||
? Icons.mic_rounded
|
||||
: Icons.send_rounded,
|
||||
? Icons
|
||||
.mic_rounded
|
||||
: Icons
|
||||
.send_rounded,
|
||||
size: 22,
|
||||
color:
|
||||
canSendText ||
|
||||
_isRecording
|
||||
? Theme.of(context)
|
||||
? Theme.of(
|
||||
context,
|
||||
)
|
||||
.colorScheme
|
||||
.onPrimary
|
||||
: Theme.of(context)
|
||||
: Theme.of(
|
||||
context,
|
||||
)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'$_characterCount/$_maxCharacters',
|
||||
'$_messageByteCount/$_maxMessageBytes',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color:
|
||||
_characterCount >
|
||||
_maxCharacters * 0.9
|
||||
_messageByteCount >
|
||||
_maxMessageBytes *
|
||||
0.9
|
||||
? Colors.orange.shade800
|
||||
: Theme.of(context)
|
||||
.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) {
|
||||
case MessageDeliveryStatus.sending:
|
||||
if (isContactMessage) {
|
||||
return l10n.pending;
|
||||
}
|
||||
return l10n.sending;
|
||||
case MessageDeliveryStatus.sent:
|
||||
return l10n.sent;
|
||||
|
||||
@@ -849,6 +849,7 @@ class DrawingToolbar extends StatelessWidget {
|
||||
contactPublicKey: room.publicKey,
|
||||
text: message,
|
||||
messageId: messageId,
|
||||
contact: room,
|
||||
);
|
||||
debugPrint(' ✅ Sent successfully');
|
||||
|
||||
|
||||
@@ -118,7 +118,26 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
);
|
||||
|
||||
// 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
|
||||
if (failedMessage.messageType == MessageType.contact) {
|
||||
@@ -132,14 +151,6 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
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
|
||||
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
||||
contactPublicKey: failedMessage.recipientPublicKey!,
|
||||
@@ -1805,7 +1816,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
? '${l10n.channel}: $channelDisplayName'
|
||||
: null;
|
||||
|
||||
final shouldFloatBubble = message.isChannelMessage || widget.isCompact;
|
||||
final shouldFloatBubble = widget.isCompact;
|
||||
final bubble = ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: shouldFloatBubble
|
||||
@@ -1946,7 +1957,11 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.draw, size: 16, color: Colors.white),
|
||||
const Icon(
|
||||
Icons.draw,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.mapDrawing,
|
||||
@@ -2075,7 +2090,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
message.pathLen == 0 ? 'direct' : '${message.pathLen}hop',
|
||||
message.pathLen == 0
|
||||
? 'direct'
|
||||
: '${message.pathLen}hop',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
@@ -2101,7 +2118,8 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
message.sarCustomEmoji ?? message.sarMarkerType!.emoji,
|
||||
message.sarCustomEmoji ??
|
||||
message.sarMarkerType!.emoji,
|
||||
style: const TextStyle(fontSize: 32),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
@@ -2129,9 +2147,8 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}',
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
style: Theme.of(context).textTheme.labelMedium
|
||||
?.copyWith(fontFamily: 'monospace'),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -2201,7 +2218,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
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
|
||||
else if (ticTacToeEvent?.type == TicTacToeEventType.start &&
|
||||
!widget.isCompact)
|
||||
TicTacToeMessageBubble(message: message, isSentByMe: isOwnMessage)
|
||||
TicTacToeMessageBubble(
|
||||
message: message,
|
||||
isSentByMe: isOwnMessage,
|
||||
)
|
||||
// Regular message content
|
||||
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 &&
|
||||
!isSarMarker &&
|
||||
@@ -2319,7 +2344,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
_isExpanded ? Icons.expand_less : Icons.expand_more,
|
||||
_isExpanded
|
||||
? Icons.expand_less
|
||||
: Icons.expand_more,
|
||||
size: 14,
|
||||
color: Theme.of(
|
||||
context,
|
||||
@@ -2343,7 +2370,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context)!.recipientDetails,
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.recipientDetails,
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
@@ -2469,7 +2498,10 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: Colors.orange, width: 1),
|
||||
border: Border.all(
|
||||
color: Colors.orange,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@@ -97,9 +97,7 @@ class TicTacToeMessageBubble extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
'Tic-Tac-Toe · Game ${state.gameId}',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelMedium?.copyWith(
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: titleColor,
|
||||
),
|
||||
@@ -167,7 +165,7 @@ class TicTacToeMessageBubble extends StatelessWidget {
|
||||
deliveryStatus: MessageDeliveryStatus.sending,
|
||||
recipientPublicKey: opponent.publicKey,
|
||||
);
|
||||
messagesProvider.addSentMessage(sentMessage);
|
||||
messagesProvider.addSentMessage(sentMessage, contact: opponent);
|
||||
|
||||
final sent = await connectionProvider.sendTextMessage(
|
||||
contactPublicKey: opponent.publicKey,
|
||||
|
||||
@@ -235,7 +235,7 @@ packages:
|
||||
source: hosted
|
||||
version: "3.3.0"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
|
||||
@@ -144,6 +144,7 @@ dev_dependencies:
|
||||
# rules and activating additional ones.
|
||||
flutter_lints: ^6.0.0
|
||||
flutter_launcher_icons: "^0.14.4"
|
||||
fake_async: ^1.3.3
|
||||
|
||||
dependency_overrides:
|
||||
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