diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 8b32870..15614b5 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -66,8 +66,10 @@ class MeshCoreConnector extends ChangeNotifier { final Map> _conversations = {}; final Map> _channelMessages = {}; final Set _loadedConversationKeys = {}; - final Map> _processedChannelReactions = {}; // channelIndex -> Set of "reactionKey_emoji" - final Map> _processedContactReactions = {}; // contactPubKeyHex -> Set of "reactionKey_emoji" + final Map> _processedChannelReactions = + {}; // channelIndex -> Set of "reactionKey_emoji" + final Map> _processedContactReactions = + {}; // contactPubKeyHex -> Set of "reactionKey_emoji" StreamSubscription>? _scanSubscription; StreamSubscription? _connectionSubscription; @@ -108,6 +110,7 @@ class MeshCoreConnector extends ChangeNotifier { int _queueSyncRetries = 0; static const int _maxQueueSyncRetries = 3; static const int _queueSyncTimeoutMs = 5000; // 5 second timeout + Map? _currentCustomVars; // Channel syncing state (sequential pattern) bool _isSyncingChannels = false; @@ -137,7 +140,8 @@ class MeshCoreConnector extends ChangeNotifier { final ContactStore _contactStore = ContactStore(); final UnreadStore _unreadStore = UnreadStore(); final Map _channelSmazEnabled = {}; - bool _lastSentWasCliCommand = false; // Track if last sent message was a CLI command + bool _lastSentWasCliCommand = + false; // Track if last sent message was a CLI command final Map _contactSmazEnabled = {}; final Set _knownContactKeys = {}; final Map _contactLastReadMs = {}; @@ -166,6 +170,7 @@ class MeshCoreConnector extends ChangeNotifier { } return 'Unknown Device'; } + List get scanResults => List.unmodifiable(_scanResults); List get contacts { final selfKey = _selfPublicKey; @@ -176,6 +181,7 @@ class MeshCoreConnector extends ChangeNotifier { _contacts.where((contact) => !listEquals(contact.publicKey, selfKey)), ); } + List get channels => List.unmodifiable(_channels); bool get isConnected => _state == MeshCoreConnectionState.connected; bool get isLoadingContacts => _isLoadingContacts; @@ -191,12 +197,14 @@ class MeshCoreConnector extends ChangeNotifier { int? get currentBwHz => _currentBwHz; int? get currentSf => _currentSf; int? get currentCr => _currentCr; + Map? get currentCustomVars => _currentCustomVars; int? get batteryMillivolts => _batteryMillivolts; int get maxContacts => _maxContacts; int get maxChannels => _maxChannels; bool get isSyncingQueuedMessages => _isSyncingQueuedMessages; bool get isSyncingChannels => _isSyncingChannels; - int get channelSyncProgress => _isSyncingChannels && _totalChannelsToRequest > 0 + int get channelSyncProgress => + _isSyncingChannels && _totalChannelsToRequest > 0 ? ((_nextChannelIndexToRequest / _totalChannelsToRequest) * 100).round() : 0; int? get batteryPercent => _batteryMillivolts == null @@ -377,7 +385,8 @@ class MeshCoreConnector extends ChangeNotifier { } void setActiveContact(String? contactKeyHex) { - if (contactKeyHex != null && !_shouldTrackUnreadForContactKey(contactKeyHex)) { + if (contactKeyHex != null && + !_shouldTrackUnreadForContactKey(contactKeyHex)) { _activeContactKey = null; return; } @@ -429,7 +438,9 @@ class MeshCoreConnector extends ChangeNotifier { /// Load persisted channel messages for a specific channel Future _loadChannelMessages(int channelIndex) async { - final allMessages = await _channelMessageStore.loadChannelMessages(channelIndex); + final allMessages = await _channelMessageStore.loadChannelMessages( + channelIndex, + ); if (allMessages.isNotEmpty) { // Keep only the most recent N messages in memory to bound memory usage final windowedMessages = allMessages.length > _messageWindowSize @@ -446,7 +457,9 @@ class MeshCoreConnector extends ChangeNotifier { int channelIndex, { int count = 50, }) async { - final allMessages = await _channelMessageStore.loadChannelMessages(channelIndex); + final allMessages = await _channelMessageStore.loadChannelMessages( + channelIndex, + ); final currentMessages = _channelMessages[channelIndex] ?? []; if (allMessages.length <= currentMessages.length) { @@ -551,7 +564,9 @@ class MeshCoreConnector extends ChangeNotifier { final contactKey = pubKeyToHex(message.senderKey); final messages = _conversations[contactKey]; if (messages != null) { - final index = messages.indexWhere((m) => m.messageId == message.messageId); + final index = messages.indexWhere( + (m) => m.messageId == message.messageId, + ); if (index != -1) { messages[index] = message; _messageStore.saveMessages(contactKey, messages); @@ -576,7 +591,9 @@ class MeshCoreConnector extends ChangeNotifier { } Contact _applyAutoSelection(Contact contact, PathSelection? selection) { - if (selection == null || selection.useFlood || selection.pathBytes.isEmpty) { + if (selection == null || + selection.useFlood || + selection.pathBytes.isEmpty) { return contact; } @@ -584,7 +601,9 @@ class MeshCoreConnector extends ChangeNotifier { publicKey: contact.publicKey, name: contact.name, type: contact.type, - pathLength: selection.hopCount >= 0 ? selection.hopCount : contact.pathLength, + pathLength: selection.hopCount >= 0 + ? selection.hopCount + : contact.pathLength, path: Uint8List.fromList(selection.pathBytes), latitude: contact.latitude, longitude: contact.longitude, @@ -593,7 +612,9 @@ class MeshCoreConnector extends ChangeNotifier { ); } - Future startScan({Duration timeout = const Duration(seconds: 10)}) async { + Future startScan({ + Duration timeout = const Duration(seconds: 10), + }) async { if (_state == MeshCoreConnectionState.scanning) return; _scanResults.clear(); @@ -714,7 +735,9 @@ class MeshCoreConnector extends ChangeNotifier { if (attempt == 2) rethrow; } } - _notifySubscription = _txCharacteristic!.onValueReceived.listen(_handleFrame); + _notifySubscription = _txCharacteristic!.onValueReceived.listen( + _handleFrame, + ); _setState(MeshCoreConnectionState.connected); @@ -771,8 +794,7 @@ class MeshCoreConnector extends ChangeNotifier { return result; } - bool get _shouldAutoReconnect => - !_manualDisconnect && _lastDeviceId != null; + bool get _shouldAutoReconnect => !_manualDisconnect && _lastDeviceId != null; void _cancelReconnectTimer() { _reconnectTimer?.cancel(); @@ -799,7 +821,8 @@ class MeshCoreConnector extends ChangeNotifier { return; } - final device = _lastDevice ?? + final device = + _lastDevice ?? (_lastDeviceId == null ? null : BluetoothDevice.fromId(_lastDeviceId!)); @@ -931,6 +954,7 @@ class MeshCoreConnector extends ChangeNotifier { await sendFrame(buildAppStartFrame()); await requestBatteryStatus(force: true); await sendFrame(buildGetRadioSettingsFrame()); + await sendFrame(buildGetCustomVarsFrame()); _scheduleSelfInfoRetry(); } @@ -938,6 +962,7 @@ class MeshCoreConnector extends ChangeNotifier { _awaitingSelfInfo = true; await sendFrame(buildDeviceQueryFrame()); await sendFrame(buildAppStartFrame()); + await sendFrame(buildGetCustomVarsFrame()); await requestBatteryStatus(); _scheduleSelfInfoRetry(); @@ -945,20 +970,19 @@ class MeshCoreConnector extends ChangeNotifier { void _scheduleSelfInfoRetry() { _selfInfoRetryTimer?.cancel(); - _selfInfoRetryTimer = Timer.periodic( - const Duration(milliseconds: 3500), - (timer) { - if (!isConnected) { - timer.cancel(); - return; - } - if (!_awaitingSelfInfo) { - timer.cancel(); - return; - } - unawaited(sendFrame(buildAppStartFrame())); - }, - ); + _selfInfoRetryTimer = Timer.periodic(const Duration(milliseconds: 3500), ( + timer, + ) { + if (!isConnected) { + timer.cancel(); + return; + } + if (!_awaitingSelfInfo) { + timer.cancel(); + return; + } + unawaited(sendFrame(buildAppStartFrame())); + }); } Future getContacts({int? since, bool preserveExisting = false}) async { @@ -979,10 +1003,7 @@ class MeshCoreConnector extends ChangeNotifier { } Future refreshContactsSinceLastmod() async { - await getContacts( - since: _latestContactLastmod(), - preserveExisting: true, - ); + await getContacts(since: _latestContactLastmod(), preserveExisting: true); } Future getContactByKey(Uint8List pubKey) async { @@ -990,18 +1011,20 @@ class MeshCoreConnector extends ChangeNotifier { await sendFrame(buildGetContactByKeyFrame(pubKey)); } - Future sendMessage( - Contact contact, - String text, - ) async { + Future sendMessage(Contact contact, String text) async { if (!isConnected || text.isEmpty) return; // Handle auto-rotation if enabled PathSelection? autoSelection; if (_appSettingsService?.settings.autoRouteRotationEnabled == true) { - autoSelection = _pathHistoryService?.getNextAutoPathSelection(contact.publicKeyHex); + autoSelection = _pathHistoryService?.getNextAutoPathSelection( + contact.publicKeyHex, + ); if (autoSelection != null) { - _pathHistoryService?.recordPathAttempt(contact.publicKeyHex, autoSelection); + _pathHistoryService?.recordPathAttempt( + contact.publicKeyHex, + autoSelection, + ); if (!autoSelection.useFlood && autoSelection.pathBytes.isNotEmpty) { await setContactPath( contact, @@ -1036,12 +1059,7 @@ class MeshCoreConnector extends ChangeNotifier { _addMessage(contact.publicKeyHex, message); notifyListeners(); final outboundText = prepareContactOutboundText(contact, text); - await sendFrame( - buildSendTextMsgFrame( - contact.publicKey, - outboundText, - ), - ); + await sendFrame(buildSendTextMsgFrame(contact.publicKey, outboundText)); } } @@ -1052,13 +1070,15 @@ class MeshCoreConnector extends ChangeNotifier { ) async { if (!isConnected) return; - await sendFrame(buildUpdateContactPathFrame( - contact.publicKey, - customPath, - pathLen, - type: contact.type, - name: contact.name, - )); + await sendFrame( + buildUpdateContactPathFrame( + contact.publicKey, + customPath, + pathLen, + type: contact.type, + name: contact.name, + ), + ); } /// Set path override for a contact (persists across contact refreshes) @@ -1068,16 +1088,27 @@ class MeshCoreConnector extends ChangeNotifier { int? pathLen, Uint8List? pathBytes, }) async { - appLogger.info('setPathOverride called for ${contact.name}: pathLen=$pathLen, bytesLen=${pathBytes?.length ?? 0}', tag: 'Connector'); + appLogger.info( + 'setPathOverride called for ${contact.name}: pathLen=$pathLen, bytesLen=${pathBytes?.length ?? 0}', + tag: 'Connector', + ); // Find contact in list - final index = _contacts.indexWhere((c) => c.publicKeyHex == contact.publicKeyHex); + final index = _contacts.indexWhere( + (c) => c.publicKeyHex == contact.publicKeyHex, + ); if (index == -1) { - appLogger.warn('setPathOverride: Contact not found in list: ${contact.name}', tag: 'Connector'); + appLogger.warn( + 'setPathOverride: Contact not found in list: ${contact.name}', + tag: 'Connector', + ); return; } - appLogger.info('Found contact at index $index. Current override: ${_contacts[index].pathOverride}', tag: 'Connector'); + appLogger.info( + 'Found contact at index $index. Current override: ${_contacts[index].pathOverride}', + tag: 'Connector', + ); // Update contact with new path override _contacts[index] = _contacts[index].copyWith( @@ -1086,7 +1117,10 @@ class MeshCoreConnector extends ChangeNotifier { clearPathOverride: pathLen == null, // Clear if pathLen is null ); - appLogger.info('Updated contact. New override: ${_contacts[index].pathOverride}, bytesLen: ${_contacts[index].pathOverrideBytes?.length}', tag: 'Connector'); + appLogger.info( + 'Updated contact. New override: ${_contacts[index].pathOverride}, bytesLen: ${_contacts[index].pathOverrideBytes?.length}', + tag: 'Connector', + ); // Save to storage await _contactStore.saveContacts(_contacts); @@ -1099,7 +1133,9 @@ class MeshCoreConnector extends ChangeNotifier { appLogger.info('Path sent to device', tag: 'Connector'); } - debugPrint('Set path override for ${contact.name}: pathLen=$pathLen, bytes=${pathBytes?.length ?? 0}'); + debugPrint( + 'Set path override for ${contact.name}: pathLen=$pathLen, bytes=${pathBytes?.length ?? 0}', + ); notifyListeners(); } @@ -1148,7 +1184,9 @@ class MeshCoreConnector extends ChangeNotifier { outboundText, selfKey, ); - final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + final ackHashHex = ackHash + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); final messageBytes = utf8.encode(outboundText).length; _pendingRepeaterAcks[ackHashHex]?.timeout?.cancel(); _pendingRepeaterAcks[ackHashHex] = _RepeaterAckContext( @@ -1206,7 +1244,7 @@ class MeshCoreConnector extends ChangeNotifier { } } - Future sendChannelMessage(Channel channel, String text) async{ + Future sendChannelMessage(Channel channel, String text) async { if (!isConnected || text.isEmpty) return; // Check if this is a reaction - if so, process it immediately instead of adding as a message @@ -1215,9 +1253,14 @@ class MeshCoreConnector extends ChangeNotifier { // Check if we've already processed this reaction _processedChannelReactions.putIfAbsent(channel.index, () => {}); final reactionKey = reactionInfo.reactionKey; - final reactionIdentifier = reactionKey != null ? '${reactionKey}_${reactionInfo.emoji}' : null; + final reactionIdentifier = reactionKey != null + ? '${reactionKey}_${reactionInfo.emoji}' + : null; - if (reactionIdentifier != null && _processedChannelReactions[channel.index]!.contains(reactionIdentifier)) { + if (reactionIdentifier != null && + _processedChannelReactions[channel.index]!.contains( + reactionIdentifier, + )) { // Already processed, don't process again return; } @@ -1242,13 +1285,19 @@ class MeshCoreConnector extends ChangeNotifier { return; } - final message = ChannelMessage.outgoing(text, _selfName ?? 'Me', channel.index); + final message = ChannelMessage.outgoing( + text, + _selfName ?? 'Me', + channel.index, + ); _addChannelMessage(channel.index, message); notifyListeners(); final trimmed = text.trim(); - final isStructuredPayload = trimmed.startsWith('g:') || trimmed.startsWith('m:'); - final outboundText = (isChannelSmazEnabled(channel.index) && !isStructuredPayload) + final isStructuredPayload = + trimmed.startsWith('g:') || trimmed.startsWith('m:'); + final outboundText = + (isChannelSmazEnabled(channel.index) && !isStructuredPayload) ? Smaz.encodeIfSmaller(text) : text; await sendFrame(buildSendChannelTextMsgFrame(channel.index, outboundText)); @@ -1264,9 +1313,7 @@ class MeshCoreConnector extends ChangeNotifier { _conversations.remove(contact.publicKeyHex); _loadedConversationKeys.remove(contact.publicKeyHex); _contactLastReadMs.remove(contact.publicKeyHex); - _unreadStore.saveContactLastRead( - Map.from(_contactLastReadMs), - ); + _unreadStore.saveContactLastRead(Map.from(_contactLastReadMs)); _messageStore.clearMessages(contact.publicKeyHex); notifyListeners(); } @@ -1275,8 +1322,9 @@ class MeshCoreConnector extends ChangeNotifier { if (!isConnected) return; await sendFrame(buildResetPathFrame(contact.publicKey)); - final existingIndex = - _contacts.indexWhere((c) => c.publicKeyHex == contact.publicKeyHex); + final existingIndex = _contacts.indexWhere( + (c) => c.publicKeyHex == contact.publicKeyHex, + ); if (existingIndex >= 0) { final existing = _contacts[existingIndex]; // Use copyWith to preserve pathOverride and pathOverrideBytes @@ -1295,8 +1343,9 @@ class MeshCoreConnector extends ChangeNotifier { Uint8List? pathBytes, int? pathLength, }) { - final existingIndex = - _contacts.indexWhere((c) => c.publicKeyHex == publicKeyHex); + final existingIndex = _contacts.indexWhere( + (c) => c.publicKeyHex == publicKeyHex, + ); if (existingIndex >= 0) { final existing = _contacts[existingIndex]; _contacts[existingIndex] = existing.copyWith( @@ -1344,7 +1393,9 @@ class MeshCoreConnector extends ChangeNotifier { _handleQueueSyncTimeout(); }); - debugPrint('[QueueSync] Requesting next message (retry: $_queueSyncRetries/$_maxQueueSyncRetries)'); + debugPrint( + '[QueueSync] Requesting next message (retry: $_queueSyncRetries/$_maxQueueSyncRetries)', + ); try { await sendFrame(buildSyncNextMessageFrame()); @@ -1358,7 +1409,9 @@ class MeshCoreConnector extends ChangeNotifier { } void _handleQueueSyncTimeout() { - debugPrint('[QueueSync] Timeout waiting for message (retry: $_queueSyncRetries/$_maxQueueSyncRetries)'); + debugPrint( + '[QueueSync] Timeout waiting for message (retry: $_queueSyncRetries/$_maxQueueSyncRetries)', + ); if (_queueSyncRetries < _maxQueueSyncRetries) { // Retry @@ -1389,11 +1442,19 @@ class MeshCoreConnector extends ChangeNotifier { await sendFrame(buildSetAdvertNameFrame(name)); } - Future setNodeLocation({required double lat, required double lon}) async { + Future setNodeLocation({ + required double lat, + required double lon, + }) async { if (!isConnected) return; await sendFrame(buildSetAdvertLatLonFrame(lat, lon)); } + Future setCustomVar(String value) async { + if (!isConnected) return; + await sendFrame(buildSetCustomVarFrame(value)); + } + Future sendSelfAdvert({bool flood = true}) async { if (!isConnected) return; await sendFrame(buildSendSelfAdvertFrame(flood: flood)); @@ -1424,7 +1485,9 @@ class MeshCoreConnector extends ChangeNotifier { _channelSyncRetries = 0; notifyListeners(); - debugPrint('[ChannelSync] Starting sync for $_totalChannelsToRequest channels'); + debugPrint( + '[ChannelSync] Starting sync for $_totalChannelsToRequest channels', + ); // Start sequential sync await _requestNextChannel(); @@ -1456,7 +1519,9 @@ class MeshCoreConnector extends ChangeNotifier { () => _handleChannelSyncTimeout(channelIndex), ); - debugPrint('[ChannelSync] Requesting channel $channelIndex/$_totalChannelsToRequest (retry: $_channelSyncRetries/$_maxChannelSyncRetries)'); + debugPrint( + '[ChannelSync] Requesting channel $channelIndex/$_totalChannelsToRequest (retry: $_channelSyncRetries/$_maxChannelSyncRetries)', + ); try { await sendFrame(buildGetChannelFrame(channelIndex)); @@ -1468,7 +1533,9 @@ class MeshCoreConnector extends ChangeNotifier { } void _handleChannelSyncTimeout(int channelIndex) { - debugPrint('[ChannelSync] Timeout waiting for channel $channelIndex (retry: $_channelSyncRetries/$_maxChannelSyncRetries)'); + debugPrint( + '[ChannelSync] Timeout waiting for channel $channelIndex (retry: $_channelSyncRetries/$_maxChannelSyncRetries)', + ); if (_channelSyncRetries < _maxChannelSyncRetries) { // Retry the same channel @@ -1477,16 +1544,20 @@ class MeshCoreConnector extends ChangeNotifier { unawaited(_requestNextChannel()); } else { // Max retries reached for this channel, restore from cache and move to next - debugPrint('[ChannelSync] Max retries reached for channel $channelIndex, attempting cache restore'); + debugPrint( + '[ChannelSync] Max retries reached for channel $channelIndex, attempting cache restore', + ); // Try to restore this channel from cache try { final cachedChannel = _previousChannelsCache.firstWhere( - (c) => c.index == channelIndex + (c) => c.index == channelIndex, ); if (!cachedChannel.isEmpty) { _channels.add(cachedChannel); - debugPrint('[ChannelSync] Restored channel $channelIndex (${cachedChannel.name}) from cache'); + debugPrint( + '[ChannelSync] Restored channel $channelIndex (${cachedChannel.name}) from cache', + ); } } catch (e) { // No cached channel found, that's okay @@ -1503,7 +1574,9 @@ class MeshCoreConnector extends ChangeNotifier { void _completeChannelSync() { _channelSyncTimeout?.cancel(); - debugPrint('[ChannelSync] Sync complete: received ${_channels.length}/$_totalChannelsToRequest channels'); + debugPrint( + '[ChannelSync] Sync complete: received ${_channels.length}/$_totalChannelsToRequest channels', + ); _cleanupChannelSync(completed: true); @@ -1541,9 +1614,7 @@ class MeshCoreConnector extends ChangeNotifier { // Delete by setting empty name and zero PSK await sendFrame(buildSetChannelFrame(index, '', Uint8List(16))); _channelLastReadMs.remove(index); - _unreadStore.saveChannelLastRead( - Map.from(_channelLastReadMs), - ); + _unreadStore.saveChannelLastRead(Map.from(_channelLastReadMs)); // Refresh channels after deleting await getChannels(); } @@ -1629,6 +1700,8 @@ class MeshCoreConnector extends ChangeNotifier { case respCodeBattAndStorage: _handleBatteryAndStorage(frame); break; + case respCodeCustomVars: + _handleCustomVars(frame); default: debugPrint('Unknown frame code: $code'); } @@ -1705,8 +1778,12 @@ class MeshCoreConnector extends ChangeNotifier { // Firmware reports MAX_CONTACTS / 2 for v3+ device info. final reportedContacts = frame[2]; final reportedChannels = frame[3]; - final nextMaxContacts = reportedContacts > 0 ? reportedContacts * 2 : _maxContacts; - final nextMaxChannels = reportedChannels > 0 ? reportedChannels : _maxChannels; + final nextMaxContacts = reportedContacts > 0 + ? reportedContacts * 2 + : _maxContacts; + final nextMaxChannels = reportedChannels > 0 + ? reportedChannels + : _maxChannels; final previousMaxChannels = _maxChannels; if (nextMaxContacts != _maxContacts || nextMaxChannels != _maxChannels) { _maxContacts = nextMaxContacts; @@ -1751,7 +1828,9 @@ class MeshCoreConnector extends ChangeNotifier { _currentBwHz = readUint32LE(frame, 5); _currentSf = frame[9]; _currentCr = frame[10]; - debugPrint('Radio settings: freq=$_currentFreqHz bw=$_currentBwHz sf=$_currentSf cr=$_currentCr'); + debugPrint( + 'Radio settings: freq=$_currentFreqHz bw=$_currentBwHz sf=$_currentSf cr=$_currentCr', + ); notifyListeners(); } } @@ -1822,11 +1901,15 @@ class MeshCoreConnector extends ChangeNotifier { if (existingIndex >= 0) { final existing = _contacts[existingIndex]; - final mergedLastMessageAt = existing.lastMessageAt.isAfter(contact.lastMessageAt) + final mergedLastMessageAt = + existing.lastMessageAt.isAfter(contact.lastMessageAt) ? existing.lastMessageAt : contact.lastMessageAt; - appLogger.info('Refreshing contact ${contact.name}: devicePath=${contact.pathLength}, existingOverride=${existing.pathOverride}', tag: 'Connector'); + appLogger.info( + 'Refreshing contact ${contact.name}: devicePath=${contact.pathLength}, existingOverride=${existing.pathOverride}', + tag: 'Connector', + ); // CRITICAL: Preserve user's path override when contact is refreshed from device _contacts[existingIndex] = contact.copyWith( @@ -1835,10 +1918,16 @@ class MeshCoreConnector extends ChangeNotifier { pathOverrideBytes: existing.pathOverrideBytes, ); - appLogger.info('After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_contacts[existingIndex].pathLength}', tag: 'Connector'); + appLogger.info( + 'After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_contacts[existingIndex].pathLength}', + tag: 'Connector', + ); } else { _contacts.add(contact); - appLogger.info('Added new contact ${contact.name}: pathLen=${contact.pathLength}', tag: 'Connector'); + appLogger.info( + 'Added new contact ${contact.name}: pathLen=${contact.pathLength}', + tag: 'Connector', + ); } _knownContactKeys.add(contact.publicKeyHex); _loadMessagesForContact(contact.publicKeyHex); @@ -1970,9 +2059,13 @@ class MeshCoreConnector extends ChangeNotifier { if (message == null && !_isLoadingContacts) { final senderPrefix = _extractSenderPrefix(frame); if (senderPrefix != null) { - final hasContact = _contacts.any((c) => _matchesPrefix(c.publicKey, senderPrefix)); + final hasContact = _contacts.any( + (c) => _matchesPrefix(c.publicKey, senderPrefix), + ); if (!hasContact) { - debugPrint('Received message from unknown contact, refreshing contacts...'); + debugPrint( + 'Received message from unknown contact, refreshing contacts...', + ); await refreshContactsSinceLastmod(); // Retry parsing after refresh message = _parseContactMessage(frame); @@ -2017,7 +2110,9 @@ class MeshCoreConnector extends ChangeNotifier { notifyListeners(); // Show notification for new incoming message - if (!message.isOutgoing && !message.isCli && _appSettingsService != null) { + if (!message.isOutgoing && + !message.isCli && + _appSettingsService != null) { final settings = _appSettingsService!.settings; if (settings.notificationsEnabled && settings.notifyOnNewMessage) { // Find the contact name @@ -2074,9 +2169,17 @@ class MeshCoreConnector extends ChangeNotifier { // Try base text offset; if empty and there is room for the optional 4-byte extra // (used by signed/plain variants), try again skipping those bytes. - var text = readCString(frame, baseTextOffset, frame.length - baseTextOffset); + var text = readCString( + frame, + baseTextOffset, + frame.length - baseTextOffset, + ); if (text.isEmpty && frame.length > baseTextOffset + 4) { - text = readCString(frame, baseTextOffset + 4, frame.length - (baseTextOffset + 4)); + text = readCString( + frame, + baseTextOffset + 4, + frame.length - (baseTextOffset + 4), + ); } if (text.isEmpty) return null; final decodedText = isCli ? text : (Smaz.tryDecodePrefixed(text) ?? text); @@ -2099,7 +2202,7 @@ class MeshCoreConnector extends ChangeNotifier { status: MessageStatus.delivered, pathLength: pathLenByte == 0xFF ? 0 : pathLenByte, pathBytes: Uint8List(0), - fourByteRoomContactKey: fourBytePubMSG + fourByteRoomContactKey: fourBytePubMSG, ); } @@ -2140,17 +2243,15 @@ class MeshCoreConnector extends ChangeNotifier { String prepareContactOutboundText(Contact contact, String text) { final trimmed = text.trim(); final isStructuredPayload = - trimmed.startsWith('g:') || trimmed.startsWith('m:') || trimmed.startsWith('V1|'); + trimmed.startsWith('g:') || + trimmed.startsWith('m:') || + trimmed.startsWith('V1|'); if (!isStructuredPayload && isContactSmazEnabled(contact.publicKeyHex)) { return Smaz.encodeIfSmaller(text); } return text; } - - - - String _channelDisplayName(int channelIndex) { for (final channel in _channels) { if (channel.index != channelIndex) continue; @@ -2184,7 +2285,10 @@ class MeshCoreConnector extends ChangeNotifier { void _handleIncomingChannelMessage(Uint8List frame) { final message = ChannelMessage.fromFrame(frame); if (message != null && message.channelIndex != null) { - if (_shouldDropSelfChannelMessage(message.senderName, message.pathBytes)) { + if (_shouldDropSelfChannelMessage( + message.senderName, + message.pathBytes, + )) { return; } _updateContactLastMessageAtByName( @@ -2257,7 +2361,9 @@ class MeshCoreConnector extends ChangeNotifier { _maybeMarkActiveChannelRead(message); notifyListeners(); if (isNew) { - final label = channel.name.isEmpty ? 'Channel ${channel.index}' : channel.name; + final label = channel.name.isEmpty + ? 'Channel ${channel.index}' + : channel.name; _maybeNotifyChannelMessage(message, channelName: label); } return; @@ -2277,7 +2383,9 @@ class MeshCoreConnector extends ChangeNotifier { // Check if this is a CLI command ACK - if so, ignore it if (_lastSentWasCliCommand) { - final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + final ackHashHex = ackHash + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); debugPrint('Ignoring CLI command ACK (sent): $ackHashHex'); _lastSentWasCliCommand = false; return; @@ -2294,7 +2402,8 @@ class MeshCoreConnector extends ChangeNotifier { // Fallback to old behavior for (var messages in _conversations.values) { for (int i = messages.length - 1; i >= 0; i--) { - if (messages[i].isOutgoing && messages[i].status == MessageStatus.pending) { + if (messages[i].isOutgoing && + messages[i].status == MessageStatus.pending) { messages[i] = messages[i].copyWith(status: MessageStatus.sent); notifyListeners(); return; @@ -2328,7 +2437,8 @@ class MeshCoreConnector extends ChangeNotifier { // Fallback to old behavior for (var messages in _conversations.values) { for (int i = messages.length - 1; i >= 0; i--) { - if (messages[i].isOutgoing && messages[i].status == MessageStatus.sent) { + if (messages[i].isOutgoing && + messages[i].status == MessageStatus.sent) { messages[i] = messages[i].copyWith(status: MessageStatus.delivered); notifyListeners(); return; @@ -2339,7 +2449,9 @@ class MeshCoreConnector extends ChangeNotifier { } bool _handleRepeaterCommandSent(Uint8List ackHash, int timeoutMs) { - final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + final ackHashHex = ackHash + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); final entry = _pendingRepeaterAcks[ackHashHex]; if (entry == null) return false; @@ -2358,7 +2470,9 @@ class MeshCoreConnector extends ChangeNotifier { } bool _handleRepeaterCommandAck(Uint8List ackHash, int tripTimeMs) { - final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + final ackHashHex = ackHash + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); final entry = _pendingRepeaterAcks.remove(ackHashHex); if (entry == null) return false; entry.timeout?.cancel(); @@ -2370,7 +2484,9 @@ class MeshCoreConnector extends ChangeNotifier { final channel = Channel.fromFrame(frame); if (channel == null) return; - debugPrint('[ChannelSync] Received channel ${channel.index}: ${channel.isEmpty ? "empty" : channel.name}'); + debugPrint( + '[ChannelSync] Received channel ${channel.index}: ${channel.isEmpty ? "empty" : channel.name}', + ); // If we're syncing and this is the channel we're waiting for if (_isSyncingChannels && _channelSyncInFlight) { @@ -2392,9 +2508,12 @@ class MeshCoreConnector extends ChangeNotifier { } else { // Received a channel but not the one we're waiting for // This can happen if device sends unsolicited updates - debugPrint('[ChannelSync] Received unexpected channel ${channel.index}, expected $_nextChannelIndexToRequest'); + debugPrint( + '[ChannelSync] Received unexpected channel ${channel.index}, expected $_nextChannelIndexToRequest', + ); // Add it anyway but don't advance sync - if (!channel.isEmpty && !_channels.any((c) => c.index == channel.index)) { + if (!channel.isEmpty && + !_channels.any((c) => c.index == channel.index)) { _channels.add(channel); } return; @@ -2404,7 +2523,9 @@ class MeshCoreConnector extends ChangeNotifier { // Not syncing, or received unsolicited update - handle normally if (!channel.isEmpty) { // Update or add channel - final existingIndex = _channels.indexWhere((c) => c.index == channel.index); + final existingIndex = _channels.indexWhere( + (c) => c.index == channel.index, + ); if (existingIndex >= 0) { _channels[existingIndex] = channel; } else { @@ -2469,26 +2590,30 @@ class MeshCoreConnector extends ChangeNotifier { return latestMs; } - void _setContactLastReadMs(String contactKeyHex, int timestampMs, {bool notify = true}) { + void _setContactLastReadMs( + String contactKeyHex, + int timestampMs, { + bool notify = true, + }) { if (!_shouldTrackUnreadForContactKey(contactKeyHex)) return; final existing = _contactLastReadMs[contactKeyHex] ?? 0; if (timestampMs <= existing) return; _contactLastReadMs[contactKeyHex] = timestampMs; - _unreadStore.saveContactLastRead( - Map.from(_contactLastReadMs), - ); + _unreadStore.saveContactLastRead(Map.from(_contactLastReadMs)); if (notify) { notifyListeners(); } } - void _setChannelLastReadMs(int channelIndex, int timestampMs, {bool notify = true}) { + void _setChannelLastReadMs( + int channelIndex, + int timestampMs, { + bool notify = true, + }) { final existing = _channelLastReadMs[channelIndex] ?? 0; if (timestampMs <= existing) return; _channelLastReadMs[channelIndex] = timestampMs; - _unreadStore.saveChannelLastRead( - Map.from(_channelLastReadMs), - ); + _unreadStore.saveChannelLastRead(Map.from(_channelLastReadMs)); if (notify) { notifyListeners(); } @@ -2526,9 +2651,12 @@ class MeshCoreConnector extends ChangeNotifier { // Check if we've already processed this exact reaction using lightweight key _processedContactReactions.putIfAbsent(pubKeyHex, () => {}); final reactionKey = reactionInfo.reactionKey; - final reactionIdentifier = reactionKey != null ? '${reactionKey}_${reactionInfo.emoji}' : null; + final reactionIdentifier = reactionKey != null + ? '${reactionKey}_${reactionInfo.emoji}' + : null; - final isDuplicate = reactionIdentifier != null && + final isDuplicate = + reactionIdentifier != null && _processedContactReactions[pubKeyHex]!.contains(reactionIdentifier); if (!isDuplicate) { @@ -2551,7 +2679,10 @@ class MeshCoreConnector extends ChangeNotifier { notifyListeners(); } - void _processContactReaction(List messages, ReactionInfo reactionInfo) { + void _processContactReaction( + List messages, + ReactionInfo reactionInfo, + ) { // Find target message by messageId for (int i = 0; i < messages.length; i++) { if (messages[i].messageId == reactionInfo.targetMessageId) { @@ -2570,7 +2701,8 @@ class MeshCoreConnector extends ChangeNotifier { var index = 0; final header = raw[index++]; final routeType = header & _phRouteMask; - final hasTransport = routeType == _routeTransportFlood || routeType == _routeTransportDirect; + final hasTransport = + routeType == _routeTransportFlood || routeType == _routeTransportDirect; if (hasTransport) { if (raw.length < index + 4) return null; index += 4; @@ -2633,7 +2765,8 @@ class MeshCoreConnector extends ChangeNotifier { if (RegExp(r'[:\[\]]').hasMatch(potentialSender)) { return _ParsedText(senderName: 'Unknown', text: text); } - final offset = (colonIndex + 1 < text.length && text[colonIndex + 1] == ' ') + final offset = + (colonIndex + 1 < text.length && text[colonIndex + 1] == ' ') ? colonIndex + 2 : colonIndex + 1; return _ParsedText( @@ -2670,10 +2803,7 @@ class MeshCoreConnector extends ChangeNotifier { return contact.path; } - int? _resolveOutgoingPathLength( - Contact contact, - PathSelection? selection, - ) { + int? _resolveOutgoingPathLength(Contact contact, PathSelection? selection) { // Priority 1: Check user's path override if (contact.pathOverride != null) { return contact.pathOverride; @@ -2714,10 +2844,15 @@ class MeshCoreConnector extends ChangeNotifier { // Check if we've already processed this exact reaction using lightweight key _processedChannelReactions.putIfAbsent(channelIndex, () => {}); final reactionKey = reactionInfo.reactionKey; - final reactionIdentifier = reactionKey != null ? '${reactionKey}_${reactionInfo.emoji}' : null; + final reactionIdentifier = reactionKey != null + ? '${reactionKey}_${reactionInfo.emoji}' + : null; - final isDuplicate = reactionIdentifier != null && - _processedChannelReactions[channelIndex]!.contains(reactionIdentifier); + final isDuplicate = + reactionIdentifier != null && + _processedChannelReactions[channelIndex]!.contains( + reactionIdentifier, + ); if (!isDuplicate) { // New reaction - process it @@ -2739,7 +2874,10 @@ class MeshCoreConnector extends ChangeNotifier { if (replyInfo != null) { // Find original message by sender name (most recent match) - final originalMessage = _findMessageBySender(messages, replyInfo.mentionedNode); + final originalMessage = _findMessageBySender( + messages, + replyInfo.mentionedNode, + ); if (originalMessage != null) { // Create new message with reply metadata @@ -2769,8 +2907,14 @@ class MeshCoreConnector extends ChangeNotifier { if (existingIndex >= 0) { isNew = false; final existing = messages[existingIndex]; - final mergedPathBytes = _selectPreferredPathBytes(existing.pathBytes, processedMessage.pathBytes); - final mergedPathVariants = _mergePathVariants(existing.pathVariants, processedMessage.pathVariants); + final mergedPathBytes = _selectPreferredPathBytes( + existing.pathBytes, + processedMessage.pathBytes, + ); + final mergedPathVariants = _mergePathVariants( + existing.pathVariants, + processedMessage.pathVariants, + ); final mergedPathLength = _mergePathLength( existing.pathLength, processedMessage.pathLength, @@ -2783,7 +2927,9 @@ class MeshCoreConnector extends ChangeNotifier { pathBytes: mergedPathBytes, pathVariants: mergedPathVariants, // Mark as sent when first repeat is heard - status: newRepeatCount == 1 && existing.status == ChannelMessageStatus.pending + status: + newRepeatCount == 1 && + existing.status == ChannelMessageStatus.pending ? ChannelMessageStatus.sent : existing.status, ); @@ -2792,14 +2938,14 @@ class MeshCoreConnector extends ChangeNotifier { } // Save to persistent storage - _channelMessageStore.saveChannelMessages( - channelIndex, - messages, - ); + _channelMessageStore.saveChannelMessages(channelIndex, messages); return isNew; } - ChannelMessage? _findMessageBySender(List messages, String mentionedNode) { + ChannelMessage? _findMessageBySender( + List messages, + String mentionedNode, + ) { // Search backwards for most recent message from this sender for (int i = messages.length - 1; i >= 0; i--) { if (messages[i].senderName == mentionedNode && !messages[i].isOutgoing) { @@ -2809,7 +2955,10 @@ class MeshCoreConnector extends ChangeNotifier { return null; } - void _processReaction(List messages, ReactionInfo reactionInfo) { + void _processReaction( + List messages, + ReactionInfo reactionInfo, + ) { // Find target message by messageId for (int i = 0; i < messages.length; i++) { if (messages[i].messageId == reactionInfo.targetMessageId) { @@ -2824,7 +2973,10 @@ class MeshCoreConnector extends ChangeNotifier { } } - int _findChannelRepeatIndex(List messages, ChannelMessage incoming) { + int _findChannelRepeatIndex( + List messages, + ChannelMessage incoming, + ) { for (int i = messages.length - 1; i >= 0; i--) { final existing = messages[i]; if (_isChannelRepeat(existing, incoming)) { @@ -2837,9 +2989,10 @@ class MeshCoreConnector extends ChangeNotifier { bool _isChannelRepeat(ChannelMessage existing, ChannelMessage incoming) { if (existing.text != incoming.text) return false; - final diffMs = (existing.timestamp.millisecondsSinceEpoch - - incoming.timestamp.millisecondsSinceEpoch) - .abs(); + final diffMs = + (existing.timestamp.millisecondsSinceEpoch - + incoming.timestamp.millisecondsSinceEpoch) + .abs(); if (diffMs > 5000) return false; if (existing.senderName == incoming.senderName) return true; @@ -2960,6 +3113,34 @@ class MeshCoreConnector extends ChangeNotifier { _scheduleReconnect(); } + Map _parseKeyValueString(String input) { + final result = {}; + + // Split on commas first – empty entries are ignored. + for (final pair in input.split(',')) { + final trimmedPair = pair.trim(); + if (trimmedPair.isEmpty) continue; + + // Each pair must contain exactly one ':'. + final separatorIndex = trimmedPair.indexOf(':'); + if (separatorIndex == -1) continue; // malformed, skip + + final key = trimmedPair.substring(0, separatorIndex).trim(); + final value = trimmedPair.substring(separatorIndex + 1).trim(); + + if (key.isNotEmpty) { + result[key] = value; + } + } + + return result; + } + + void _handleCustomVars(Uint8List frame) { + final buf = BufferReader(frame.sublist(1)); + _currentCustomVars = _parseKeyValueString(buf.readString()); + } + void _setState(MeshCoreConnectionState newState) { if (_state != newState) { _state = newState; @@ -3013,17 +3194,15 @@ class _RawPacket { required this.payload, }); - bool get isFlood => routeType == _routeFlood || routeType == _routeTransportFlood; + bool get isFlood => + routeType == _routeFlood || routeType == _routeTransportFlood; } class _ParsedText { final String senderName; final String text; - _ParsedText({ - required this.senderName, - required this.text, - }); + _ParsedText({required this.senderName, required this.text}); } class _RepeaterAckContext { diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index c828a1a..8469d61 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -20,7 +20,8 @@ class BufferReader { Uint8List readRemainingBytes() => readBytes(remaining); - String readString() => utf8.decode(readRemainingBytes(), allowMalformed: true); + String readString() => + utf8.decode(readRemainingBytes(), allowMalformed: true); String readCString(int maxLength) { final value = []; @@ -38,13 +39,19 @@ class BufferReader { int readUInt8() => readBytes(1).buffer.asByteData().getUint8(0); int readInt8() => readBytes(1).buffer.asByteData().getInt8(0); - int readUInt16LE() => readBytes(2).buffer.asByteData().getUint16(0, Endian.little); - int readUInt16BE() => readBytes(2).buffer.asByteData().getUint16(0, Endian.big); - int readUInt32LE() => readBytes(4).buffer.asByteData().getUint32(0, Endian.little); - int readUInt32BE() => readBytes(4).buffer.asByteData().getUint32(0, Endian.big); - int readInt16LE() => readBytes(2).buffer.asByteData().getInt16(0, Endian.little); + int readUInt16LE() => + readBytes(2).buffer.asByteData().getUint16(0, Endian.little); + int readUInt16BE() => + readBytes(2).buffer.asByteData().getUint16(0, Endian.big); + int readUInt32LE() => + readBytes(4).buffer.asByteData().getUint32(0, Endian.little); + int readUInt32BE() => + readBytes(4).buffer.asByteData().getUint32(0, Endian.big); + int readInt16LE() => + readBytes(2).buffer.asByteData().getInt16(0, Endian.little); int readInt16BE() => readBytes(2).buffer.asByteData().getInt16(0, Endian.big); - int readInt32LE() => readBytes(4).buffer.asByteData().getInt32(0, Endian.little); + int readInt32LE() => + readBytes(4).buffer.asByteData().getInt32(0, Endian.little); int readInt24BE() { var value = (readByte() << 16) | (readByte() << 8) | readByte(); @@ -63,21 +70,25 @@ class BufferWriter { void writeBytes(Uint8List bytes) => _builder.add(bytes); void writeUInt16LE(int num) { - final bytes = Uint8List(2)..buffer.asByteData().setUint16(0, num, Endian.little); + final bytes = Uint8List(2) + ..buffer.asByteData().setUint16(0, num, Endian.little); writeBytes(bytes); } void writeUInt32LE(int num) { - final bytes = Uint8List(4)..buffer.asByteData().setUint32(0, num, Endian.little); + final bytes = Uint8List(4) + ..buffer.asByteData().setUint32(0, num, Endian.little); writeBytes(bytes); } void writeInt32LE(int num) { - final bytes = Uint8List(4)..buffer.asByteData().setInt32(0, num, Endian.little); + final bytes = Uint8List(4) + ..buffer.asByteData().setInt32(0, num, Endian.little); writeBytes(bytes); } - void writeString(String string) => writeBytes(Uint8List.fromList(utf8.encode(string))); + void writeString(String string) => + writeBytes(Uint8List.fromList(utf8.encode(string))); void writeCString(String string, int maxLength) { final bytes = Uint8List(maxLength); @@ -118,6 +129,8 @@ const int cmdGetChannel = 31; const int cmdSetChannel = 32; const int cmdGetRadioSettings = 57; const int cmdGetTelemetryReq = 39; +const int cmdGetCustomVar = 40; +const int cmdSetCustomVar = 41; const int cmdSendBinaryReq = 50; // Text message types @@ -152,6 +165,7 @@ const int respCodeContactMsgRecvV3 = 16; const int respCodeChannelMsgRecvV3 = 17; const int respCodeChannelInfo = 18; const int respCodeRadioSettings = 25; +const int respCodeCustomVars = 21; // Push codes (async from device) const int pushCodeAdvert = 0x80; @@ -166,7 +180,6 @@ const int pushCodeNewAdvert = 0x8A; const int pushCodeTelemetryResponse = 0x8B; const int pushCodeBinaryResponse = 0x8C; - // Contact/advertisement types const int advTypeChat = 1; const int advTypeRepeater = 2; @@ -233,10 +246,7 @@ class ParsedContactText { final Uint8List senderPrefix; final String text; - const ParsedContactText({ - required this.senderPrefix, - required this.text, - }); + const ParsedContactText({required this.senderPrefix, required this.text}); } ParsedContactText? parseContactMessageText(Uint8List frame) { @@ -265,10 +275,17 @@ ParsedContactText? parseContactMessageText(Uint8List frame) { return null; } - var text = readCString(frame, baseTextOffset, frame.length - baseTextOffset).trim(); + var text = readCString( + frame, + baseTextOffset, + frame.length - baseTextOffset, + ).trim(); if (text.isEmpty && frame.length > baseTextOffset + 4) { - text = - readCString(frame, baseTextOffset + 4, frame.length - (baseTextOffset + 4)).trim(); + text = readCString( + frame, + baseTextOffset + 4, + frame.length - (baseTextOffset + 4), + ).trim(); } if (text.isEmpty) return null; @@ -362,7 +379,8 @@ Uint8List buildSendTextMsgFrame( int attempt = 0, int? timestampSeconds, }) { - final timestamp = timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000); + final timestamp = + timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000); final writer = BufferWriter(); writer.writeByte(cmdSendTxtMsg); writer.writeByte(txtTypePlain); @@ -444,7 +462,9 @@ Uint8List buildSendSelfAdvertFrame({bool flood = false}) { // Format: [cmd][name...] Uint8List buildSetAdvertNameFrame(String name) { final nameBytes = utf8.encode(name); - final nameLen = nameBytes.length < maxNameSize ? nameBytes.length : maxNameSize - 1; + final nameLen = nameBytes.length < maxNameSize + ? nameBytes.length + : maxNameSize - 1; final writer = BufferWriter(); writer.writeByte(cmdSetAdvertName); writer.writeBytes(Uint8List.fromList(nameBytes.sublist(0, nameLen))); @@ -461,6 +481,14 @@ Uint8List buildSetAdvertLatLonFrame(double lat, double lon) { return writer.toBytes(); } +Uint8List buildSetCustomVarFrame(String value) { + final writer = BufferWriter(); + writer.writeByte(cmdSetCustomVar); + writer.writeString(value); + writer.writeByte(0); + return writer.toBytes(); +} + // Build CMD_REBOOT frame // Format: [cmd]["reboot"] Uint8List buildRebootFrame() { @@ -544,7 +572,9 @@ Uint8List buildUpdateContactPathFrame( // Path data (64 bytes, zero-padded) final pathPadded = Uint8List(maxPathSize); if (customPath.isNotEmpty && pathLen > 0) { - final copyLen = customPath.length < maxPathSize ? customPath.length : maxPathSize; + final copyLen = customPath.length < maxPathSize + ? customPath.length + : maxPathSize; for (int i = 0; i < copyLen; i++) { pathPadded[i] = customPath[i]; } @@ -575,6 +605,11 @@ Uint8List buildGetRadioSettingsFrame() { return Uint8List.fromList([cmdGetRadioSettings]); } +//Build CMD_GET_CUSTOM_VARS frame +Uint8List buildGetCustomVarsFrame() { + return Uint8List.fromList([cmdGetCustomVar]); +} + // Calculate LoRa airtime for a packet // Based on Semtech SX127x datasheet formula // Returns airtime in milliseconds @@ -598,9 +633,11 @@ int calculateLoRaAirtime({ final crc = 1; // CRC enabled final de = lowDataRateOptimize ? 1 : 0; - final numerator = 8 * payloadBytes - 4 * spreadingFactor + 28 + 16 * crc - headerBytes; + final numerator = + 8 * payloadBytes - 4 * spreadingFactor + 28 + 16 * crc - headerBytes; final denominator = 4 * (spreadingFactor - 2 * de); - var payloadSymbols = 8 + ((numerator / denominator).ceil()) * (codingRate + 4); + var payloadSymbols = + 8 + ((numerator / denominator).ceil()) * (codingRate + 4); if (payloadSymbols < 0) { payloadSymbols = 8; @@ -647,7 +684,8 @@ Uint8List buildSendCliCommandFrame( int attempt = 0, int? timestampSeconds, }) { - final timestamp = timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000); + final timestamp = + timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000); final writer = BufferWriter(); writer.writeByte(cmdSendTxtMsg); writer.writeByte(txtTypeCliData); @@ -661,10 +699,7 @@ Uint8List buildSendCliCommandFrame( // Build a telemetry request frame // Format: [cmd][pub_key x32][payload] -Uint8List buildSendBinaryReq( - Uint8List repeaterPubKey, { - Uint8List? payload, -}) { +Uint8List buildSendBinaryReq(Uint8List repeaterPubKey, {Uint8List? payload}) { final writer = BufferWriter(); writer.writeByte(cmdSendBinaryReq); writer.writeBytes(repeaterPubKey); @@ -672,4 +707,4 @@ Uint8List buildSendBinaryReq( writer.writeBytes(payload); } return writer.toBytes(); -} \ No newline at end of file +} diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index b9a221d..a5571aa 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -1378,5 +1378,11 @@ } }, "neighbors_heardAgo": "Слушано преди {time}.", - "neighbors_unknownContact": "Неизвестна {pubkey}" + "neighbors_unknownContact": "Неизвестна {pubkey}", + "settings_locationIntervalSec": "Интервал за GPS (Секунди)", + "settings_locationGPSEnable": "Активиране на GPS", + "settings_locationGPSEnableSubtitle": "Активирайте автоматичното актуализиране на местоположението чрез GPS.", + "settings_locationIntervalInvalid": "Интервалът трябва да бъде поне 60 секунди и по-малко от 86400 секунди.", + "room_management": "Управление на сървъра за стая", + "contacts_manageRoom": "Управление на сървър за стая" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a098e27..d80b3d9 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -83,9 +83,13 @@ "settings_radioSettingsUpdated": "Radio settings updated", "settings_location": "Location", "settings_locationSubtitle": "GPS coordinates", - "settings_locationUpdated": "Location updated", + "settings_locationUpdated": "Location and GPS settings updated", "settings_locationBothRequired": "Enter both latitude and longitude.", "settings_locationInvalid": "Invalid latitude or longitude.", + "settings_locationGPSEnable": "GPS Enable", + "settings_locationGPSEnableSubtitle": "Enables GPS to automatically update location.", + "settings_locationIntervalSec": "Interval for GPS (Seconds)", + "settings_locationIntervalInvalid": "Interval must be at least 60 seconds, and less than 86400 seconds.", "settings_latitude": "Latitude", "settings_longitude": "Longitude", "settings_privacyMode": "Privacy Mode", @@ -253,7 +257,8 @@ } }, "contacts_manageRepeater": "Manage Repeater", - "contacts_roomLogin": "Room Login", + "contacts_manageRoom": "Manage Room Server", + "contacts_roomLogin": "Room Server Login", "contacts_openChat": "Open Chat", "contacts_editGroup": "Edit Group", "contacts_deleteGroup": "Delete Group", @@ -697,7 +702,7 @@ "dialog_disconnectConfirm": "Are you sure you want to disconnect from this device?", "login_repeaterLogin": "Repeater Login", - "login_roomLogin": "Room Login", + "login_roomLogin": "Room Server Login", "login_password": "Password", "login_enterPassword": "Enter password", "login_savePassword": "Save password", @@ -760,6 +765,7 @@ "path_setPath": "Set Path", "repeater_management": "Repeater Management", + "room_management": "Room Server Management", "repeater_managementTools": "Management Tools", "repeater_status": "Status", "repeater_statusSubtitle": "View repeater status, stats, and neighbors", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 0581a63..472ba43 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1378,5 +1378,11 @@ } }, "neighbors_unknownContact": "Clave pública desconocida {pubkey}", - "neighbors_heardAgo": "Escuchado: {time} hace atrás" + "neighbors_heardAgo": "Escuchado: {time} hace atrás", + "settings_locationGPSEnable": "Habilitar GPS", + "settings_locationGPSEnableSubtitle": "Habilita la actualización automática de la ubicación mediante GPS.", + "settings_locationIntervalSec": "Intervalo para GPS (Segundos)", + "settings_locationIntervalInvalid": "El intervalo debe ser de al menos 60 segundos y menor que 86400 segundos.", + "contacts_manageRoom": "Gestionar Servidor de Habitación", + "room_management": "Administración del Servidor de Habitación" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index d69de69..bf78c92 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1378,5 +1378,11 @@ } }, "neighbors_unknownContact": "Clé publique inconnue {pubkey}", - "neighbors_heardAgo": "Écouté : {time} auparavant" + "neighbors_heardAgo": "Écouté : {time} auparavant", + "settings_locationGPSEnable": "Habilita GPS", + "settings_locationGPSEnableSubtitle": "Habilita la actualización automática de la ubicación mediante GPS.", + "settings_locationIntervalSec": "Intervalo pour GPS (Segundos)", + "settings_locationIntervalInvalid": "El intervalo debe ser de al menos 60 segundos y menor que 86400 segundos.", + "contacts_manageRoom": "Gestionar Servidor de Habitación", + "room_management": "Administración del Servidor de Habitación" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index f34029e..7ad31ac 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1378,5 +1378,11 @@ } }, "neighbors_heardAgo": "Sentito: {time} fa", - "neighbors_unknownContact": "Chiave pubblica sconosciuta {pubkey}" + "neighbors_unknownContact": "Chiave pubblica sconosciuta {pubkey}", + "settings_locationGPSEnable": "Abilita GPS", + "settings_locationGPSEnableSubtitle": "Abilita l'aggiornamento automatico della posizione tramite GPS.", + "settings_locationIntervalSec": "Intervallo GPS (Secondi)", + "settings_locationIntervalInvalid": "L'intervallo deve essere di almeno 60 secondi e inferiore a 86400 secondi.", + "contacts_manageRoom": "Gestisci Server Camera", + "room_management": "Gestione del Server di Camera" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 33ecf4d..96c6704 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -465,7 +465,7 @@ abstract class AppLocalizations { /// No description provided for @settings_locationUpdated. /// /// In en, this message translates to: - /// **'Location updated'** + /// **'Location and GPS settings updated'** String get settings_locationUpdated; /// No description provided for @settings_locationBothRequired. @@ -480,6 +480,30 @@ abstract class AppLocalizations { /// **'Invalid latitude or longitude.'** String get settings_locationInvalid; + /// No description provided for @settings_locationGPSEnable. + /// + /// In en, this message translates to: + /// **'GPS Enable'** + String get settings_locationGPSEnable; + + /// No description provided for @settings_locationGPSEnableSubtitle. + /// + /// In en, this message translates to: + /// **'Enables GPS to automatically update location.'** + String get settings_locationGPSEnableSubtitle; + + /// No description provided for @settings_locationIntervalSec. + /// + /// In en, this message translates to: + /// **'Interval for GPS (Seconds)'** + String get settings_locationIntervalSec; + + /// No description provided for @settings_locationIntervalInvalid. + /// + /// In en, this message translates to: + /// **'Interval must be at least 60 seconds, and less than 86400 seconds.'** + String get settings_locationIntervalInvalid; + /// No description provided for @settings_latitude. /// /// In en, this message translates to: @@ -1284,10 +1308,16 @@ abstract class AppLocalizations { /// **'Manage Repeater'** String get contacts_manageRepeater; + /// No description provided for @contacts_manageRoom. + /// + /// In en, this message translates to: + /// **'Manage Room Server'** + String get contacts_manageRoom; + /// No description provided for @contacts_roomLogin. /// /// In en, this message translates to: - /// **'Room Login'** + /// **'Room Server Login'** String get contacts_roomLogin; /// No description provided for @contacts_openChat. @@ -2672,7 +2702,7 @@ abstract class AppLocalizations { /// No description provided for @login_roomLogin. /// /// In en, this message translates to: - /// **'Room Login'** + /// **'Room Server Login'** String get login_roomLogin; /// No description provided for @login_password. @@ -2867,6 +2897,12 @@ abstract class AppLocalizations { /// **'Repeater Management'** String get repeater_management; + /// No description provided for @room_management. + /// + /// In en, this message translates to: + /// **'Room Server Management'** + String get room_management; + /// No description provided for @repeater_managementTools. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 10911ab..09c8e8b 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -201,6 +201,20 @@ class AppLocalizationsBg extends AppLocalizations { @override String get settings_locationInvalid => 'Невалидна ширина или дължина.'; + @override + String get settings_locationGPSEnable => 'Активиране на GPS'; + + @override + String get settings_locationGPSEnableSubtitle => + 'Активирайте автоматичното актуализиране на местоположението чрез GPS.'; + + @override + String get settings_locationIntervalSec => 'Интервал за GPS (Секунди)'; + + @override + String get settings_locationIntervalInvalid => + 'Интервалът трябва да бъде поне 60 секунди и по-малко от 86400 секунди.'; + @override String get settings_latitude => 'Широчина'; @@ -650,6 +664,9 @@ class AppLocalizationsBg extends AppLocalizations { @override String get contacts_manageRepeater => 'Управление на Повтарящ се Елемент'; + @override + String get contacts_manageRoom => 'Управление на сървър за стая'; + @override String get contacts_roomLogin => 'Вход в стаята'; @@ -1587,6 +1604,9 @@ class AppLocalizationsBg extends AppLocalizations { @override String get repeater_management => 'Управление на повторители'; + @override + String get room_management => 'Управление на сървъра за стая'; + @override String get repeater_managementTools => 'Инструменти за управление'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 2fa9ea3..d474780 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -200,6 +200,20 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settings_locationInvalid => 'Ungültige Breiten- oder Längengrade.'; + @override + String get settings_locationGPSEnable => 'GPS Enable'; + + @override + String get settings_locationGPSEnableSubtitle => + 'Enables GPS to automatically update location.'; + + @override + String get settings_locationIntervalSec => 'Interval for GPS (Seconds)'; + + @override + String get settings_locationIntervalInvalid => + 'Interval must be at least 60 seconds, and less than 86400 seconds.'; + @override String get settings_latitude => 'Breitengrad'; @@ -647,6 +661,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get contacts_manageRepeater => 'Wiederholungen verwalten'; + @override + String get contacts_manageRoom => 'Manage Room Server'; + @override String get contacts_roomLogin => 'Raum-Login'; @@ -1586,6 +1603,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get repeater_management => 'Repeater-Verwaltung'; + @override + String get room_management => 'Room Server Management'; + @override String get repeater_managementTools => 'Verwaltungs-Tools'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 5d8d81b..e9ef0ce 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -190,7 +190,7 @@ class AppLocalizationsEn extends AppLocalizations { String get settings_locationSubtitle => 'GPS coordinates'; @override - String get settings_locationUpdated => 'Location updated'; + String get settings_locationUpdated => 'Location and GPS settings updated'; @override String get settings_locationBothRequired => @@ -199,6 +199,20 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settings_locationInvalid => 'Invalid latitude or longitude.'; + @override + String get settings_locationGPSEnable => 'GPS Enable'; + + @override + String get settings_locationGPSEnableSubtitle => + 'Enables GPS to automatically update location.'; + + @override + String get settings_locationIntervalSec => 'Interval for GPS (Seconds)'; + + @override + String get settings_locationIntervalInvalid => + 'Interval must be at least 60 seconds, and less than 86400 seconds.'; + @override String get settings_latitude => 'Latitude'; @@ -641,7 +655,10 @@ class AppLocalizationsEn extends AppLocalizations { String get contacts_manageRepeater => 'Manage Repeater'; @override - String get contacts_roomLogin => 'Room Login'; + String get contacts_manageRoom => 'Manage Room Server'; + + @override + String get contacts_roomLogin => 'Room Server Login'; @override String get contacts_openChat => 'Open Chat'; @@ -1439,7 +1456,7 @@ class AppLocalizationsEn extends AppLocalizations { String get login_repeaterLogin => 'Repeater Login'; @override - String get login_roomLogin => 'Room Login'; + String get login_roomLogin => 'Room Server Login'; @override String get login_password => 'Password'; @@ -1561,6 +1578,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get repeater_management => 'Repeater Management'; + @override + String get room_management => 'Room Server Management'; + @override String get repeater_managementTools => 'Management Tools'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 9708889..74e2c2c 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -200,6 +200,20 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settings_locationInvalid => 'Latitud o longitud inválidos.'; + @override + String get settings_locationGPSEnable => 'Habilitar GPS'; + + @override + String get settings_locationGPSEnableSubtitle => + 'Habilita la actualización automática de la ubicación mediante GPS.'; + + @override + String get settings_locationIntervalSec => 'Intervalo para GPS (Segundos)'; + + @override + String get settings_locationIntervalInvalid => + 'El intervalo debe ser de al menos 60 segundos y menor que 86400 segundos.'; + @override String get settings_latitude => 'Latitud'; @@ -648,6 +662,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get contacts_manageRepeater => 'Gestionar Repetidor'; + @override + String get contacts_manageRoom => 'Gestionar Servidor de Habitación'; + @override String get contacts_roomLogin => 'Inicio de Sala'; @@ -1585,6 +1602,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get repeater_management => 'Gestión de Repetidores'; + @override + String get room_management => 'Administración del Servidor de Habitación'; + @override String get repeater_managementTools => 'Herramientas de Gestión'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index cedf427..1eb76d1 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -200,6 +200,20 @@ class AppLocalizationsFr extends AppLocalizations { @override String get settings_locationInvalid => 'Latitude ou longitude invalide.'; + @override + String get settings_locationGPSEnable => 'Habilita GPS'; + + @override + String get settings_locationGPSEnableSubtitle => + 'Habilita la actualización automática de la ubicación mediante GPS.'; + + @override + String get settings_locationIntervalSec => 'Intervalo pour GPS (Segundos)'; + + @override + String get settings_locationIntervalInvalid => + 'El intervalo debe ser de al menos 60 segundos y menor que 86400 segundos.'; + @override String get settings_latitude => 'Latitude'; @@ -649,6 +663,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get contacts_manageRepeater => 'Gérer le répétiteur'; + @override + String get contacts_manageRoom => 'Gestionar Servidor de Habitación'; + @override String get contacts_roomLogin => 'Connexion Salle'; @@ -1591,6 +1608,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get repeater_management => 'Gestion des répétiteurs'; + @override + String get room_management => 'Administración del Servidor de Habitación'; + @override String get repeater_managementTools => 'Outils de Gestion'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 3c91ca0..61b2664 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -200,6 +200,20 @@ class AppLocalizationsIt extends AppLocalizations { @override String get settings_locationInvalid => 'Latitudine o longitudine non valida.'; + @override + String get settings_locationGPSEnable => 'Abilita GPS'; + + @override + String get settings_locationGPSEnableSubtitle => + 'Abilita l\'aggiornamento automatico della posizione tramite GPS.'; + + @override + String get settings_locationIntervalSec => 'Intervallo GPS (Secondi)'; + + @override + String get settings_locationIntervalInvalid => + 'L\'intervallo deve essere di almeno 60 secondi e inferiore a 86400 secondi.'; + @override String get settings_latitude => 'Latitudine'; @@ -646,6 +660,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get contacts_manageRepeater => 'Gestisci Ripetitore'; + @override + String get contacts_manageRoom => 'Gestisci Server Camera'; + @override String get contacts_roomLogin => 'Login Camera'; @@ -1583,6 +1600,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get repeater_management => 'Gestione Ripetitori'; + @override + String get room_management => 'Gestione del Server di Camera'; + @override String get repeater_managementTools => 'Strumenti di Gestione'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 6cebd32..d69afb7 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -200,6 +200,20 @@ class AppLocalizationsNl extends AppLocalizations { String get settings_locationInvalid => 'Ongeldige breedtegraad of lengtegraad.'; + @override + String get settings_locationGPSEnable => 'GPS inschakelen'; + + @override + String get settings_locationGPSEnableSubtitle => + 'Activeer automatisch locatieupdates via GPS.'; + + @override + String get settings_locationIntervalSec => 'Interval voor GPS (Seconden)'; + + @override + String get settings_locationIntervalInvalid => + 'De intervallen moeten minstens 60 seconden zijn en minder dan 86400 seconden.'; + @override String get settings_latitude => 'Breedtegraad'; @@ -644,6 +658,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get contacts_manageRepeater => 'Beheer Repeater'; + @override + String get contacts_manageRoom => 'Beheer Ruimte Server'; + @override String get contacts_roomLogin => 'Ruimte Inloggen'; @@ -1578,6 +1595,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get repeater_management => 'Beheer Repeaters'; + @override + String get room_management => 'Beheer Server Kamer'; + @override String get repeater_managementTools => 'Beheerinstrumenten'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 54d8a61..96b528e 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -202,6 +202,20 @@ class AppLocalizationsPl extends AppLocalizations { String get settings_locationInvalid => 'Nieprawidłowa szerokość geograficzna lub długość geograficzna.'; + @override + String get settings_locationGPSEnable => 'Włącz GPS'; + + @override + String get settings_locationGPSEnableSubtitle => + 'Włącza automatyczne aktualizowanie pozycji za pomocą GPS.'; + + @override + String get settings_locationIntervalSec => 'Interwał dla GPS (Sekundy)'; + + @override + String get settings_locationIntervalInvalid => + 'Interwał musi wynosić co najmniej 60 sekund i mniej niż 86400 sekund.'; + @override String get settings_latitude => 'Szerokość'; @@ -649,6 +663,9 @@ class AppLocalizationsPl extends AppLocalizations { @override String get contacts_manageRepeater => 'Zarządzaj Powtórzami'; + @override + String get contacts_manageRoom => 'Zarządzaj Serwerem Pokoju'; + @override String get contacts_roomLogin => 'Logowanie do pokoju'; @@ -1587,6 +1604,9 @@ class AppLocalizationsPl extends AppLocalizations { @override String get repeater_management => 'Zarządzanie Powtórzami'; + @override + String get room_management => 'Zarządzanie Serwerem Pokoju'; + @override String get repeater_managementTools => 'Narzędzia Zarządzania'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 0e87390..66c519f 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -201,6 +201,20 @@ class AppLocalizationsPt extends AppLocalizations { @override String get settings_locationInvalid => 'Latitude ou longitude inválidos.'; + @override + String get settings_locationGPSEnable => 'Ativar GPS'; + + @override + String get settings_locationGPSEnableSubtitle => + 'Habilita a atualização automática da localização via GPS.'; + + @override + String get settings_locationIntervalSec => 'Intervalo para GPS (Segundos)'; + + @override + String get settings_locationIntervalInvalid => + 'O intervalo deve ser de pelo menos 60 segundos e inferior a 86400 segundos.'; + @override String get settings_latitude => 'Latitude'; @@ -649,6 +663,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get contacts_manageRepeater => 'Gerenciar Repetidor'; + @override + String get contacts_manageRoom => 'Gerenciar Servidor de Sala'; + @override String get contacts_roomLogin => 'Login no Quarto'; @@ -1585,6 +1602,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get repeater_management => 'Gerenciamento de Repetidor'; + @override + String get room_management => 'Gerenciamento de Servidor de Sala'; + @override String get repeater_managementTools => 'Ferramentas de Gerenciamento'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index ced3089..1d7e1b2 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -200,6 +200,20 @@ class AppLocalizationsSk extends AppLocalizations { @override String get settings_locationInvalid => 'Neplatná šírka alebo dĺžka.'; + @override + String get settings_locationGPSEnable => 'Aktivovať GPS'; + + @override + String get settings_locationGPSEnableSubtitle => + 'Povolí automatické aktualizovanie polohy pomocou GPS.'; + + @override + String get settings_locationIntervalSec => 'Interval pre GPS (Sekundy)'; + + @override + String get settings_locationIntervalInvalid => + 'Interval musí byť aspoň 60 sekúnd a menej ako 86400 sekúnd.'; + @override String get settings_latitude => 'Súradnica'; @@ -642,6 +656,9 @@ class AppLocalizationsSk extends AppLocalizations { @override String get contacts_manageRepeater => 'Spravovať opakované zoznamy'; + @override + String get contacts_manageRoom => 'Spravovať server miestnosti'; + @override String get contacts_roomLogin => 'Prihlásenie do miestnosti'; @@ -1100,7 +1117,7 @@ class AppLocalizationsSk extends AppLocalizations { @override String get chat_clearPathSubtitle => - 'Znovu nájsť vynútene pri nasledujacej pošlite'; + 'Znovu nájsť vynútene pri nasledujúcej pošlite'; @override String get chat_pathCleared => @@ -1580,6 +1597,9 @@ class AppLocalizationsSk extends AppLocalizations { @override String get repeater_management => 'Správa opakérov'; + @override + String get room_management => 'Správa servera miestnosti'; + @override String get repeater_managementTools => 'Nástroje na správu'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 7614ec6..778ef09 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -200,6 +200,20 @@ class AppLocalizationsSl extends AppLocalizations { String get settings_locationInvalid => 'Neveljna zemeljska širina ali dolžina.'; + @override + String get settings_locationGPSEnable => 'Omogoči GPS'; + + @override + String get settings_locationGPSEnableSubtitle => + 'Omogoči samodejno posodabljanje lokacije z GPS-jem.'; + + @override + String get settings_locationIntervalSec => 'Interval za GPS (Sekunde)'; + + @override + String get settings_locationIntervalInvalid => + 'Intervallo mora biti vsaj 60 sekund in manj kot 86400 sekund.'; + @override String get settings_latitude => 'Širina'; @@ -435,7 +449,7 @@ class AppLocalizationsSl extends AppLocalizations { @override String get appSettings_enableNotificationsSubtitle => - 'Prejmujte obvestila o sporočilih in oglasih'; + 'Prejmite obvestila o sporočilih in oglasih'; @override String get appSettings_notificationPermissionDenied => @@ -631,7 +645,7 @@ class AppLocalizationsSl extends AppLocalizations { @override String get contacts_noContactsFound => - 'Niti ena osebe ali skupine ni najdena.'; + 'Niti ena oseba ali skupine ni najdena.'; @override String get contacts_deleteContact => 'Izbrisati Kontakt'; @@ -644,6 +658,9 @@ class AppLocalizationsSl extends AppLocalizations { @override String get contacts_manageRepeater => 'Upravljajte Ponovitve'; + @override + String get contacts_manageRoom => 'Upravljajte strežnik sobe'; + @override String get contacts_roomLogin => 'Vnos v sobo'; @@ -680,7 +697,7 @@ class AppLocalizationsSl extends AppLocalizations { @override String get contacts_noContactsMatchFilter => - 'Niti ena osebe ne ustreza vašemu kriteriju.'; + 'Niti ena oseba ne ustreza vašemu kriteriju.'; @override String get contacts_noMembers => 'Nič članov.'; @@ -1186,7 +1203,7 @@ class AppLocalizationsSl extends AppLocalizations { @override String get map_nodesNeedGps => - 'Omrežje morajo deliti svoje GPS koordinate,\nda se prikazajo na zemljeobrazniku.'; + 'Omrežje morajo deliti svoje GPS koordinate,\nda se prikazao na zemljeobrazniku.'; @override String map_nodesCount(int count) { @@ -1580,6 +1597,9 @@ class AppLocalizationsSl extends AppLocalizations { @override String get repeater_management => 'Upravljanje ponovitve'; + @override + String get room_management => 'Upravljanje stremlišča'; + @override String get repeater_managementTools => 'Upravne orodje'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 688fb5e..1e12ada 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -199,6 +199,20 @@ class AppLocalizationsSv extends AppLocalizations { @override String get settings_locationInvalid => 'Ogiltig latitud eller longitud.'; + @override + String get settings_locationGPSEnable => 'Aktivera GPS'; + + @override + String get settings_locationGPSEnableSubtitle => + 'Aktivera automatiska uppdateringar av platsen med hjälp av GPS.'; + + @override + String get settings_locationIntervalSec => 'Interval för GPS (Sekunder)'; + + @override + String get settings_locationIntervalInvalid => + 'Intervalet måste vara minst 60 sekunder och mindre än 86400 sekunder.'; + @override String get settings_latitude => 'Latitud'; @@ -638,6 +652,9 @@ class AppLocalizationsSv extends AppLocalizations { @override String get contacts_manageRepeater => 'Hantera Upprepare'; + @override + String get contacts_manageRoom => 'Hantera Rumserver'; + @override String get contacts_roomLogin => 'Rum Inloggning'; @@ -1569,6 +1586,9 @@ class AppLocalizationsSv extends AppLocalizations { @override String get repeater_management => 'Återuppspelarens Hantering'; + @override + String get room_management => 'Rumserverhantering'; + @override String get repeater_managementTools => 'Administrationsverktyg'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 5a5aa37..5dda9a2 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -196,6 +196,18 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settings_locationInvalid => '无效的纬度或经度。'; + @override + String get settings_locationGPSEnable => '启用GPS'; + + @override + String get settings_locationGPSEnableSubtitle => '启用GPS自动更新位置。'; + + @override + String get settings_locationIntervalSec => 'GPS 间隔(秒)'; + + @override + String get settings_locationIntervalInvalid => '时间间隔必须至少为60秒,且小于86400秒。'; + @override String get settings_latitude => '纬度'; @@ -611,6 +623,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get contacts_manageRepeater => '管理重复项'; + @override + String get contacts_manageRoom => '管理房间服务器'; + @override String get contacts_roomLogin => '房间登录'; @@ -1513,6 +1528,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get repeater_management => '重复器管理'; + @override + String get room_management => '房间服务器管理'; + @override String get repeater_managementTools => '管理工具'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 754e606..91d5b9b 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1378,5 +1378,11 @@ } }, "neighbors_unknownContact": "Onbekende {pubkey}", - "neighbors_heardAgo": "Horen: {time} geleden" + "neighbors_heardAgo": "Horen: {time} geleden", + "settings_locationGPSEnable": "GPS inschakelen", + "settings_locationGPSEnableSubtitle": "Activeer automatisch locatieupdates via GPS.", + "settings_locationIntervalSec": "Interval voor GPS (Seconden)", + "settings_locationIntervalInvalid": "De intervallen moeten minstens 60 seconden zijn en minder dan 86400 seconden.", + "contacts_manageRoom": "Beheer Ruimte Server", + "room_management": "Beheer Server Kamer" } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index f54644c..9f4a562 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1378,5 +1378,11 @@ } }, "neighbors_heardAgo": "Usłyszano: {time} temu", - "neighbors_unknownContact": "Nieznana {pubkey}" + "neighbors_unknownContact": "Nieznana {pubkey}", + "settings_locationGPSEnable": "Włącz GPS", + "settings_locationGPSEnableSubtitle": "Włącza automatyczne aktualizowanie pozycji za pomocą GPS.", + "settings_locationIntervalSec": "Interwał dla GPS (Sekundy)", + "settings_locationIntervalInvalid": "Interwał musi wynosić co najmniej 60 sekund i mniej niż 86400 sekund.", + "contacts_manageRoom": "Zarządzaj Serwerem Pokoju", + "room_management": "Zarządzanie Serwerem Pokoju" } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index ee9ff56..fa8de3f 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1378,5 +1378,11 @@ } }, "neighbors_heardAgo": "Ouvido: {time} atrás", - "neighbors_unknownContact": "{pubkey} Desconhecido" + "neighbors_unknownContact": "{pubkey} Desconhecido", + "settings_locationGPSEnable": "Ativar GPS", + "settings_locationGPSEnableSubtitle": "Habilita a atualização automática da localização via GPS.", + "settings_locationIntervalInvalid": "O intervalo deve ser de pelo menos 60 segundos e inferior a 86400 segundos.", + "settings_locationIntervalSec": "Intervalo para GPS (Segundos)", + "contacts_manageRoom": "Gerenciar Servidor de Sala", + "room_management": "Gerenciamento de Servidor de Sala" } diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index 2355258..be58c8b 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -559,7 +559,7 @@ "chat_setCustomPath": "Nastaviť vlastnú cestu", "chat_setCustomPathSubtitle": "Ručne zadajte trasu.", "chat_clearPath": "Vyčistiš cestu", - "chat_clearPathSubtitle": "Znovu nájsť vynútene pri nasledujacej pošlite", + "chat_clearPathSubtitle": "Znovu nájsť vynútene pri nasledujúcej pošlite", "chat_pathCleared": "Cesta vyčistená. Nasledujúce prepočetné získa trasu znova.", "chat_floodModeSubtitle": "Použite prepínanie trasy v navigačnom paneli.", "chat_floodModeEnabled": "Odosporňovacia prevádzka je zapnutá. Vypnite ju znova cez ikonu routovania v navigačnom páse.", @@ -1378,5 +1378,11 @@ } }, "neighbors_heardAgo": "Počuli sme to: {time} dozadu", - "neighbors_unknownContact": "Neznáma {pubkey}" + "neighbors_unknownContact": "Neznáma {pubkey}", + "settings_locationGPSEnable": "Aktivovať GPS", + "settings_locationGPSEnableSubtitle": "Povolí automatické aktualizovanie polohy pomocou GPS.", + "settings_locationIntervalSec": "Interval pre GPS (Sekundy)", + "settings_locationIntervalInvalid": "Interval musí byť aspoň 60 sekúnd a menej ako 86400 sekúnd.", + "contacts_manageRoom": "Spravovať server miestnosti", + "room_management": "Správa servera miestnosti" } diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index 2390bad..e4bd604 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -176,7 +176,7 @@ "appSettings_languageBg": "Български", "appSettings_notifications": "Obveščanja", "appSettings_enableNotifications": "Omogoči obveščanje", - "appSettings_enableNotificationsSubtitle": "Prejmujte obvestila o sporočilih in oglasih", + "appSettings_enableNotificationsSubtitle": "Prejmite obvestila o sporočilih in oglasih", "appSettings_notificationPermissionDenied": "Odobritev obvestila zavrnjena", "appSettings_notificationsEnabled": "Obvestila omogočena", "appSettings_notificationsDisabled": "Obvestila so izklopljena", @@ -256,7 +256,7 @@ "contacts_contactsWillAppear": "Kontakti se bodo prikazali, ko naprave oglasijo.", "contacts_searchContacts": "Iskanje kontaktov...", "contacts_noUnreadContacts": "Nerešeno kontaktov.", - "contacts_noContactsFound": "Niti ena osebe ali skupine ni najdena.", + "contacts_noContactsFound": "Niti ena oseba ali skupine ni najdena.", "contacts_deleteContact": "Izbrisati Kontakt", "contacts_removeConfirm": "Izbrisati {contactName} iz kontaktov?", "@contacts_removeConfirm": { @@ -291,7 +291,7 @@ } }, "contacts_filterContacts": "Filtri kontakt\\,...", - "contacts_noContactsMatchFilter": "Niti ena osebe ne ustreza vašemu kriteriju.", + "contacts_noContactsMatchFilter": "Niti ena oseba ne ustreza vašemu kriteriju.", "contacts_noMembers": "Nič članov.", "contacts_lastSeenNow": "Datum zadnjega vpisa zdaj", "contacts_lastSeenMinsAgo": "Zadnjič videti {minutes} minut nazaj", @@ -606,7 +606,7 @@ }, "map_title": "Mapa omrežja", "map_noNodesWithLocation": "Nihče od notranjih elementov nima podatkov o lokaciji.", - "map_nodesNeedGps": "Omrežje morajo deliti svoje GPS koordinate,\nda se prikazajo na zemljeobrazniku.", + "map_nodesNeedGps": "Omrežje morajo deliti svoje GPS koordinate,\nda se prikazao na zemljeobrazniku.", "map_nodesCount": "Omize: {count}", "@map_nodesCount": { "placeholders": { @@ -1378,5 +1378,11 @@ } }, "neighbors_unknownContact": "Nepoznano {pubkey}", - "neighbors_heardAgo": "Udeleženec je prejel sporočilo {time} nazaj." + "neighbors_heardAgo": "Udeleženec je prejel sporočilo {time} nazaj.", + "settings_locationGPSEnable": "Omogoči GPS", + "settings_locationGPSEnableSubtitle": "Omogoči samodejno posodabljanje lokacije z GPS-jem.", + "settings_locationIntervalSec": "Interval za GPS (Sekunde)", + "settings_locationIntervalInvalid": "Intervallo mora biti vsaj 60 sekund in manj kot 86400 sekund.", + "contacts_manageRoom": "Upravljajte strežnik sobe", + "room_management": "Upravljanje stremlišča" } diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index 58adcca..6972c4c 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -1378,5 +1378,11 @@ } }, "neighbors_heardAgo": "Hördes: {time} sedan", - "neighbors_unknownContact": "Okänd {pubkey}" + "neighbors_unknownContact": "Okänd {pubkey}", + "settings_locationGPSEnable": "Aktivera GPS", + "settings_locationGPSEnableSubtitle": "Aktivera automatiska uppdateringar av platsen med hjälp av GPS.", + "settings_locationIntervalSec": "Interval för GPS (Sekunder)", + "settings_locationIntervalInvalid": "Intervalet måste vara minst 60 sekunder och mindre än 86400 sekunder.", + "contacts_manageRoom": "Hantera Rumserver", + "room_management": "Rumserverhantering" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index b71190f..267c96a 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1378,5 +1378,11 @@ } }, "neighbors_heardAgo": "听到的时间:{time}前", - "neighbors_unknownContact": "未知{pubkey}" + "neighbors_unknownContact": "未知{pubkey}", + "settings_locationGPSEnable": "启用GPS", + "settings_locationGPSEnableSubtitle": "启用GPS自动更新位置。", + "settings_locationIntervalSec": "GPS 间隔(秒)", + "settings_locationIntervalInvalid": "时间间隔必须至少为60秒,且小于86400秒。", + "contacts_manageRoom": "管理房间服务器", + "room_management": "房间服务器管理" } diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index b9ceb84..e91cd94 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -27,13 +27,15 @@ import 'map_screen.dart'; import 'repeater_hub_screen.dart'; import 'settings_screen.dart'; +enum RoomLoginDestination { + chat, + management, +} + class ContactsScreen extends StatefulWidget { final bool hideBackButton; - const ContactsScreen({ - super.key, - this.hideBackButton = false, - }); + const ContactsScreen({super.key, this.hideBackButton = false}); @override State createState() => _ContactsScreenState(); @@ -114,7 +116,8 @@ class _ContactsScreenState extends State top: false, child: QuickSwitchBar( selectedIndex: 0, - onDestinationSelected: (index) => _handleQuickSwitch(index, context), + onDestinationSelected: (index) => + _handleQuickSwitch(index, context), ), ), ), @@ -168,8 +171,9 @@ class _ContactsScreenState extends State } final filteredAndSorted = _filterAndSortContacts(contacts, connector); - final filteredGroups = - _showUnreadOnly ? const [] : _filterAndSortGroups(_groups, contacts); + final filteredGroups = _showUnreadOnly + ? const [] + : _filterAndSortGroups(_groups, contacts); return Column( children: [ @@ -199,7 +203,10 @@ class _ContactsScreenState extends State border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), ), onChanged: (value) { _searchDebounce?.cancel(); @@ -238,14 +245,18 @@ class _ContactsScreenState extends State final group = filteredGroups[index]; return _buildGroupTile(context, group, contacts); } - final contact = filteredAndSorted[index - filteredGroups.length]; - final unreadCount = connector.getUnreadCountForContact(contact); + final contact = + filteredAndSorted[index - filteredGroups.length]; + final unreadCount = connector.getUnreadCountForContact( + contact, + ); return _ContactTile( contact: contact, lastSeen: _resolveLastSeen(contact), unreadCount: unreadCount, onTap: () => _openChat(context, contact), - onLongPress: () => _showContactOptions(context, connector, contact), + onLongPress: () => + _showContactOptions(context, connector, contact), ); }, ), @@ -255,35 +266,48 @@ class _ContactsScreenState extends State ); } - List _filterAndSortGroups(List groups, List contacts) { + List _filterAndSortGroups( + List groups, + List contacts, + ) { final query = _searchQuery.trim().toLowerCase(); final contactsByKey = {}; for (final contact in contacts) { contactsByKey[contact.publicKeyHex] = contact; } - final filtered = groups.where((group) { - if (query.isEmpty) return true; - if (group.name.toLowerCase().contains(query)) return true; - for (final key in group.memberKeys) { - final contact = contactsByKey[key]; - if (contact != null && matchesContactQuery(contact, query)) return true; - } - return false; - }).where((group) { - if (_typeFilter == ContactTypeFilter.all) return true; - for (final key in group.memberKeys) { - final contact = contactsByKey[key]; - if (contact != null && _matchesTypeFilter(contact)) return true; - } - return false; - }).toList(); + final filtered = groups + .where((group) { + if (query.isEmpty) return true; + if (group.name.toLowerCase().contains(query)) return true; + for (final key in group.memberKeys) { + final contact = contactsByKey[key]; + if (contact != null && matchesContactQuery(contact, query)) { + return true; + } + } + return false; + }) + .where((group) { + if (_typeFilter == ContactTypeFilter.all) return true; + for (final key in group.memberKeys) { + final contact = contactsByKey[key]; + if (contact != null && _matchesTypeFilter(contact)) return true; + } + return false; + }) + .toList(); - filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + filtered.sort( + (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), + ); return filtered; } - List _filterAndSortContacts(List contacts, MeshCoreConnector connector) { + List _filterAndSortContacts( + List contacts, + MeshCoreConnector connector, + ) { var filtered = contacts.where((contact) { if (_searchQuery.isEmpty) return true; return matchesContactQuery(contact, _searchQuery); @@ -301,19 +325,27 @@ class _ContactsScreenState extends State switch (_sortOption) { case ContactSortOption.lastSeen: - filtered.sort((a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a))); + filtered.sort( + (a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a)), + ); break; case ContactSortOption.recentMessages: filtered.sort((a, b) { final aMessages = connector.getMessages(a); final bMessages = connector.getMessages(b); - final aLastMsg = aMessages.isEmpty ? DateTime(1970) : aMessages.last.timestamp; - final bLastMsg = bMessages.isEmpty ? DateTime(1970) : bMessages.last.timestamp; + final aLastMsg = aMessages.isEmpty + ? DateTime(1970) + : aMessages.last.timestamp; + final bLastMsg = bMessages.isEmpty + ? DateTime(1970) + : bMessages.last.timestamp; return bLastMsg.compareTo(aLastMsg); }); break; case ContactSortOption.name: - filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + filtered.sort( + (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), + ); break; } @@ -340,7 +372,11 @@ class _ContactsScreenState extends State : contact.lastSeen; } - Widget _buildGroupTile(BuildContext context, ContactGroup group, List contacts) { + Widget _buildGroupTile( + BuildContext context, + ContactGroup group, + List contacts, + ) { final memberContacts = _resolveGroupContacts(group, contacts); final subtitle = _formatGroupMembers(context, memberContacts); return ListTile( @@ -359,7 +395,10 @@ class _ContactsScreenState extends State ); } - List _resolveGroupContacts(ContactGroup group, List contacts) { + List _resolveGroupContacts( + ContactGroup group, + List contacts, + ) { final byKey = {}; for (final contact in contacts) { byKey[contact.publicKeyHex] = contact; @@ -371,7 +410,9 @@ class _ContactsScreenState extends State resolved.add(contact); } } - resolved.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + resolved.sort( + (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), + ); return resolved; } @@ -387,7 +428,7 @@ class _ContactsScreenState extends State if (contact.type == advTypeRepeater) { _showRepeaterLogin(context, contact); } else if (contact.type == advTypeRoom) { - _showRoomLogin(context, contact); + _showRoomLogin(context, contact, RoomLoginDestination.chat); } else { context.read().markContactRead(contact.publicKeyHex); Navigator.push( @@ -403,17 +444,13 @@ class _ContactsScreenState extends State case 1: Navigator.pushReplacement( context, - buildQuickSwitchRoute( - const ChannelsScreen(hideBackButton: true), - ), + buildQuickSwitchRoute(const ChannelsScreen(hideBackButton: true)), ); break; case 2: Navigator.pushReplacement( context, - buildQuickSwitchRoute( - const MapScreen(hideBackButton: true), - ), + buildQuickSwitchRoute(const MapScreen(hideBackButton: true)), ); break; } @@ -429,10 +466,8 @@ class _ContactsScreenState extends State Navigator.push( context, MaterialPageRoute( - builder: (context) => RepeaterHubScreen( - repeater: repeater, - password: password, - ), + builder: (context) => + RepeaterHubScreen(repeater: repeater, password: password), ), ); }, @@ -440,18 +475,23 @@ class _ContactsScreenState extends State ); } - void _showRoomLogin(BuildContext context, Contact room) { + void _showRoomLogin( + BuildContext context, + Contact room, + RoomLoginDestination destination, + ) { showDialog( context: context, builder: (context) => RoomLoginDialog( room: room, onLogin: (password) { - // Navigate to chat screen after successful login context.read().markContactRead(room.publicKeyHex); Navigator.push( context, MaterialPageRoute( - builder: (context) => ChatScreen(contact: room), + builder: (context) => destination == RoomLoginDestination.management + ? RepeaterHubScreen(repeater: room, password: password) + : ChatScreen(contact: room), ), ); }, @@ -459,7 +499,11 @@ class _ContactsScreenState extends State ); } - void _showGroupOptions(BuildContext context, ContactGroup group, List contacts) { + void _showGroupOptions( + BuildContext context, + ContactGroup group, + List contacts, + ) { final members = _resolveGroupContacts(group, contacts); showModalBottomSheet( context: context, @@ -478,7 +522,10 @@ class _ContactsScreenState extends State ), ListTile( leading: const Icon(Icons.delete, color: Colors.red), - title: Text(context.l10n.contacts_deleteGroup, style: const TextStyle(color: Colors.red)), + title: Text( + context.l10n.contacts_deleteGroup, + style: const TextStyle(color: Colors.red), + ), onTap: () { Navigator.pop(sheetContext); _confirmDeleteGroup(context, group); @@ -522,7 +569,10 @@ class _ContactsScreenState extends State }); await _saveGroups(); }, - child: Text(context.l10n.common_delete, style: const TextStyle(color: Colors.red)), + child: Text( + context.l10n.common_delete, + style: const TextStyle(color: Colors.red), + ), ), ], ), @@ -548,10 +598,16 @@ class _ContactsScreenState extends State final filteredContacts = filterQuery.isEmpty ? sortedContacts : sortedContacts - .where((contact) => matchesContactQuery(contact, filterQuery)) - .toList(); + .where( + (contact) => matchesContactQuery(contact, filterQuery), + ) + .toList(); return AlertDialog( - title: Text(isEditing ? context.l10n.contacts_editGroup : context.l10n.contacts_newGroup), + title: Text( + isEditing + ? context.l10n.contacts_editGroup + : context.l10n.contacts_newGroup, + ), content: SizedBox( width: double.maxFinite, child: Column( @@ -582,12 +638,18 @@ class _ContactsScreenState extends State SizedBox( height: 240, child: filteredContacts.isEmpty - ? Center(child: Text(context.l10n.contacts_noContactsMatchFilter)) + ? Center( + child: Text( + context.l10n.contacts_noContactsMatchFilter, + ), + ) : ListView.builder( itemCount: filteredContacts.length, itemBuilder: (context, index) { final contact = filteredContacts[index]; - final isSelected = selectedKeys.contains(contact.publicKeyHex); + final isSelected = selectedKeys.contains( + contact.publicKeyHex, + ); return CheckboxListTile( value: isSelected, title: Text(contact.name), @@ -618,7 +680,9 @@ class _ContactsScreenState extends State final name = nameController.text.trim(); if (name.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.contacts_groupNameRequired)), + SnackBar( + content: Text(context.l10n.contacts_groupNameRequired), + ), ); return; } @@ -628,13 +692,19 @@ class _ContactsScreenState extends State }); if (exists) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.contacts_groupAlreadyExists(name))), + SnackBar( + content: Text( + context.l10n.contacts_groupAlreadyExists(name), + ), + ), ); return; } setState(() { if (isEditing) { - final index = _groups.indexWhere((g) => g.name == group.name); + final index = _groups.indexWhere( + (g) => g.name == group.name, + ); if (index != -1) { _groups[index] = ContactGroup( name: name, @@ -642,7 +712,12 @@ class _ContactsScreenState extends State ); } } else { - _groups.add(ContactGroup(name: name, memberKeys: selectedKeys.toList())); + _groups.add( + ContactGroup( + name: name, + memberKeys: selectedKeys.toList(), + ), + ); } }); await _saveGroups(); @@ -650,7 +725,11 @@ class _ContactsScreenState extends State Navigator.pop(dialogContext); } }, - child: Text(isEditing ? context.l10n.common_save : context.l10n.common_create), + child: Text( + isEditing + ? context.l10n.common_save + : context.l10n.common_create, + ), ), ], ); @@ -682,16 +761,24 @@ class _ContactsScreenState extends State _showRepeaterLogin(context, contact); }, ) - else if (isRoom) + else if (isRoom) ...[ ListTile( leading: const Icon(Icons.room, color: Colors.blue), title: Text(context.l10n.contacts_roomLogin), onTap: () { Navigator.pop(sheetContext); - _showRoomLogin(context, contact); + _showRoomLogin(context, contact, RoomLoginDestination.chat); }, - ) - else + ), + ListTile( + leading: const Icon(Icons.room_preferences, color: Colors.orange), + title: Text(context.l10n.room_management), + onTap: () { + Navigator.pop(sheetContext); + _showRoomLogin(context, contact, RoomLoginDestination.management); + }, + ), + ] else ListTile( leading: const Icon(Icons.chat), title: Text(context.l10n.contacts_openChat), @@ -702,7 +789,10 @@ class _ContactsScreenState extends State ), ListTile( leading: const Icon(Icons.delete, color: Colors.red), - title: Text(context.l10n.contacts_deleteContact, style: const TextStyle(color: Colors.red)), + title: Text( + context.l10n.contacts_deleteContact, + style: const TextStyle(color: Colors.red), + ), onTap: () { Navigator.pop(sheetContext); _confirmDelete(context, connector, contact); @@ -734,7 +824,10 @@ class _ContactsScreenState extends State Navigator.pop(dialogContext); connector.removeContact(contact); }, - child: Text(context.l10n.common_delete, style: const TextStyle(color: Colors.red)), + child: Text( + context.l10n.common_delete, + style: const TextStyle(color: Colors.red), + ), ), ], ), @@ -759,14 +852,17 @@ class _ContactTile extends StatelessWidget { @override Widget build(BuildContext context) { - final shotPublicKey = "<${contact.publicKeyHex.substring(0, 8)}...${contact.publicKeyHex.substring(contact.publicKeyHex.length - 8)}>"; + final shotPublicKey = + "<${contact.publicKeyHex.substring(0, 8)}...${contact.publicKeyHex.substring(contact.publicKeyHex.length - 8)}>"; return ListTile( leading: CircleAvatar( backgroundColor: _getTypeColor(contact.type), child: _buildContactAvatar(contact), ), title: Text(contact.name), - subtitle: Text('${contact.typeLabel} • ${contact.pathLabel} $shotPublicKey'), + subtitle: Text( + '${contact.typeLabel} • ${contact.pathLabel} $shotPublicKey', + ), trailing: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.end, @@ -791,10 +887,7 @@ class _ContactTile extends StatelessWidget { Widget _buildContactAvatar(Contact contact) { final emoji = firstEmoji(contact.name); if (emoji != null) { - return Text( - emoji, - style: const TextStyle(fontSize: 18), - ); + return Text(emoji, style: const TextStyle(fontSize: 18)); } return Icon(_getTypeIcon(contact.type), color: Colors.white, size: 20); } @@ -833,13 +926,21 @@ class _ContactTile extends StatelessWidget { final now = DateTime.now(); final diff = now.difference(lastSeen); - if (diff.isNegative || diff.inMinutes < 5) return context.l10n.contacts_lastSeenNow; - if (diff.inMinutes < 60) return context.l10n.contacts_lastSeenMinsAgo(diff.inMinutes); + if (diff.isNegative || diff.inMinutes < 5) { + return context.l10n.contacts_lastSeenNow; + } + if (diff.inMinutes < 60) { + return context.l10n.contacts_lastSeenMinsAgo(diff.inMinutes); + } if (diff.inHours < 24) { final hours = diff.inHours; - return hours == 1 ? context.l10n.contacts_lastSeenHourAgo : context.l10n.contacts_lastSeenHoursAgo(hours); + return hours == 1 + ? context.l10n.contacts_lastSeenHourAgo + : context.l10n.contacts_lastSeenHoursAgo(hours); } final days = diff.inDays; - return days == 1 ? context.l10n.contacts_lastSeenDayAgo : context.l10n.contacts_lastSeenDaysAgo(days); + return days == 1 + ? context.l10n.contacts_lastSeenDayAgo + : context.l10n.contacts_lastSeenDaysAgo(days); } } diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 2aa3daa..5b804eb 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -273,6 +273,9 @@ class _MapScreenState extends State { initialZoom: initialZoom, minZoom: 2.0, maxZoom: 18.0, + interactionOptions: InteractionOptions( + flags: ~InteractiveFlag.rotate + ), onTap: (_, latLng) { if (_isSelectingPoi) { setState(() { diff --git a/lib/screens/repeater_hub_screen.dart b/lib/screens/repeater_hub_screen.dart index cad4f1f..5a545f3 100644 --- a/lib/screens/repeater_hub_screen.dart +++ b/lib/screens/repeater_hub_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:meshcore_open/connector/meshcore_protocol.dart'; import '../l10n/l10n.dart'; import '../models/contact.dart'; import 'repeater_status_screen.dart'; @@ -26,10 +27,17 @@ class RepeaterHubScreen extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text(l10n.repeater_management), + Text( + repeater.type == advTypeRepeater + ? l10n.repeater_management + : l10n.room_management, + ), Text( repeater.name, - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.normal, + ), ), ], ), @@ -40,155 +48,167 @@ class RepeaterHubScreen extends StatelessWidget { child: ListView( padding: const EdgeInsets.all(16), children: [ - // Repeater info card - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - CircleAvatar( - radius: 40, - backgroundColor: Colors.orange, - child: const Icon(Icons.cell_tower, size: 40, color: Colors.white), + // Repeater info card + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + CircleAvatar( + radius: 40, + backgroundColor: Colors.orange, + child: const Icon( + Icons.cell_tower, + size: 40, + color: Colors.white, ), - const SizedBox(height: 16), - Text( - repeater.name, - style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + Text( + repeater.name, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, ), - const SizedBox(height: 8), - Text( - '<${repeater.publicKeyHex.substring(0, 8)}...${repeater.publicKeyHex.substring(repeater.publicKeyHex.length - 8)}>', - style: TextStyle(fontSize: 14, color: Colors.grey[600]), - ), - const SizedBox(height: 8), - Text( - repeater.pathLabel, - style: TextStyle(fontSize: 14, color: Colors.grey[600]), - ), - if (repeater.hasLocation) ...[ - const SizedBox(height: 4), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.location_on, size: 14, color: Colors.grey[600]), - const SizedBox(width: 4), - Text( - '${repeater.latitude?.toStringAsFixed(4)}, ${repeater.longitude?.toStringAsFixed(4)}', - style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + const SizedBox(height: 8), + Text( + '<${repeater.publicKeyHex.substring(0, 8)}...${repeater.publicKeyHex.substring(repeater.publicKeyHex.length - 8)}>', + style: TextStyle(fontSize: 14, color: Colors.grey[600]), + ), + const SizedBox(height: 8), + Text( + repeater.pathLabel, + style: TextStyle(fontSize: 14, color: Colors.grey[600]), + ), + if (repeater.hasLocation) ...[ + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.location_on, + size: 14, + color: Colors.grey[600], + ), + const SizedBox(width: 4), + Text( + '${repeater.latitude?.toStringAsFixed(4)}, ${repeater.longitude?.toStringAsFixed(4)}', + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], ), - ], - ), - ], + ), + ], + ), ], - ), + ], ), ), - const SizedBox(height: 24), - Text( - l10n.repeater_managementTools, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 16), - // Status button - _buildManagementCard( - context, - icon: Icons.analytics, - title: l10n.repeater_status, - subtitle: l10n.repeater_statusSubtitle, - color: Colors.blue, - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => RepeaterStatusScreen( - repeater: repeater, - password: password, - ), + ), + const SizedBox(height: 24), + Text( + l10n.repeater_managementTools, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + // Status button + _buildManagementCard( + context, + icon: Icons.analytics, + title: l10n.repeater_status, + subtitle: l10n.repeater_statusSubtitle, + color: Colors.blue, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => RepeaterStatusScreen( + repeater: repeater, + password: password, ), - ); - }, - ), - const SizedBox(height: 16), - // Telemetry button - _buildManagementCard( - context, - icon: Icons.bar_chart_sharp, - title: l10n.repeater_telemetry, - subtitle: l10n.repeater_telemetrySubtitle, - color: Colors.teal, - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => TelemetryScreen( - repeater: repeater, - password: password, - ), + ), + ); + }, + ), + const SizedBox(height: 16), + // Telemetry button + _buildManagementCard( + context, + icon: Icons.bar_chart_sharp, + title: l10n.repeater_telemetry, + subtitle: l10n.repeater_telemetrySubtitle, + color: Colors.teal, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + TelemetryScreen(repeater: repeater, password: password), + ), + ); + }, + ), + const SizedBox(height: 12), + // CLI button + _buildManagementCard( + context, + icon: Icons.terminal, + title: l10n.repeater_cli, + subtitle: l10n.repeater_cliSubtitle, + color: Colors.green, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => RepeaterCliScreen( + repeater: repeater, + password: password, ), - ); - }, - ), - const SizedBox(height: 12), - // CLI button - _buildManagementCard( - context, - icon: Icons.terminal, - title: l10n.repeater_cli, - subtitle: l10n.repeater_cliSubtitle, - color: Colors.green, - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => RepeaterCliScreen( - repeater: repeater, - password: password, - ), + ), + ); + }, + ), + const SizedBox(height: 12), + // Neighbors button + _buildManagementCard( + context, + icon: Icons.group, + title: l10n.repeater_neighbours, + subtitle: l10n.repeater_neighboursSubtitle, + color: Colors.orange, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => NeighboursScreen( + repeater: repeater, + password: password, ), - ); - }, - ), - const SizedBox(height: 12), - // Settings button - _buildManagementCard( - context, - icon: Icons.group, - title: l10n.repeater_neighbours, - subtitle: l10n.repeater_neighboursSubtitle, - color: Colors.orange, - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => NeighboursScreen( - repeater: repeater, - password: password, - ), + ), + ); + }, + ), + const SizedBox(height: 12), + // Settings button + _buildManagementCard( + context, + icon: Icons.settings, + title: l10n.repeater_settings, + subtitle: l10n.repeater_settingsSubtitle, + color: Colors.deepOrange, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => RepeaterSettingsScreen( + repeater: repeater, + password: password, ), - ); - }, - ), - const SizedBox(height: 12), - // Settings button - _buildManagementCard( - context, - icon: Icons.settings, - title: l10n.repeater_settings, - subtitle: l10n.repeater_settingsSubtitle, - color: Colors.deepOrange, - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => RepeaterSettingsScreen( - repeater: repeater, - password: password, - ), - ), - ); - }, - ), + ), + ); + }, + ), ], ), ), @@ -235,10 +255,7 @@ class RepeaterHubScreen extends StatelessWidget { const SizedBox(height: 4), Text( subtitle, - style: TextStyle( - fontSize: 14, - color: Colors.grey[600], - ), + style: TextStyle(fontSize: 14, color: Colors.grey[600]), ), ], ), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 8e23825..c6a85d7 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:meshcore_open/widgets/elements_ui.dart'; import 'package:provider/provider.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -38,10 +39,7 @@ class _SettingsScreenState extends State { Widget build(BuildContext context) { final l10n = context.l10n; return Scaffold( - appBar: AppBar( - title: Text(l10n.settings_title), - centerTitle: true, - ), + appBar: AppBar(title: Text(l10n.settings_title), centerTitle: true), body: SafeArea( top: false, child: Consumer( @@ -68,7 +66,10 @@ class _SettingsScreenState extends State { ); } - Widget _buildDeviceInfoCard(BuildContext context, MeshCoreConnector connector) { + Widget _buildDeviceInfoCard( + BuildContext context, + MeshCoreConnector connector, + ) { final l10n = context.l10n; return Card( child: Padding( @@ -83,21 +84,38 @@ class _SettingsScreenState extends State { const SizedBox(height: 16), _buildInfoRow(l10n.settings_infoName, connector.deviceDisplayName), _buildInfoRow(l10n.settings_infoId, connector.deviceIdLabel), - _buildInfoRow(l10n.settings_infoStatus, connector.isConnected ? l10n.common_connected : l10n.common_disconnected), + _buildInfoRow( + l10n.settings_infoStatus, + connector.isConnected + ? l10n.common_connected + : l10n.common_disconnected, + ), _buildBatteryInfoRow(context, connector), if (connector.selfName != null) _buildInfoRow(l10n.settings_nodeName, connector.selfName!), if (connector.selfPublicKey != null) - _buildInfoRow(l10n.settings_infoPublicKey, '${pubKeyToHex(connector.selfPublicKey!).substring(0, 16)}...'), - _buildInfoRow(l10n.settings_infoContactsCount, '${connector.contacts.length}'), - _buildInfoRow(l10n.settings_infoChannelCount, '${connector.channels.length}'), + _buildInfoRow( + l10n.settings_infoPublicKey, + '${pubKeyToHex(connector.selfPublicKey!).substring(0, 16)}...', + ), + _buildInfoRow( + l10n.settings_infoContactsCount, + '${connector.contacts.length}', + ), + _buildInfoRow( + l10n.settings_infoChannelCount, + '${connector.channels.length}', + ), ], ), ), ); } - Widget _buildBatteryInfoRow(BuildContext context, MeshCoreConnector connector) { + Widget _buildBatteryInfoRow( + BuildContext context, + MeshCoreConnector connector, + ) { final l10n = context.l10n; final percent = connector.batteryPercent; final millivolts = connector.batteryMillivolts; @@ -167,7 +185,10 @@ class _SettingsScreenState extends State { ); } - Widget _buildNodeSettingsCard(BuildContext context, MeshCoreConnector connector) { + Widget _buildNodeSettingsCard( + BuildContext context, + MeshCoreConnector connector, + ) { final l10n = context.l10n; return Card( child: Column( @@ -298,7 +319,9 @@ class _SettingsScreenState extends State { onTap: () { Navigator.push( context, - MaterialPageRoute(builder: (context) => const BleDebugLogScreen()), + MaterialPageRoute( + builder: (context) => const BleDebugLogScreen(), + ), ); }, ), @@ -311,7 +334,9 @@ class _SettingsScreenState extends State { onTap: () { Navigator.push( context, - MaterialPageRoute(builder: (context) => const AppDebugLogScreen()), + MaterialPageRoute( + builder: (context) => const AppDebugLogScreen(), + ), ); }, ), @@ -334,20 +359,14 @@ class _SettingsScreenState extends State { children: [ Row( children: [ - if (leading != null) ...[ - leading, - const SizedBox(width: 8), - ], + if (leading != null) ...[leading, const SizedBox(width: 8)], Text(label, style: TextStyle(color: Colors.grey[600])), ], ), Flexible( child: Text( value, - style: TextStyle( - fontWeight: FontWeight.w500, - color: valueColor, - ), + style: TextStyle(fontWeight: FontWeight.w500, color: valueColor), overflow: TextOverflow.ellipsis, ), ), @@ -413,75 +432,154 @@ class _SettingsScreenState extends State { final l10n = context.l10n; final latController = TextEditingController(); final lonController = TextEditingController(); + final intervalController = TextEditingController(); + latController.text = connector.selfLatitude?.toStringAsFixed(6) ?? ''; + lonController.text = connector.selfLongitude?.toStringAsFixed(6) ?? ''; + + // Safe access to custom vars - may be null before device responds + final customVars = connector.currentCustomVars ?? {}; + final bool hasGPS = customVars.containsKey("gps"); + bool isGPSEnabled = customVars["gps"] == "1"; + + // Read current interval or default to 900 (15 minutes) + final currentInterval = int.tryParse(customVars["gps_interval"] ?? "") ?? 900; + intervalController.text = currentInterval.toString(); + showDialog( context: context, - builder: (context) => AlertDialog( - title: Text(l10n.settings_location), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: latController, - decoration: InputDecoration( - labelText: l10n.settings_latitude, - border: const OutlineInputBorder(), + builder: (dialogContext) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + title: Text(l10n.settings_location), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: latController, + decoration: InputDecoration( + labelText: l10n.settings_latitude, + border: const OutlineInputBorder(), + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + signed: true, + ), ), - keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true), + const SizedBox(height: 16), + TextField( + controller: lonController, + decoration: InputDecoration( + labelText: l10n.settings_longitude, + border: const OutlineInputBorder(), + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + signed: true, + ), + ), + if (hasGPS) ...[ + const SizedBox(height: 16), + TextField( + controller: intervalController, + decoration: InputDecoration( + labelText: l10n.settings_locationIntervalSec, + border: const OutlineInputBorder(), + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: false, + signed: false, + ), + ), + const SizedBox(height: 16), + FeatureToggleRow( + title: l10n.settings_locationGPSEnable, + subtitle: l10n.settings_locationGPSEnableSubtitle, + value: isGPSEnabled, + onChanged: (value) async { + setDialogState(() => isGPSEnabled = value); + if (value) { + await connector.setCustomVar("gps:1"); + } else { + await connector.setCustomVar("gps:0"); + } + }, + ), + ], + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.common_cancel), ), - const SizedBox(height: 16), - TextField( - controller: lonController, - decoration: InputDecoration( - labelText: l10n.settings_longitude, - border: const OutlineInputBorder(), - ), - keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true), + TextButton( + onPressed: () async { + Navigator.pop(context); + + if (hasGPS) { + final intervalText = intervalController.text.trim(); + if (intervalText.isEmpty) { + return; + } + + final interval = int.tryParse(intervalText); + if (interval == null || interval < 60 || interval >= 86400) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.settings_locationIntervalInvalid), + ), + ); + return; + } + + await connector.setCustomVar("gps_interval:$interval"); + await connector.refreshDeviceInfo(); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.settings_locationUpdated)), + ); + } + + final latText = latController.text.trim(); + final lonText = lonController.text.trim(); + if (latText.isEmpty && lonText.isEmpty) { + return; + } + + final currentLat = connector.selfLatitude; + final currentLon = connector.selfLongitude; + final lat = latText.isNotEmpty + ? double.tryParse(latText) + : currentLat; + final lon = lonText.isNotEmpty + ? double.tryParse(lonText) + : currentLon; + if (lat == null || lon == null) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.settings_locationBothRequired)), + ); + return; + } + if (lat < -90 || lat > 90 || lon < -180 || lon > 180) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.settings_locationInvalid)), + ); + return; + } + + await connector.setNodeLocation(lat: lat, lon: lon); + await connector.refreshDeviceInfo(); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.settings_locationUpdated)), + ); + }, + child: Text(l10n.common_save), ), ], ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(l10n.common_cancel), - ), - TextButton( - onPressed: () async { - Navigator.pop(context); - final latText = latController.text.trim(); - final lonText = lonController.text.trim(); - if (latText.isEmpty && lonText.isEmpty) { - return; - } - - final currentLat = connector.selfLatitude; - final currentLon = connector.selfLongitude; - final lat = latText.isNotEmpty ? double.tryParse(latText) : currentLat; - final lon = lonText.isNotEmpty ? double.tryParse(lonText) : currentLon; - if (lat == null || lon == null) { - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.settings_locationBothRequired)), - ); - return; - } - if (lat < -90 || lat > 90 || lon < -180 || lon > 180) { - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.settings_locationInvalid)), - ); - return; - } - - await connector.setNodeLocation(lat: lat, lon: lon); - await connector.refreshDeviceInfo(); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.settings_locationUpdated)), - ); - }, - child: Text(l10n.common_save), - ), - ], ), ); } @@ -530,17 +628,17 @@ class _SettingsScreenState extends State { void _sendAdvert(BuildContext context, MeshCoreConnector connector) { final l10n = context.l10n; connector.sendSelfAdvert(flood: true); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.settings_advertisementSent)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.settings_advertisementSent))); } void _syncTime(BuildContext context, MeshCoreConnector connector) { final l10n = context.l10n; connector.syncTime(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.settings_timeSynchronized)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.settings_timeSynchronized))); } void _confirmReboot(BuildContext context, MeshCoreConnector connector) { @@ -560,7 +658,10 @@ class _SettingsScreenState extends State { Navigator.pop(context); connector.rebootDevice(); }, - child: Text(l10n.common_reboot, style: const TextStyle(color: Colors.orange)), + child: Text( + l10n.common_reboot, + style: const TextStyle(color: Colors.orange), + ), ), ], ), @@ -572,7 +673,9 @@ class _SettingsScreenState extends State { showAboutDialog( context: context, applicationName: l10n.appTitle, - applicationVersion: _appVersion.isEmpty ? l10n.common_loading : _appVersion, + applicationVersion: _appVersion.isEmpty + ? l10n.common_loading + : _appVersion, applicationLegalese: l10n.settings_aboutLegalese, children: [ const SizedBox(height: 16), @@ -604,7 +707,8 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { // Populate with current settings if available if (widget.connector.currentFreqHz != null) { - _frequencyController.text = (widget.connector.currentFreqHz! / 1000.0).toStringAsFixed(3); + _frequencyController.text = (widget.connector.currentFreqHz! / 1000.0) + .toStringAsFixed(3); } else { _frequencyController.text = '915.0'; } @@ -670,26 +774,31 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { final txPower = int.tryParse(_txPowerController.text); if (freqMHz == null || freqMHz < 300 || freqMHz > 2500) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.settings_frequencyInvalid)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.settings_frequencyInvalid))); return; } if (txPower == null || txPower < 0 || txPower > 22) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.settings_txPowerInvalid)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.settings_txPowerInvalid))); return; } final freqHz = (freqMHz * 1000).round(); final bwHz = _bandwidth.hz; final sf = _spreadingFactor.value; - final cr = _toDeviceCodingRate(_codingRate.value, widget.connector.currentCr); + final cr = _toDeviceCodingRate( + _codingRate.value, + widget.connector.currentCr, + ); try { - await widget.connector.sendFrame(buildSetRadioParamsFrame(freqHz, bwHz, sf, cr)); + await widget.connector.sendFrame( + buildSetRadioParamsFrame(freqHz, bwHz, sf, cr), + ); await widget.connector.sendFrame(buildSetRadioTxPowerFrame(txPower)); await widget.connector.refreshDeviceInfo(); @@ -727,7 +836,10 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(l10n.settings_presets, style: const TextStyle(fontWeight: FontWeight.bold)), + Text( + l10n.settings_presets, + style: const TextStyle(fontWeight: FontWeight.bold), + ), const SizedBox(height: 8), Wrap( spacing: 8, @@ -762,7 +874,9 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { border: const OutlineInputBorder(), helperText: l10n.settings_frequencyHelper, ), - keyboardType: const TextInputType.numberWithOptions(decimal: true), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), ), const SizedBox(height: 16), DropdownButtonFormField( @@ -772,10 +886,9 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { border: const OutlineInputBorder(), ), items: LoRaBandwidth.values - .map((bw) => DropdownMenuItem( - value: bw, - child: Text(bw.label), - )) + .map( + (bw) => DropdownMenuItem(value: bw, child: Text(bw.label)), + ) .toList(), onChanged: (value) { if (value != null) setState(() => _bandwidth = value); @@ -789,10 +902,9 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { border: const OutlineInputBorder(), ), items: LoRaSpreadingFactor.values - .map((sf) => DropdownMenuItem( - value: sf, - child: Text(sf.label), - )) + .map( + (sf) => DropdownMenuItem(value: sf, child: Text(sf.label)), + ) .toList(), onChanged: (value) { if (value != null) setState(() => _spreadingFactor = value); @@ -806,10 +918,9 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { border: const OutlineInputBorder(), ), items: LoRaCodingRate.values - .map((cr) => DropdownMenuItem( - value: cr, - child: Text(cr.label), - )) + .map( + (cr) => DropdownMenuItem(value: cr, child: Text(cr.label)), + ) .toList(), onChanged: (value) { if (value != null) setState(() => _codingRate = value); @@ -833,10 +944,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { onPressed: () => Navigator.pop(context), child: Text(l10n.common_cancel), ), - FilledButton( - onPressed: _saveSettings, - child: Text(l10n.common_save), - ), + FilledButton(onPressed: _saveSettings, child: Text(l10n.common_save)), ], ); } @@ -850,9 +958,6 @@ class _PresetChip extends StatelessWidget { @override Widget build(BuildContext context) { - return ActionChip( - label: Text(label), - onPressed: onTap, - ); + return ActionChip(label: Text(label), onPressed: onTap); } } diff --git a/lib/services/ble_debug_log_service.dart b/lib/services/ble_debug_log_service.dart index 002161b..a53ad5d 100644 --- a/lib/services/ble_debug_log_service.dart +++ b/lib/services/ble_debug_log_service.dart @@ -16,7 +16,9 @@ class BleDebugLogEntry { String get hexPreview { const maxBytes = 64; - final bytes = payload.length > maxBytes ? payload.sublist(0, maxBytes) : payload; + final bytes = payload.length > maxBytes + ? payload.sublist(0, maxBytes) + : payload; final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' '); return payload.length > maxBytes ? '$hex …' : hex; } @@ -26,14 +28,13 @@ class BleRawLogRxEntry { final DateTime timestamp; final Uint8List payload; - BleRawLogRxEntry({ - required this.timestamp, - required this.payload, - }); + BleRawLogRxEntry({required this.timestamp, required this.payload}); String get hexPreview { const maxBytes = 64; - final bytes = payload.length > maxBytes ? payload.sublist(0, maxBytes) : payload; + final bytes = payload.length > maxBytes + ? payload.sublist(0, maxBytes) + : payload; final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' '); return payload.length > maxBytes ? '$hex …' : hex; } @@ -45,7 +46,8 @@ class BleDebugLogService extends ChangeNotifier { final List _rawLogRxEntries = []; List get entries => List.unmodifiable(_entries); - List get rawLogRxEntries => List.unmodifiable(_rawLogRxEntries); + List get rawLogRxEntries => + List.unmodifiable(_rawLogRxEntries); void logFrame(Uint8List frame, {required bool outgoing, String? note}) { if (frame.isEmpty) return; @@ -85,7 +87,12 @@ class BleDebugLogService extends ChangeNotifier { notifyListeners(); } - String _describeFrame(int code, Uint8List frame, bool outgoing, String? note) { + String _describeFrame( + int code, + Uint8List frame, + bool outgoing, + String? note, + ) { final label = _codeLabel(code, outgoing: outgoing); final prefix = outgoing ? 'TX' : 'RX'; final extra = _frameDetail(code, frame); @@ -147,6 +154,8 @@ class BleDebugLogService extends ChangeNotifier { return 'CMD_SET_CHANNEL'; case cmdGetRadioSettings: return 'CMD_GET_RADIO_SETTINGS'; + case cmdSetCustomVar: + return 'CMD_SET_CUSTOM_VAR'; default: return null; } diff --git a/lib/widgets/elements_ui.dart b/lib/widgets/elements_ui.dart new file mode 100644 index 0000000..0c46249 --- /dev/null +++ b/lib/widgets/elements_ui.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +class FeatureToggleRow extends StatefulWidget { + final String title; + final String subtitle; + final bool value; + final bool hasRefreshing; + final bool isRefreshing; + final ValueChanged? onChanged; + final VoidCallback? onRefresh; + final String? refreshTooltip; + + const FeatureToggleRow({ + super.key, + required this.title, + required this.subtitle, + required this.value, + this.hasRefreshing = false, + this.isRefreshing = false, + this.onChanged, + this.onRefresh, + this.refreshTooltip, + }); + + @override + State createState() => _FeatureToggleRow(); +} + +class _FeatureToggleRow extends State { + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: SwitchListTile( + title: Text(widget.title), + subtitle: Text(widget.subtitle), + value: widget.value, + onChanged: widget.onChanged, + contentPadding: EdgeInsets.zero, + ), + ), + if (widget.hasRefreshing) + IconButton( + icon: widget.isRefreshing + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh, size: 20), + onPressed: widget.isRefreshing ? null : widget.onRefresh, + tooltip: widget.refreshTooltip, + visualDensity: VisualDensity.compact, + ), + ], + ); + } +}