From 1f0b7d8d7b1b30bfeac88b700a4372e931c5c5ff Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Sun, 18 Jan 2026 01:02:48 -0800 Subject: [PATCH 1/8] added buildSetCustomVarFrame and setCustomVar --- lib/connector/meshcore_connector.dart | 451 +++++++++++++++++--------- lib/connector/meshcore_protocol.dart | 88 +++-- 2 files changed, 356 insertions(+), 183 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 8b328700..96fb229e 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; @@ -137,7 +139,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 +169,7 @@ class MeshCoreConnector extends ChangeNotifier { } return 'Unknown Device'; } + List get scanResults => List.unmodifiable(_scanResults); List get contacts { final selfKey = _selfPublicKey; @@ -176,6 +180,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; @@ -196,7 +201,8 @@ class MeshCoreConnector extends ChangeNotifier { 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 +383,8 @@ class MeshCoreConnector extends ChangeNotifier { } void setActiveContact(String? contactKeyHex) { - if (contactKeyHex != null && !_shouldTrackUnreadForContactKey(contactKeyHex)) { + if (contactKeyHex != null && + !_shouldTrackUnreadForContactKey(contactKeyHex)) { _activeContactKey = null; return; } @@ -429,7 +436,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 +455,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 +562,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 +589,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 +599,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 +610,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 +733,9 @@ class MeshCoreConnector extends ChangeNotifier { if (attempt == 2) rethrow; } } - _notifySubscription = _txCharacteristic!.onValueReceived.listen(_handleFrame); + _notifySubscription = _txCharacteristic!.onValueReceived.listen( + _handleFrame, + ); _setState(MeshCoreConnectionState.connected); @@ -771,8 +792,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 +819,8 @@ class MeshCoreConnector extends ChangeNotifier { return; } - final device = _lastDevice ?? + final device = + _lastDevice ?? (_lastDeviceId == null ? null : BluetoothDevice.fromId(_lastDeviceId!)); @@ -945,20 +966,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 +999,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 +1007,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 +1055,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 +1066,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 +1084,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 +1113,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 +1129,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 +1180,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 +1240,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 +1249,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 +1281,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 +1309,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 +1318,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 +1339,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 +1389,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 +1405,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 +1438,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 +1481,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 +1515,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 +1529,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 +1540,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 +1570,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 +1610,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(); } @@ -1705,8 +1772,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 +1822,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 +1895,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 +1912,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 +2053,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 +2104,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 +2163,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 +2196,7 @@ class MeshCoreConnector extends ChangeNotifier { status: MessageStatus.delivered, pathLength: pathLenByte == 0xFF ? 0 : pathLenByte, pathBytes: Uint8List(0), - fourByteRoomContactKey: fourBytePubMSG + fourByteRoomContactKey: fourBytePubMSG, ); } @@ -2140,17 +2237,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 +2279,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 +2355,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 +2377,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 +2396,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 +2431,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 +2443,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 +2464,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 +2478,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 +2502,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 +2517,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 +2584,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 +2645,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 +2673,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 +2695,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 +2759,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 +2797,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 +2838,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 +2868,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 +2901,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 +2921,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 +2932,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 +2949,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 +2967,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 +2983,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; @@ -3013,17 +3160,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..21c43927 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,7 @@ const int cmdGetChannel = 31; const int cmdSetChannel = 32; const int cmdGetRadioSettings = 57; const int cmdGetTelemetryReq = 39; +const int cmdSetCustomVar = 41; const int cmdSendBinaryReq = 50; // Text message types @@ -166,7 +178,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 +244,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 +273,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 +377,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 +460,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 +479,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 +570,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]; } @@ -598,9 +626,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 +677,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 +692,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 +700,4 @@ Uint8List buildSendBinaryReq( writer.writeBytes(payload); } return writer.toBytes(); -} \ No newline at end of file +} From 2e1a5e0fbf3b7c51446a219ffb3b61ba7100687d Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Sun, 18 Jan 2026 01:03:45 -0800 Subject: [PATCH 2/8] added CMD_SET_CUSTOM_VAR to BLE debug --- lib/services/ble_debug_log_service.dart | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) 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; } From 714aecd7e6685ee5ddc4e44d859c51a87fe4272c Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Sun, 18 Jan 2026 01:05:46 -0800 Subject: [PATCH 3/8] Added GPS enable and interval settings --- lib/l10n/app_bg.arb | 7 +- lib/l10n/app_de.arb | 7 +- lib/l10n/app_en.arb | 5 + lib/l10n/app_es.arb | 7 +- lib/l10n/app_fr.arb | 7 +- lib/l10n/app_it.arb | 7 +- lib/l10n/app_localizations.dart | 26 ++- lib/l10n/app_localizations_bg.dart | 14 ++ lib/l10n/app_localizations_de.dart | 14 ++ lib/l10n/app_localizations_en.dart | 16 +- lib/l10n/app_localizations_es.dart | 14 ++ lib/l10n/app_localizations_fr.dart | 14 ++ lib/l10n/app_localizations_it.dart | 14 ++ lib/l10n/app_localizations_nl.dart | 14 ++ lib/l10n/app_localizations_pl.dart | 14 ++ lib/l10n/app_localizations_pt.dart | 14 ++ lib/l10n/app_localizations_sk.dart | 14 ++ lib/l10n/app_localizations_sl.dart | 14 ++ lib/l10n/app_localizations_sv.dart | 14 ++ lib/l10n/app_localizations_zh.dart | 12 + lib/l10n/app_nl.arb | 7 +- lib/l10n/app_pl.arb | 7 +- lib/l10n/app_pt.arb | 7 +- lib/l10n/app_sk.arb | 7 +- lib/l10n/app_sl.arb | 7 +- lib/l10n/app_sv.arb | 7 +- lib/l10n/app_zh.arb | 7 +- lib/screens/settings_screen.dart | 346 +++++++++++++++++++---------- 28 files changed, 495 insertions(+), 148 deletions(-) diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index 30a2c1b5..effbd6c2 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -822,7 +822,6 @@ } }, "login_failedMessage": "Входът не беше успешен. Или паролата е грешна, или повторителят е недостъпен.", - "common_reload": "Презареди", "common_clear": "Изчисти", "path_currentPath": "Текущ път: {path}", @@ -1349,5 +1348,9 @@ "channels_scanQrCode": "Сканирайте QR код", "channels_scanQrCodeComingSoon": "Ще излезе скоро", "channels_enterHashtag": "Въведете хаштаг", - "channels_hashtagHint": "напр. #отбор" + "channels_hashtagHint": "напр. #отбор", + "settings_locationIntervalSec": "Интервал (Секунди)", + "settings_locationGPSEnableSubtitle": "Активирайте GPS, за автоматично изпращане на данни за местоположението (ако е поддържано).", + "settings_locationIntervalInvalid": "Интервалът трябва да бъде поне 60 секунди и по-малко от 86400 секунди.", + "settings_locationGPSEnable": "Активиране на GPS" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 9d2701f5..dead738f 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -822,7 +822,6 @@ } }, "login_failedMessage": "Anmeldung fehlgeschlagen. Entweder ist das Passwort falsch oder der Repeater ist nicht erreichbar.", - "common_reload": "Neu laden", "common_clear": "Löschen", "path_currentPath": "Aktiver Pfad: {path}", @@ -1349,5 +1348,9 @@ "channels_scanQrCode": "Scannen Sie einen QR-Code", "channels_scanQrCodeComingSoon": "Bald verfügbar", "channels_enterHashtag": "Gib Hashtag ein", - "channels_hashtagHint": "z.B. #team" + "channels_hashtagHint": "z.B. #team", + "settings_locationGPSEnable": "GPS aktivieren", + "settings_locationGPSEnableSubtitle": "Aktivieren Sie GPS, um Standortdaten automatisch zu senden (falls unterstützt).", + "settings_locationIntervalSec": "Zeitintervall (Sekunden)", + "settings_locationIntervalInvalid": "Der Zeitraum muss mindestens 60 Sekunden betragen und weniger als 86400 Sekunden sein." } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 203f64fb..9b2547cb 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -86,6 +86,11 @@ "settings_locationUpdated": "Location updated", "settings_locationBothRequired": "Enter both latitude and longitude.", "settings_locationInvalid": "Invalid latitude or longitude.", + "settings_locationGPSEnable": "GPS Enable", + "settings_locationGPSEnableSubtitle": "Enable GPS to automatically send location data (if supported)", + "settings_locationIntervalSec": "Interval (Seconds)", + "settings_locationIntervalInvalid": "Interval must be at least 60 seconds, and less than 86400 seconds.", + "settings_locationUpdated": "GPS settings updated.", "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 b23cfcee..bf90fe4e 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -822,7 +822,6 @@ } }, "login_failedMessage": "Inicio fallido. La contraseña es incorrecta o el repetidor no está disponible.", - "common_reload": "Recargar", "common_clear": "Borrar", "path_currentPath": "Ruta actual: {path}", @@ -1349,5 +1348,9 @@ "channels_scanQrCode": "Escanear un Código QR", "channels_scanQrCodeComingSoon": "Próximamente", "channels_enterHashtag": "Introducir hashtag", - "channels_hashtagHint": "ej. #equipo" + "channels_hashtagHint": "ej. #equipo", + "settings_locationGPSEnableSubtitle": "Habilitar GPS para enviar automáticamente datos de ubicación (si está disponible).", + "settings_locationGPSEnable": "Habilitar GPS", + "settings_locationIntervalSec": "Intervalo (Segundos)", + "settings_locationIntervalInvalid": "El intervalo debe ser de al menos 60 segundos y menor que 86400 segundos." } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 0e7b9528..82d73d7a 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -822,7 +822,6 @@ } }, "login_failedMessage": "Connexion échouée. Soit le mot de passe est incorrect, soit le relais est injoignable.", - "common_reload": "Recharger", "common_clear": "Effacer", "path_currentPath": "Chemin actuel : {path}", @@ -1349,5 +1348,9 @@ "channels_scanQrCode": "Scanner un code QR", "channels_scanQrCodeComingSoon": "Bientôt disponible", "channels_enterHashtag": "Entrez le hashtag", - "channels_hashtagHint": "ex. #équipe" + "channels_hashtagHint": "ex. #équipe", + "settings_locationGPSEnable": "Activer le GPS", + "settings_locationGPSEnableSubtitle": "Activer le GPS pour envoyer automatiquement les données de localisation (si pris en charge).", + "settings_locationIntervalSec": "Intervalle (Secondes)", + "settings_locationIntervalInvalid": "L'intervalle doit être d'au moins 60 secondes et inférieur à 86400 secondes." } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index b77450fc..f9828e54 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -822,7 +822,6 @@ } }, "login_failedMessage": "Accesso fallito. La password non è corretta oppure il ripetitore non è raggiungibile.", - "common_reload": "Ricaricare", "common_clear": "Cancella", "path_currentPath": "Percorso corrente: {path}", @@ -1349,5 +1348,9 @@ "channels_scanQrCode": "Scansiona un codice QR", "channels_scanQrCodeComingSoon": "Arriverà presto", "channels_enterHashtag": "Inserisci hashtag", - "channels_hashtagHint": "es. #team" + "channels_hashtagHint": "es. #team", + "settings_locationGPSEnableSubtitle": "Abilita il GPS per inviare automaticamente i dati di posizione (se supportato).", + "settings_locationGPSEnable": "Abilita GPS", + "settings_locationIntervalSec": "Intervallo (Secondi)", + "settings_locationIntervalInvalid": "L'intervallo deve essere di almeno 60 secondi e inferiore a 86400 secondi." } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index bedd4072..cfe77e2c 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'** + /// **'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: + /// **'Enable GPS to automatically send location data (if supported)'** + String get settings_locationGPSEnableSubtitle; + + /// No description provided for @settings_locationIntervalSec. + /// + /// In en, this message translates to: + /// **'Interval (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 cf2b35c8..2b5d9c76 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 => 'Интервал (Секунди)'; + + @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 3d0cbcd3..01615335 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 aktivieren'; + + @override + String get settings_locationGPSEnableSubtitle => + 'Aktivieren Sie GPS, um Standortdaten automatisch zu senden (falls unterstützt).'; + + @override + String get settings_locationIntervalSec => 'Zeitintervall (Sekunden)'; + + @override + String get settings_locationIntervalInvalid => + 'Der Zeitraum muss mindestens 60 Sekunden betragen und weniger als 86400 Sekunden sein.'; + @override String get settings_latitude => 'Breitengrad'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index b8c4f905..e7ea0cf2 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 => '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 => + 'Enable GPS to automatically send location data (if supported)'; + + @override + String get settings_locationIntervalSec => 'Interval (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 21aad2cb..497dd2a7 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 => + 'Habilitar GPS para enviar automáticamente datos de ubicación (si está disponible).'; + + @override + String get settings_locationIntervalSec => 'Intervalo (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 21f63b52..50beba2e 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 => 'Activer le GPS'; + + @override + String get settings_locationGPSEnableSubtitle => + 'Activer le GPS pour envoyer automatiquement les données de localisation (si pris en charge).'; + + @override + String get settings_locationIntervalSec => 'Intervalle (Secondes)'; + + @override + String get settings_locationIntervalInvalid => + 'L\'intervalle doit être d\'au moins 60 secondes et inférieur à 86400 secondes.'; + @override String get settings_latitude => 'Latitude'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 1cc567c3..875e7930 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 il GPS per inviare automaticamente i dati di posizione (se supportato).'; + + @override + String get settings_locationIntervalSec => 'Intervallo (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 db1f68e3..a7112bd3 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 => + 'Zijze GPS inschakelen om locatiegegevens automatisch te verzenden (indien ondersteund).'; + + @override + String get settings_locationIntervalSec => 'Interval (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 e4373d1a..3e727c08 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łącz GPS, aby automatycznie wysyłać dane o lokalizacji (jeśli jest obsługiwane).'; + + @override + String get settings_locationIntervalSec => 'Interwał (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 2aaa940c..769853ec 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 => + 'Habilite o GPS para enviar dados de localização automaticamente (se suportado).'; + + @override + String get settings_locationIntervalSec => 'Intervalo (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 ffd327a5..aac0ced7 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 => + 'Zapnite GPS na automatické posielanie dát o polohe (ak je podporované).'; + + @override + String get settings_locationIntervalSec => 'Interval (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'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 994b7fa6..61e4033e 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 GPS za samodejno pošiljanje podatkov o lokaciji (če je podprto).'; + + @override + String get settings_locationIntervalSec => 'Interval (Sekunde)'; + + @override + String get settings_locationIntervalInvalid => + 'Intervallo mora biti vsaj 60 sekund in manj kot 86400 sekund.'; + @override String get settings_latitude => 'Širina'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index a4765a90..2348e95c 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 GPS för att automatiskt skicka platsdata (om det stöds).'; + + @override + String get settings_locationIntervalSec => 'Tidsintervall (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 99f4e90f..c68a38ba 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 => '时间间隔(秒)'; + + @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 b7e1a35a..a0ddfe87 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -822,7 +822,6 @@ } }, "login_failedMessage": "Inloggen mislukt. Het wachtwoord is onjuist of de repeater is niet bereikbaar.", - "common_reload": "Opnieuw laden", "common_clear": "Schoonmaken", "path_currentPath": "Huidige pad: {path}", @@ -1349,5 +1348,9 @@ "channels_scanQrCode": "Scan een QR-code", "channels_scanQrCodeComingSoon": "Komt later", "channels_enterHashtag": "Voer hashtag in", - "channels_hashtagHint": "bijv. #team" + "channels_hashtagHint": "bijv. #team", + "settings_locationGPSEnable": "GPS inschakelen", + "settings_locationGPSEnableSubtitle": "Zijze GPS inschakelen om locatiegegevens automatisch te verzenden (indien ondersteund).", + "settings_locationIntervalInvalid": "De intervallen moeten minstens 60 seconden zijn en minder dan 86400 seconden.", + "settings_locationIntervalSec": "Interval (Seconden)" } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 56fe869d..23938a5a 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -822,7 +822,6 @@ } }, "login_failedMessage": "Logowanie nie powiodło się. Hasło jest nieprawidłowe albo repeater jest nieosiągalny.", - "common_reload": "Ponownie załadować", "common_clear": "Wyczyść", "path_currentPath": "Aktualny ścieżka: {path}", @@ -1349,5 +1348,9 @@ "channels_scanQrCode": "Skanuj kod QR", "channels_scanQrCodeComingSoon": "Wkrótce", "channels_enterHashtag": "Wprowadź hashtag", - "channels_hashtagHint": "np. #zespół" + "channels_hashtagHint": "np. #zespół", + "settings_locationGPSEnable": "Włącz GPS", + "settings_locationGPSEnableSubtitle": "Włącz GPS, aby automatycznie wysyłać dane o lokalizacji (jeśli jest obsługiwane).", + "settings_locationIntervalSec": "Interwał (Sekundy)", + "settings_locationIntervalInvalid": "Interwał musi wynosić co najmniej 60 sekund i mniej niż 86400 sekund." } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 99da3747..b49f2861 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -822,7 +822,6 @@ } }, "login_failedMessage": "Falha no login. A senha está incorreta ou o repetidor está inacessível.", - "common_reload": "Recarregar", "common_clear": "Limpar", "path_currentPath": "Caminho atual: {path}", @@ -1349,5 +1348,9 @@ "channels_scanQrCode": "Digitalizar um Código QR", "channels_scanQrCodeComingSoon": "Em breve", "channels_enterHashtag": "Insira hashtag", - "channels_hashtagHint": "ex. #equipe" + "channels_hashtagHint": "ex. #equipe", + "settings_locationGPSEnable": "Ativar GPS", + "settings_locationGPSEnableSubtitle": "Habilite o GPS para enviar dados de localização automaticamente (se suportado).", + "settings_locationIntervalSec": "Intervalo (Segundos)", + "settings_locationIntervalInvalid": "O intervalo deve ser de pelo menos 60 segundos e inferior a 86400 segundos." } diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index 1ffb197a..aa242c37 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -822,7 +822,6 @@ } }, "login_failedMessage": "Prihlásenie zlyhalo. Heslo je nesprávne alebo je opakovač nedostupný.", - "common_reload": "Načítať", "common_clear": "Zmazať", "path_currentPath": "Aktívna cesta: {path}", @@ -1349,5 +1348,9 @@ "channels_scanQrCode": "Skenujte QR kód", "channels_scanQrCodeComingSoon": "Čoskoro", "channels_enterHashtag": "Zadajte hashtag", - "channels_hashtagHint": "napr. #tím" + "channels_hashtagHint": "napr. #tím", + "settings_locationGPSEnable": "Aktivovať GPS", + "settings_locationGPSEnableSubtitle": "Zapnite GPS na automatické posielanie dát o polohe (ak je podporované).", + "settings_locationIntervalSec": "Interval (Sekundy)", + "settings_locationIntervalInvalid": "Interval musí byť aspoň 60 sekúnd a menej ako 86400 sekúnd." } diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index bac8b2b0..e078d409 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -822,7 +822,6 @@ } }, "login_failedMessage": "Prijava je bila neuspešna. Geslo je napačno ali pa je repetitor nedosegljiv.", - "common_reload": "Ponovno naloži", "common_clear": "Ponoviti", "path_currentPath": "Trenutna pot: {path}", @@ -1349,5 +1348,9 @@ "channels_scanQrCode": "Skeniraj QR kodo", "channels_scanQrCodeComingSoon": "Prihajajoča", "channels_enterHashtag": "Vnesite hashtag", - "channels_hashtagHint": "npr. #ekipa" + "channels_hashtagHint": "npr. #ekipa", + "settings_locationGPSEnable": "Omogoči GPS", + "settings_locationGPSEnableSubtitle": "Omogoči GPS za samodejno pošiljanje podatkov o lokaciji (če je podprto).", + "settings_locationIntervalSec": "Interval (Sekunde)", + "settings_locationIntervalInvalid": "Intervallo mora biti vsaj 60 sekund in manj kot 86400 sekund." } diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index 3c8d470e..2f580cbd 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -822,7 +822,6 @@ } }, "login_failedMessage": "Inloggning misslyckades. Antingen är lösenordet fel eller så går det inte att nå repeatern.", - "common_reload": "Ladda om", "common_clear": "Rensa", "path_currentPath": "Nuvarande sökväg: {path}", @@ -1349,5 +1348,9 @@ "channels_scanQrCode": "Skanna en QR-kod", "channels_scanQrCodeComingSoon": "Kommer snart", "channels_enterHashtag": "Ange hashtag", - "channels_hashtagHint": "t.ex. #team" + "channels_hashtagHint": "t.ex. #team", + "settings_locationGPSEnable": "Aktivera GPS", + "settings_locationIntervalSec": "Tidsintervall (Sekunder)", + "settings_locationGPSEnableSubtitle": "Aktivera GPS för att automatiskt skicka platsdata (om det stöds).", + "settings_locationIntervalInvalid": "Intervalet måste vara minst 60 sekunder och mindre än 86400 sekunder." } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index fc5ac553..fa9e64d4 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -822,7 +822,6 @@ } }, "login_failedMessage": "登录失败。密码不正确或中继器不可达。", - "common_reload": "重新加载", "common_clear": "清除", "path_currentPath": "当前路径:{path}", @@ -1349,5 +1348,9 @@ "channels_scanQrCode": "扫描二维码", "channels_scanQrCodeComingSoon": "即将到来", "channels_enterHashtag": "输入标签", - "channels_hashtagHint": "例如 #团队" + "channels_hashtagHint": "例如 #团队", + "settings_locationGPSEnableSubtitle": "启用GPS自动发送位置数据(如果支持)。", + "settings_locationGPSEnable": "启用GPS", + "settings_locationIntervalSec": "时间间隔(秒)", + "settings_locationIntervalInvalid": "时间间隔必须至少为60秒,且小于86400秒。" } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 8e238254..d925c30b 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -38,10 +38,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 +65,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 +83,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 +184,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 +318,9 @@ class _SettingsScreenState extends State { onTap: () { Navigator.push( context, - MaterialPageRoute(builder: (context) => const BleDebugLogScreen()), + MaterialPageRoute( + builder: (context) => const BleDebugLogScreen(), + ), ); }, ), @@ -311,7 +333,9 @@ class _SettingsScreenState extends State { onTap: () { Navigator.push( context, - MaterialPageRoute(builder: (context) => const AppDebugLogScreen()), + MaterialPageRoute( + builder: (context) => const AppDebugLogScreen(), + ), ); }, ), @@ -334,20 +358,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 +431,152 @@ class _SettingsScreenState extends State { final l10n = context.l10n; final latController = TextEditingController(); final lonController = TextEditingController(); + final intervalController = TextEditingController(); + intervalController.text = "900"; + latController.text = connector.selfLatitude?.toStringAsFixed(6) ?? ''; + lonController.text = connector.selfLongitude?.toStringAsFixed(6) ?? ''; + bool isGPSEnabled = false; + 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: [ + if (!isGPSEnabled) ...[ + TextField( + controller: latController, + decoration: InputDecoration( + labelText: l10n.settings_latitude, + border: const OutlineInputBorder(), + ), + 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, + ), + ), + ], + const SizedBox(height: 16), + CheckboxListTile( + value: isGPSEnabled, + enabled: true, + onChanged: (v) => + setDialogState(() => isGPSEnabled = v ?? false), + //controlAffinity: ListTileControlAffinity.leading, + title: Text( + l10n.settings_locationGPSEnable, + style: TextStyle(fontSize: 12), + ), + subtitle: Text( + l10n.settings_locationGPSEnableSubtitle, + style: TextStyle(fontSize: 10), + ), ), - keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true), + if (isGPSEnabled) ...{ + const SizedBox(height: 16), + TextField( + controller: intervalController, + decoration: InputDecoration( + labelText: l10n.settings_locationIntervalSec, + border: const OutlineInputBorder(), + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: false, + signed: false, + ), + ), + }, + ], + ), + 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 (isGPSEnabled) { + final intervalText = intervalController.text.trim(); + if (intervalText.isEmpty) { + return; + } + final interval = int.tryParse(intervalText); + if (interval == null || interval < 60) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.settings_locationIntervalInvalid), + ), + ); + return; + } + await connector.setCustomVar("gps:1"); + await connector.setCustomVar("gps_interval:$interval"); + await connector.refreshDeviceInfo(); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.settings_locationUpdated)), + ); + } else { + 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.setCustomVar("gps:0"); + 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 +625,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 +655,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 +670,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 +704,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 +771,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 +833,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 +871,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 +883,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 +899,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 +915,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 +941,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 +955,6 @@ class _PresetChip extends StatelessWidget { @override Widget build(BuildContext context) { - return ActionChip( - label: Text(label), - onPressed: onTap, - ); + return ActionChip(label: Text(label), onPressed: onTap); } } From 2becbb342cbd80daf7d8f5750b9d19ec2c952e89 Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Mon, 19 Jan 2026 16:55:39 -0800 Subject: [PATCH 4/8] Added buildGetCustomVarsFrame And added update to refreshDeviceInfo and _requestDeviceInfo. Added parsing of Custom Vars --- lib/connector/meshcore_connector.dart | 34 +++++++++++++++++++++++++++ lib/connector/meshcore_protocol.dart | 7 ++++++ 2 files changed, 41 insertions(+) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 96fb229e..15614b5f 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -110,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; @@ -196,6 +197,7 @@ 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; @@ -952,6 +954,7 @@ class MeshCoreConnector extends ChangeNotifier { await sendFrame(buildAppStartFrame()); await requestBatteryStatus(force: true); await sendFrame(buildGetRadioSettingsFrame()); + await sendFrame(buildGetCustomVarsFrame()); _scheduleSelfInfoRetry(); } @@ -959,6 +962,7 @@ class MeshCoreConnector extends ChangeNotifier { _awaitingSelfInfo = true; await sendFrame(buildDeviceQueryFrame()); await sendFrame(buildAppStartFrame()); + await sendFrame(buildGetCustomVarsFrame()); await requestBatteryStatus(); _scheduleSelfInfoRetry(); @@ -1696,6 +1700,8 @@ class MeshCoreConnector extends ChangeNotifier { case respCodeBattAndStorage: _handleBatteryAndStorage(frame); break; + case respCodeCustomVars: + _handleCustomVars(frame); default: debugPrint('Unknown frame code: $code'); } @@ -3107,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; diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index 21c43927..8469d615 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -129,6 +129,7 @@ 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; @@ -164,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; @@ -603,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 From 98fc2d6e0ab68ae3c30a0f2da08a878c348a3b16 Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Mon, 19 Jan 2026 16:56:32 -0800 Subject: [PATCH 5/8] Updated gps setting to follow state of companion. --- lib/l10n/app_bg.arb | 8 +- lib/l10n/app_de.arb | 6 +- lib/l10n/app_en.arb | 4 +- lib/l10n/app_es.arb | 4 +- lib/l10n/app_fr.arb | 6 +- lib/l10n/app_it.arb | 4 +- lib/l10n/app_localizations.dart | 4 +- lib/l10n/app_localizations_en.dart | 4 +- lib/l10n/app_nl.arb | 4 +- lib/l10n/app_pl.arb | 4 +- lib/l10n/app_pt.arb | 6 +- lib/l10n/app_sk.arb | 4 +- lib/l10n/app_sl.arb | 6 +- lib/l10n/app_sv.arb | 4 +- lib/l10n/app_zh.arb | 4 +- lib/screens/settings_screen.dart | 156 +++++++++++++++-------------- lib/widgets/elements_ui.dart | 59 +++++++++++ 17 files changed, 174 insertions(+), 113 deletions(-) create mode 100644 lib/widgets/elements_ui.dart diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index effbd6c2..7a3216d4 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -1349,8 +1349,8 @@ "channels_scanQrCodeComingSoon": "Ще излезе скоро", "channels_enterHashtag": "Въведете хаштаг", "channels_hashtagHint": "напр. #отбор", - "settings_locationIntervalSec": "Интервал (Секунди)", - "settings_locationGPSEnableSubtitle": "Активирайте GPS, за автоматично изпращане на данни за местоположението (ако е поддържано).", - "settings_locationIntervalInvalid": "Интервалът трябва да бъде поне 60 секунди и по-малко от 86400 секунди.", - "settings_locationGPSEnable": "Активиране на GPS" + "settings_locationIntervalSec": "Интервал за GPS (Секунди)", + "settings_locationGPSEnable": "Активиране на GPS", + "settings_locationGPSEnableSubtitle": "Активирайте автоматичното актуализиране на местоположението чрез GPS.", + "settings_locationIntervalInvalid": "Интервалът трябва да бъде поне 60 секунди и по-малко от 86400 секунди." } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index dead738f..a208393e 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1350,7 +1350,7 @@ "channels_enterHashtag": "Gib Hashtag ein", "channels_hashtagHint": "z.B. #team", "settings_locationGPSEnable": "GPS aktivieren", - "settings_locationGPSEnableSubtitle": "Aktivieren Sie GPS, um Standortdaten automatisch zu senden (falls unterstützt).", - "settings_locationIntervalSec": "Zeitintervall (Sekunden)", - "settings_locationIntervalInvalid": "Der Zeitraum muss mindestens 60 Sekunden betragen und weniger als 86400 Sekunden sein." + "settings_locationGPSEnableSubtitle": "Aktivieren Sie die automatische Aktualisierung der Standortdaten per GPS.", + "settings_locationIntervalInvalid": "Der Zeitraum muss mindestens 60 Sekunden betragen und weniger als 86400 Sekunden sein.", + "settings_locationIntervalSec": "Zeitintervall für GPS (Sekunden)" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9b2547cb..988a8764 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -87,8 +87,8 @@ "settings_locationBothRequired": "Enter both latitude and longitude.", "settings_locationInvalid": "Invalid latitude or longitude.", "settings_locationGPSEnable": "GPS Enable", - "settings_locationGPSEnableSubtitle": "Enable GPS to automatically send location data (if supported)", - "settings_locationIntervalSec": "Interval (Seconds)", + "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_locationUpdated": "GPS settings updated.", "settings_latitude": "Latitude", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index bf90fe4e..3c4453a3 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1349,8 +1349,8 @@ "channels_scanQrCodeComingSoon": "Próximamente", "channels_enterHashtag": "Introducir hashtag", "channels_hashtagHint": "ej. #equipo", - "settings_locationGPSEnableSubtitle": "Habilitar GPS para enviar automáticamente datos de ubicación (si está disponible).", "settings_locationGPSEnable": "Habilitar GPS", - "settings_locationIntervalSec": "Intervalo (Segundos)", + "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." } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 82d73d7a..13cde8ed 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1349,8 +1349,8 @@ "channels_scanQrCodeComingSoon": "Bientôt disponible", "channels_enterHashtag": "Entrez le hashtag", "channels_hashtagHint": "ex. #équipe", + "settings_locationGPSEnableSubtitle": "Activer la mise à jour automatique de la position grâce au GPS.", + "settings_locationIntervalInvalid": "L'intervalle doit être d’au moins 60 secondes et inférieur à 86400 secondes.", "settings_locationGPSEnable": "Activer le GPS", - "settings_locationGPSEnableSubtitle": "Activer le GPS pour envoyer automatiquement les données de localisation (si pris en charge).", - "settings_locationIntervalSec": "Intervalle (Secondes)", - "settings_locationIntervalInvalid": "L'intervalle doit être d'au moins 60 secondes et inférieur à 86400 secondes." + "settings_locationIntervalSec": "Intervalle GPS (Secondes)" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index f9828e54..d447c505 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1349,8 +1349,8 @@ "channels_scanQrCodeComingSoon": "Arriverà presto", "channels_enterHashtag": "Inserisci hashtag", "channels_hashtagHint": "es. #team", - "settings_locationGPSEnableSubtitle": "Abilita il GPS per inviare automaticamente i dati di posizione (se supportato).", "settings_locationGPSEnable": "Abilita GPS", - "settings_locationIntervalSec": "Intervallo (Secondi)", + "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." } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index cfe77e2c..2916b914 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -489,13 +489,13 @@ abstract class AppLocalizations { /// No description provided for @settings_locationGPSEnableSubtitle. /// /// In en, this message translates to: - /// **'Enable GPS to automatically send location data (if supported)'** + /// **'Enables GPS to automatically send location data (if supported)'** String get settings_locationGPSEnableSubtitle; /// No description provided for @settings_locationIntervalSec. /// /// In en, this message translates to: - /// **'Interval (Seconds)'** + /// **'Interval for GPS (Seconds)'** String get settings_locationIntervalSec; /// No description provided for @settings_locationIntervalInvalid. diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index e7ea0cf2..a7e88cbd 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -204,10 +204,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settings_locationGPSEnableSubtitle => - 'Enable GPS to automatically send location data (if supported)'; + 'Enables GPS to automatically send location data (if supported)'; @override - String get settings_locationIntervalSec => 'Interval (Seconds)'; + String get settings_locationIntervalSec => 'Interval for GPS (Seconds)'; @override String get settings_locationIntervalInvalid => diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index a0ddfe87..5f2e2f70 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1350,7 +1350,7 @@ "channels_enterHashtag": "Voer hashtag in", "channels_hashtagHint": "bijv. #team", "settings_locationGPSEnable": "GPS inschakelen", - "settings_locationGPSEnableSubtitle": "Zijze GPS inschakelen om locatiegegevens automatisch te verzenden (indien ondersteund).", + "settings_locationIntervalSec": "Interval voor GPS (Seconden)", "settings_locationIntervalInvalid": "De intervallen moeten minstens 60 seconden zijn en minder dan 86400 seconden.", - "settings_locationIntervalSec": "Interval (Seconden)" + "settings_locationGPSEnableSubtitle": "Activeer automatisch locatieupdates via GPS." } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 23938a5a..15ce2b85 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1350,7 +1350,7 @@ "channels_enterHashtag": "Wprowadź hashtag", "channels_hashtagHint": "np. #zespół", "settings_locationGPSEnable": "Włącz GPS", - "settings_locationGPSEnableSubtitle": "Włącz GPS, aby automatycznie wysyłać dane o lokalizacji (jeśli jest obsługiwane).", - "settings_locationIntervalSec": "Interwał (Sekundy)", + "settings_locationIntervalSec": "Interwał dla GPS (Sekundy)", + "settings_locationGPSEnableSubtitle": "Włącza automatyczne aktualizowanie pozycji za pomocą GPS.", "settings_locationIntervalInvalid": "Interwał musi wynosić co najmniej 60 sekund i mniej niż 86400 sekund." } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index b49f2861..0b09582e 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1350,7 +1350,7 @@ "channels_enterHashtag": "Insira hashtag", "channels_hashtagHint": "ex. #equipe", "settings_locationGPSEnable": "Ativar GPS", - "settings_locationGPSEnableSubtitle": "Habilite o GPS para enviar dados de localização automaticamente (se suportado).", - "settings_locationIntervalSec": "Intervalo (Segundos)", - "settings_locationIntervalInvalid": "O intervalo deve ser de pelo menos 60 segundos e inferior a 86400 segundos." + "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)" } diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index aa242c37..06de0240 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -1350,7 +1350,7 @@ "channels_enterHashtag": "Zadajte hashtag", "channels_hashtagHint": "napr. #tím", "settings_locationGPSEnable": "Aktivovať GPS", - "settings_locationGPSEnableSubtitle": "Zapnite GPS na automatické posielanie dát o polohe (ak je podporované).", - "settings_locationIntervalSec": "Interval (Sekundy)", + "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." } diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index e078d409..05e99b7f 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -1350,7 +1350,7 @@ "channels_enterHashtag": "Vnesite hashtag", "channels_hashtagHint": "npr. #ekipa", "settings_locationGPSEnable": "Omogoči GPS", - "settings_locationGPSEnableSubtitle": "Omogoči GPS za samodejno pošiljanje podatkov o lokaciji (če je podprto).", - "settings_locationIntervalSec": "Interval (Sekunde)", - "settings_locationIntervalInvalid": "Intervallo mora biti vsaj 60 sekund in manj kot 86400 sekund." + "settings_locationGPSEnableSubtitle": "Omogoči samodejno posodabljanje lokacije z GPS-jem.", + "settings_locationIntervalInvalid": "Intervallo mora biti vsaj 60 sekund in manj kot 86400 sekund.", + "settings_locationIntervalSec": "Interval za GPS (Sekunde)" } diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index 2f580cbd..deef6c63 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -1349,8 +1349,8 @@ "channels_scanQrCodeComingSoon": "Kommer snart", "channels_enterHashtag": "Ange hashtag", "channels_hashtagHint": "t.ex. #team", + "settings_locationGPSEnableSubtitle": "Aktiverar automatiska uppdateringar av platsen med hjälp av GPS.", "settings_locationGPSEnable": "Aktivera GPS", - "settings_locationIntervalSec": "Tidsintervall (Sekunder)", - "settings_locationGPSEnableSubtitle": "Aktivera GPS för att automatiskt skicka platsdata (om det stöds).", + "settings_locationIntervalSec": "Interval för GPS (Sekunder)", "settings_locationIntervalInvalid": "Intervalet måste vara minst 60 sekunder och mindre än 86400 sekunder." } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index fa9e64d4..d0f4dce1 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1349,8 +1349,8 @@ "channels_scanQrCodeComingSoon": "即将到来", "channels_enterHashtag": "输入标签", "channels_hashtagHint": "例如 #团队", - "settings_locationGPSEnableSubtitle": "启用GPS自动发送位置数据(如果支持)。", "settings_locationGPSEnable": "启用GPS", - "settings_locationIntervalSec": "时间间隔(秒)", + "settings_locationGPSEnableSubtitle": "启用GPS自动更新位置。", + "settings_locationIntervalSec": "GPS 间隔(秒)", "settings_locationIntervalInvalid": "时间间隔必须至少为60秒,且小于86400秒。" } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index d925c30b..c4ce483b 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'; @@ -435,7 +436,13 @@ class _SettingsScreenState extends State { intervalController.text = "900"; latController.text = connector.selfLatitude?.toStringAsFixed(6) ?? ''; lonController.text = connector.selfLongitude?.toStringAsFixed(6) ?? ''; - bool isGPSEnabled = false; + bool hasGPS = connector.currentCustomVars!.isNotEmpty + ? connector.currentCustomVars!.containsKey("gps") + : false; + + bool isGPSEnabled = hasGPS + ? connector.currentCustomVars!["gps"] == "1" + : false; showDialog( context: context, @@ -445,48 +452,30 @@ class _SettingsScreenState extends State { content: Column( mainAxisSize: MainAxisSize.min, children: [ - if (!isGPSEnabled) ...[ - TextField( - controller: latController, - decoration: InputDecoration( - labelText: l10n.settings_latitude, - border: const OutlineInputBorder(), - ), - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - signed: true, - ), + TextField( + controller: latController, + decoration: InputDecoration( + labelText: l10n.settings_latitude, + border: const OutlineInputBorder(), ), - const SizedBox(height: 16), - TextField( - controller: lonController, - decoration: InputDecoration( - labelText: l10n.settings_longitude, - border: const OutlineInputBorder(), - ), - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - signed: true, - ), - ), - ], - const SizedBox(height: 16), - CheckboxListTile( - value: isGPSEnabled, - enabled: true, - onChanged: (v) => - setDialogState(() => isGPSEnabled = v ?? false), - //controlAffinity: ListTileControlAffinity.leading, - title: Text( - l10n.settings_locationGPSEnable, - style: TextStyle(fontSize: 12), - ), - subtitle: Text( - l10n.settings_locationGPSEnableSubtitle, - style: TextStyle(fontSize: 10), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + signed: true, ), ), - if (isGPSEnabled) ...{ + 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, @@ -499,7 +488,21 @@ class _SettingsScreenState extends State { 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: [ @@ -510,11 +513,13 @@ class _SettingsScreenState extends State { TextButton( onPressed: () async { Navigator.pop(context); - if (isGPSEnabled) { + + if (hasGPS) { final intervalText = intervalController.text.trim(); if (intervalText.isEmpty) { return; } + final interval = int.tryParse(intervalText); if (interval == null || interval < 60) { if (!context.mounted) return; @@ -525,53 +530,50 @@ class _SettingsScreenState extends State { ); return; } - await connector.setCustomVar("gps:1"); + await connector.setCustomVar("gps_interval:$interval"); await connector.refreshDeviceInfo(); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l10n.settings_locationUpdated)), ); - } else { - 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; - } + final latText = latController.text.trim(); + final lonText = lonController.text.trim(); + if (latText.isEmpty && lonText.isEmpty) { + return; + } - await connector.setCustomVar("gps:0"); - await connector.setNodeLocation(lat: lat, lon: lon); - await connector.refreshDeviceInfo(); + 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_locationUpdated)), + 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), ), 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, + ), + ], + ); + } +} From 3fef594fe579ce373dfbe40bff904db12714500c Mon Sep 17 00:00:00 2001 From: zjs81 Date: Mon, 19 Jan 2026 18:56:06 -0700 Subject: [PATCH 6/8] localization: update GPS settings messages and improve handling of custom variables --- lib/l10n/app_en.arb | 3 +-- lib/screens/settings_screen.dart | 17 +++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7224b228..cc470776 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -83,14 +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_locationUpdated": "GPS settings updated.", "settings_latitude": "Latitude", "settings_longitude": "Longitude", "settings_privacyMode": "Privacy Mode", diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index c4ce483b..c6a85d79 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -433,16 +433,17 @@ class _SettingsScreenState extends State { final latController = TextEditingController(); final lonController = TextEditingController(); final intervalController = TextEditingController(); - intervalController.text = "900"; latController.text = connector.selfLatitude?.toStringAsFixed(6) ?? ''; lonController.text = connector.selfLongitude?.toStringAsFixed(6) ?? ''; - bool hasGPS = connector.currentCustomVars!.isNotEmpty - ? connector.currentCustomVars!.containsKey("gps") - : false; - bool isGPSEnabled = hasGPS - ? connector.currentCustomVars!["gps"] == "1" - : false; + // 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, @@ -521,7 +522,7 @@ class _SettingsScreenState extends State { } final interval = int.tryParse(intervalText); - if (interval == null || interval < 60) { + if (interval == null || interval < 60 || interval >= 86400) { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( From 9cb667fad08c68d26d30ff037f7396e739bf3d14 Mon Sep 17 00:00:00 2001 From: zjs81 Date: Mon, 19 Jan 2026 19:00:24 -0700 Subject: [PATCH 7/8] localization: fix punctuation in GPS interval settings for Spanish and Portuguese --- lib/l10n/app_es.arb | 2 +- lib/l10n/app_pt.arb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 03ea4ddb..571bc65f 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1352,7 +1352,7 @@ "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." + "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_pt.arb b/lib/l10n/app_pt.arb index 8b957691..6f6471f7 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1352,7 +1352,7 @@ "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)" + "settings_locationIntervalSec": "Intervalo para GPS (Segundos)", "contacts_manageRoom": "Gerenciar Servidor de Sala", "room_management": "Gerenciamento de Servidor de Sala" } From 9a9f59e53f2fc56acaf3cb5be5afea926dac7f9f Mon Sep 17 00:00:00 2001 From: zjs81 Date: Mon, 19 Jan 2026 19:00:30 -0700 Subject: [PATCH 8/8] localization: update GPS settings messages for clarity and consistency across multiple languages --- lib/l10n/app_localizations.dart | 4 ++-- lib/l10n/app_localizations_bg.dart | 4 ++-- lib/l10n/app_localizations_de.dart | 12 ++++++------ lib/l10n/app_localizations_en.dart | 4 ++-- lib/l10n/app_localizations_es.dart | 4 ++-- lib/l10n/app_localizations_fr.dart | 12 ++++++------ lib/l10n/app_localizations_it.dart | 4 ++-- lib/l10n/app_localizations_nl.dart | 4 ++-- lib/l10n/app_localizations_pl.dart | 4 ++-- lib/l10n/app_localizations_pt.dart | 4 ++-- lib/l10n/app_localizations_sk.dart | 6 +++--- lib/l10n/app_localizations_sl.dart | 12 ++++++------ lib/l10n/app_localizations_sv.dart | 4 ++-- lib/l10n/app_localizations_zh.dart | 4 ++-- 14 files changed, 41 insertions(+), 41 deletions(-) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index fc0e8e77..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: - /// **'GPS settings updated.'** + /// **'Location and GPS settings updated'** String get settings_locationUpdated; /// No description provided for @settings_locationBothRequired. @@ -489,7 +489,7 @@ abstract class AppLocalizations { /// No description provided for @settings_locationGPSEnableSubtitle. /// /// In en, this message translates to: - /// **'Enables GPS to automatically send location data (if supported)'** + /// **'Enables GPS to automatically update location.'** String get settings_locationGPSEnableSubtitle; /// No description provided for @settings_locationIntervalSec. diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 5627eea9..7cbb2d6d 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -206,10 +206,10 @@ class AppLocalizationsBg extends AppLocalizations { @override String get settings_locationGPSEnableSubtitle => - 'Активирайте GPS, за автоматично изпращане на данни за местоположението (ако е поддържано).'; + 'Активирайте автоматичното актуализиране на местоположението чрез GPS.'; @override - String get settings_locationIntervalSec => 'Интервал (Секунди)'; + String get settings_locationIntervalSec => 'Интервал за GPS (Секунди)'; @override String get settings_locationIntervalInvalid => diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 09290c56..5107d235 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -201,18 +201,18 @@ class AppLocalizationsDe extends AppLocalizations { String get settings_locationInvalid => 'Ungültige Breiten- oder Längengrade.'; @override - String get settings_locationGPSEnable => 'GPS aktivieren'; + String get settings_locationGPSEnable => 'GPS Enable'; @override String get settings_locationGPSEnableSubtitle => - 'Aktivieren Sie GPS, um Standortdaten automatisch zu senden (falls unterstützt).'; + 'Enables GPS to automatically update location.'; @override - String get settings_locationIntervalSec => 'Zeitintervall (Sekunden)'; + String get settings_locationIntervalSec => 'Interval for GPS (Seconds)'; @override String get settings_locationIntervalInvalid => - 'Der Zeitraum muss mindestens 60 Sekunden betragen und weniger als 86400 Sekunden sein.'; + 'Interval must be at least 60 seconds, and less than 86400 seconds.'; @override String get settings_latitude => 'Breitengrad'; @@ -662,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'; @@ -1604,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 6dcaebff..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 => 'GPS settings updated.'; + String get settings_locationUpdated => 'Location and GPS settings updated'; @override String get settings_locationBothRequired => @@ -204,7 +204,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settings_locationGPSEnableSubtitle => - 'Enables GPS to automatically send location data (if supported)'; + 'Enables GPS to automatically update location.'; @override String get settings_locationIntervalSec => 'Interval for GPS (Seconds)'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index e70fc6ad..8ab2c19f 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -205,10 +205,10 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settings_locationGPSEnableSubtitle => - 'Habilitar GPS para enviar automáticamente datos de ubicación (si está disponible).'; + 'Habilita la actualización automática de la ubicación mediante GPS.'; @override - String get settings_locationIntervalSec => 'Intervalo (Segundos)'; + String get settings_locationIntervalSec => 'Intervalo para GPS (Segundos)'; @override String get settings_locationIntervalInvalid => diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 587043d8..224f6a17 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -201,18 +201,18 @@ class AppLocalizationsFr extends AppLocalizations { String get settings_locationInvalid => 'Latitude ou longitude invalide.'; @override - String get settings_locationGPSEnable => 'Activer le GPS'; + String get settings_locationGPSEnable => 'Habilita GPS'; @override String get settings_locationGPSEnableSubtitle => - 'Activer le GPS pour envoyer automatiquement les données de localisation (si pris en charge).'; + 'Habilita la actualización automática de la ubicación mediante GPS.'; @override - String get settings_locationIntervalSec => 'Intervalle (Secondes)'; + String get settings_locationIntervalSec => 'Intervalo pour GPS (Segundos)'; @override String get settings_locationIntervalInvalid => - 'L\'intervalle doit être d\'au moins 60 secondes et inférieur à 86400 secondes.'; + 'El intervalo debe ser de al menos 60 segundos y menor que 86400 segundos.'; @override String get settings_latitude => 'Latitude'; @@ -664,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'; @@ -1609,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 c103eb52..b034425e 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -205,10 +205,10 @@ class AppLocalizationsIt extends AppLocalizations { @override String get settings_locationGPSEnableSubtitle => - 'Abilita il GPS per inviare automaticamente i dati di posizione (se supportato).'; + 'Abilita l\'aggiornamento automatico della posizione tramite GPS.'; @override - String get settings_locationIntervalSec => 'Intervallo (Secondi)'; + String get settings_locationIntervalSec => 'Intervallo GPS (Secondi)'; @override String get settings_locationIntervalInvalid => diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 0b75744e..a938b50d 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -205,10 +205,10 @@ class AppLocalizationsNl extends AppLocalizations { @override String get settings_locationGPSEnableSubtitle => - 'Zijze GPS inschakelen om locatiegegevens automatisch te verzenden (indien ondersteund).'; + 'Activeer automatisch locatieupdates via GPS.'; @override - String get settings_locationIntervalSec => 'Interval (Seconden)'; + String get settings_locationIntervalSec => 'Interval voor GPS (Seconden)'; @override String get settings_locationIntervalInvalid => diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index c8a11f62..adb1f620 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -207,10 +207,10 @@ class AppLocalizationsPl extends AppLocalizations { @override String get settings_locationGPSEnableSubtitle => - 'Włącz GPS, aby automatycznie wysyłać dane o lokalizacji (jeśli jest obsługiwane).'; + 'Włącza automatyczne aktualizowanie pozycji za pomocą GPS.'; @override - String get settings_locationIntervalSec => 'Interwał (Sekundy)'; + String get settings_locationIntervalSec => 'Interwał dla GPS (Sekundy)'; @override String get settings_locationIntervalInvalid => diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 339a77c6..6bea4b7c 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -206,10 +206,10 @@ class AppLocalizationsPt extends AppLocalizations { @override String get settings_locationGPSEnableSubtitle => - 'Habilite o GPS para enviar dados de localização automaticamente (se suportado).'; + 'Habilita a atualização automática da localização via GPS.'; @override - String get settings_locationIntervalSec => 'Intervalo (Segundos)'; + String get settings_locationIntervalSec => 'Intervalo para GPS (Segundos)'; @override String get settings_locationIntervalInvalid => diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 20a79439..0c261848 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -205,10 +205,10 @@ class AppLocalizationsSk extends AppLocalizations { @override String get settings_locationGPSEnableSubtitle => - 'Zapnite GPS na automatické posielanie dát o polohe (ak je podporované).'; + 'Povolí automatické aktualizovanie polohy pomocou GPS.'; @override - String get settings_locationIntervalSec => 'Interval (Sekundy)'; + String get settings_locationIntervalSec => 'Interval pre GPS (Sekundy)'; @override String get settings_locationIntervalInvalid => @@ -1117,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 571ddcf0..c33c9d6b 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -205,10 +205,10 @@ class AppLocalizationsSl extends AppLocalizations { @override String get settings_locationGPSEnableSubtitle => - 'Omogoči GPS za samodejno pošiljanje podatkov o lokaciji (če je podprto).'; + 'Omogoči samodejno posodabljanje lokacije z GPS-jem.'; @override - String get settings_locationIntervalSec => 'Interval (Sekunde)'; + String get settings_locationIntervalSec => 'Interval za GPS (Sekunde)'; @override String get settings_locationIntervalInvalid => @@ -449,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 => @@ -645,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'; @@ -697,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.'; @@ -1203,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 59b322a5..d876dd9f 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -204,10 +204,10 @@ class AppLocalizationsSv extends AppLocalizations { @override String get settings_locationGPSEnableSubtitle => - 'Aktivera GPS för att automatiskt skicka platsdata (om det stöds).'; + 'Aktivera automatiska uppdateringar av platsen med hjälp av GPS.'; @override - String get settings_locationIntervalSec => 'Tidsintervall (Sekunder)'; + String get settings_locationIntervalSec => 'Interval för GPS (Sekunder)'; @override String get settings_locationIntervalInvalid => diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 0a60655d..f1dd5069 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -200,10 +200,10 @@ class AppLocalizationsZh extends AppLocalizations { String get settings_locationGPSEnable => '启用GPS'; @override - String get settings_locationGPSEnableSubtitle => '启用GPS自动发送位置数据(如果支持)。'; + String get settings_locationGPSEnableSubtitle => '启用GPS自动更新位置。'; @override - String get settings_locationIntervalSec => '时间间隔(秒)'; + String get settings_locationIntervalSec => 'GPS 间隔(秒)'; @override String get settings_locationIntervalInvalid => '时间间隔必须至少为60秒,且小于86400秒。';