diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 8b328700..15614b5f 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 c828a1ab..8469d615 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 1f0f671a..377e0f59 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -1349,6 +1349,10 @@ "channels_scanQrCodeComingSoon": "Ще излезе скоро", "channels_enterHashtag": "Въведете хаштаг", "channels_hashtagHint": "напр. #отбор", + "settings_locationIntervalSec": "Интервал за GPS (Секунди)", + "settings_locationGPSEnable": "Активиране на GPS", + "settings_locationGPSEnableSubtitle": "Активирайте автоматичното актуализиране на местоположението чрез GPS.", + "settings_locationIntervalInvalid": "Интервалът трябва да бъде поне 60 секунди и по-малко от 86400 секунди.", "room_management": "Управление на сървъра за стая", "contacts_manageRoom": "Управление на сървър за стая" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 8b76346c..ba8f4d06 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1348,7 +1348,5 @@ "channels_scanQrCode": "Scannen Sie einen QR-Code", "channels_scanQrCodeComingSoon": "Bald verfügbar", "channels_enterHashtag": "Gib Hashtag ein", - "channels_hashtagHint": "z.B. #team", - "contacts_manageRoom": "Verwalten Sie den Raumserver", - "room_management": "Raumserververwaltung" + "channels_hashtagHint": "z.B. #team" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index cf33b6c9..cc470776 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", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index af604e1f..571bc65f 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1349,6 +1349,10 @@ "channels_scanQrCodeComingSoon": "Próximamente", "channels_enterHashtag": "Introducir hashtag", "channels_hashtagHint": "ej. #equipo", + "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 6513b403..d7f4b227 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1349,6 +1349,10 @@ "channels_scanQrCodeComingSoon": "Bientôt disponible", "channels_enterHashtag": "Entrez le hashtag", "channels_hashtagHint": "ex. #équipe", - "contacts_manageRoom": "Gérer le serveur de salle", - "room_management": "Gestion du serveur de pièce" + "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 ea8c6aca..f2f5d28a 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1349,6 +1349,10 @@ "channels_scanQrCodeComingSoon": "Arriverà presto", "channels_enterHashtag": "Inserisci hashtag", "channels_hashtagHint": "es. #team", - "room_management": "Gestione del Server di Camera", - "contacts_manageRoom": "Gestisci Server Camera" + "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 22c13f83..95e69351 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: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index dcabe325..7cbb2d6d 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 => 'Широчина'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index c42ed407..5107d235 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'; @@ -648,7 +662,7 @@ class AppLocalizationsDe extends AppLocalizations { String get contacts_manageRepeater => 'Wiederholungen verwalten'; @override - String get contacts_manageRoom => 'Verwalten Sie den Raumserver'; + String get contacts_manageRoom => 'Manage Room Server'; @override String get contacts_roomLogin => 'Raum-Login'; @@ -1590,7 +1604,7 @@ class AppLocalizationsDe extends AppLocalizations { String get repeater_management => 'Repeater-Verwaltung'; @override - String get room_management => 'Raumserververwaltung'; + 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 30510611..abb776a1 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'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index a88cccfa..8ab2c19f 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'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index ceb2be5d..224f6a17 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'; @@ -650,7 +664,7 @@ class AppLocalizationsFr extends AppLocalizations { String get contacts_manageRepeater => 'Gérer le répétiteur'; @override - String get contacts_manageRoom => 'Gérer le serveur de salle'; + String get contacts_manageRoom => 'Gestionar Servidor de Habitación'; @override String get contacts_roomLogin => 'Connexion Salle'; @@ -1595,7 +1609,7 @@ class AppLocalizationsFr extends AppLocalizations { String get repeater_management => 'Gestion des répétiteurs'; @override - String get room_management => 'Gestion du serveur de pièce'; + 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 50e50de0..b034425e 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'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index d9283655..a938b50d 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'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 435a5893..adb1f620 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ść'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 2cdd2f3a..6bea4b7c 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'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 2dda3175..0c261848 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'; @@ -1103,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 => diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 122741ca..c33c9d6b 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'; @@ -683,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.'; @@ -1189,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) { diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index a1d8bd81..d876dd9f 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'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 57b1a96e..f1dd5069 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 => '纬度'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index fd069fcc..9131b4e3 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1349,6 +1349,10 @@ "channels_scanQrCodeComingSoon": "Komt later", "channels_enterHashtag": "Voer hashtag in", "channels_hashtagHint": "bijv. #team", - "room_management": "Beheer Server Kamer", - "contacts_manageRoom": "Beheer Ruimte Server" + "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 60a6ab5b..81d4df02 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1349,6 +1349,10 @@ "channels_scanQrCodeComingSoon": "Wkrótce", "channels_enterHashtag": "Wprowadź hashtag", "channels_hashtagHint": "np. #zespół", + "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 e05297d3..6f6471f7 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1349,6 +1349,10 @@ "channels_scanQrCodeComingSoon": "Em breve", "channels_enterHashtag": "Insira hashtag", "channels_hashtagHint": "ex. #equipe", + "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 688a1bb4..7b237911 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.", @@ -1349,6 +1349,10 @@ "channels_scanQrCodeComingSoon": "Čoskoro", "channels_enterHashtag": "Zadajte hashtag", "channels_hashtagHint": "napr. #tím", - "room_management": "Správa servera miestnosti", - "contacts_manageRoom": "Spravovať server miestnosti" + "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 abd55ccd..e07b1f25 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": { @@ -1349,6 +1349,10 @@ "channels_scanQrCodeComingSoon": "Prihajajoča", "channels_enterHashtag": "Vnesite hashtag", "channels_hashtagHint": "npr. #ekipa", + "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 9b70a3eb..020e8b2a 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -1349,6 +1349,10 @@ "channels_scanQrCodeComingSoon": "Kommer snart", "channels_enterHashtag": "Ange hashtag", "channels_hashtagHint": "t.ex. #team", + "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 56580fa9..52973fa5 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1349,6 +1349,10 @@ "channels_scanQrCodeComingSoon": "即将到来", "channels_enterHashtag": "输入标签", "channels_hashtagHint": "例如 #团队", + "settings_locationGPSEnable": "启用GPS", + "settings_locationGPSEnableSubtitle": "启用GPS自动更新位置。", + "settings_locationIntervalSec": "GPS 间隔(秒)", + "settings_locationIntervalInvalid": "时间间隔必须至少为60秒,且小于86400秒。", "contacts_manageRoom": "管理房间服务器", "room_management": "房间服务器管理" } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 8e238254..c6a85d79 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 002161b0..a53ad5d9 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 00000000..0c462499 --- /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, + ), + ], + ); + } +}