feat: Add relay ping and lock message destination

This commit is contained in:
Janez T
2026-03-31 17:52:44 +02:00
parent 7df750c8d5
commit 3392e5f9c1
9 changed files with 955 additions and 123 deletions

View File

@@ -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<String, Future<PingResult>> _pendingSmartPings = {};
final Map<int, Completer<RelayPingResult>> _pendingRelayPings = {};
final Map<int, int> _relayPingStartTimes = {};
// Expose room login states
Map<String, RoomLoginState> 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<RelayPingResult> 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<RelayPingResult>();
_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('');
}

View File

@@ -148,11 +148,13 @@ class _MessagesTabState extends State<MessagesTab> {
TextRange? _activeMentionRange;
String _mentionQuery = '';
List<Contact> _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<MessagesTab> {
super.initState();
_textController.addListener(_handleComposerChanged);
_focusNode.addListener(_handleFocusChanged);
// Load saved message destination
_loadSavedDestination();
_loadVoiceSettings();
_loadAllChannelRegionScopes();
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkForNavigationRequest();
});
_scheduleDestinationSync();
}
Future<void> _loadVoiceSettings() async {
@@ -203,11 +201,13 @@ class _MessagesTabState extends State<MessagesTab> {
@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<ContactsProvider>();
if (!identical(_contactsProvider, contactsProvider)) {
_contactsProvider?.removeListener(_handleContactsChanged);
_contactsProvider = contactsProvider;
_contactsProvider?.addListener(_handleContactsChanged);
}
_scheduleDestinationSync();
}
@override
@@ -216,6 +216,7 @@ class _MessagesTabState extends State<MessagesTab> {
_channelReadTimer?.cancel();
_voiceStreamSub?.cancel();
_voiceRecorder.dispose();
_contactsProvider?.removeListener(_handleContactsChanged);
_focusNode.removeListener(_handleFocusChanged);
_textController.dispose();
_focusNode.dispose();
@@ -228,13 +229,37 @@ class _MessagesTabState extends State<MessagesTab> {
super.didUpdateWidget(oldWidget);
if (oldWidget.isActive != widget.isActive) {
_syncChannelAutoReadTimer(context.read<MessagesProvider>());
if (widget.isActive) {
_scheduleDestinationSync();
}
}
}
void _checkForNavigationRequest() {
void _scheduleDestinationSync() {
WidgetsBinding.instance.addPostFrameCallback((_) {
_synchronizeDestinationState();
});
}
void _handleContactsChanged() {
if (!mounted) return;
_scheduleDestinationSync();
}
Future<void> _synchronizeDestinationState() async {
if (!mounted) return;
final messagesProvider = context.read<MessagesProvider>();
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<MessagesTab> {
}
if (targetDestinationType != null) {
_applyPendingDestination(
type: targetDestinationType,
recipientPublicKeyHex: messagesProvider.targetRecipientPublicKeyHex,
);
messagesProvider.clearDestinationNavigation();
_focusNode.requestFocus();
}
}
@@ -447,56 +469,91 @@ class _MessagesTabState extends State<MessagesTab> {
_updateCharacterCount();
}
/// Load saved message destination from preferences
Future<void> _loadSavedDestination() async {
Future<void> _restoreDestinationState({
String? overrideType,
String? overrideRecipientPublicKeyHex,
}) async {
final lockedDestination =
await MessageDestinationPreferences.getLockedDestination();
final savedDestination =
await MessageDestinationPreferences.getDestination();
if (savedDestination == null || !mounted) {
// Default to public channel
return;
}
final type = savedDestination['type']!;
final publicKey = savedDestination['publicKey'];
setState(() {
_destinationType = type;
});
// If it's a contact or room, try to find it in the contacts list
if (publicKey != null && mounted) {
final contactsProvider = context.read<ContactsProvider>();
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 =
final effectiveType =
overrideType ??
lockedDestination?['type'] ??
savedDestination?['type'] ??
MessageDestinationPreferences.destinationTypeChannel;
_selectedRecipient = null;
final effectivePublicKeyHex =
overrideRecipientPublicKeyHex ??
lockedDestination?['publicKey'] ??
savedDestination?['publicKey'];
if (!mounted) return;
final contactsProvider = context.read<ContactsProvider>();
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 (!mounted) return;
setState(() {
_isDestinationLocked = lockedDestination != null;
_destinationType = destinationType;
_selectedRecipient = selectedRecipient;
});
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<ContactsProvider>();
final messagesProvider = context.read<MessagesProvider>();
@@ -552,7 +609,11 @@ class _MessagesTabState extends State<MessagesTab> {
}
/// Handle recipient selection
Future<void> _onRecipientSelected(String type, Contact? recipient) async {
Future<void> _onRecipientSelected(
String type,
Contact? recipient, {
bool persistSelection = true,
}) async {
setState(() {
_destinationType = type;
_selectedRecipient = recipient;
@@ -565,11 +626,12 @@ class _MessagesTabState extends State<MessagesTab> {
// Load region scope for channel destinations
await _loadRegionScope();
// Save to preferences
if (persistSelection) {
await MessageDestinationPreferences.setDestination(
type,
recipientPublicKey: recipient?.publicKeyHex,
);
}
// Show confirmation toast
if (!mounted) return;
@@ -620,23 +682,6 @@ class _MessagesTabState extends State<MessagesTab> {
});
}
Future<void> _applyPendingDestination({
required String type,
String? recipientPublicKeyHex,
}) async {
Contact? recipient;
if (recipientPublicKeyHex != null) {
final contactsProvider = context.read<ContactsProvider>();
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<MessagesTab> {
}
}
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<MessagesTab> {
bottomPadding: composerBottomPadding,
destinationLabel: _getDestinationLabel(),
destinationAvatar: _buildDestinationAvatar(context),
destinationLocked: _isDestinationLocked,
mentionSuggestions: _mentionSuggestions,
mentionQuery: _mentionQuery,
onMentionSelected: _selectMention,

View File

@@ -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<SettingsScreen> {
static const String _publicChannelPublicKeyHex =
'0000000000000000000000000000000000000000000000000000000000000000';
late AppThemeMode _selectedTheme;
late Locale? _selectedLocale;
PackageInfo? _packageInfo;
@@ -91,6 +94,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
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<SettingsScreen> {
_loadOnlineTraceCacheStatus();
_loadMapPreferences();
_loadNotificationPreferences();
_loadMessageDestinationLock();
}
@override
@@ -184,6 +192,94 @@ class _SettingsScreenState extends State<SettingsScreen> {
});
}
Future<void> _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<Contact> _messageDestinationLockOptions(
ContactsProvider contactsProvider,
) {
final channels = List<Contact>.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<Contact>.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<Contact> destinations) {
final currentValue = _messageDestinationLockPublicKey;
if (currentValue != null &&
destinations.any((contact) => contact.publicKeyHex == currentValue)) {
return currentValue;
}
return destinations.isEmpty ? null : destinations.first.publicKeyHex;
}
Future<void> _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<void> _handleVersionTap() async {
if (_isDeveloperModeEnabled) {
await DeveloperModeService.setEnabled(false);
@@ -1430,6 +1526,90 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
),
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<ContactsProvider>();
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<ContactsProvider>(
builder: (context, contactsProvider, child) {
final options = _messageDestinationLockOptions(
contactsProvider,
);
final selectedValue =
_selectedMessageDestinationLockValue(options);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: DropdownButtonFormField<String>(
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<String>(
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(

View File

@@ -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<Map<String, String>?> 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<void> 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) {

View File

@@ -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<RelayPingResult> _history = [];
@override
void initState() {
super.initState();
_doPing();
}
Future<void> _doPing() async {
setState(() => _pinging = true);
final connectionProvider = context.read<ConnectionProvider>();
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',
),
);
},
),
),
],
),
),
);
}
}

View File

@@ -19,6 +19,7 @@ class MessagesComposer extends StatelessWidget {
final double bottomPadding;
final String destinationLabel;
final Widget destinationAvatar;
final bool destinationLocked;
final List<Contact> mentionSuggestions;
final String mentionQuery;
final ValueChanged<Contact> 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,25 +302,27 @@ 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) {
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onTap,
child: Ink(
final content = Ink(
key: ValueKey(
isLocked
? 'messages_composer_destination_locked'
: 'messages_composer_destination_selector',
),
height: 40,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
@@ -341,6 +348,7 @@ class _DestinationSelector extends StatelessWidget {
),
),
),
if (!isLocked)
Icon(
Icons.expand_more_rounded,
size: 18,
@@ -349,7 +357,18 @@ class _DestinationSelector extends StatelessWidget {
],
),
),
),
);
if (onTap == null) {
return Material(color: Colors.transparent, child: content);
}
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onTap,
child: content,
),
);
}

View File

@@ -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

View File

@@ -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:

View File

@@ -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();
},
);
}