mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 08:20:36 +00:00
feat: Add relay ping and lock message destination
This commit is contained in:
@@ -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
|
/// Scanned device with RSSI information
|
||||||
class ScannedDevice {
|
class ScannedDevice {
|
||||||
final BluetoothDevice device;
|
final BluetoothDevice device;
|
||||||
@@ -172,6 +189,8 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
MessageDeliveryTracker();
|
MessageDeliveryTracker();
|
||||||
final PingTracker _pingTracker = PingTracker();
|
final PingTracker _pingTracker = PingTracker();
|
||||||
final Map<String, Future<PingResult>> _pendingSmartPings = {};
|
final Map<String, Future<PingResult>> _pendingSmartPings = {};
|
||||||
|
final Map<int, Completer<RelayPingResult>> _pendingRelayPings = {};
|
||||||
|
final Map<int, int> _relayPingStartTimes = {};
|
||||||
|
|
||||||
// Expose room login states
|
// Expose room login states
|
||||||
Map<String, RoomLoginState> get roomLoginStates =>
|
Map<String, RoomLoginState> get roomLoginStates =>
|
||||||
@@ -631,6 +650,10 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
service.onTraceDataReceived = (nonce, hopCount, snrThere, snrBack) {
|
||||||
|
_handleTraceDataReceived(nonce, hopCount, snrThere, snrBack);
|
||||||
|
};
|
||||||
|
|
||||||
service.onTxActivity = () {
|
service.onTxActivity = () {
|
||||||
_txActivity = true;
|
_txActivity = true;
|
||||||
notifyListeners();
|
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) {
|
String _publicKeyToHex(Uint8List publicKey) {
|
||||||
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
|
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,11 +148,13 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
TextRange? _activeMentionRange;
|
TextRange? _activeMentionRange;
|
||||||
String _mentionQuery = '';
|
String _mentionQuery = '';
|
||||||
List<Contact> _mentionSuggestions = const [];
|
List<Contact> _mentionSuggestions = const [];
|
||||||
|
ContactsProvider? _contactsProvider;
|
||||||
|
|
||||||
// Message destination state
|
// Message destination state
|
||||||
String _destinationType =
|
String _destinationType =
|
||||||
MessageDestinationPreferences.destinationTypeChannel;
|
MessageDestinationPreferences.destinationTypeChannel;
|
||||||
Contact? _selectedRecipient;
|
Contact? _selectedRecipient;
|
||||||
|
bool _isDestinationLocked = false;
|
||||||
|
|
||||||
// Region scope state
|
// Region scope state
|
||||||
String? _channelRegionScopeName;
|
String? _channelRegionScopeName;
|
||||||
@@ -183,13 +185,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
_textController.addListener(_handleComposerChanged);
|
_textController.addListener(_handleComposerChanged);
|
||||||
_focusNode.addListener(_handleFocusChanged);
|
_focusNode.addListener(_handleFocusChanged);
|
||||||
// Load saved message destination
|
|
||||||
_loadSavedDestination();
|
|
||||||
_loadVoiceSettings();
|
_loadVoiceSettings();
|
||||||
_loadAllChannelRegionScopes();
|
_loadAllChannelRegionScopes();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
_scheduleDestinationSync();
|
||||||
_checkForNavigationRequest();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadVoiceSettings() async {
|
Future<void> _loadVoiceSettings() async {
|
||||||
@@ -203,11 +201,13 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
// Reload saved destination and check for navigation request whenever dependencies change
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
if (!identical(_contactsProvider, contactsProvider)) {
|
||||||
_loadSavedDestination();
|
_contactsProvider?.removeListener(_handleContactsChanged);
|
||||||
_checkForNavigationRequest();
|
_contactsProvider = contactsProvider;
|
||||||
});
|
_contactsProvider?.addListener(_handleContactsChanged);
|
||||||
|
}
|
||||||
|
_scheduleDestinationSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -216,6 +216,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
_channelReadTimer?.cancel();
|
_channelReadTimer?.cancel();
|
||||||
_voiceStreamSub?.cancel();
|
_voiceStreamSub?.cancel();
|
||||||
_voiceRecorder.dispose();
|
_voiceRecorder.dispose();
|
||||||
|
_contactsProvider?.removeListener(_handleContactsChanged);
|
||||||
_focusNode.removeListener(_handleFocusChanged);
|
_focusNode.removeListener(_handleFocusChanged);
|
||||||
_textController.dispose();
|
_textController.dispose();
|
||||||
_focusNode.dispose();
|
_focusNode.dispose();
|
||||||
@@ -228,13 +229,37 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (oldWidget.isActive != widget.isActive) {
|
if (oldWidget.isActive != widget.isActive) {
|
||||||
_syncChannelAutoReadTimer(context.read<MessagesProvider>());
|
_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 messagesProvider = context.read<MessagesProvider>();
|
||||||
final targetMessageId = messagesProvider.targetMessageId;
|
final targetMessageId = messagesProvider.targetMessageId;
|
||||||
final targetDestinationType = messagesProvider.targetDestinationType;
|
final targetDestinationType = messagesProvider.targetDestinationType;
|
||||||
|
final targetRecipientPublicKeyHex =
|
||||||
|
messagesProvider.targetRecipientPublicKeyHex;
|
||||||
|
|
||||||
|
await _restoreDestinationState(
|
||||||
|
overrideType: targetDestinationType,
|
||||||
|
overrideRecipientPublicKeyHex: targetRecipientPublicKeyHex,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
if (targetMessageId != null) {
|
if (targetMessageId != null) {
|
||||||
_scrollToMessage(targetMessageId);
|
_scrollToMessage(targetMessageId);
|
||||||
@@ -242,11 +267,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (targetDestinationType != null) {
|
if (targetDestinationType != null) {
|
||||||
_applyPendingDestination(
|
|
||||||
type: targetDestinationType,
|
|
||||||
recipientPublicKeyHex: messagesProvider.targetRecipientPublicKeyHex,
|
|
||||||
);
|
|
||||||
messagesProvider.clearDestinationNavigation();
|
messagesProvider.clearDestinationNavigation();
|
||||||
|
_focusNode.requestFocus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,56 +469,91 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
_updateCharacterCount();
|
_updateCharacterCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load saved message destination from preferences
|
Future<void> _restoreDestinationState({
|
||||||
Future<void> _loadSavedDestination() async {
|
String? overrideType,
|
||||||
|
String? overrideRecipientPublicKeyHex,
|
||||||
|
}) async {
|
||||||
|
final lockedDestination =
|
||||||
|
await MessageDestinationPreferences.getLockedDestination();
|
||||||
final savedDestination =
|
final savedDestination =
|
||||||
await MessageDestinationPreferences.getDestination();
|
await MessageDestinationPreferences.getDestination();
|
||||||
|
final effectiveType =
|
||||||
if (savedDestination == null || !mounted) {
|
overrideType ??
|
||||||
// Default to public channel
|
lockedDestination?['type'] ??
|
||||||
return;
|
savedDestination?['type'] ??
|
||||||
}
|
|
||||||
|
|
||||||
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 =
|
|
||||||
MessageDestinationPreferences.destinationTypeChannel;
|
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();
|
await MessageDestinationPreferences.clearDestination();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
_enforceMessageByteLimit();
|
_enforceMessageByteLimit();
|
||||||
|
|
||||||
// Load region scope for channel destinations
|
|
||||||
await _loadRegionScope();
|
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
|
/// Show recipient selector bottom sheet
|
||||||
void _showRecipientSelector() {
|
void _showRecipientSelector() {
|
||||||
|
if (_isDestinationLocked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
final messagesProvider = context.read<MessagesProvider>();
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
|
|
||||||
@@ -552,7 +609,11 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Handle recipient selection
|
/// Handle recipient selection
|
||||||
Future<void> _onRecipientSelected(String type, Contact? recipient) async {
|
Future<void> _onRecipientSelected(
|
||||||
|
String type,
|
||||||
|
Contact? recipient, {
|
||||||
|
bool persistSelection = true,
|
||||||
|
}) async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_destinationType = type;
|
_destinationType = type;
|
||||||
_selectedRecipient = recipient;
|
_selectedRecipient = recipient;
|
||||||
@@ -565,11 +626,12 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
// Load region scope for channel destinations
|
// Load region scope for channel destinations
|
||||||
await _loadRegionScope();
|
await _loadRegionScope();
|
||||||
|
|
||||||
// Save to preferences
|
if (persistSelection) {
|
||||||
await MessageDestinationPreferences.setDestination(
|
await MessageDestinationPreferences.setDestination(
|
||||||
type,
|
type,
|
||||||
recipientPublicKey: recipient?.publicKeyHex,
|
recipientPublicKey: recipient?.publicKeyHex,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Show confirmation toast
|
// Show confirmation toast
|
||||||
if (!mounted) return;
|
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}) {
|
void _insertReplyMention(String displayName, {TextRange? replacementRange}) {
|
||||||
final trimmedName = displayName.trim();
|
final trimmedName = displayName.trim();
|
||||||
if (trimmedName.isEmpty) return;
|
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 (!mounted) return;
|
||||||
if ((message.isChannelMessage || recipient?.isRoom == true) &&
|
if ((message.isChannelMessage || recipient?.isRoom == true) &&
|
||||||
senderDisplayName != null &&
|
senderDisplayName != null &&
|
||||||
@@ -2442,6 +2491,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
bottomPadding: composerBottomPadding,
|
bottomPadding: composerBottomPadding,
|
||||||
destinationLabel: _getDestinationLabel(),
|
destinationLabel: _getDestinationLabel(),
|
||||||
destinationAvatar: _buildDestinationAvatar(context),
|
destinationAvatar: _buildDestinationAvatar(context),
|
||||||
|
destinationLocked: _isDestinationLocked,
|
||||||
mentionSuggestions: _mentionSuggestions,
|
mentionSuggestions: _mentionSuggestions,
|
||||||
mentionQuery: _mentionQuery,
|
mentionQuery: _mentionQuery,
|
||||||
onMentionSelected: _selectMention,
|
onMentionSelected: _selectMention,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import '../services/update_checker_service.dart';
|
|||||||
import '../services/voice_bitrate_preferences.dart';
|
import '../services/voice_bitrate_preferences.dart';
|
||||||
import '../services/image_preferences.dart';
|
import '../services/image_preferences.dart';
|
||||||
import '../services/route_hash_preferences.dart';
|
import '../services/route_hash_preferences.dart';
|
||||||
|
import '../services/message_destination_preferences.dart';
|
||||||
import '../services/image_codec_service.dart';
|
import '../services/image_codec_service.dart';
|
||||||
import '../services/developer_mode_service.dart';
|
import '../services/developer_mode_service.dart';
|
||||||
import '../services/notification_service.dart';
|
import '../services/notification_service.dart';
|
||||||
@@ -60,6 +61,8 @@ class SettingsScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SettingsScreenState extends State<SettingsScreen> {
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
|
static const String _publicChannelPublicKeyHex =
|
||||||
|
'0000000000000000000000000000000000000000000000000000000000000000';
|
||||||
late AppThemeMode _selectedTheme;
|
late AppThemeMode _selectedTheme;
|
||||||
late Locale? _selectedLocale;
|
late Locale? _selectedLocale;
|
||||||
PackageInfo? _packageInfo;
|
PackageInfo? _packageInfo;
|
||||||
@@ -91,6 +94,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
bool _muteForegroundNotifications = true;
|
bool _muteForegroundNotifications = true;
|
||||||
bool _isDeveloperModeEnabled = false;
|
bool _isDeveloperModeEnabled = false;
|
||||||
bool _profilesEnabled = false;
|
bool _profilesEnabled = false;
|
||||||
|
bool _messageDestinationLockEnabled = false;
|
||||||
|
String _messageDestinationLockType =
|
||||||
|
MessageDestinationPreferences.destinationTypeChannel;
|
||||||
|
String? _messageDestinationLockPublicKey = _publicChannelPublicKeyHex;
|
||||||
DateTime? _onlineTraceCacheUpdatedAt;
|
DateTime? _onlineTraceCacheUpdatedAt;
|
||||||
bool _isClearingOnlineTraceCache = false;
|
bool _isClearingOnlineTraceCache = false;
|
||||||
int _versionTapCount = 0;
|
int _versionTapCount = 0;
|
||||||
@@ -114,6 +121,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_loadOnlineTraceCacheStatus();
|
_loadOnlineTraceCacheStatus();
|
||||||
_loadMapPreferences();
|
_loadMapPreferences();
|
||||||
_loadNotificationPreferences();
|
_loadNotificationPreferences();
|
||||||
|
_loadMessageDestinationLock();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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 {
|
Future<void> _handleVersionTap() async {
|
||||||
if (_isDeveloperModeEnabled) {
|
if (_isDeveloperModeEnabled) {
|
||||||
await DeveloperModeService.setEnabled(false);
|
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(
|
ListTile(
|
||||||
leading: const Icon(Icons.delete_sweep, color: Colors.red),
|
leading: const Icon(Icons.delete_sweep, color: Colors.red),
|
||||||
title: const Text(
|
title: const Text(
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
class MessageDestinationPreferences {
|
class MessageDestinationPreferences {
|
||||||
static const String _destinationTypeKey = 'message_destination_type';
|
static const String _destinationTypeKey = 'message_destination_type';
|
||||||
static const String _recipientPublicKeyKey = 'message_recipient_public_key';
|
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
|
/// Destination types
|
||||||
static const String destinationTypeAll = 'all';
|
static const String destinationTypeAll = 'all';
|
||||||
@@ -12,6 +18,10 @@ class MessageDestinationPreferences {
|
|||||||
static const String destinationTypeContact = 'contact';
|
static const String destinationTypeContact = 'contact';
|
||||||
static const String destinationTypeRoom = 'room';
|
static const String destinationTypeRoom = 'room';
|
||||||
|
|
||||||
|
static bool isLockableDestinationType(String type) {
|
||||||
|
return type == destinationTypeChannel || type == destinationTypeRoom;
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the saved destination configuration
|
/// Get the saved destination configuration
|
||||||
/// Returns a map with 'type' and optional 'publicKey'
|
/// Returns a map with 'type' and optional 'publicKey'
|
||||||
/// Returns null if no preference is saved (defaults to public channel)
|
/// Returns null if no preference is saved (defaults to public channel)
|
||||||
@@ -53,6 +63,53 @@ class MessageDestinationPreferences {
|
|||||||
await prefs.remove(_recipientPublicKeyKey);
|
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
|
/// Get display name for destination type
|
||||||
static String getDestinationTypeName(String type) {
|
static String getDestinationTypeName(String type) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '../../providers/contacts_provider.dart';
|
|||||||
import '../../providers/map_provider.dart';
|
import '../../providers/map_provider.dart';
|
||||||
import '../../providers/messages_provider.dart';
|
import '../../providers/messages_provider.dart';
|
||||||
import '../../providers/sensors_provider.dart';
|
import '../../providers/sensors_provider.dart';
|
||||||
|
import '../../services/location_tracking_service.dart';
|
||||||
import '../../services/message_destination_preferences.dart';
|
import '../../services/message_destination_preferences.dart';
|
||||||
import 'contact_route_dialog.dart';
|
import 'contact_route_dialog.dart';
|
||||||
import 'contact_trace_sheet.dart';
|
import 'contact_trace_sheet.dart';
|
||||||
@@ -529,6 +530,16 @@ class ContactTile extends StatelessWidget {
|
|||||||
_showNeighbours(context, contact);
|
_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)
|
if (!contact.isPublicChannel)
|
||||||
_ContactSheetAction(
|
_ContactSheetAction(
|
||||||
icon: Icons.edit_outlined,
|
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(
|
void _showDeleteConfirmation(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Contact contact, {
|
Contact contact, {
|
||||||
@@ -2325,3 +2348,181 @@ class _MappedNeighbour {
|
|||||||
required this.location,
|
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',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class MessagesComposer extends StatelessWidget {
|
|||||||
final double bottomPadding;
|
final double bottomPadding;
|
||||||
final String destinationLabel;
|
final String destinationLabel;
|
||||||
final Widget destinationAvatar;
|
final Widget destinationAvatar;
|
||||||
|
final bool destinationLocked;
|
||||||
final List<Contact> mentionSuggestions;
|
final List<Contact> mentionSuggestions;
|
||||||
final String mentionQuery;
|
final String mentionQuery;
|
||||||
final ValueChanged<Contact> onMentionSelected;
|
final ValueChanged<Contact> onMentionSelected;
|
||||||
@@ -44,6 +45,7 @@ class MessagesComposer extends StatelessWidget {
|
|||||||
required this.bottomPadding,
|
required this.bottomPadding,
|
||||||
required this.destinationLabel,
|
required this.destinationLabel,
|
||||||
required this.destinationAvatar,
|
required this.destinationAvatar,
|
||||||
|
required this.destinationLocked,
|
||||||
required this.mentionSuggestions,
|
required this.mentionSuggestions,
|
||||||
required this.mentionQuery,
|
required this.mentionQuery,
|
||||||
required this.onMentionSelected,
|
required this.onMentionSelected,
|
||||||
@@ -118,10 +120,13 @@ class MessagesComposer extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _DestinationSelector(
|
child: _DestinationPill(
|
||||||
destinationLabel: destinationLabel,
|
destinationLabel: destinationLabel,
|
||||||
destinationAvatar: destinationAvatar,
|
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 String destinationLabel;
|
||||||
final Widget destinationAvatar;
|
final Widget destinationAvatar;
|
||||||
final VoidCallback onTap;
|
final bool isLocked;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
const _DestinationSelector({
|
const _DestinationPill({
|
||||||
required this.destinationLabel,
|
required this.destinationLabel,
|
||||||
required this.destinationAvatar,
|
required this.destinationAvatar,
|
||||||
required this.onTap,
|
required this.isLocked,
|
||||||
|
this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Material(
|
final content = Ink(
|
||||||
color: Colors.transparent,
|
key: ValueKey(
|
||||||
child: InkWell(
|
isLocked
|
||||||
borderRadius: BorderRadius.circular(20),
|
? 'messages_composer_destination_locked'
|
||||||
onTap: onTap,
|
: 'messages_composer_destination_selector',
|
||||||
child: Ink(
|
),
|
||||||
height: 40,
|
height: 40,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surface,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
@@ -341,6 +348,7 @@ class _DestinationSelector extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (!isLocked)
|
||||||
Icon(
|
Icon(
|
||||||
Icons.expand_more_rounded,
|
Icons.expand_more_rounded,
|
||||||
size: 18,
|
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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -826,11 +826,9 @@ packages:
|
|||||||
meshcore_client:
|
meshcore_client:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: "."
|
path: "../meshcore_client"
|
||||||
ref: a0deff8
|
relative: true
|
||||||
resolved-ref: a0deff80fcbca974f0e18fe47971b76bec583109
|
source: path
|
||||||
url: "https://github.com/dz0ny/meshcore_client.git"
|
|
||||||
source: git
|
|
||||||
version: "0.1.0"
|
version: "0.1.0"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
|
|||||||
@@ -42,9 +42,7 @@ dependencies:
|
|||||||
|
|
||||||
# MeshCore BLE protocol client
|
# MeshCore BLE protocol client
|
||||||
meshcore_client:
|
meshcore_client:
|
||||||
git:
|
path: ../meshcore_client
|
||||||
url: https://github.com/dz0ny/meshcore_client.git
|
|
||||||
ref: a0deff8
|
|
||||||
|
|
||||||
# Codec2 ultra-low-bitrate speech codec (FFI plugin)
|
# Codec2 ultra-low-bitrate speech codec (FFI plugin)
|
||||||
codec2_flutter:
|
codec2_flutter:
|
||||||
|
|||||||
@@ -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/messages_provider.dart';
|
||||||
import 'package:meshcore_sar_app/providers/voice_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/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_codec_service.dart';
|
||||||
import 'package:meshcore_sar_app/services/voice_player_service.dart';
|
import 'package:meshcore_sar_app/services/voice_player_service.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -208,4 +209,227 @@ void main() {
|
|||||||
connectionProvider.dispose();
|
connectionProvider.dispose();
|
||||||
channelsProvider.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();
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user