From 3392e5f9c1d97e6ec5f32be00c453e9a94885b0f Mon Sep 17 00:00:00 2001 From: Janez T Date: Tue, 31 Mar 2026 17:52:44 +0200 Subject: [PATCH] feat: Add relay ping and lock message destination --- lib/providers/connection_provider.dart | 105 ++++++++ lib/screens/messages_tab.dart | 198 ++++++++++------ lib/screens/settings_screen.dart | 180 ++++++++++++++ .../message_destination_preferences.dart | 57 +++++ lib/widgets/contacts/contact_tile.dart | 201 ++++++++++++++++ lib/widgets/messages/messages_composer.dart | 101 ++++---- pubspec.lock | 8 +- pubspec.yaml | 4 +- test/screens/messages_tab_test.dart | 224 ++++++++++++++++++ 9 files changed, 955 insertions(+), 123 deletions(-) diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 1fbde18..25addf5 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -47,6 +47,23 @@ class PingResult { }); } +/// Result of a relay ping (trace path) operation +class RelayPingResult { + final bool success; + final int durationMs; + final double snrThere; + final double snrBack; + final int hopCount; + + const RelayPingResult({ + required this.success, + required this.durationMs, + required this.snrThere, + required this.snrBack, + required this.hopCount, + }); +} + /// Scanned device with RSSI information class ScannedDevice { final BluetoothDevice device; @@ -172,6 +189,8 @@ class ConnectionProvider with ChangeNotifier { MessageDeliveryTracker(); final PingTracker _pingTracker = PingTracker(); final Map> _pendingSmartPings = {}; + final Map> _pendingRelayPings = {}; + final Map _relayPingStartTimes = {}; // Expose room login states Map get roomLoginStates => @@ -631,6 +650,10 @@ class ConnectionProvider with ChangeNotifier { notifyListeners(); }; + service.onTraceDataReceived = (nonce, hopCount, snrThere, snrBack) { + _handleTraceDataReceived(nonce, hopCount, snrThere, snrBack); + }; + service.onTxActivity = () { _txActivity = true; notifyListeners(); @@ -2036,6 +2059,88 @@ class ConnectionProvider with ChangeNotifier { } } + /// Ping a relay/repeater using trace path (command 36). + /// Returns RTT, SNR there/back, and hop count. + Future pingRelay(Contact contact) async { + if (!_activeService.isConnected) { + return const RelayPingResult( + success: false, durationMs: 0, snrThere: 0, snrBack: 0, hopCount: 0, + ); + } + + final nonce = Random().nextInt(0xFFFFFFFF); + final completer = Completer(); + _pendingRelayPings[nonce] = completer; + _relayPingStartTimes[nonce] = DateTime.now().millisecondsSinceEpoch; + + // Map ContactType to hop type: chat=0, repeater=1, room=2, sensor=3 + int hopType; + switch (contact.type) { + case ContactType.chat: + hopType = 0; + break; + case ContactType.repeater: + hopType = 1; + break; + case ContactType.room: + hopType = 2; + break; + case ContactType.sensor: + hopType = 3; + break; + default: + hopType = 0; + } + + // Timeout after 10 seconds + final timer = Timer(const Duration(seconds: 10), () { + _pendingRelayPings.remove(nonce); + _relayPingStartTimes.remove(nonce); + if (!completer.isCompleted) { + completer.complete(const RelayPingResult( + success: false, durationMs: 0, snrThere: 0, snrBack: 0, hopCount: 0, + )); + } + }); + + try { + await _activeService.sendTracePath( + nonce: nonce, + hopType: hopType, + contactPublicKey: contact.publicKey, + ); + final result = await completer.future; + timer.cancel(); + return result; + } catch (e) { + timer.cancel(); + _pendingRelayPings.remove(nonce); + _relayPingStartTimes.remove(nonce); + return const RelayPingResult( + success: false, durationMs: 0, snrThere: 0, snrBack: 0, hopCount: 0, + ); + } + } + + void _handleTraceDataReceived( + int nonce, int hopCount, double snrThere, double snrBack, + ) { + final completer = _pendingRelayPings.remove(nonce); + final startTime = _relayPingStartTimes.remove(nonce); + if (completer != null && !completer.isCompleted) { + final durationMs = startTime != null + ? DateTime.now().millisecondsSinceEpoch - startTime + : 0; + completer.complete(RelayPingResult( + success: true, + durationMs: durationMs, + snrThere: snrThere, + snrBack: snrBack, + hopCount: hopCount, + )); + } + } + String _publicKeyToHex(Uint8List publicKey) { return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); } diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index 57c0919..80a7e79 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -148,11 +148,13 @@ class _MessagesTabState extends State { TextRange? _activeMentionRange; String _mentionQuery = ''; List _mentionSuggestions = const []; + ContactsProvider? _contactsProvider; // Message destination state String _destinationType = MessageDestinationPreferences.destinationTypeChannel; Contact? _selectedRecipient; + bool _isDestinationLocked = false; // Region scope state String? _channelRegionScopeName; @@ -183,13 +185,9 @@ class _MessagesTabState extends State { super.initState(); _textController.addListener(_handleComposerChanged); _focusNode.addListener(_handleFocusChanged); - // Load saved message destination - _loadSavedDestination(); _loadVoiceSettings(); _loadAllChannelRegionScopes(); - WidgetsBinding.instance.addPostFrameCallback((_) { - _checkForNavigationRequest(); - }); + _scheduleDestinationSync(); } Future _loadVoiceSettings() async { @@ -203,11 +201,13 @@ class _MessagesTabState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); - // Reload saved destination and check for navigation request whenever dependencies change - WidgetsBinding.instance.addPostFrameCallback((_) { - _loadSavedDestination(); - _checkForNavigationRequest(); - }); + final contactsProvider = context.read(); + if (!identical(_contactsProvider, contactsProvider)) { + _contactsProvider?.removeListener(_handleContactsChanged); + _contactsProvider = contactsProvider; + _contactsProvider?.addListener(_handleContactsChanged); + } + _scheduleDestinationSync(); } @override @@ -216,6 +216,7 @@ class _MessagesTabState extends State { _channelReadTimer?.cancel(); _voiceStreamSub?.cancel(); _voiceRecorder.dispose(); + _contactsProvider?.removeListener(_handleContactsChanged); _focusNode.removeListener(_handleFocusChanged); _textController.dispose(); _focusNode.dispose(); @@ -228,13 +229,37 @@ class _MessagesTabState extends State { super.didUpdateWidget(oldWidget); if (oldWidget.isActive != widget.isActive) { _syncChannelAutoReadTimer(context.read()); + if (widget.isActive) { + _scheduleDestinationSync(); + } } } - void _checkForNavigationRequest() { + void _scheduleDestinationSync() { + WidgetsBinding.instance.addPostFrameCallback((_) { + _synchronizeDestinationState(); + }); + } + + void _handleContactsChanged() { + if (!mounted) return; + _scheduleDestinationSync(); + } + + Future _synchronizeDestinationState() async { + if (!mounted) return; final messagesProvider = context.read(); final targetMessageId = messagesProvider.targetMessageId; final targetDestinationType = messagesProvider.targetDestinationType; + final targetRecipientPublicKeyHex = + messagesProvider.targetRecipientPublicKeyHex; + + await _restoreDestinationState( + overrideType: targetDestinationType, + overrideRecipientPublicKeyHex: targetRecipientPublicKeyHex, + ); + + if (!mounted) return; if (targetMessageId != null) { _scrollToMessage(targetMessageId); @@ -242,11 +267,8 @@ class _MessagesTabState extends State { } if (targetDestinationType != null) { - _applyPendingDestination( - type: targetDestinationType, - recipientPublicKeyHex: messagesProvider.targetRecipientPublicKeyHex, - ); messagesProvider.clearDestinationNavigation(); + _focusNode.requestFocus(); } } @@ -447,56 +469,91 @@ class _MessagesTabState extends State { _updateCharacterCount(); } - /// Load saved message destination from preferences - Future _loadSavedDestination() async { + Future _restoreDestinationState({ + String? overrideType, + String? overrideRecipientPublicKeyHex, + }) async { + final lockedDestination = + await MessageDestinationPreferences.getLockedDestination(); final savedDestination = await MessageDestinationPreferences.getDestination(); + final effectiveType = + overrideType ?? + lockedDestination?['type'] ?? + savedDestination?['type'] ?? + MessageDestinationPreferences.destinationTypeChannel; + final effectivePublicKeyHex = + overrideRecipientPublicKeyHex ?? + lockedDestination?['publicKey'] ?? + savedDestination?['publicKey']; + if (!mounted) return; + final contactsProvider = context.read(); + final recipient = _resolveDestinationRecipient( + contactsProvider, + effectiveType, + effectivePublicKeyHex, + ); + final allowsEmptyRecipient = + effectiveType == MessageDestinationPreferences.destinationTypeAll || + (effectiveType == MessageDestinationPreferences.destinationTypeChannel && + effectivePublicKeyHex == null); + final shouldFallbackToPublicChannel = + recipient == null && !allowsEmptyRecipient; + final destinationType = shouldFallbackToPublicChannel + ? MessageDestinationPreferences.destinationTypeChannel + : effectiveType; + final selectedRecipient = shouldFallbackToPublicChannel ? null : recipient; + final shouldClearSavedDestination = + lockedDestination == null && + overrideType == null && + shouldFallbackToPublicChannel && + savedDestination != null; - if (savedDestination == null || !mounted) { - // Default to public channel - return; - } - - final type = savedDestination['type']!; - final publicKey = savedDestination['publicKey']; + if (!mounted) return; setState(() { - _destinationType = type; + _isDestinationLocked = lockedDestination != null; + _destinationType = destinationType; + _selectedRecipient = selectedRecipient; }); - // If it's a contact or room, try to find it in the contacts list - if (publicKey != null && mounted) { - final contactsProvider = context.read(); - final contact = contactsProvider.contacts.where((c) { - return c.publicKeyHex == publicKey; - }).firstOrNull; - - if (contact != null) { - setState(() { - _selectedRecipient = contact; - }); - } else { - // Contact/room not found, fallback to public channel - debugPrint( - '⚠️ [MessagesTab] Saved recipient not found, falling back to public channel', - ); - setState(() { - _destinationType = - MessageDestinationPreferences.destinationTypeChannel; - _selectedRecipient = null; - }); - await MessageDestinationPreferences.clearDestination(); - } + if (shouldClearSavedDestination) { + await MessageDestinationPreferences.clearDestination(); } _enforceMessageByteLimit(); - - // Load region scope for channel destinations await _loadRegionScope(); } + Contact? _resolveDestinationRecipient( + ContactsProvider contactsProvider, + String type, + String? publicKeyHex, + ) { + if (publicKeyHex == null) { + return null; + } + + final candidates = switch (type) { + MessageDestinationPreferences.destinationTypeChannel => + contactsProvider.channels, + MessageDestinationPreferences.destinationTypeRoom => contactsProvider.rooms, + MessageDestinationPreferences.destinationTypeContact => + contactsProvider.chatContacts, + _ => contactsProvider.contacts, + }; + + return candidates.where((contact) { + return contact.publicKeyHex == publicKeyHex; + }).firstOrNull; + } + /// Show recipient selector bottom sheet void _showRecipientSelector() { + if (_isDestinationLocked) { + return; + } + final contactsProvider = context.read(); final messagesProvider = context.read(); @@ -552,7 +609,11 @@ class _MessagesTabState extends State { } /// Handle recipient selection - Future _onRecipientSelected(String type, Contact? recipient) async { + Future _onRecipientSelected( + String type, + Contact? recipient, { + bool persistSelection = true, + }) async { setState(() { _destinationType = type; _selectedRecipient = recipient; @@ -565,11 +626,12 @@ class _MessagesTabState extends State { // Load region scope for channel destinations await _loadRegionScope(); - // Save to preferences - await MessageDestinationPreferences.setDestination( - type, - recipientPublicKey: recipient?.publicKeyHex, - ); + if (persistSelection) { + await MessageDestinationPreferences.setDestination( + type, + recipientPublicKey: recipient?.publicKeyHex, + ); + } // Show confirmation toast if (!mounted) return; @@ -620,23 +682,6 @@ class _MessagesTabState extends State { }); } - Future _applyPendingDestination({ - required String type, - String? recipientPublicKeyHex, - }) async { - Contact? recipient; - if (recipientPublicKeyHex != null) { - final contactsProvider = context.read(); - recipient = contactsProvider.contacts.where((contact) { - return contact.publicKeyHex == recipientPublicKeyHex; - }).firstOrNull; - } - - await _onRecipientSelected(type, recipient); - if (!mounted) return; - _focusNode.requestFocus(); - } - void _insertReplyMention(String displayName, {TextRange? replacementRange}) { final trimmedName = displayName.trim(); if (trimmedName.isEmpty) return; @@ -746,7 +791,11 @@ class _MessagesTabState extends State { } } - await _onRecipientSelected(destinationType, recipient); + await _onRecipientSelected( + destinationType, + recipient, + persistSelection: !_isDestinationLocked, + ); if (!mounted) return; if ((message.isChannelMessage || recipient?.isRoom == true) && senderDisplayName != null && @@ -2442,6 +2491,7 @@ class _MessagesTabState extends State { bottomPadding: composerBottomPadding, destinationLabel: _getDestinationLabel(), destinationAvatar: _buildDestinationAvatar(context), + destinationLocked: _isDestinationLocked, mentionSuggestions: _mentionSuggestions, mentionQuery: _mentionQuery, onMentionSelected: _selectMention, diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 9a7992d..b160b6a 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -25,6 +25,7 @@ import '../services/update_checker_service.dart'; import '../services/voice_bitrate_preferences.dart'; import '../services/image_preferences.dart'; import '../services/route_hash_preferences.dart'; +import '../services/message_destination_preferences.dart'; import '../services/image_codec_service.dart'; import '../services/developer_mode_service.dart'; import '../services/notification_service.dart'; @@ -60,6 +61,8 @@ class SettingsScreen extends StatefulWidget { } class _SettingsScreenState extends State { + static const String _publicChannelPublicKeyHex = + '0000000000000000000000000000000000000000000000000000000000000000'; late AppThemeMode _selectedTheme; late Locale? _selectedLocale; PackageInfo? _packageInfo; @@ -91,6 +94,10 @@ class _SettingsScreenState extends State { bool _muteForegroundNotifications = true; bool _isDeveloperModeEnabled = false; bool _profilesEnabled = false; + bool _messageDestinationLockEnabled = false; + String _messageDestinationLockType = + MessageDestinationPreferences.destinationTypeChannel; + String? _messageDestinationLockPublicKey = _publicChannelPublicKeyHex; DateTime? _onlineTraceCacheUpdatedAt; bool _isClearingOnlineTraceCache = false; int _versionTapCount = 0; @@ -114,6 +121,7 @@ class _SettingsScreenState extends State { _loadOnlineTraceCacheStatus(); _loadMapPreferences(); _loadNotificationPreferences(); + _loadMessageDestinationLock(); } @override @@ -184,6 +192,94 @@ class _SettingsScreenState extends State { }); } + Future _loadMessageDestinationLock() async { + final lockedDestination = + await MessageDestinationPreferences.getLockedDestination(); + if (!mounted) return; + + setState(() { + _messageDestinationLockEnabled = lockedDestination != null; + _messageDestinationLockType = + lockedDestination?['publicKey'] == null + ? MessageDestinationPreferences.destinationTypeChannel + : lockedDestination?['type'] ?? + MessageDestinationPreferences.destinationTypeChannel; + _messageDestinationLockPublicKey = + lockedDestination?['publicKey'] ?? _publicChannelPublicKeyHex; + }); + } + + List _messageDestinationLockOptions( + ContactsProvider contactsProvider, + ) { + final channels = List.from(contactsProvider.channels) + ..sort((a, b) { + if (a.isPublicChannel != b.isPublicChannel) { + return a.isPublicChannel ? -1 : 1; + } + return a.displayName.toLowerCase().compareTo( + b.displayName.toLowerCase(), + ); + }); + final rooms = List.from(contactsProvider.rooms) + ..sort( + (a, b) => a.displayName.toLowerCase().compareTo( + b.displayName.toLowerCase(), + ), + ); + + return [...channels, ...rooms]; + } + + String _messageDestinationLockLabel(BuildContext context, Contact contact) { + final name = contact.isChannel + ? contact.getLocalizedDisplayName(context) + : contact.displayName; + return contact.isRoom ? 'Room: $name' : 'Channel: $name'; + } + + String _messageDestinationLockTypeForContact(Contact contact) { + return contact.isRoom + ? MessageDestinationPreferences.destinationTypeRoom + : MessageDestinationPreferences.destinationTypeChannel; + } + + String? _selectedMessageDestinationLockValue(List destinations) { + final currentValue = _messageDestinationLockPublicKey; + if (currentValue != null && + destinations.any((contact) => contact.publicKeyHex == currentValue)) { + return currentValue; + } + + return destinations.isEmpty ? null : destinations.first.publicKeyHex; + } + + Future _setMessageDestinationLock({ + required bool enabled, + String? type, + String? recipientPublicKey, + }) async { + final nextType = type ?? _messageDestinationLockType; + final nextRecipientPublicKey = + recipientPublicKey ?? + _messageDestinationLockPublicKey ?? + _publicChannelPublicKeyHex; + + await MessageDestinationPreferences.setLockedDestination( + enabled: enabled, + type: nextType, + recipientPublicKey: enabled ? nextRecipientPublicKey : null, + ); + + if (!mounted) return; + + setState(() { + _messageDestinationLockEnabled = enabled; + _messageDestinationLockType = nextType; + _messageDestinationLockPublicKey = nextRecipientPublicKey; + }); + } + Future _handleVersionTap() async { if (_isDeveloperModeEnabled) { await DeveloperModeService.setEnabled(false); @@ -1430,6 +1526,90 @@ class _SettingsScreenState extends State { }, ), ), + SwitchListTile( + secondary: const Icon(Icons.lock_outline), + title: const Text('Lock messages to one channel or room'), + subtitle: const Text( + 'Keep the Messages tab and composer fixed on one destination. Direct messages from Contacts still open as usual.', + ), + value: _messageDestinationLockEnabled, + onChanged: (value) async { + final contactsProvider = context.read(); + final options = _messageDestinationLockOptions( + contactsProvider, + ); + final selectedPublicKey = + _selectedMessageDestinationLockValue(options) ?? + _publicChannelPublicKeyHex; + final selectedContact = options.where((contact) { + return contact.publicKeyHex == selectedPublicKey; + }).firstOrNull; + + await _setMessageDestinationLock( + enabled: value, + type: selectedContact == null + ? MessageDestinationPreferences.destinationTypeChannel + : _messageDestinationLockTypeForContact(selectedContact), + recipientPublicKey: selectedPublicKey, + ); + }, + ), + Consumer( + builder: (context, contactsProvider, child) { + final options = _messageDestinationLockOptions( + contactsProvider, + ); + final selectedValue = + _selectedMessageDestinationLockValue(options); + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: DropdownButtonFormField( + key: ValueKey(selectedValue), + initialValue: selectedValue, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Locked channel or room', + prefixIcon: Icon(Icons.forum_outlined), + border: OutlineInputBorder(), + ), + items: [ + for (final contact in options) + DropdownMenuItem( + value: contact.publicKeyHex, + child: Text( + _messageDestinationLockLabel(context, contact), + overflow: TextOverflow.ellipsis, + ), + ), + ], + onChanged: + _messageDestinationLockEnabled && options.isNotEmpty + ? (value) async { + if (value == null) { + return; + } + + final selectedContact = options.where((contact) { + return contact.publicKeyHex == value; + }).firstOrNull; + if (selectedContact == null) { + return; + } + + await _setMessageDestinationLock( + enabled: true, + type: _messageDestinationLockTypeForContact( + selectedContact, + ), + recipientPublicKey: value, + ); + } + : null, + ), + ); + }, + ), ListTile( leading: const Icon(Icons.delete_sweep, color: Colors.red), title: const Text( diff --git a/lib/services/message_destination_preferences.dart b/lib/services/message_destination_preferences.dart index f2b5f13..afb4018 100644 --- a/lib/services/message_destination_preferences.dart +++ b/lib/services/message_destination_preferences.dart @@ -5,6 +5,12 @@ import 'package:shared_preferences/shared_preferences.dart'; class MessageDestinationPreferences { static const String _destinationTypeKey = 'message_destination_type'; static const String _recipientPublicKeyKey = 'message_recipient_public_key'; + static const String _lockedDestinationEnabledKey = + 'message_locked_destination_enabled'; + static const String _lockedDestinationTypeKey = + 'message_locked_destination_type'; + static const String _lockedRecipientPublicKeyKey = + 'message_locked_recipient_public_key'; /// Destination types static const String destinationTypeAll = 'all'; @@ -12,6 +18,10 @@ class MessageDestinationPreferences { static const String destinationTypeContact = 'contact'; static const String destinationTypeRoom = 'room'; + static bool isLockableDestinationType(String type) { + return type == destinationTypeChannel || type == destinationTypeRoom; + } + /// Get the saved destination configuration /// Returns a map with 'type' and optional 'publicKey' /// Returns null if no preference is saved (defaults to public channel) @@ -53,6 +63,53 @@ class MessageDestinationPreferences { await prefs.remove(_recipientPublicKeyKey); } + /// Get the saved locked destination configuration. + /// Returns null when the lock is disabled. + static Future?> getLockedDestination() async { + final prefs = await SharedPreferences.getInstance(); + final isEnabled = prefs.getBool(_lockedDestinationEnabledKey) ?? false; + + if (!isEnabled) { + return null; + } + + final savedType = + prefs.getString(_lockedDestinationTypeKey) ?? destinationTypeChannel; + final type = isLockableDestinationType(savedType) + ? savedType + : destinationTypeChannel; + final publicKey = prefs.getString(_lockedRecipientPublicKeyKey); + + return {'type': type, 'publicKey': ?publicKey}; + } + + static Future setLockedDestination({ + required bool enabled, + String type = destinationTypeChannel, + String? recipientPublicKey, + }) async { + final prefs = await SharedPreferences.getInstance(); + + await prefs.setBool(_lockedDestinationEnabledKey, enabled); + + if (!enabled) { + await prefs.remove(_lockedDestinationTypeKey); + await prefs.remove(_lockedRecipientPublicKeyKey); + return; + } + + final sanitizedType = isLockableDestinationType(type) + ? type + : destinationTypeChannel; + await prefs.setString(_lockedDestinationTypeKey, sanitizedType); + + if (recipientPublicKey != null) { + await prefs.setString(_lockedRecipientPublicKeyKey, recipientPublicKey); + } else { + await prefs.remove(_lockedRecipientPublicKeyKey); + } + } + /// Get display name for destination type static String getDestinationTypeName(String type) { switch (type) { diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index 22c76c9..653ba97 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -11,6 +11,7 @@ import '../../providers/contacts_provider.dart'; import '../../providers/map_provider.dart'; import '../../providers/messages_provider.dart'; import '../../providers/sensors_provider.dart'; +import '../../services/location_tracking_service.dart'; import '../../services/message_destination_preferences.dart'; import 'contact_route_dialog.dart'; import 'contact_trace_sheet.dart'; @@ -529,6 +530,16 @@ class ContactTile extends StatelessWidget { _showNeighbours(context, contact); }, ), + if (contact.type == ContactType.repeater || + contact.type == ContactType.room) + _ContactSheetAction( + icon: Icons.network_ping, + label: 'Ping', + onTap: () async { + Navigator.pop(context); + _pingRelay(context, contact); + }, + ), if (!contact.isPublicChannel) _ContactSheetAction( icon: Icons.edit_outlined, @@ -664,6 +675,18 @@ class ContactTile extends StatelessWidget { ); } + void _pingRelay(BuildContext context, Contact contact) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => _PingRelaySheet(contact: contact), + ); + } + void _showDeleteConfirmation( BuildContext context, Contact contact, { @@ -2325,3 +2348,181 @@ class _MappedNeighbour { required this.location, }); } + +/// Bottom sheet for pinging a relay/repeater with history and distance. +class _PingRelaySheet extends StatefulWidget { + final Contact contact; + + const _PingRelaySheet({required this.contact}); + + @override + State<_PingRelaySheet> createState() => _PingRelaySheetState(); +} + +class _PingRelaySheetState extends State<_PingRelaySheet> { + bool _pinging = false; + final List _history = []; + + @override + void initState() { + super.initState(); + _doPing(); + } + + Future _doPing() async { + setState(() => _pinging = true); + final connectionProvider = context.read(); + final result = await connectionProvider.pingRelay(widget.contact); + if (!mounted) return; + setState(() { + _pinging = false; + _history.insert(0, result); + }); + } + + String? _distanceText() { + final location = widget.contact.displayLocation; + if (location == null) return null; + final currentPosition = + LocationTrackingService().currentPosition; + if (currentPosition == null) return null; + final meters = Geolocator.distanceBetween( + currentPosition.latitude, + currentPosition.longitude, + location.latitude, + location.longitude, + ); + if (meters < 1000) return '${meters.round()} m'; + if (meters < 10000) return '${(meters / 1000).toStringAsFixed(2)} km'; + return '${(meters / 1000).toStringAsFixed(1)} km'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final displayName = widget.contact.displayName; + final distance = _distanceText(); + + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: theme.dividerColor, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: Text( + 'Ping $displayName', + style: theme.textTheme.titleLarge, + ), + ), + FilledButton.icon( + onPressed: _pinging ? null : _doPing, + icon: _pinging + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.refresh, size: 18), + label: Text(_pinging ? 'Pinging...' : 'Ping Again'), + ), + ], + ), + if (distance != null) ...[ + const SizedBox(height: 4), + Text( + 'Distance: $distance', + style: theme.textTheme.bodySmall, + ), + ], + const SizedBox(height: 12), + if (_history.isEmpty && _pinging) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CircularProgressIndicator()), + ) + else if (_history.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Center( + child: Text( + 'No results yet', + style: theme.textTheme.bodyMedium, + ), + ), + ) + else + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 300), + child: ListView.separated( + shrinkWrap: true, + itemCount: _history.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + final r = _history[index]; + final seq = _history.length - index; + if (!r.success) { + return ListTile( + dense: true, + leading: CircleAvatar( + radius: 14, + backgroundColor: theme.colorScheme.error, + child: Text( + '$seq', + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + title: const Text('Timeout'), + subtitle: const Text('No response received'), + ); + } + return ListTile( + dense: true, + leading: CircleAvatar( + radius: 14, + backgroundColor: Colors.green, + child: Text( + '$seq', + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + title: Text('${r.durationMs} ms'), + subtitle: Text( + 'SNR there: ${r.snrThere.toStringAsFixed(1)} dB ' + 'SNR back: ${r.snrBack.toStringAsFixed(1)} dB', + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/messages/messages_composer.dart b/lib/widgets/messages/messages_composer.dart index 45664ca..0ece626 100644 --- a/lib/widgets/messages/messages_composer.dart +++ b/lib/widgets/messages/messages_composer.dart @@ -19,6 +19,7 @@ class MessagesComposer extends StatelessWidget { final double bottomPadding; final String destinationLabel; final Widget destinationAvatar; + final bool destinationLocked; final List mentionSuggestions; final String mentionQuery; final ValueChanged onMentionSelected; @@ -44,6 +45,7 @@ class MessagesComposer extends StatelessWidget { required this.bottomPadding, required this.destinationLabel, required this.destinationAvatar, + required this.destinationLocked, required this.mentionSuggestions, required this.mentionQuery, required this.onMentionSelected, @@ -118,10 +120,13 @@ class MessagesComposer extends StatelessWidget { ], const SizedBox(width: 8), Expanded( - child: _DestinationSelector( + child: _DestinationPill( destinationLabel: destinationLabel, destinationAvatar: destinationAvatar, - onTap: onShowRecipientSelector, + isLocked: destinationLocked, + onTap: destinationLocked + ? null + : onShowRecipientSelector, ), ), ], @@ -297,59 +302,73 @@ class _ComposerActionButton extends StatelessWidget { } } -class _DestinationSelector extends StatelessWidget { +class _DestinationPill extends StatelessWidget { final String destinationLabel; final Widget destinationAvatar; - final VoidCallback onTap; + final bool isLocked; + final VoidCallback? onTap; - const _DestinationSelector({ + const _DestinationPill({ required this.destinationLabel, required this.destinationAvatar, - required this.onTap, + required this.isLocked, + this.onTap, }); @override Widget build(BuildContext context) { + final content = Ink( + key: ValueKey( + isLocked + ? 'messages_composer_destination_locked' + : 'messages_composer_destination_selector', + ), + height: 40, + 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: 12), + child: Row( + children: [ + destinationAvatar, + const SizedBox(width: 8), + Expanded( + child: Text( + destinationLabel, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + if (!isLocked) + Icon( + Icons.expand_more_rounded, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ); + + if (onTap == null) { + return Material(color: Colors.transparent, child: content); + } + return Material( color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(20), onTap: onTap, - child: Ink( - height: 40, - 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: 12), - child: Row( - children: [ - destinationAvatar, - const SizedBox(width: 8), - Expanded( - child: Text( - destinationLabel, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - Icon( - Icons.expand_more_rounded, - size: 18, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ], - ), - ), - ), + child: content, ), ); } diff --git a/pubspec.lock b/pubspec.lock index 2362c37..b0db661 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -826,11 +826,9 @@ packages: meshcore_client: dependency: "direct main" description: - path: "." - ref: a0deff8 - resolved-ref: a0deff80fcbca974f0e18fe47971b76bec583109 - url: "https://github.com/dz0ny/meshcore_client.git" - source: git + path: "../meshcore_client" + relative: true + source: path version: "0.1.0" meta: dependency: transitive diff --git a/pubspec.yaml b/pubspec.yaml index 1a53511..d4e12f9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,9 +42,7 @@ dependencies: # MeshCore BLE protocol client meshcore_client: - git: - url: https://github.com/dz0ny/meshcore_client.git - ref: a0deff8 + path: ../meshcore_client # Codec2 ultra-low-bitrate speech codec (FFI plugin) codec2_flutter: diff --git a/test/screens/messages_tab_test.dart b/test/screens/messages_tab_test.dart index 680d04a..d83ad1d 100644 --- a/test/screens/messages_tab_test.dart +++ b/test/screens/messages_tab_test.dart @@ -13,6 +13,7 @@ import 'package:meshcore_sar_app/providers/map_provider.dart'; import 'package:meshcore_sar_app/providers/messages_provider.dart'; import 'package:meshcore_sar_app/providers/voice_provider.dart'; import 'package:meshcore_sar_app/screens/messages_tab.dart'; +import 'package:meshcore_sar_app/services/message_destination_preferences.dart'; 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'; @@ -208,4 +209,227 @@ void main() { connectionProvider.dispose(); channelsProvider.dispose(); }); + + testWidgets( + 'locks messages tab to the configured channel and removes the selector affordance', + (tester) async { + final lockedChannel = buildContact( + name: '#ops', + type: ContactType.channel, + secondByte: 2, + ); + SharedPreferences.setMockInitialValues({ + 'message_locked_destination_enabled': true, + 'message_locked_destination_type': + MessageDestinationPreferences.destinationTypeChannel, + 'message_locked_recipient_public_key': lockedChannel.publicKeyHex, + }); + + 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([lockedChannel]); + messagesProvider.addMessage( + Message( + id: 'public-message', + messageType: MessageType.channel, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000200, + text: 'Public chatter', + receivedAt: DateTime.now(), + channelIdx: 0, + ), + ); + messagesProvider.addMessage( + Message( + id: 'locked-message', + messageType: MessageType.channel, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000201, + text: 'Ops chatter', + receivedAt: DateTime.now(), + channelIdx: 2, + ), + ); + + 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(); + + expect(find.text('Ops chatter'), findsOneWidget); + expect(find.text('Public chatter'), findsNothing); + expect( + find.byKey(const ValueKey('messages_composer_destination_locked')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('messages_composer_destination_selector')), + findsNothing, + ); + + appProvider.dispose(); + voiceProvider.dispose(); + imageProvider.dispose(); + drawingProvider.dispose(); + mapProvider.dispose(); + messagesProvider.dispose(); + contactsProvider.dispose(); + connectionProvider.dispose(); + channelsProvider.dispose(); + }, + ); + + testWidgets( + 'contact navigation temporarily overrides the lock while keeping the selector hidden', + (tester) async { + final directContact = buildContact( + name: 'Tim', + type: ContactType.chat, + secondByte: 9, + ); + SharedPreferences.setMockInitialValues({ + 'message_locked_destination_enabled': true, + 'message_locked_destination_type': + MessageDestinationPreferences.destinationTypeChannel, + }); + + 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([directContact]); + messagesProvider.addMessage( + Message( + id: 'public-message', + messageType: MessageType.channel, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000300, + text: 'Public chatter', + receivedAt: DateTime.now(), + channelIdx: 0, + ), + ); + messagesProvider.addMessage( + Message( + id: 'direct-message', + messageType: MessageType.contact, + senderPublicKeyPrefix: directContact.publicKey.sublist(0, 6), + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000301, + text: 'Direct chatter', + receivedAt: DateTime.now(), + ), + ); + messagesProvider.navigateToDestination( + MessageDestinationPreferences.destinationTypeContact, + recipientPublicKeyHex: directContact.publicKeyHex, + ); + + 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(); + + expect(find.text('Direct chatter'), findsOneWidget); + expect(find.text('Public chatter'), findsNothing); + expect( + find.byKey(const ValueKey('messages_composer_destination_locked')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('messages_composer_destination_selector')), + findsNothing, + ); + + appProvider.dispose(); + voiceProvider.dispose(); + imageProvider.dispose(); + drawingProvider.dispose(); + mapProvider.dispose(); + messagesProvider.dispose(); + contactsProvider.dispose(); + connectionProvider.dispose(); + channelsProvider.dispose(); + }, + ); }