diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index f2458dd..81ab02e 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -264,7 +264,6 @@ class ConnectionProvider with ChangeNotifier { try { await _activeService.addOrUpdateContact(pendingOp.contact!); - await Future.delayed(const Duration(milliseconds: 300)); if (pendingOp.messageId != null) { _messageDeliveryTracker.trackPendingDirectMessage( @@ -1272,12 +1271,11 @@ class ConnectionProvider with ChangeNotifier { '⚠️ [ConnectionProvider] Rate limit hit: $pendingCount pending ACKs (max 7)', ); debugPrint( - '⚠️ Firmware only tracks 8 ACKs - waiting for delivery confirmations...', + '⚠️ Firmware only tracks 8 ACKs - waiting for delivery confirmation...', ); - // Wait briefly for some ACKs to arrive, then proceed anyway - // (User action shouldn't be blocked forever) - await Future.delayed(const Duration(milliseconds: 500)); + // Wait for a slot to free up (delivery/timeout), max 500ms + await _messageDeliveryTracker.waitForSlot(); if (_messageDeliveryTracker.shouldRateLimit) { debugPrint( @@ -1353,30 +1351,14 @@ class ConnectionProvider with ChangeNotifier { attempt: retryAttempt, ); - if (messageId != null) { - Future.delayed(const Duration(milliseconds: 350), () { - if (_messageDeliveryTracker.hasAckForMessage(messageId)) { - return; - } - - debugPrint( - 'ℹ️ [ConnectionProvider] Missing RESP_CODE_SENT for $messageId; promoting to sent via fallback', - ); - onMessageSent?.call(messageId, 0, 0); - }); - } - - // Clear pending operation after successful send (no error) - // If ERR_CODE_NOT_FOUND occurs, the operation will be recovered automatically + // Clear pending operation — any ERR_CODE_NOT_FOUND has already been + // handled synchronously by onContactNotFound before sendTextMessage returns. if (effectiveContact != null) { final operationId = contactPublicKey .sublist(0, 6) .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(':'); - // Use a small delay to allow error response to arrive before clearing - Future.delayed(const Duration(milliseconds: 500), () { - _pendingSendOperations.remove(operationId); - }); + _pendingSendOperations.remove(operationId); } return true; diff --git a/lib/providers/helpers/message_delivery_tracker.dart b/lib/providers/helpers/message_delivery_tracker.dart index d91ea9b..c2e5e58 100644 --- a/lib/providers/helpers/message_delivery_tracker.dart +++ b/lib/providers/helpers/message_delivery_tracker.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:typed_data'; /// Message delivery tracking helper @@ -30,6 +31,9 @@ class MessageDeliveryTracker { /// Map of ACK tag to timestamp for timeout cleanup final Map _ackTagTimestamps = {}; + /// Completer signalled when a pending ACK slot is freed (delivery or removal). + Completer? _slotFreedCompleter; + /// Track a pending message ID before sending /// /// This is called BEFORE sending the message. When RESP_CODE_SENT @@ -112,6 +116,7 @@ class MessageDeliveryTracker { _messageIdToAckTag.remove(messageId); } _ackTagTimestamps.remove(ackCode); + _notifySlotFreed(); } /// Remove ACK tag mapping by message ID @@ -122,6 +127,7 @@ class MessageDeliveryTracker { if (ackTag != null) { _ackTagToMessageId.remove(ackTag); _ackTagTimestamps.remove(ackTag); + _notifySlotFreed(); } _pendingMessageIds.remove(messageId); final emptyKeys = []; @@ -166,6 +172,7 @@ class MessageDeliveryTracker { _ackTagToMessageId.clear(); _messageIdToAckTag.clear(); _ackTagTimestamps.clear(); + _notifySlotFreed(); } /// Get count of pending ACK tags @@ -179,6 +186,27 @@ class MessageDeliveryTracker { /// Returns true if >= 7 pending ACKs (stay under firmware limit of 8) bool get shouldRateLimit => pendingCount >= 7; + /// Wait until a pending ACK slot is freed, or [timeout] elapses. + /// + /// Returns immediately if not at the rate limit. + Future waitForSlot({ + Duration timeout = const Duration(milliseconds: 500), + }) async { + if (!shouldRateLimit) return; + _slotFreedCompleter ??= Completer(); + await _slotFreedCompleter!.future.timeout( + timeout, + onTimeout: () {}, + ); + } + + void _notifySlotFreed() { + if (_slotFreedCompleter != null && !_slotFreedCompleter!.isCompleted) { + _slotFreedCompleter!.complete(); + } + _slotFreedCompleter = null; + } + /// Get oldest pending ACK timestamp (for debugging) DateTime? get oldestPendingTimestamp { if (_ackTagTimestamps.isEmpty) return null; diff --git a/lib/screens/live_traffic_screen.dart b/lib/screens/live_traffic_screen.dart index 2a6b889..ec1edf1 100644 --- a/lib/screens/live_traffic_screen.dart +++ b/lib/screens/live_traffic_screen.dart @@ -134,11 +134,12 @@ class _LiveTrafficScreenState extends State { final routeHashCounts = _RouteHashCounts.fromContacts( contactsProvider?.contacts ?? const [], ); - final packetTypes = unfilteredSnapshot.visibleEntries - .map((entry) => entry.payloadLabel) - .toSet() - .toList() - ..sort(); + final packetTypes = + unfilteredSnapshot.visibleEntries + .map((entry) => entry.payloadLabel) + .toSet() + .toList() + ..sort(); final filteredEntries = snapshot.visibleEntries; return Scaffold( @@ -202,7 +203,9 @@ class _LiveTrafficScreenState extends State { Icon( Icons.radar_rounded, size: 64, - color: theme.colorScheme.primary.withValues(alpha: 0.45), + color: theme.colorScheme.primary.withValues( + alpha: 0.45, + ), ), const SizedBox(height: 16), const Text( @@ -218,7 +221,9 @@ class _LiveTrafficScreenState extends State { ? 'This view only shows in-memory traffic while it is active.' : 'Try a different packet type or switch back to All.', textAlign: TextAlign.center, - style: TextStyle(color: theme.colorScheme.onSurfaceVariant), + style: TextStyle( + color: theme.colorScheme.onSurfaceVariant, + ), ), ], ), @@ -488,7 +493,9 @@ class _FilterChip extends StatelessWidget { decoration: BoxDecoration( color: color.withValues(alpha: selected ? 0.14 : 0.08), borderRadius: BorderRadius.circular(999), - border: Border.all(color: color.withValues(alpha: selected ? 0.45 : 0.22)), + border: Border.all( + color: color.withValues(alpha: selected ? 0.45 : 0.22), + ), ), child: Text( label, @@ -641,8 +648,9 @@ class _LiveTrafficCard extends StatelessWidget { final isRx = log.direction == PacketDirection.rx; final accent = isRx ? Colors.green : Colors.blue; final rxInfo = log.logRxDataInfo; - final routePreview = _resolvedRoutePreview(context, entry); final originDistance = _originDistanceLabel(context, entry); + final packetDetails = _LiveTrafficPacketDetails.fromEntry(entry); + final signalMetric = _SignalMetric.fromRxInfo(rxInfo); return Container( padding: const EdgeInsets.all(14), @@ -657,7 +665,10 @@ class _LiveTrafficCard extends StatelessWidget { Row( children: [ Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), decoration: BoxDecoration( color: accent.withValues(alpha: 0.14), borderRadius: BorderRadius.circular(999), @@ -677,7 +688,7 @@ class _LiveTrafficCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - entry.payloadLabel, + packetDetails.title, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w700, @@ -696,16 +707,32 @@ class _LiveTrafficCard extends StatelessWidget { ], ), ), - Text( - _timeAgo(log.timestamp, now), - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurfaceVariant, + if (signalMetric != null) ...[ + const SizedBox(width: 12), + _CompactSignalIndicator(metric: signalMetric), + ] else + Text( + _timeAgo(log.timestamp, now), + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), - ), ], ), const SizedBox(height: 10), + _PacketInfoLine( + text: + '${_formatClock(log.timestamp)} • Size: ${log.rawData.length} bytes', + ), + _PacketInfoLine(text: 'Hash: ${packetDetails.packetHashHex}'), + if (packetDetails.pathLine != null) + _PacketInfoLine(text: packetDetails.pathLine!), + if (packetDetails.pathHashLine != null) + _PacketInfoLine(text: packetDetails.pathHashLine!), + if (packetDetails.endpointLine != null) + _PacketInfoLine(text: packetDetails.endpointLine!), + const SizedBox(height: 10), Wrap( spacing: 8, runSpacing: 8, @@ -714,29 +741,18 @@ class _LiveTrafficCard extends StatelessWidget { label: '${log.rawData.length} bytes', onTap: () => _showPacketBytesSheet(context, log.rawData), ), + if (entry.isMultiHop) + const _PacketMetaChip(label: 'MULTI-HOP', emphasized: true), + if (originDistance != null) + _PacketMetaChip(label: 'Origin $originDistance'), if (rxInfo?.rssiDbm != null) _PacketMetaChip(label: 'RSSI ${rxInfo!.rssiDbm} dBm'), if (rxInfo?.snrDb != null) _PacketMetaChip( label: 'SNR ${rxInfo!.snrDb!.toStringAsFixed(1)} dB', ), - if (entry.hopCount != null) - _PacketMetaChip(label: '${entry.hopCount} hops'), - if (entry.isMultiHop) - const _PacketMetaChip(label: 'MULTI-HOP', emphasized: true), - if (originDistance != null) - _PacketMetaChip(label: 'Origin $originDistance'), ], ), - const SizedBox(height: 10), - Text( - routePreview, - style: TextStyle( - fontSize: 12, - fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), ], ), ); @@ -748,6 +764,13 @@ class _LiveTrafficCard extends StatelessWidget { return '${diff.inMinutes}m ago'; } + static String _formatClock(DateTime timestamp) { + final hour = timestamp.hour.toString().padLeft(2, '0'); + final minute = timestamp.minute.toString().padLeft(2, '0'); + final second = timestamp.second.toString().padLeft(2, '0'); + return '$hour:$minute:$second'; + } + static String _resolvedRoutePreview( BuildContext context, LiveTrafficEntry entry, @@ -856,11 +879,16 @@ class _LiveTrafficCard extends StatelessWidget { final hex = chunk .map((byte) => byte.toRadixString(16).padLeft(2, '0').toUpperCase()) .join(' '); - hexLines.add('${offset.toRadixString(16).padLeft(4, '0').toUpperCase()}: $hex'); + hexLines.add( + '${offset.toRadixString(16).padLeft(4, '0').toUpperCase()}: $hex', + ); } final ascii = data - .map((byte) => (byte >= 32 && byte <= 126) ? String.fromCharCode(byte) : '.') + .map( + (byte) => + (byte >= 32 && byte <= 126) ? String.fromCharCode(byte) : '.', + ) .join(); return showModalBottomSheet( @@ -886,10 +914,7 @@ class _LiveTrafficCard extends StatelessWidget { style: TextStyle(color: scheme.onSurfaceVariant), ), const SizedBox(height: 16), - Text( - 'Hex', - style: Theme.of(context).textTheme.titleSmall, - ), + Text('Hex', style: Theme.of(context).textTheme.titleSmall), const SizedBox(height: 8), Container( width: double.infinity, @@ -909,10 +934,7 @@ class _LiveTrafficCard extends StatelessWidget { ), ), const SizedBox(height: 16), - Text( - 'ASCII', - style: Theme.of(context).textTheme.titleSmall, - ), + Text('ASCII', style: Theme.of(context).textTheme.titleSmall), const SizedBox(height: 8), Container( width: double.infinity, @@ -951,6 +973,253 @@ class _LiveTrafficCard extends StatelessWidget { } } +class _LiveTrafficPacketDetails { + final String title; + final String packetHashHex; + final String? pathLine; + final String? pathHashLine; + final String? endpointLine; + + const _LiveTrafficPacketDetails({ + required this.title, + required this.packetHashHex, + required this.pathLine, + required this.pathHashLine, + required this.endpointLine, + }); + + factory _LiveTrafficPacketDetails.fromEntry(LiveTrafficEntry entry) { + final route = entry.route; + final payloadType = route?.payloadType; + final parsedPayload = _ParsedTrafficPayload.tryParse( + entry.log.rawData, + route, + ); + final title = switch (payloadType) { + 0x00 => 'FLOOD REQUEST', + 0x01 => 'FLOOD RESPONSE', + 0x02 => 'FLOOD TEXT', + 0x03 => 'FLOOD ACK', + 0x04 => 'FLOOD ADVERTISEMENT', + 0x05 => 'FLOOD GROUP_TEXT', + 0x06 => 'FLOOD GROUP_DATA', + 0x07 => 'FLOOD ANON_REQUEST', + 0x08 => 'FLOOD RETURNED_PATH', + 0x09 => 'FLOOD TRACE_PATH', + 0x0A => 'FLOOD MULTIPART', + 0x0B => 'FLOOD CONTROL', + _ => entry.payloadLabel.toUpperCase(), + }; + + final hopHashes = route?.hopHashes ?? const []; + final pathLine = hopHashes.isEmpty + ? null + : 'Path: ${hopHashes.length} hop${hopHashes.length == 1 ? '' : 's'} [${hopHashes.join(',')}]'; + final pathHashLine = route == null + ? null + : 'Path Hashes: ${route.hashSize}-byte per hop'; + + return _LiveTrafficPacketDetails( + title: title, + packetHashHex: _packetHash(entry.log.rawData), + pathLine: pathLine, + pathHashLine: pathHashLine, + endpointLine: parsedPayload?.endpointLine, + ); + } + + static String _packetHash(List bytes) { + const fnvOffset = 0xcbf29ce484222325; + const fnvPrime = 0x100000001b3; + const mask = 0xFFFFFFFFFFFFFFFF; + var hash = fnvOffset; + for (final byte in bytes) { + hash ^= byte & 0xFF; + hash = (hash * fnvPrime) & mask; + } + return hash.toRadixString(16).padLeft(16, '0').toUpperCase(); + } +} + +class _ParsedTrafficPayload { + final String? endpointLine; + + const _ParsedTrafficPayload({this.endpointLine}); + + static _ParsedTrafficPayload? tryParse( + List rawData, + DecodedLogRxRoute? route, + ) { + if (rawData.length < 5 || + rawData.first != LiveTrafficSummary.logRxDataResponseCode) { + return null; + } + + final rawPacketData = rawData.sublist(3); + if (rawPacketData.length < 2) return null; + + final header = rawPacketData[0]; + final routeType = header & 0x03; + final payloadType = (header >> 2) & 0x0F; + + var index = 1; + if (routeType == 0x00 || routeType == 0x03) { + if (rawPacketData.length < index + 5) return null; + index += 4; + } + + if (rawPacketData.length <= index) return null; + final pathDescriptor = rawPacketData[index++]; + final pathByteLen = + LogRxRouteDecoder.descriptorByteLength(pathDescriptor) ?? + (pathDescriptor == 0xFF ? 0 : null); + if (pathByteLen == null || rawPacketData.length < index + pathByteLen) { + return null; + } + index += pathByteLen; + final payload = rawPacketData.sublist(index); + final senderHash = route?.hopHashes.isNotEmpty == true + ? route!.hopHashes.first + : null; + + switch (payloadType) { + case 0x05: + case 0x06: + if (payload.isEmpty) return const _ParsedTrafficPayload(); + return _ParsedTrafficPayload( + endpointLine: + 'Channel Hash: ${payload.first.toRadixString(16).padLeft(2, '0').toUpperCase()}', + ); + case 0x00: + case 0x01: + case 0x07: + if (payload.isEmpty) return const _ParsedTrafficPayload(); + final destinationHash = payload.first + .toRadixString(16) + .padLeft(2, '0') + .toUpperCase(); + return _ParsedTrafficPayload( + endpointLine: senderHash == null + ? 'To: <$destinationHash>' + : 'From: <${senderHash.toUpperCase()}> To: <$destinationHash>', + ); + default: + return const _ParsedTrafficPayload(); + } + } +} + +class _PacketInfoLine extends StatelessWidget { + final String text; + + const _PacketInfoLine({required this.text}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + text, + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ); + } +} + +class _SignalMetric { + final String valueLabel; + final Color color; + final int activeBars; + + const _SignalMetric({ + required this.valueLabel, + required this.color, + required this.activeBars, + }); + + static _SignalMetric? fromRxInfo(LogRxDataInfo? rxInfo) { + if (rxInfo == null) return null; + if (rxInfo?.snrDb != null) { + final snr = rxInfo.snrDb!; + return _SignalMetric( + valueLabel: '${snr.toStringAsFixed(1)}dB', + color: snr >= 10 + ? Colors.green + : snr >= 0 + ? Colors.amber + : Colors.redAccent, + activeBars: snr >= 10 + ? 3 + : snr >= 0 + ? 2 + : 1, + ); + } + if (rxInfo?.rssiDbm != null) { + final rssi = rxInfo.rssiDbm!; + return _SignalMetric( + valueLabel: '$rssi dBm', + color: rssi >= -80 + ? Colors.green + : rssi >= -95 + ? Colors.amber + : Colors.redAccent, + activeBars: rssi >= -80 + ? 3 + : rssi >= -95 + ? 2 + : 1, + ); + } + return null; + } +} + +class _CompactSignalIndicator extends StatelessWidget { + final _SignalMetric metric; + + const _CompactSignalIndicator({required this.metric}); + + @override + Widget build(BuildContext context) { + final inactive = Theme.of(context).colorScheme.outlineVariant; + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + for (var index = 0; index < 3; index++) ...[ + if (index > 0) const SizedBox(width: 3), + Container( + width: 5, + height: 10.0 + (index * 8), + decoration: BoxDecoration( + color: index < metric.activeBars ? metric.color : inactive, + borderRadius: BorderRadius.circular(2), + ), + ), + ], + ], + ), + const SizedBox(height: 6), + Text( + metric.valueLabel, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ], + ); + } +} + class _GeoPoint { final double latitude; final double longitude; diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index 29e2f1e..1e42548 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -61,9 +61,10 @@ class _MessagesTabState extends State { Timer? _highlightTimer; // Timer for clearing message highlight Timer? _channelReadTimer; String? _pendingChannelReadKey; - TextEditingValue _lastComposerValue = const TextEditingValue(); - bool _isMentionPickerOpen = false; bool _suppressMentionTrigger = false; + TextRange? _activeMentionRange; + String _mentionQuery = ''; + List _mentionSuggestions = const []; // Message destination state String _destinationType = @@ -92,8 +93,8 @@ class _MessagesTabState extends State { @override void initState() { super.initState(); - _lastComposerValue = _textController.value; _textController.addListener(_handleComposerChanged); + _focusNode.addListener(_handleFocusChanged); // Load saved message destination _loadSavedDestination(); _loadVoiceSettings(); @@ -126,6 +127,7 @@ class _MessagesTabState extends State { _channelReadTimer?.cancel(); _voiceStreamSub?.cancel(); _voiceRecorder.dispose(); + _focusNode.removeListener(_handleFocusChanged); _textController.dispose(); _focusNode.dispose(); _scrollController.dispose(); @@ -195,25 +197,13 @@ class _MessagesTabState extends State { } void _handleComposerChanged() { - final previousValue = _lastComposerValue; - final currentValue = _textController.value; - _lastComposerValue = currentValue; - _updateCharacterCount(); - if (_suppressMentionTrigger || _isMentionPickerOpen) { + if (_suppressMentionTrigger) { return; } - final mentionTriggerRange = _getMentionTriggerRange( - previousValue: previousValue, - currentValue: currentValue, - ); - if (mentionTriggerRange == null) { - return; - } - - unawaited(_showMentionSelectorForRange(mentionTriggerRange)); + _updateMentionSuggestions(_textController.value); } void _updateCharacterCount() { @@ -222,43 +212,112 @@ class _MessagesTabState extends State { }); } - TextRange? _getMentionTriggerRange({ - required TextEditingValue previousValue, - required TextEditingValue currentValue, - }) { - if (!previousValue.selection.isValid || - !currentValue.selection.isValid || - !previousValue.selection.isCollapsed || - !currentValue.selection.isCollapsed) { + void _handleFocusChanged() { + if (!_focusNode.hasFocus) { + _clearMentionSuggestions(); + return; + } + + if (_suppressMentionTrigger) { + return; + } + + _updateMentionSuggestions(_textController.value); + } + + void _updateMentionSuggestions(TextEditingValue value) { + final triggerRange = _getMentionTriggerRange(value); + if (triggerRange == null) { + _clearMentionSuggestions(); + return; + } + + final query = value.text.substring( + triggerRange.start + 1, + triggerRange.end, + ); + final contacts = _buildMentionSuggestions(query); + if (!mounted) { + return; + } + + setState(() { + _activeMentionRange = triggerRange; + _mentionQuery = query; + _mentionSuggestions = contacts; + }); + } + + void _clearMentionSuggestions() { + if (_activeMentionRange == null && + _mentionQuery.isEmpty && + _mentionSuggestions.isEmpty) { + return; + } + + setState(() { + _activeMentionRange = null; + _mentionQuery = ''; + _mentionSuggestions = const []; + }); + } + + TextRange? _getMentionTriggerRange(TextEditingValue value) { + if (!value.selection.isValid || !value.selection.isCollapsed) { return null; } - final previousOffset = previousValue.selection.baseOffset; - final currentOffset = currentValue.selection.baseOffset; - if (previousOffset < 0 || currentOffset < 0) { + final cursorOffset = value.selection.baseOffset; + if (cursorOffset <= 0) { return null; } - if (currentValue.text.length != previousValue.text.length + 1 || - currentOffset != previousOffset + 1) { + final text = value.text; + final triggerStart = text.lastIndexOf('@', cursorOffset - 1); + if (triggerStart == -1) { return null; } - if (currentValue.text.substring(0, previousOffset) != - previousValue.text.substring(0, previousOffset)) { + final leadingChar = triggerStart > 0 ? text[triggerStart - 1] : null; + if (leadingChar != null && !RegExp(r'[\s\(\[\{]').hasMatch(leadingChar)) { return null; } - if (currentValue.text.substring(currentOffset) != - previousValue.text.substring(previousOffset)) { + final query = text.substring(triggerStart + 1, cursorOffset); + if (query.contains(RegExp(r'[\s@\[\]\n\r]'))) { return null; } - if (currentValue.text[previousOffset] != '@') { - return null; - } + return TextRange(start: triggerStart, end: cursorOffset); + } - return TextRange(start: previousOffset, end: currentOffset); + List _buildMentionSuggestions(String query) { + final contactsProvider = context.read(); + final normalizedQuery = query.trim().toLowerCase(); + final contacts = + contactsProvider.contacts + .where((contact) => contact.type == ContactType.chat) + .where((contact) { + if (normalizedQuery.isEmpty) return true; + return contact.displayName.toLowerCase().contains( + normalizedQuery, + ); + }) + .toList() + ..sort((a, b) { + final aName = a.displayName.toLowerCase(); + final bName = b.displayName.toLowerCase(); + final aStarts = + normalizedQuery.isNotEmpty && aName.startsWith(normalizedQuery); + final bStarts = + normalizedQuery.isNotEmpty && bName.startsWith(normalizedQuery); + if (aStarts != bStarts) { + return aStarts ? -1 : 1; + } + return aName.compareTo(bName); + }); + + return contacts.take(8).toList(growable: false); } int get _maxMessageBytes => @@ -378,56 +437,6 @@ class _MessagesTabState extends State { ); } - Future _showMentionSelectorForRange(TextRange triggerRange) async { - final contactsProvider = context.read(); - final contacts = contactsProvider.contacts - .where((contact) => contact.type == ContactType.chat) - .toList(); - - if (contacts.isEmpty || !mounted) { - return; - } - - _isMentionPickerOpen = true; - Contact? selectedContact; - - await showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => RecipientSelectorSheet( - contacts: contacts, - rooms: const [], - channels: const [], - unreadCount: 0, - unreadCountsByPublicKey: { - for (final contact in contacts) contact.publicKeyHex: 0, - }, - currentDestinationType: null, - currentRecipientPublicKey: null, - showAllOption: false, - onSelect: (_, recipient) { - selectedContact = recipient; - }, - ), - ); - - _isMentionPickerOpen = false; - - if (!mounted) { - return; - } - - if (selectedContact != null) { - _insertReplyMention( - selectedContact!.displayName, - replacementRange: triggerRange, - ); - } - - _focusNode.requestFocus(); - } - /// Handle recipient selection Future _onRecipientSelected(String type, Contact? recipient) async { setState(() { @@ -489,11 +498,19 @@ class _MessagesTabState extends State { selection: TextSelection.collapsed(offset: nextOffset), composing: TextRange.empty, ); - _lastComposerValue = _textController.value; _suppressMentionTrigger = false; + _clearMentionSuggestions(); _enforceMessageByteLimit(); } + void _selectMention(Contact contact) { + _insertReplyMention( + contact.displayName, + replacementRange: _activeMentionRange, + ); + _focusNode.requestFocus(); + } + Future _replyToMessage(Message message) async { final l10n = AppLocalizations.of(context)!; final contactsProvider = context.read(); @@ -2040,6 +2057,9 @@ class _MessagesTabState extends State { bottomPadding: composerBottomPadding, destinationLabel: _getDestinationLabel(), destinationAvatar: _buildDestinationAvatar(context), + mentionSuggestions: _mentionSuggestions, + mentionQuery: _mentionQuery, + onMentionSelected: _selectMention, onShowComposerActions: _showComposerActions, onShowRecipientSelector: _showRecipientSelector, onStartVoiceRecording: _startVoiceRecording, diff --git a/lib/widgets/messages/messages_composer.dart b/lib/widgets/messages/messages_composer.dart index 8b492fa..a2537b0 100644 --- a/lib/widgets/messages/messages_composer.dart +++ b/lib/widgets/messages/messages_composer.dart @@ -3,7 +3,9 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../l10n/app_localizations.dart'; +import '../../models/contact.dart'; import '../../providers/app_provider.dart'; +import '../common/contact_avatar.dart'; class MessagesComposer extends StatelessWidget { final TextEditingController textController; @@ -17,6 +19,9 @@ class MessagesComposer extends StatelessWidget { final double bottomPadding; final String destinationLabel; final Widget destinationAvatar; + final List mentionSuggestions; + final String mentionQuery; + final ValueChanged onMentionSelected; final VoidCallback onShowComposerActions; final VoidCallback onShowRecipientSelector; final Future Function() onStartVoiceRecording; @@ -36,6 +41,9 @@ class MessagesComposer extends StatelessWidget { required this.bottomPadding, required this.destinationLabel, required this.destinationAvatar, + required this.mentionSuggestions, + required this.mentionQuery, + required this.onMentionSelected, required this.onShowComposerActions, required this.onShowRecipientSelector, required this.onStartVoiceRecording, @@ -50,6 +58,15 @@ class MessagesComposer extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ + if (mentionSuggestions.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), + child: _MentionSuggestionsCard( + suggestions: mentionSuggestions, + query: mentionQuery, + onSelected: onMentionSelected, + ), + ), SafeArea( top: false, child: Padding( @@ -153,6 +170,96 @@ class MessagesComposer extends StatelessWidget { } } +class _MentionSuggestionsCard extends StatelessWidget { + final List suggestions; + final String query; + final ValueChanged onSelected; + + const _MentionSuggestionsCard({ + required this.suggestions, + required this.query, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + final title = query.isEmpty ? '@' : '@$query'; + + return Material( + elevation: 10, + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(20), + child: Container( + constraints: const BoxConstraints(maxHeight: 360), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: Theme.of(context).dividerColor.withValues(alpha: 0.30), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(18, 14, 18, 8), + child: Text( + title, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + Flexible( + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.fromLTRB(10, 0, 10, 10), + itemCount: suggestions.length, + separatorBuilder: (_, _) => const SizedBox(height: 4), + itemBuilder: (context, index) { + final contact = suggestions[index]; + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () => onSelected(contact), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 10, + ), + child: Row( + children: [ + ContactAvatar(contact: contact, radius: 19), + const SizedBox(width: 12), + Expanded( + child: Text( + contact.displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +} + class _ComposerActionButton extends StatelessWidget { final bool isRecording; final VoidCallback onPressed; diff --git a/pubspec.lock b/pubspec.lock index 58d059c..c192f51 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -795,8 +795,8 @@ packages: dependency: "direct main" description: path: "." - ref: "27e65f4" - resolved-ref: "27e65f48b1b5dc3dbcafc0167c4c5755770116ba" + ref: "55e303a" + resolved-ref: "55e303aa71d9fd847cedd11bc1243504fbe363e0" url: "https://github.com/dz0ny/meshcore_client.git" source: git version: "0.1.0" diff --git a/pubspec.yaml b/pubspec.yaml index b78c488..612b781 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,7 +44,7 @@ dependencies: meshcore_client: git: url: https://github.com/dz0ny/meshcore_client.git - ref: "27e65f4" + ref: "55e303a" # Codec2 ultra-low-bitrate speech codec (FFI plugin) codec2_flutter: diff --git a/test/screens/live_traffic_screen_test.dart b/test/screens/live_traffic_screen_test.dart index 5ad947f..e8701aa 100644 --- a/test/screens/live_traffic_screen_test.dart +++ b/test/screens/live_traffic_screen_test.dart @@ -115,9 +115,15 @@ void main() { expect(find.text('1 pkt/min'), findsOneWidget); expect(find.text('Device total 7'), findsOneWidget); - expect(find.text('Response'), findsWidgets); + expect(find.text('FLOOD RESPONSE'), findsOneWidget); expect(find.text('MULTI-HOP'), findsOneWidget); expect(find.textContaining('RSSI -84 dBm'), findsOneWidget); + expect(find.textContaining('Hash:'), findsOneWidget); + expect( + find.textContaining('Path: 3 hops [c010,6301,68d9]'), + findsOneWidget, + ); + expect(find.textContaining('Path Hashes: 2-byte per hop'), findsOneWidget); }); testWidgets('clear live view only resets transient screen state', ( @@ -174,6 +180,6 @@ void main() { await tester.pump(); expect(find.text('No packets for this filter'), findsNothing); - expect(find.textContaining('3 bytes'), findsOneWidget); + expect(find.textContaining('Size: 3 bytes'), findsOneWidget); }); } diff --git a/test/screens/messages_tab_test.dart b/test/screens/messages_tab_test.dart index a93466f..680d04a 100644 --- a/test/screens/messages_tab_test.dart +++ b/test/screens/messages_tab_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:meshcore_sar_app/l10n/app_localizations.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; import 'package:meshcore_sar_app/models/message.dart'; import 'package:meshcore_sar_app/providers/app_provider.dart'; import 'package:meshcore_sar_app/providers/channels_provider.dart'; @@ -16,10 +17,34 @@ import 'package:meshcore_sar_app/services/voice_codec_service.dart'; import 'package:meshcore_sar_app/services/voice_player_service.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'dart:typed_data'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + Contact buildContact({ + required String name, + required ContactType type, + int secondByte = 1, + }) { + final publicKey = Uint8List(32); + publicKey[0] = secondByte; + publicKey[1] = secondByte; + + return Contact( + publicKey: publicKey, + type: type, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: name, + lastAdvert: 0, + advLat: 0, + advLon: 0, + lastMod: 0, + ); + } + setUp(() { SharedPreferences.setMockInitialValues({}); }); @@ -104,4 +129,83 @@ void main() { connectionProvider.dispose(); channelsProvider.dispose(); }); + + testWidgets('shows mention overlay above composer and inserts selection', ( + tester, + ) async { + final connectionProvider = ConnectionProvider(); + final contactsProvider = ContactsProvider(); + final messagesProvider = MessagesProvider(); + final mapProvider = MapProvider(); + final drawingProvider = DrawingProvider(); + await messagesProvider.initialize(); + await drawingProvider.initialize(); + final channelsProvider = ChannelsProvider()..initializePublicChannel(); + final voiceProvider = VoiceProvider( + codec: VoiceCodecService(), + player: VoicePlayerService(), + ); + final imageProvider = ip.ImageProvider(); + final appProvider = AppProvider( + connectionProvider: connectionProvider, + contactsProvider: contactsProvider, + messagesProvider: messagesProvider, + drawingProvider: drawingProvider, + channelsProvider: channelsProvider, + voiceProvider: voiceProvider, + imageProvider: imageProvider, + ); + + contactsProvider.addContacts([ + buildContact(name: 'Tim', type: ContactType.chat, secondByte: 2), + buildContact(name: 'Slane', type: ContactType.chat, secondByte: 3), + ]); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: connectionProvider), + ChangeNotifierProvider.value(value: contactsProvider), + ChangeNotifierProvider.value(value: messagesProvider), + ChangeNotifierProvider.value(value: mapProvider), + ChangeNotifierProvider.value(value: drawingProvider), + ChangeNotifierProvider.value(value: channelsProvider), + ChangeNotifierProvider.value(value: voiceProvider), + ChangeNotifierProvider.value(value: imageProvider), + ChangeNotifierProvider.value(value: appProvider), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: MessagesTab(isActive: true)), + ), + ), + ); + + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 60)); + + await tester.tap(find.byType(TextField).first); + await tester.enterText(find.byType(TextField).first, '@t'); + await tester.pumpAndSettle(); + + expect(find.text('Tim'), findsOneWidget); + expect(find.text('Slane'), findsNothing); + + await tester.tap(find.text('Tim')); + await tester.pumpAndSettle(); + + expect(find.text('@[Tim] '), findsOneWidget); + expect(find.text('Tim'), findsNothing); + + appProvider.dispose(); + voiceProvider.dispose(); + imageProvider.dispose(); + drawingProvider.dispose(); + mapProvider.dispose(); + messagesProvider.dispose(); + contactsProvider.dispose(); + connectionProvider.dispose(); + channelsProvider.dispose(); + }); }