diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 8ffbbad..b132d37 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -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'); } diff --git a/lib/providers/helpers/message_delivery_tracker.dart b/lib/providers/helpers/message_delivery_tracker.dart index 0b652b4..9ebaba6 100644 --- a/lib/providers/helpers/message_delivery_tracker.dart +++ b/lib/providers/helpers/message_delivery_tracker.dart @@ -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 _pendingMessageIds = []; + /// Contact-scoped FIFOs for matching direct-message SENT responses. + final Map> _pendingMessageIdsByContact = {}; + /// Map of ACK tag to message ID for delivery confirmation final Map _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 = []; + 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(); + } } diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index 84d167e..fc898c5 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -28,6 +28,9 @@ class MessagesProvider with ChangeNotifier { // Key: message ID (not ACK tag, since multiple messages can share same ACK) final Map _timeoutTimers = {}; + // Recently completed ACKs are kept briefly to ignore duplicate confirms. + final Map _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); + } + } } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index ded6a7b..999e565 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -46,9 +46,9 @@ class HomeScreen extends StatefulWidget { State createState() => _HomeScreenState(); } -class _HomeScreenState extends State - with SingleTickerProviderStateMixin { +class _HomeScreenState extends State 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 @override void initState() { super.initState(); + _appProvider = context.read(); + _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 } } - Future _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 }); } + 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 } 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 _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 @override void dispose() { + _appProvider.removeListener(_handleAppProviderChanged); _tabController.removeListener(_onTabChanged); _tabController.dispose(); super.dispose(); @@ -343,18 +345,7 @@ class _HomeScreenState extends State messagesProvider.setLocalizations(localizations); } - // Check if tab visibility settings changed and update tab controller - final appProvider = context.watch(); - if (_isMapEnabled != appProvider.isMapEnabled || - _isContactsEnabled != appProvider.isContactsEnabled) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - _updateTabController( - mapEnabled: appProvider.isMapEnabled, - contactsEnabled: appProvider.isContactsEnabled, - ); - }); - } + context.watch(); final enabledTabs = _enabledTabs; final isMapTabActive = _currentTab == _HomeTab.map; diff --git a/lib/screens/map_tab.dart b/lib/screens/map_tab.dart index aaf8da7..edf86a0 100644 --- a/lib/screens/map_tab.dart +++ b/lib/screens/map_tab.dart @@ -1349,9 +1349,6 @@ class _MapTabState extends State 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(); final roomContact = contactsProvider.contacts.where((c) { @@ -1359,6 +1356,9 @@ class _MapTabState extends State 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!, diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index 7692d53..c3b9101 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -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 { + 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 { 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 _loadSavedDestination() async { final savedDestination = @@ -211,6 +248,8 @@ class _MessagesTabState extends State { await MessageDestinationPreferences.clearDestination(); } } + + _enforceMessageByteLimit(); } /// Show recipient selector bottom sheet @@ -250,6 +289,8 @@ class _MessagesTabState extends State { _selectedRecipient = recipient; }); + _enforceMessageByteLimit(); + // Save to preferences await MessageDestinationPreferences.setDestination( type, @@ -383,7 +424,7 @@ class _MessagesTabState extends State { ); // 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 { 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 { 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 { // 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(); final roomContact = contactsProvider.contacts.where((c) { @@ -1337,6 +1375,9 @@ class _MessagesTabState extends State { 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 { 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,270 +1611,329 @@ class _MessagesTabState extends State { SafeArea( top: false, child: Padding( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 12), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( + 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, children: [ - Container( - width: 46, - height: 46, - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .primaryContainer - .withValues(alpha: 0.95), - shape: BoxShape.circle, - ), - child: IconButton( - icon: Icon( - _isRecording ? Icons.stop : Icons.add, - ), - tooltip: _isRecording - ? 'Stop recording' - : 'More actions', - onPressed: _isRecording - ? _stopAndSendVoice - : _showComposerActions, - color: _isRecording - ? Colors.red - : Theme.of( + Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surface, + shape: BoxShape.circle, + border: Border.all( + color: Theme.of( context, - ).colorScheme.onPrimaryContainer, - ), - ), - const SizedBox(width: 10), - Expanded( - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(22), - onTap: _showRecipientSelector, - child: Ink( - height: 46, - 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), + ).dividerColor.withValues(alpha: 0.35), + ), + ), + child: IconButton( + icon: Icon( + _isRecording ? Icons.stop : Icons.add, + size: 22, + ), + tooltip: _isRecording + ? 'Stop recording' + : 'More actions', + onPressed: _isRecording + ? _stopAndSendVoice + : _showComposerActions, + color: _isRecording + ? Colors.red + : Theme.of( + context, + ).colorScheme.primary, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: _showRecipientSelector, + child: Ink( + height: 42, + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surface, + borderRadius: BorderRadius.circular( + 20, + ), + border: Border.all( + color: Theme.of(context) + .dividerColor + .withValues(alpha: 0.35), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 14, + ), + child: Row( + children: [ + Icon( + _getDestinationIcon(), + size: 17, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + _getDestinationLabel(), + overflow: + TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15, + fontWeight: + FontWeight.w600, + color: Theme.of( + context, + ).colorScheme.onSurface, + ), + ), + ), + Icon( + Icons.expand_more_rounded, + size: 20, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ], + ), + ), ), ), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: AnimatedContainer( + duration: const Duration( + milliseconds: 180, + ), + constraints: const BoxConstraints( + minHeight: 46, + maxHeight: 132, + ), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surface, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: _focusNode.hasFocus + ? Theme.of( + context, + ).colorScheme.primary + : Theme.of(context).dividerColor + .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: 14, + horizontal: 16, + vertical: 12, ), - child: Row( - children: [ - Icon( - _getDestinationIcon(), - size: 18, - ), - const SizedBox(width: 10), - Expanded( - child: Text( - _getDestinationLabel(), - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - color: - _destinationType == - MessageDestinationPreferences - .destinationTypeChannel - ? Theme.of( - context, - ).colorScheme.onSurface - : Theme.of(context) - .colorScheme - .onSecondaryContainer, - ), - ), - ), - Icon( - Icons.expand_more_rounded, - size: 20, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), + child: TextField( + controller: _textController, + focusNode: _focusNode, + minLines: 1, + maxLines: 4, + keyboardType: TextInputType.multiline, + inputFormatters: [ + _messageByteLimiter, ], - ), - ), - ), - ), - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: AnimatedContainer( - duration: const Duration(milliseconds: 180), - constraints: const BoxConstraints( - minHeight: 48, - maxHeight: 140, - ), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(28), - border: Border.all( - color: _focusNode.hasFocus - ? Theme.of( + style: const TextStyle(fontSize: 15), + textAlignVertical: + TextAlignVertical.center, + decoration: InputDecoration( + hintText: AppLocalizations.of( context, - ).colorScheme.primary - : Theme.of(context).dividerColor - .withValues(alpha: 0.6), - width: _focusNode.hasFocus ? 1.5 : 1, - ), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 18, - vertical: 12, - ), - child: TextField( - controller: _textController, - focusNode: _focusNode, - minLines: 1, - maxLines: 4, - keyboardType: TextInputType.multiline, - inputFormatters: [ - LengthLimitingTextInputFormatter( - _maxCharacters, + )!.typeYourMessage, + hintStyle: TextStyle( + fontSize: 15, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.9), + ), + filled: false, + fillColor: Colors.transparent, + border: InputBorder.none, + isCollapsed: true, + ), + textInputAction: + TextInputAction.newline, ), - ], - style: const TextStyle(fontSize: 15), - textAlignVertical: - TextAlignVertical.center, - decoration: InputDecoration( - hintText: AppLocalizations.of( - context, - )!.typeYourMessage, - hintStyle: TextStyle( - fontSize: 15, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), - border: InputBorder.none, - isCollapsed: true, ), - textInputAction: TextInputAction.newline, ), ), - ), - ), - const SizedBox(width: 10), - Builder( - builder: (context) { - final canSendText = - !_isRecording && - !_isSendingVoice && - _textController.text.trim().isNotEmpty; - final semanticsLabel = _isRecording - ? 'Recording... release to send voice' - : (_isSendingVoice - ? 'Sending voice...' - : _voiceSupported - ? 'Send (long press to record voice)' - : 'Send'); + const SizedBox(width: 8), + Builder( + builder: (context) { + final canSendText = + !_isRecording && + !_isSendingVoice && + _textController.text + .trim() + .isNotEmpty; + final semanticsLabel = _isRecording + ? 'Recording... release to send voice' + : (_isSendingVoice + ? 'Sending voice...' + : _voiceSupported + ? 'Send (long press to record voice)' + : 'Send'); - return Semantics( - button: true, - enabled: - canSendText || - (_voiceSupported && !_isSendingVoice), - label: semanticsLabel, - onTap: canSendText ? _sendMessage : null, - onLongPress: - (_voiceSupported && !_isSendingVoice) - ? () { - if (_isRecording) { - _stopAndSendVoice(); - return; - } - _startVoiceRecording(); - } - : null, - child: Tooltip( - message: semanticsLabel, - excludeFromSemantics: true, - child: GestureDetector( - excludeFromSemantics: true, - onLongPressStart: + return Semantics( + button: true, + enabled: + canSendText || + (_voiceSupported && + !_isSendingVoice), + label: semanticsLabel, + onTap: canSendText + ? _sendMessage + : null, + onLongPress: (_voiceSupported && !_isSendingVoice) - ? (_) => _startVoiceRecording() + ? () { + if (_isRecording) { + _stopAndSendVoice(); + return; + } + _startVoiceRecording(); + } : null, - onLongPressEnd: - (_voiceSupported && _isRecording) - ? (_) => _stopAndSendVoice() - : null, - onLongPressCancel: - (_voiceSupported && _isRecording) - ? () => _stopAndSendVoice() - : null, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AnimatedContainer( - duration: const Duration( - milliseconds: 180, - ), - width: 48, - height: 48, - decoration: BoxDecoration( - color: - canSendText || _isRecording - ? Theme.of( - context, - ).colorScheme.primary - : Theme.of(context) - .colorScheme - .surfaceContainerHighest, - shape: BoxShape.circle, - boxShadow: - canSendText || _isRecording - ? [ - BoxShadow( - color: - Theme.of(context) - .colorScheme - .primary - .withValues( - alpha: 0.28, + child: Tooltip( + message: semanticsLabel, + excludeFromSemantics: true, + child: GestureDetector( + excludeFromSemantics: true, + onLongPressStart: + (_voiceSupported && + !_isSendingVoice) + ? (_) => _startVoiceRecording() + : null, + onLongPressEnd: + (_voiceSupported && + _isRecording) + ? (_) => _stopAndSendVoice() + : null, + onLongPressCancel: + (_voiceSupported && + _isRecording) + ? () => _stopAndSendVoice() + : null, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedContainer( + duration: const Duration( + milliseconds: 180, + ), + width: 46, + height: 46, + decoration: BoxDecoration( + color: + canSendText || + _isRecording + ? Theme.of( + context, + ).colorScheme.primary + : 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 + ? [ + BoxShadow( + color: + Theme.of( + context, + ) + .colorScheme + .primary + .withValues( + alpha: + 0.22, + ), + blurRadius: 14, + offset: + const Offset( + 0, + 6, ), - blurRadius: 16, - offset: const Offset( - 0, - 6, - ), - ), - ] - : null, - ), - child: _isSendingVoice - ? Center( - child: - CircularProgressIndicator( + ), + ] + : null, + ), + child: _isSendingVoice + ? Center( + child: CircularProgressIndicator( strokeWidth: 2, color: Theme.of( @@ -1848,47 +1942,60 @@ class _MessagesTabState extends State { .colorScheme .onPrimary, ), - ) - : Icon( - _isRecording - ? Icons.mic_rounded - : Icons.send_rounded, - color: - canSendText || - _isRecording - ? Theme.of(context) - .colorScheme - .onPrimary - : Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), + ) + : Icon( + _isRecording + ? Icons + .mic_rounded + : Icons + .send_rounded, + size: 22, + color: + canSendText || + _isRecording + ? Theme.of( + context, + ) + .colorScheme + .onPrimary + : Theme.of( + context, + ) + .colorScheme + .onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + '$_messageByteCount/$_maxMessageBytes', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: + _messageByteCount > + _maxMessageBytes * + 0.9 + ? Colors.orange.shade800 + : Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues( + alpha: 0.9, + ), + ), + ), + ], ), - const SizedBox(height: 6), - Text( - '$_characterCount/$_maxCharacters', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: - _characterCount > - _maxCharacters * 0.9 - ? Colors.orange.shade800 - : Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - ), - ], + ), ), - ), - ), - ); - }, + ); + }, + ), + ], ), ], ), - ], + ), ), ), ), diff --git a/lib/utils/message_extensions.dart b/lib/utils/message_extensions.dart index 1b3c8d4..42d7b43 100644 --- a/lib/utils/message_extensions.dart +++ b/lib/utils/message_extensions.dart @@ -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; diff --git a/lib/widgets/map/drawing_toolbar.dart b/lib/widgets/map/drawing_toolbar.dart index 5c7831e..646f954 100644 --- a/lib/widgets/map/drawing_toolbar.dart +++ b/lib/widgets/map/drawing_toolbar.dart @@ -849,6 +849,7 @@ class DrawingToolbar extends StatelessWidget { contactPublicKey: room.publicKey, text: message, messageId: messageId, + contact: room, ); debugPrint(' โœ… Sent successfully'); diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index 0e2d331..b6794e9 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -118,7 +118,26 @@ class _MessageBubbleState extends State { ); // 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(); + 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 { return; } - // Look up the room contact for path logging - final contactsProvider = context.read(); - 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 { ? '${l10n.channel}: $channelDisplayName' : null; - final shouldFloatBubble = message.isChannelMessage || widget.isCompact; + final shouldFloatBubble = widget.isCompact; final bubble = ConstrainedBox( constraints: BoxConstraints( maxWidth: shouldFloatBubble @@ -1813,692 +1824,713 @@ class _MessageBubbleState extends State { : double.infinity, ), child: GestureDetector( - onTap: () => _handleBubbleTap( - isSarMarker: isSarMarker, - isDrawing: message.isDrawing, - ), - onLongPress: widget.isCompact - ? null - : () => _showMessageOptions(context), - child: Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: widget.isHighlighted - ? Theme.of(context).colorScheme.primaryContainer - : isSarMarker - ? _getSarMarkerColor(context, isDarkMode) - : message.isDrawing - ? (isDarkMode - ? Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.15) - : Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.08)) - : _getMessageBubbleColor(context, isOwnMessage, isDarkMode), - borderRadius: BorderRadius.circular(12), - border: widget.isHighlighted - ? Border.all( - color: Theme.of(context).colorScheme.primary, - width: 3, - ) - : isSarMarker - ? Border.all( - color: _getSarMarkerBorderColor(context, isDarkMode), - width: 2, - ) - : message.isDrawing - ? Border.all( - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.4), - width: 2, - ) - : isOwnMessage - ? Border.all( - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.3), - width: 1.5, - ) - : !message.isRead && - !message.isSentMessage && - !message.isSystemMessage - ? Border.all(color: Colors.blue, width: 1.5) - : null, - boxShadow: widget.isHighlighted - ? [ - BoxShadow( - color: Theme.of( + onTap: () => _handleBubbleTap( + isSarMarker: isSarMarker, + isDrawing: message.isDrawing, + ), + onLongPress: widget.isCompact + ? null + : () => _showMessageOptions(context), + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: widget.isHighlighted + ? Theme.of(context).colorScheme.primaryContainer + : isSarMarker + ? _getSarMarkerColor(context, isDarkMode) + : message.isDrawing + ? (isDarkMode + ? Theme.of( context, - ).colorScheme.primary.withValues(alpha: 0.5), - blurRadius: 12, - spreadRadius: 2, - offset: const Offset(0, 2), - ), - ] - : isSarMarker || message.isDrawing - ? [ - BoxShadow( - color: - (isSarMarker - ? _getSarMarkerBorderColor( - context, - isDarkMode, - ) - : Theme.of(context).colorScheme.primary) - .withValues(alpha: 0.3), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ] - : null, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header: Badge (if SAR or drawing) and time - if (isSarMarker || message.isDrawing) - Row( - children: [ - if (isSarMarker) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - decoration: BoxDecoration( - color: _getSarMarkerBorderColor(context, isDarkMode), - borderRadius: BorderRadius.circular(6), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.warning_amber_rounded, - size: 16, - color: Colors.white, - ), - const SizedBox(width: 4), - Text( - AppLocalizations.of(context)!.sarAlert, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: Colors.white, - fontWeight: FontWeight.bold, - letterSpacing: 0.5, - ), - ), - ], - ), - ) - else if (message.isDrawing) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - borderRadius: BorderRadius.circular(6), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.draw, size: 16, color: Colors.white), - const SizedBox(width: 4), - Text( - AppLocalizations.of(context)!.mapDrawing, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: Colors.white, - fontWeight: FontWeight.bold, - letterSpacing: 0.5, - ), - ), - ], - ), - ), - const Spacer(), - Text( - message.getLocalizedTimeAgo(context), - style: Theme.of(context).textTheme.labelSmall?.copyWith( - fontWeight: isSarMarker - ? FontWeight.w600 - : FontWeight.normal, - ), - ), - ], - ), - - // Sender info row (shown for all messages) - Row( - children: [ - // Unread indicator badge (only for regular messages, not SAR/drawing) - if (!message.isRead && - !message.isSentMessage && - !message.isSystemMessage && - !isSarMarker && - !message.isDrawing) - Container( - width: 8, - height: 8, - margin: const EdgeInsets.only(right: 8), - decoration: const BoxDecoration( - color: Colors.blue, - shape: BoxShape.circle, - ), - ), - if (isOwnMessage) - Icon( - Icons.account_circle, - size: 16, + ).colorScheme.primary.withValues(alpha: 0.15) + : Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.08)) + : _getMessageBubbleColor(context, isOwnMessage, isDarkMode), + borderRadius: BorderRadius.circular(12), + border: widget.isHighlighted + ? Border.all( color: Theme.of(context).colorScheme.primary, + width: 3, ) - else if (message.isChannelMessage) - const Icon(Icons.tag, size: 16) - else - const Icon(Icons.person, size: 16), - const SizedBox(width: 4), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - displayName, - style: Theme.of(context).textTheme.labelMedium - ?.copyWith( - fontWeight: FontWeight.bold, - color: isOwnMessage - ? Theme.of(context).colorScheme.primary - : null, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - // Show destination/source context on a separate line. - if (!widget.isCompact && - (recipientSubtitle != null || - receivedChannelSubtitle != null)) ...[ - const SizedBox(height: 2), - Row( + : isSarMarker + ? Border.all( + color: _getSarMarkerBorderColor(context, isDarkMode), + width: 2, + ) + : message.isDrawing + ? Border.all( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.4), + width: 2, + ) + : isOwnMessage + ? Border.all( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.3), + width: 1.5, + ) + : !message.isRead && + !message.isSentMessage && + !message.isSystemMessage + ? Border.all(color: Colors.blue, width: 1.5) + : null, + boxShadow: widget.isHighlighted + ? [ + BoxShadow( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.5), + blurRadius: 12, + spreadRadius: 2, + offset: const Offset(0, 2), + ), + ] + : isSarMarker || message.isDrawing + ? [ + BoxShadow( + color: + (isSarMarker + ? _getSarMarkerBorderColor( + context, + isDarkMode, + ) + : Theme.of(context).colorScheme.primary) + .withValues(alpha: 0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header: Badge (if SAR or drawing) and time + if (isSarMarker || message.isDrawing) + Row( + children: [ + if (isSarMarker) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: _getSarMarkerBorderColor(context, isDarkMode), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, children: [ - Icon( - isOwnMessage - ? Icons.arrow_forward - : Icons.arrow_back, - size: 12, - color: Theme.of(context) - .textTheme - .labelSmall - ?.color - ?.withValues(alpha: 0.7), + const Icon( + Icons.warning_amber_rounded, + size: 16, + color: Colors.white, ), const SizedBox(width: 4), - Expanded( - child: Text( - isOwnMessage - ? recipientSubtitle! - : receivedChannelSubtitle!, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: Theme.of(context) - .textTheme - .labelSmall - ?.color - ?.withValues(alpha: 0.75), - fontStyle: FontStyle.italic, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + Text( + AppLocalizations.of(context)!.sarAlert, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), ), ], ), - ], - ], - ), - ), - // Time for regular messages (not shown for SAR/drawing as it's already above) - if (!isSarMarker && !message.isDrawing) ...[ - const SizedBox(width: 8), - // Hop count indicator for received messages - if (!isOwnMessage && message.pathLen < 255) ...[ - const SizedBox(width: 4), - Icon( - Icons.alt_route, - size: 11, - color: Theme.of( - context, - ).textTheme.labelSmall?.color?.withValues(alpha: 0.6), - ), - const SizedBox(width: 2), + ) + else if (message.isDrawing) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.draw, + size: 16, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + AppLocalizations.of(context)!.mapDrawing, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), + ), + ], + ), + ), + const Spacer(), Text( - message.pathLen == 0 ? 'direct' : '${message.pathLen}hop', + message.getLocalizedTimeAgo(context), style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontWeight: isSarMarker + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + ], + ), + + // Sender info row (shown for all messages) + Row( + children: [ + // Unread indicator badge (only for regular messages, not SAR/drawing) + if (!message.isRead && + !message.isSentMessage && + !message.isSystemMessage && + !isSarMarker && + !message.isDrawing) + Container( + width: 8, + height: 8, + margin: const EdgeInsets.only(right: 8), + decoration: const BoxDecoration( + color: Colors.blue, + shape: BoxShape.circle, + ), + ), + if (isOwnMessage) + Icon( + Icons.account_circle, + size: 16, + color: Theme.of(context).colorScheme.primary, + ) + else if (message.isChannelMessage) + const Icon(Icons.tag, size: 16) + else + const Icon(Icons.person, size: 16), + const SizedBox(width: 4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + displayName, + style: Theme.of(context).textTheme.labelMedium + ?.copyWith( + fontWeight: FontWeight.bold, + color: isOwnMessage + ? Theme.of(context).colorScheme.primary + : null, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + // Show destination/source context on a separate line. + if (!widget.isCompact && + (recipientSubtitle != null || + receivedChannelSubtitle != null)) ...[ + const SizedBox(height: 2), + Row( + children: [ + Icon( + isOwnMessage + ? Icons.arrow_forward + : Icons.arrow_back, + size: 12, + color: Theme.of(context) + .textTheme + .labelSmall + ?.color + ?.withValues(alpha: 0.7), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + isOwnMessage + ? recipientSubtitle! + : receivedChannelSubtitle!, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: Theme.of(context) + .textTheme + .labelSmall + ?.color + ?.withValues(alpha: 0.75), + fontStyle: FontStyle.italic, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ], + ), + ), + // Time for regular messages (not shown for SAR/drawing as it's already above) + if (!isSarMarker && !message.isDrawing) ...[ + const SizedBox(width: 8), + // Hop count indicator for received messages + if (!isOwnMessage && message.pathLen < 255) ...[ + const SizedBox(width: 4), + Icon( + Icons.alt_route, + size: 11, color: Theme.of( context, ).textTheme.labelSmall?.color?.withValues(alpha: 0.6), ), - ), - const SizedBox(width: 4), - ], - Text( - message.getLocalizedTimeAgo(context), - style: Theme.of(context).textTheme.labelSmall, - ), - ], - ], - ), - const SizedBox(height: 8), - - // SAR marker content - if (isSarMarker && message.sarMarkerType != null) ...[ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ + const SizedBox(width: 2), Text( - message.sarCustomEmoji ?? message.sarMarkerType!.emoji, - style: const TextStyle(fontSize: 32), - ), - const SizedBox(width: 10), - Expanded( - child: Text( - message.sarNotes != null && - message.sarNotes!.isNotEmpty - ? message.sarNotes! - : message.sarMarkerType!.getLocalizedName( - context, - ), - style: Theme.of(context).textTheme.titleSmall - ?.copyWith(fontWeight: FontWeight.bold), + message.pathLen == 0 + ? 'direct' + : '${message.pathLen}hop', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of( + context, + ).textTheme.labelSmall?.color?.withValues(alpha: 0.6), ), ), - if (!widget.isCompact) + const SizedBox(width: 4), + ], + Text( + message.getLocalizedTimeAgo(context), + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ], + ), + const SizedBox(height: 8), + + // SAR marker content + if (isSarMarker && message.sarMarkerType != null) ...[ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + message.sarCustomEmoji ?? + message.sarMarkerType!.emoji, + style: const TextStyle(fontSize: 32), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + message.sarNotes != null && + message.sarNotes!.isNotEmpty + ? message.sarNotes! + : message.sarMarkerType!.getLocalizedName( + context, + ), + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + if (!widget.isCompact) + Icon( + Icons.chevron_right, + size: 18, + color: Theme.of(context).colorScheme.primary, + ), + ], + ), + if (message.sarGpsCoordinates != null) ...[ + const SizedBox(height: 6), + Text( + '${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}', + style: Theme.of(context).textTheme.labelMedium + ?.copyWith(fontFamily: 'monospace'), + ), + ], + ], + ), + ] + // Drawing message content (skip in compact mode - drawings hidden) + else if (message.isDrawing && + message.drawingId != null && + !widget.isCompact) + Consumer( + builder: (context, drawingProvider, child) { + final drawing = drawingProvider.getDrawingById( + message.drawingId!, + ); + + if (drawing == null) { + return Text( + message.text, + style: Theme.of(context).textTheme.bodyMedium, + ); + } + + final String drawingTypeLabel; + if (drawing is LineDrawing) { + drawingTypeLabel = AppLocalizations.of( + context, + )!.lineDrawing; + } else if (drawing is RectangleDrawing) { + drawingTypeLabel = AppLocalizations.of( + context, + )!.rectangleDrawing; + } else { + drawingTypeLabel = AppLocalizations.of(context)!.drawing; + } + + final colorName = DrawingColors.colorToName(drawing.color); + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DrawingMinimapPreview(drawing: drawing), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + drawingTypeLabel, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Row( + children: [ + Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: drawing.color, + shape: BoxShape.circle, + border: Border.all( + color: Colors.black26, + width: 1, + ), + ), + ), + const SizedBox(width: 6), + Text( + colorName, + style: Theme.of( + context, + ).textTheme.labelSmall, + ), + ], + ), + ], + ), + ), Icon( Icons.chevron_right, size: 18, color: Theme.of(context).colorScheme.primary, ), - ], - ), - if (message.sarGpsCoordinates != null) ...[ - const SizedBox(height: 6), - Text( - '${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}', - style: Theme.of(context).textTheme.labelMedium?.copyWith( - fontFamily: 'monospace', - ), - ), - ], - ], - ), - ] - // Drawing message content (skip in compact mode - drawings hidden) - else if (message.isDrawing && - message.drawingId != null && - !widget.isCompact) - Consumer( - builder: (context, drawingProvider, child) { - final drawing = drawingProvider.getDrawingById( - message.drawingId!, - ); - - if (drawing == null) { - return Text( - message.text, - style: Theme.of(context).textTheme.bodyMedium, + ], ); - } + }, + ) + // Voice message content + else if (message.isVoice && + message.voiceId != null && + !widget.isCompact) + VoiceMessageBubble(message: message, isSentByMe: isOwnMessage) + // Image message content (IE1 envelope) + else if (ImageEnvelope.isEnvelope(message.text) && + !widget.isCompact) + ImageMessageBubble(message: message, isSentByMe: isOwnMessage) + // Tic-Tac-Toe control message content + else if (ticTacToeEvent?.type == TicTacToeEventType.start && + !widget.isCompact) + TicTacToeMessageBubble( + message: message, + isSentByMe: isOwnMessage, + ) + // Regular message content + else if (!message.isDrawing || widget.isCompact) + Text( + message.text, + style: Theme.of(context).textTheme.bodyMedium, + ), - final String drawingTypeLabel; - if (drawing is LineDrawing) { - drawingTypeLabel = AppLocalizations.of( - context, - )!.lineDrawing; - } else if (drawing is RectangleDrawing) { - drawingTypeLabel = AppLocalizations.of( - context, - )!.rectangleDrawing; - } else { - drawingTypeLabel = AppLocalizations.of(context)!.drawing; - } + if (!widget.isCompact && + !isSarMarker && + !message.isDrawing && + _showReceivedStats) ...[ + const SizedBox(height: 6), + _buildReceivedSignalStatus( + context, + message, + rssiDbm: rssiDbm, + snrDb: snrDb, + ), + ], - final colorName = DrawingColors.colorToName(drawing.color); - - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - DrawingMinimapPreview(drawing: drawing), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + // Delivery status for sent messages (skip in compact mode) + if (message.isSentMessage && !widget.isCompact) ...[ + const SizedBox(height: 6), + // Debug: Log grouped message detection + Builder( + builder: (context) { + if (message.isGroupedMessage) { + debugPrint( + '๐ŸŽฏ [MessageBubble] Rendering grouped message: ${message.id}', + ); + debugPrint( + ' Recipients: ${message.recipients?.length ?? 0}', + ); + debugPrint( + ' Delivered: ${message.deliveredRecipientsCount}', + ); + debugPrint(' Failed: ${message.failedRecipientsCount}'); + } + return const SizedBox.shrink(); + }, + ), + // Show grouped message delivery count + if (message.isGroupedMessage) ...[ + GestureDetector( + onTap: _toggleExpanded, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, children: [ - Text( - drawingTypeLabel, - style: Theme.of(context).textTheme.titleSmall - ?.copyWith(fontWeight: FontWeight.bold), + Icon( + message.deliveredRecipientsCount == + message.recipients!.length + ? Icons.done_all + : message.failedRecipientsCount > 0 + ? Icons.error_outline + : Icons.schedule, + size: 12, + color: + message.deliveredRecipientsCount == + message.recipients!.length + ? Colors.green + : message.failedRecipientsCount > 0 + ? Colors.red + : Colors.orange, ), - const SizedBox(height: 4), - Row( - children: [ - Container( - width: 16, - height: 16, - decoration: BoxDecoration( - color: drawing.color, - shape: BoxShape.circle, - border: Border.all( - color: Colors.black26, - width: 1, + const SizedBox(width: 3), + Text( + message.deliveredRecipientsCount == + message.recipients!.length + ? AppLocalizations.of(context)!.allDelivered + : AppLocalizations.of( + context, + )!.deliveredToContacts( + message.deliveredRecipientsCount, + message.recipients!.length, ), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: + message.deliveredRecipientsCount == + message.recipients!.length + ? Colors.green + : message.failedRecipientsCount > 0 + ? Colors.red + : Colors.orange, + fontStyle: FontStyle.italic, ), - ), - const SizedBox(width: 6), - Text( - colorName, - style: Theme.of(context).textTheme.labelSmall, - ), - ], + ), + const SizedBox(width: 4), + Icon( + _isExpanded + ? Icons.expand_less + : Icons.expand_more, + size: 14, + color: Theme.of( + context, + ).textTheme.labelSmall?.color, ), ], ), - ), - Icon( - Icons.chevron_right, - size: 18, - color: Theme.of(context).colorScheme.primary, - ), - ], - ); - }, - ) - // Voice message content - else if (message.isVoice && - message.voiceId != null && - !widget.isCompact) - VoiceMessageBubble(message: message, isSentByMe: isOwnMessage) - // Image message content (IE1 envelope) - else if (ImageEnvelope.isEnvelope(message.text) && - !widget.isCompact) - ImageMessageBubble(message: message, isSentByMe: isOwnMessage) - // Tic-Tac-Toe control message content - else if (ticTacToeEvent?.type == TicTacToeEventType.start && - !widget.isCompact) - TicTacToeMessageBubble(message: message, isSentByMe: isOwnMessage) - // Regular message content - else if (!message.isDrawing || widget.isCompact) - Text(message.text, style: Theme.of(context).textTheme.bodyMedium), - - if (!widget.isCompact && - !isSarMarker && - !message.isDrawing && - _showReceivedStats) ...[ - const SizedBox(height: 6), - _buildReceivedSignalStatus( - context, - message, - rssiDbm: rssiDbm, - snrDb: snrDb, - ), - ], - - // Delivery status for sent messages (skip in compact mode) - if (message.isSentMessage && !widget.isCompact) ...[ - const SizedBox(height: 6), - // Debug: Log grouped message detection - Builder( - builder: (context) { - if (message.isGroupedMessage) { - debugPrint( - '๐ŸŽฏ [MessageBubble] Rendering grouped message: ${message.id}', - ); - debugPrint( - ' Recipients: ${message.recipients?.length ?? 0}', - ); - debugPrint( - ' Delivered: ${message.deliveredRecipientsCount}', - ); - debugPrint(' Failed: ${message.failedRecipientsCount}'); - } - return const SizedBox.shrink(); - }, - ), - // Show grouped message delivery count - if (message.isGroupedMessage) ...[ - GestureDetector( - onTap: _toggleExpanded, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - message.deliveredRecipientsCount == - message.recipients!.length - ? Icons.done_all - : message.failedRecipientsCount > 0 - ? Icons.error_outline - : Icons.schedule, - size: 12, - color: - message.deliveredRecipientsCount == - message.recipients!.length - ? Colors.green - : message.failedRecipientsCount > 0 - ? Colors.red - : Colors.orange, - ), - const SizedBox(width: 3), - Text( - message.deliveredRecipientsCount == - message.recipients!.length - ? AppLocalizations.of(context)!.allDelivered - : AppLocalizations.of( + // Expandable recipient details + if (_isExpanded) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(6), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of( context, - )!.deliveredToContacts( - message.deliveredRecipientsCount, - message.recipients!.length, - ), - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: - message.deliveredRecipientsCount == - message.recipients!.length - ? Colors.green - : message.failedRecipientsCount > 0 - ? Colors.red - : Colors.orange, - fontStyle: FontStyle.italic, + )!.recipientDetails, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith(fontWeight: FontWeight.bold), ), - ), - const SizedBox(width: 4), - Icon( - _isExpanded ? Icons.expand_less : Icons.expand_more, - size: 14, - color: Theme.of( - context, - ).textTheme.labelSmall?.color, + const SizedBox(height: 4), + ...message.recipients!.map((recipient) { + final Color statusColor; + final IconData statusIcon; + final String statusText; + + switch (recipient.deliveryStatus) { + case MessageDeliveryStatus.delivered: + statusColor = Colors.green; + statusIcon = Icons.check_circle; + statusText = + recipient.roundTripTimeMs != null + ? '${recipient.roundTripTimeMs}ms' + : AppLocalizations.of( + context, + )!.delivered; + break; + case MessageDeliveryStatus.failed: + statusColor = Colors.red; + statusIcon = Icons.cancel; + statusText = AppLocalizations.of( + context, + )!.failed; + break; + case MessageDeliveryStatus.sending: + case MessageDeliveryStatus.sent: + default: + statusColor = Colors.orange; + statusIcon = Icons.schedule; + statusText = AppLocalizations.of( + context, + )!.pending; + } + + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Row( + children: [ + Icon( + statusIcon, + size: 14, + color: statusColor, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + recipient.displayName, + style: Theme.of( + context, + ).textTheme.labelSmall, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + statusText, + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith( + color: statusColor, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + }), + ], + ), ), ], + ], + ), + ), + ] + // Show single message delivery status + else + Row( + mainAxisSize: MainAxisSize.max, + children: [ + Icon( + _getDeliveryStatusIcon(message.deliveryStatus), + size: 12, + color: _getDeliveryStatusColor(message.deliveryStatus), ), - // Expandable recipient details - if (_isExpanded) ...[ - const SizedBox(height: 8), - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest - .withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(6), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - AppLocalizations.of(context)!.recipientDetails, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 4), - ...message.recipients!.map((recipient) { - final Color statusColor; - final IconData statusIcon; - final String statusText; - - switch (recipient.deliveryStatus) { - case MessageDeliveryStatus.delivered: - statusColor = Colors.green; - statusIcon = Icons.check_circle; - statusText = - recipient.roundTripTimeMs != null - ? '${recipient.roundTripTimeMs}ms' - : AppLocalizations.of( - context, - )!.delivered; - break; - case MessageDeliveryStatus.failed: - statusColor = Colors.red; - statusIcon = Icons.cancel; - statusText = AppLocalizations.of( - context, - )!.failed; - break; - case MessageDeliveryStatus.sending: - case MessageDeliveryStatus.sent: - default: - statusColor = Colors.orange; - statusIcon = Icons.schedule; - statusText = AppLocalizations.of( - context, - )!.pending; - } - - return Padding( - padding: const EdgeInsets.only(top: 4), - child: Row( - children: [ - Icon( - statusIcon, - size: 14, - color: statusColor, - ), - const SizedBox(width: 6), - Expanded( - child: Text( - recipient.displayName, - style: Theme.of( - context, - ).textTheme.labelSmall, - overflow: TextOverflow.ellipsis, + const SizedBox(width: 3), + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: + message.isChannelMessage && + message.deliveryStatus == + MessageDeliveryStatus.sent + ? _buildChannelEchoStatus(context, message) + : Text( + message.getLocalizedDeliveryStatus(context), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: _getDeliveryStatusColor( + message.deliveryStatus, ), + fontStyle: FontStyle.italic, ), - Text( - statusText, - style: Theme.of(context) - .textTheme - .labelSmall - ?.copyWith( - color: statusColor, - fontWeight: FontWeight.w500, - ), + ), + ), + ), + // Show retry button for failed messages + if (message.deliveryStatus == + MessageDeliveryStatus.failed) ...[ + const SizedBox(width: 6), + GestureDetector( + onTap: () => _retryFailedMessage(context, message), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: Colors.orange, + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.refresh, + size: 12, + color: Colors.orange, + ), + const SizedBox(width: 4), + Text( + 'Retry', + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: Colors.orange, + fontWeight: FontWeight.bold, ), - ], - ), - ); - }), - ], + ), + ], + ), ), ), ], ], ), - ), - ] - // Show single message delivery status - else - Row( - mainAxisSize: MainAxisSize.max, - children: [ - Icon( - _getDeliveryStatusIcon(message.deliveryStatus), - size: 12, - color: _getDeliveryStatusColor(message.deliveryStatus), - ), - const SizedBox(width: 3), - Expanded( - child: Align( - alignment: Alignment.centerLeft, - child: - message.isChannelMessage && - message.deliveryStatus == - MessageDeliveryStatus.sent - ? _buildChannelEchoStatus(context, message) - : Text( - message.getLocalizedDeliveryStatus(context), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: _getDeliveryStatusColor( - message.deliveryStatus, - ), - fontStyle: FontStyle.italic, - ), - ), - ), - ), - // Show retry button for failed messages - if (message.deliveryStatus == - MessageDeliveryStatus.failed) ...[ - const SizedBox(width: 6), - GestureDetector( - onTap: () => _retryFailedMessage(context, message), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: Colors.orange.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(4), - border: Border.all(color: Colors.orange, width: 1), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.refresh, - size: 12, - color: Colors.orange, - ), - const SizedBox(width: 4), - Text( - 'Retry', - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: Colors.orange, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), - ], - ], - ), + ], ], - ], - ), ), + ), ), ); diff --git a/lib/widgets/messages/tictactoe_message_bubble.dart b/lib/widgets/messages/tictactoe_message_bubble.dart index 0b3b6a6..a04dbd4 100644 --- a/lib/widgets/messages/tictactoe_message_bubble.dart +++ b/lib/widgets/messages/tictactoe_message_bubble.dart @@ -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, diff --git a/pubspec.lock b/pubspec.lock index 9ae1fa2..692111d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -235,7 +235,7 @@ packages: source: hosted version: "3.3.0" fake_async: - dependency: transitive + dependency: "direct dev" description: name: fake_async sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" diff --git a/pubspec.yaml b/pubspec.yaml index b62d7f2..aba5744 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: diff --git a/test/providers/helpers/message_delivery_tracker_test.dart b/test/providers/helpers/message_delivery_tracker_test.dart new file mode 100644 index 0000000..1493e3f --- /dev/null +++ b/test/providers/helpers/message_delivery_tracker_test.dart @@ -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.filled(32, 0xAA)); + final bob = Uint8List.fromList(List.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.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); + }); + }); +} diff --git a/test/providers/messages_provider_retransmission_test.dart b/test/providers/messages_provider_retransmission_test.dart new file mode 100644 index 0000000..721a2a1 --- /dev/null +++ b/test/providers/messages_provider_retransmission_test.dart @@ -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.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.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); + }); + }); + }); +}