diff --git a/.gitignore b/.gitignore index 2d9a3fc..157c7ec 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ migrate_working_dir/ .flutter-plugins-dependencies .pub-cache/ .pub/ +pubspec.lock /build/ /coverage/ diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..fcdb2e1 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +4.0.0 diff --git a/README.md b/README.md index bad9b6c..10fb0a5 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh - ✅ **Android**: Full support (API 21+) - ✅ **iOS**: Full support (iOS 12+) - 🚧 **Desktop**: Limited support (macOS/Linux/Windows) +- 🚧 **Web**: Under construction (Chrome) ### Dependencies diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index e7d2b42..e0a8029 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -19,13 +19,13 @@ android { ndkVersion = flutter.ndkVersion compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 isCoreLibraryDesugaringEnabled = true } kotlinOptions { - jvmTarget = JavaVersion.VERSION_11.toString() + jvmTarget = JavaVersion.VERSION_17.toString() } defaultConfig { diff --git a/assets/icons/done_all.svg b/assets/icons/done_all.svg new file mode 100644 index 0000000..bfeeec0 --- /dev/null +++ b/assets/icons/done_all.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index f74d524..ef19f02 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -29,6 +29,7 @@ import '../storage/contact_store.dart'; import '../storage/message_store.dart'; import '../storage/unread_store.dart'; import '../utils/app_logger.dart'; +import '../utils/battery_utils.dart'; import 'meshcore_protocol.dart'; class MeshCoreUuids { @@ -37,6 +38,42 @@ class MeshCoreUuids { static const String txCharacteristic = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"; } +class DirectRepeater { + static const int maxAgeMinutes = 30; // Max age for direct repeater info + final int pubkeyFirstByte; + double snr; + DateTime lastUpdated; + + DirectRepeater({ + required this.pubkeyFirstByte, + required this.snr, + DateTime? lastUpdated, + }) : lastUpdated = lastUpdated ?? DateTime.now(); + + void update(double newSNR) { + snr = newSNR; + lastUpdated = DateTime.now(); + } + + int get ranking { + if (isStale()) { + return -1; // Stale repeaters get lowest rank + } + // Higher SNR gets higher rank and recency within maxAgeMinutes breaks ties. + final ageMs = + DateTime.now().millisecondsSinceEpoch - + lastUpdated.millisecondsSinceEpoch; + final maxAgeMs = maxAgeMinutes * 60 * 1000; + final recencyScore = (maxAgeMs - ageMs).clamp(0, maxAgeMs); + return ((snr - 31.75) * 1000).round() + recencyScore; + } + + bool isStale() { + return DateTime.now().difference(lastUpdated) > + const Duration(minutes: maxAgeMinutes); + } +} + enum MeshCoreConnectionState { disconnected, scanning, @@ -45,6 +82,18 @@ enum MeshCoreConnectionState { disconnecting, } +class RepeaterBatterySnapshot { + final int millivolts; + final DateTime updatedAt; + final String source; + + const RepeaterBatterySnapshot({ + required this.millivolts, + required this.updatedAt, + required this.source, + }); +} + class MeshCoreConnector extends ChangeNotifier { // Message windowing to limit memory usage static const int _messageWindowSize = 200; @@ -65,6 +114,10 @@ class MeshCoreConnector extends ChangeNotifier { final List _channels = []; final Map> _conversations = {}; final Map> _channelMessages = {}; + final List _pendingChannelSentQueue = []; + final List<_PendingCommandAck> _pendingGenericAckQueue = []; + static const String _reactionSendQueuePrefix = '__reaction_send__'; + int _reactionSendQueueSequence = 0; final Set _loadedConversationKeys = {}; final Map> _processedChannelReactions = {}; // channelIndex -> Set of "targetHash_emoji" @@ -95,6 +148,7 @@ class MeshCoreConnector extends ChangeNotifier { int? _batteryMillivolts; double? _selfLatitude; double? _selfLongitude; + final List _directRepeaters = List.empty(growable: true); bool _isLoadingContacts = false; bool _isLoadingChannels = false; bool _hasLoadedChannels = false; @@ -150,6 +204,7 @@ class MeshCoreConnector extends ChangeNotifier { final Map _contactSmazEnabled = {}; final Set _knownContactKeys = {}; final Map _contactUnreadCount = {}; + final Map _repeaterBatterySnapshots = {}; bool _unreadStateLoaded = false; final Map _pendingRepeaterAcks = {}; String? _activeContactKey; @@ -196,6 +251,7 @@ class MeshCoreConnector extends ChangeNotifier { String? get selfName => _selfName; double? get selfLatitude => _selfLatitude; double? get selfLongitude => _selfLongitude; + List get directRepeaters => _directRepeaters; int? get currentTxPower => _currentTxPower; int? get maxTxPower => _maxTxPower; int? get currentFreqHz => _currentFreqHz; @@ -216,10 +272,32 @@ class MeshCoreConnector extends ChangeNotifier { : 0; int? get batteryPercent => _batteryMillivolts == null ? null - : _estimateBatteryPercent( + : estimateBatteryPercentFromMillivolts( _batteryMillivolts!, _batteryChemistryForDevice(), ); + RepeaterBatterySnapshot? getRepeaterBatterySnapshot(String contactKeyHex) => + _repeaterBatterySnapshots[contactKeyHex]; + int? getRepeaterBatteryMillivolts(String contactKeyHex) => + _repeaterBatterySnapshots[contactKeyHex]?.millivolts; + + void updateRepeaterBatterySnapshot( + String contactKeyHex, + int millivolts, { + String source = 'unknown', + }) { + if (contactKeyHex.isEmpty || millivolts <= 0) return; + final previous = _repeaterBatterySnapshots[contactKeyHex]; + final snapshot = RepeaterBatterySnapshot( + millivolts: millivolts, + updatedAt: DateTime.now(), + source: source, + ); + _repeaterBatterySnapshots[contactKeyHex] = snapshot; + if (previous?.millivolts != millivolts) { + notifyListeners(); + } + } String _batteryChemistryForDevice() { final deviceId = _device?.remoteId.toString(); @@ -227,27 +305,6 @@ class MeshCoreConnector extends ChangeNotifier { return _appSettingsService!.batteryChemistryForDevice(deviceId); } - int _estimateBatteryPercent(int millivolts, String chemistry) { - final range = _batteryVoltageRange(chemistry); - final minMv = range.$1; - final maxMv = range.$2; - if (millivolts <= minMv) return 0; - if (millivolts >= maxMv) return 100; - return (((millivolts - minMv) * 100) / (maxMv - minMv)).round(); - } - - (int, int) _batteryVoltageRange(String chemistry) { - switch (chemistry) { - case 'lifepo4': - return (2600, 3650); - case 'lipo': - return (3000, 4200); - case 'nmc': - default: - return (3000, 4200); - } - } - List getMessages(Contact contact) { return _conversations[contact.publicKeyHex] ?? []; } @@ -612,6 +669,7 @@ class MeshCoreConnector extends ChangeNotifier { publicKey: contact.publicKey, name: contact.name, type: contact.type, + flags: contact.flags, pathLength: selection.hopCount >= 0 ? selection.hopCount : contact.pathLength, @@ -923,6 +981,7 @@ class MeshCoreConnector extends ChangeNotifier { _clientRepeat = null; _firmwareVerCode = null; _batteryMillivolts = null; + _repeaterBatterySnapshots.clear(); _batteryRequested = false; _awaitingSelfInfo = false; _maxContacts = _defaultMaxContacts; @@ -934,6 +993,9 @@ class MeshCoreConnector extends ChangeNotifier { _isSyncingChannels = false; _channelSyncInFlight = false; _hasLoadedChannels = false; + _pendingChannelSentQueue.clear(); + _pendingGenericAckQueue.clear(); + _reactionSendQueueSequence = 0; _setState(MeshCoreConnectionState.disconnected); if (!manual) { @@ -941,7 +1003,11 @@ class MeshCoreConnector extends ChangeNotifier { } } - Future sendFrame(Uint8List data) async { + Future sendFrame( + Uint8List data, { + String? channelSendQueueId, + bool expectsGenericAck = false, + }) async { if (!isConnected || _rxCharacteristic == null) { throw Exception("Not connected to a MeshCore device"); } @@ -960,6 +1026,11 @@ class MeshCoreConnector extends ChangeNotifier { data.toList(), withoutResponse: canWriteWithoutResponse, ); + _trackPendingGenericAck( + data, + channelSendQueueId: channelSendQueueId, + expectsGenericAck: expectsGenericAck, + ); } Future requestBatteryStatus({bool force = false}) async { @@ -1115,11 +1186,78 @@ class MeshCoreConnector extends ChangeNotifier { customPath, pathLen, type: contact.type, + flags: contact.flags, name: contact.name, ), ); } + Future setContactFavorite(Contact contact, bool isFavorite) async { + if (!isConnected) return; + final latestContact = + await _fetchContactSnapshotFromDevice(contact.publicKey) ?? contact; + final updatedFlags = isFavorite + ? (latestContact.flags | contactFlagFavorite) + : (latestContact.flags & ~contactFlagFavorite); + + await sendFrame( + buildUpdateContactPathFrame( + latestContact.publicKey, + latestContact.path, + latestContact.pathLength, + type: latestContact.type, + flags: updatedFlags, + name: latestContact.name, + ), + ); + + final index = _contacts.indexWhere( + (c) => c.publicKeyHex == contact.publicKeyHex, + ); + if (index >= 0) { + _contacts[index] = _contacts[index].copyWith( + type: latestContact.type, + name: latestContact.name, + pathLength: latestContact.pathLength, + path: latestContact.path, + flags: updatedFlags, + ); + notifyListeners(); + unawaited(_persistContacts()); + } + } + + Future _fetchContactSnapshotFromDevice( + Uint8List pubKey, { + Duration timeout = const Duration(seconds: 3), + }) async { + if (!isConnected) return null; + final expectedKeyHex = pubKeyToHex(pubKey); + final completer = Completer(); + + void finish(Contact? result) { + if (!completer.isCompleted) { + completer.complete(result); + } + } + + final subscription = receivedFrames.listen((frame) { + if (frame.isEmpty || frame[0] != respCodeContact) return; + final parsed = Contact.fromFrame(frame); + if (parsed == null || parsed.publicKeyHex != expectedKeyHex) return; + finish(parsed); + }); + + final timer = Timer(timeout, () => finish(null)); + try { + await getContactByKey(pubKey); + return await completer.future; + } finally { + timer.cancel(); + await subscription.cancel(); + } + } + /// Set path override for a contact (persists across contact refreshes) /// pathLen: -1 = force flood, null = auto (use device path), >= 0 = specific path Future setPathOverride( @@ -1315,7 +1453,13 @@ class MeshCoreConnector extends ChangeNotifier { notifyListeners(); // Send the reaction to the device (don't add as a visible message) - await sendFrame(buildSendChannelTextMsgFrame(channel.index, text)); + final reactionQueueId = _nextReactionSendQueueId(); + _pendingChannelSentQueue.add(reactionQueueId); + await sendFrame( + buildSendChannelTextMsgFrame(channel.index, text), + channelSendQueueId: reactionQueueId, + expectsGenericAck: true, + ); return; } @@ -1325,6 +1469,7 @@ class MeshCoreConnector extends ChangeNotifier { channel.index, ); _addChannelMessage(channel.index, message); + _pendingChannelSentQueue.add(message.messageId); notifyListeners(); final trimmed = text.trim(); @@ -1334,7 +1479,11 @@ class MeshCoreConnector extends ChangeNotifier { (isChannelSmazEnabled(channel.index) && !isStructuredPayload) ? Smaz.encodeIfSmaller(text) : text; - await sendFrame(buildSendChannelTextMsgFrame(channel.index, outboundText)); + await sendFrame( + buildSendChannelTextMsgFrame(channel.index, outboundText), + channelSendQueueId: message.messageId, + expectsGenericAck: true, + ); } Future removeContact(Contact contact) async { @@ -1681,6 +1830,9 @@ class MeshCoreConnector extends ChangeNotifier { debugPrint('RX frame: code=$code len=${frame.length}'); switch (code) { + case respCodeOk: + _handleOk(); + break; case respCodeDeviceInfo: _handleDeviceInfo(frame); break; @@ -1696,6 +1848,11 @@ class MeshCoreConnector extends ChangeNotifier { _isLoadingContacts = true; notifyListeners(); break; + case pushCodeNewAdvert: + debugPrint('Got New CONTACT'); + // It's the same format as respCodeContact, so we can reuse the handler + _handleContact(frame); + break; case respCodeContact: debugPrint('Got CONTACT'); _handleContact(frame); @@ -1740,6 +1897,7 @@ class MeshCoreConnector extends ChangeNotifier { case pushCodeStatusResponse: break; case pushCodeLogRxData: + _handleRxData(frame); _handleLogRxData(frame); break; case respCodeChannelInfo: @@ -1769,6 +1927,17 @@ class MeshCoreConnector extends ChangeNotifier { 'Firmware responded with error code: $errCode', tag: 'Protocol', ); + + if (_pendingGenericAckQueue.isEmpty) { + return; + } + + final failedAck = _pendingGenericAckQueue.removeAt(0); + if (failedAck.commandCode != cmdSendChannelTxtMsg || + failedAck.channelSendQueueId == null) { + return; + } + _pendingChannelSentQueue.remove(failedAck.channelSendQueueId); } void _handlePathUpdated(Uint8List frame) { @@ -2028,6 +2197,80 @@ class MeshCoreConnector extends ChangeNotifier { } } + void _handleContactAdvert(Contact contact) { + if (listEquals(contact.publicKey, _selfPublicKey)) { + return; + } + + if (contact.type == advTypeRepeater) { + _contactUnreadCount.remove(contact.publicKeyHex); + _unreadStore.saveContactUnreadCount( + Map.from(_contactUnreadCount), + ); + } + // Check if this is a new contact + final isNewContact = !_knownContactKeys.contains(contact.publicKeyHex); + final existingIndex = _contacts.indexWhere( + (c) => c.publicKeyHex == contact.publicKeyHex, + ); + + if (existingIndex >= 0) { + final existing = _contacts[existingIndex]; + 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', + ); + + // CRITICAL: Preserve user's path override when contact is refreshed from device + _contacts[existingIndex] = contact.copyWith( + lastMessageAt: mergedLastMessageAt, + pathOverride: existing.pathOverride, // Preserve user's path choice + pathOverrideBytes: existing.pathOverrideBytes, + ); + + 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', + ); + } + _knownContactKeys.add(contact.publicKeyHex); + _loadMessagesForContact(contact.publicKeyHex); + + // Add path to history if we have a valid path + if (_pathHistoryService != null && contact.pathLength >= 0) { + _pathHistoryService!.handlePathUpdated(contact); + } + + notifyListeners(); + + // Show notification for new contact (advertisement) + if (isNewContact && _appSettingsService != null) { + final settings = _appSettingsService!.settings; + if (settings.notificationsEnabled && settings.notifyOnNewAdvert) { + _notificationService.showAdvertNotification( + contactName: contact.name, + contactType: contact.typeLabel, + contactId: contact.publicKeyHex, + ); + } + } + + if (!_isLoadingContacts) { + unawaited(_persistContacts()); + } + } + Future _persistContacts() async { await _contactStore.saveContacts(_contacts); } @@ -2354,6 +2597,8 @@ class MeshCoreConnector extends ChangeNotifier { } final label = channelName ?? _channelDisplayName(channelIndex); + if (_appSettingsService!.isChannelMuted(label)) return; + _notificationService.showChannelMessageNotification( channelName: label, message: message.text, @@ -2477,8 +2722,22 @@ class MeshCoreConnector extends ChangeNotifier { return; } - if (_retryService != null) { - _retryService!.updateMessageFromSent(ackHash, timeoutMs); + final retryService = _retryService; + if (retryService != null && + retryService.updateMessageFromSent( + ackHash, + timeoutMs, + allowQueueFallback: false, + )) { + return; + } + + if (_markNextPendingChannelMessageSent()) { + return; + } + + if (retryService != null) { + retryService.updateMessageFromSent(ackHash, timeoutMs); } } else { // Fallback to old behavior @@ -2495,6 +2754,64 @@ class MeshCoreConnector extends ChangeNotifier { } } + bool _markNextPendingChannelMessageSent() { + while (_pendingChannelSentQueue.isNotEmpty) { + final queuedMessageId = _pendingChannelSentQueue.removeAt(0); + if (_isReactionSendQueueId(queuedMessageId)) { + return true; + } + if (_markPendingChannelMessageSentById(queuedMessageId)) { + return true; + } + } + return false; + } + + bool _markPendingChannelMessageSentById(String messageId) { + for (final entry in _channelMessages.entries) { + final channelMessages = entry.value; + for (int i = channelMessages.length - 1; i >= 0; i--) { + final message = channelMessages[i]; + if (message.messageId != messageId) { + continue; + } + if (!message.isOutgoing || + message.status != ChannelMessageStatus.pending) { + return false; + } + channelMessages[i] = message.copyWith( + status: ChannelMessageStatus.sent, + ); + _pendingChannelSentQueue.remove(messageId); + unawaited( + _channelMessageStore.saveChannelMessages(entry.key, channelMessages), + ); + notifyListeners(); + return true; + } + } + return false; + } + + void _handleOk() { + if (_pendingGenericAckQueue.isEmpty) { + return; + } + + final pendingAck = _pendingGenericAckQueue.removeAt(0); + if (pendingAck.commandCode != cmdSendChannelTxtMsg || + pendingAck.channelSendQueueId == null) { + return; + } + + final queueId = pendingAck.channelSendQueueId!; + _pendingChannelSentQueue.remove(queueId); + if (_isReactionSendQueueId(queueId)) { + return; + } + _markPendingChannelMessageSentById(queueId); + } + void _handleSendConfirmed(Uint8List frame) { // Frame format from C++: // [0] = PUSH_CODE_SEND_CONFIRMED @@ -3073,18 +3390,22 @@ class MeshCoreConnector extends ChangeNotifier { mergedPathBytes.length, ); final newRepeatCount = existing.repeatCount + 1; + final promotedFromPending = + newRepeatCount == 1 && + existing.status == ChannelMessageStatus.pending; messages[existingIndex] = existing.copyWith( repeatCount: newRepeatCount, pathLength: mergedPathLength, pathBytes: mergedPathBytes, pathVariants: mergedPathVariants, // Mark as sent when first repeat is heard - status: - newRepeatCount == 1 && - existing.status == ChannelMessageStatus.pending + status: promotedFromPending ? ChannelMessageStatus.sent : existing.status, ); + if (promotedFromPending) { + _pendingChannelSentQueue.remove(existing.messageId); + } } else { messages.add(processedMessage); } @@ -3257,11 +3578,37 @@ class MeshCoreConnector extends ChangeNotifier { _queuedMessageSyncInFlight = false; _isSyncingChannels = false; _channelSyncInFlight = false; + _pendingChannelSentQueue.clear(); + _pendingGenericAckQueue.clear(); + _reactionSendQueueSequence = 0; _setState(MeshCoreConnectionState.disconnected); _scheduleReconnect(); } + void _trackPendingGenericAck( + Uint8List data, { + String? channelSendQueueId, + required bool expectsGenericAck, + }) { + if (!expectsGenericAck || data.isEmpty) return; + _pendingGenericAckQueue.add( + _PendingCommandAck( + commandCode: data[0], + channelSendQueueId: channelSendQueueId, + ), + ); + } + + String _nextReactionSendQueueId() { + _reactionSendQueueSequence++; + return '$_reactionSendQueuePrefix$_reactionSendQueueSequence'; + } + + bool _isReactionSendQueueId(String queueId) { + return queueId.startsWith(_reactionSendQueuePrefix); + } + Map _parseKeyValueString(String input) { final result = {}; @@ -3287,7 +3634,11 @@ class MeshCoreConnector extends ChangeNotifier { void _handleCustomVars(Uint8List frame) { final buf = BufferReader(frame.sublist(1)); - _currentCustomVars = _parseKeyValueString(buf.readString()); + try { + _currentCustomVars = _parseKeyValueString(buf.readString()); + } catch (e) { + appLogger.warn('Malformed custom vars frame: $e', tag: 'Connector'); + } } void _setState(MeshCoreConnectionState newState) { @@ -3311,6 +3662,191 @@ class MeshCoreConnector extends ChangeNotifier { super.dispose(); } + + void _handleRxData(Uint8List frame) { + final packet = BufferReader(frame); + double snr = 0.0; + int routeType = 0; + int payloadType = 0; + Uint8List pathBytes = Uint8List(0); + Uint8List payload = Uint8List(0); + try { + packet.skipBytes(1); // Skip frame type byte + snr = packet.readInt8() / 4.0; + packet.skipBytes(1); // Skip RSSI byte + //final rssi = packet.readByte(); + final header = packet.readByte(); + routeType = header & 0x03; + payloadType = (header >> 2) & 0x0F; + //final payloadVer = (header >> 6) & 0x03; + final pathLen = packet.readByte(); + pathBytes = packet.readBytes(pathLen); + payload = packet.readBytes(packet.remaining); + } catch (e) { + appLogger.warn('Malformed RX frame: $e', tag: 'Connector'); + return; + } + + switch (payloadType) { + case payloadTypeADVERT: + _handlePayloadAdvertReceived(payload, pathBytes, routeType, snr); + break; + default: + } + } + + void _handlePayloadAdvertReceived( + Uint8List frame, + Uint8List path, + int routeType, + double snr, + ) { + final advert = BufferReader(frame); + double latitude = 0.0; + double longitude = 0.0; + String name = ''; + String contactKeyHex = ''; + Uint8List publicKey = Uint8List(0); + int type = 0; + int timestamp = 0; + bool hasLocation = false; + bool hasName = false; + try { + publicKey = advert.readBytes(32); + contactKeyHex = publicKey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + + timestamp = advert.readInt32LE(); + //TODO add signature verification + advert.skipBytes(64); // Skip signature for now + final flags = advert.readByte(); + type = flags & 0x0F; + hasLocation = (flags & 0x10) != 0; + // For future use: + //final hasFeature1 = (flags & 0x20) != 0; + //final hasFeature2 = (flags & 0x40) != 0; + hasName = (flags & 0x80) != 0; + if (hasLocation && advert.remaining >= 8) { + latitude = advert.readInt32LE() / 1e6; + longitude = advert.readInt32LE() / 1e6; + } + if (hasName && advert.remaining > 0) { + name = advert.readString(); + } + } catch (e) { + appLogger.warn('Malformed advert frame: $e', tag: 'Connector'); + return; + } + + if (listEquals(publicKey, _selfPublicKey)) { + return; + } + + // Check if this is a new contact + final isNewContact = !_knownContactKeys.contains(contactKeyHex); + + if (isNewContact) { + final newContact = Contact( + publicKey: publicKey, + name: name, + type: type, + pathLength: path.length, + path: Uint8List.fromList( + path.reversed.toList(), + ), // Store path in reverse for easier use in outgoing messages + latitude: latitude, + longitude: longitude, + lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), + ); + _handleContactAdvert(newContact); + _updateDirectRepeater(newContact, snr, path); + return; + } + + final existingIndex = _contacts.indexWhere( + (c) => c.publicKeyHex == contactKeyHex, + ); + + if (existingIndex >= 0) { + final existing = _contacts[existingIndex]; + final mergedLastMessageAt = existing.lastMessageAt.isAfter(DateTime.now()) + ? DateTime.now() + : existing.lastMessageAt; + + appLogger.info( + 'Refreshing contact ${existing.name}: devicePath=${existing.pathLength}, existingOverride=${existing.pathOverride}', + tag: 'Connector', + ); + + // CRITICAL: Preserve user's path override when contact is refreshed from device + _contacts[existingIndex] = existing.copyWith( + latitude: hasLocation ? latitude : existing.latitude, + longitude: hasLocation ? longitude : existing.longitude, + name: hasName ? name : existing.name, + path: Uint8List.fromList(path.reversed.toList()), + pathLength: path.length, + lastMessageAt: mergedLastMessageAt, + lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), + pathOverride: existing.pathOverride, // Preserve user's path choice + pathOverrideBytes: existing.pathOverrideBytes, + ); + + // Add path to history if we have a valid path + if (_pathHistoryService != null && + _contacts[existingIndex].pathLength >= 0) { + _pathHistoryService!.handlePathUpdated(_contacts[existingIndex]); + } + + _updateDirectRepeater(_contacts[existingIndex], snr, path); + + appLogger.info( + 'After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_contacts[existingIndex].pathLength}', + tag: 'Connector', + ); + } + } + + void _updateDirectRepeater(Contact contact, double snr, Uint8List path) { + final pubkeyFirstByte = path.isNotEmpty + ? path.last + : contact.publicKey.first; + + _directRepeaters.removeWhere((r) => r.isStale()); + + //We can use adverts from chat and sensor nodes, but only if the advert has a path to get the last hop. + if ((contact.type == advTypeChat || contact.type == advTypeSensor) && + path.isEmpty) { + notifyListeners(); + return; + } + + final isTracked = _directRepeaters.where( + (r) => r.pubkeyFirstByte == pubkeyFirstByte, + ); + + final sortedRepeaters = List.from(_directRepeaters) + ..sort((a, b) => b.snr.compareTo(a.snr)); + final weakestRepeater = sortedRepeaters.isNotEmpty + ? sortedRepeaters.last + : null; + + if (_directRepeaters.length >= 5 && + weakestRepeater != null && + isTracked.isEmpty) { + _directRepeaters.remove(weakestRepeater); + } + + if (isTracked.isNotEmpty) { + final repeater = isTracked.first; + repeater.update(snr); + } else if (_directRepeaters.length < 5) { + _directRepeaters.add( + DirectRepeater(pubkeyFirstByte: pubkeyFirstByte, snr: snr), + ); + } + notifyListeners(); + } } const int _phRouteMask = 0x03; @@ -3368,3 +3904,10 @@ class _RepeaterAckContext { required this.messageBytes, }); } + +class _PendingCommandAck { + final int commandCode; + final String? channelSendQueueId; + + _PendingCommandAck({required this.commandCode, this.channelSendQueueId}); +} diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index ee83578..d5ce9ee 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -13,12 +13,22 @@ class BufferReader { int readByte() => readBytes(1)[0]; Uint8List readBytes(int count) { + if (_pointer + count > _buffer.length) { + throw RangeError( + 'Attempted to read $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}', + ); + } final data = _buffer.sublist(_pointer, _pointer + count); _pointer += count; return data; } void skipBytes(int count) { + if (_pointer + count > _buffer.length) { + throw RangeError( + 'Attempted to skip $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}', + ); + } _pointer += count; } @@ -151,6 +161,7 @@ const int cmdGetContactByKey = 30; const int cmdGetChannel = 31; const int cmdSetChannel = 32; const int cmdSendTracePath = 36; +const int cmdSetOtherParams = 38; const int cmdGetRadioSettings = 57; const int cmdGetTelemetryReq = 39; const int cmdGetCustomVar = 40; @@ -166,7 +177,7 @@ const int reqTypeGetStatus = 0x01; const int reqTypeKeepAlive = 0x02; const int reqTypeGetTelemetry = 0x03; const int reqTypeGetAccessList = 0x05; -const int reqTypeGetNeighbours = 0x06; +const int reqTypeGetNeighbors = 0x06; // Repeater response codes const int respServerLoginOk = 0; @@ -212,6 +223,30 @@ const int advTypeRepeater = 2; const int advTypeRoom = 3; const int advTypeSensor = 4; +// Payload Types +const int payloadTypeREQ = + 0x00; // request (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob) +const int payloadTypeRESPONSE = + 0x01; // response to REQ or ANON_REQ (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob) +const int payloadTypeTXTMSG = + 0x02; // a plain text message (prefixed with dest/src hashes, MAC) (enc data: timestamp, text) +const int payloadTypeACK = 0x03; // a simple ack +const int payloadTypeADVERT = 0x04; // a node advertising its Identity +const int payloadTypeGRPTXT = + 0x05; // an (unverified) group text message (prefixed with channel hash, MAC) (enc data: timestamp, "name: msg") +const int payloadTypeGRPDATA = + 0x06; // an (unverified) group datagram (prefixed with channel hash, MAC) (enc data: timestamp, blob) +const int payloadTypeANONREQ = + 0x07; // generic request (prefixed with dest_hash, ephemeral pub_key, MAC) (enc data: ...) +const int payloadTypePATH = + 0x08; // returned path (prefixed with dest/src hashes, MAC) (enc data: path, extra) +const int payloadTypeTRACE = 0x09; // trace a path, collecting SNI for each hop +const int payloadTypeMULTIPART = 0x0A; // packet is one of a set of packets +const int payloadTypeCONTROL = 0x0B; // a control/discovery packet +//... +const int payloadTypeRawCustom = + 0x0F; // custom packet as raw bytes, for applications with custom encryption, payloads, etc + // Sizes const int pubKeySize = 32; const int maxPathSize = 64; @@ -255,6 +290,7 @@ int _minPositive(int a, int b) { const int contactPubKeyOffset = 1; const int contactTypeOffset = 33; const int contactFlagsOffset = 34; +const int contactFlagFavorite = 0x01; const int contactPathLenOffset = 35; const int contactPathOffset = 36; const int contactNameOffset = 100; @@ -788,3 +824,22 @@ Uint8List buildZeroHopContact(Uint8List pubKey) { writer.writeBytes(pubKey); return writer.toBytes(); } + +// Build CMD_SET_OTHER_PARAMS frame +// Format: [cmd][allowAutoAddContacts][allowTelemetryFlags][advertLocationPolicy][multiAcks] +Uint8List buildSetOtherParamsFrame( + bool allowAutoAddContacts, + int allowTelemetryFlags, + int advertLocationPolicy, + int multiAcks, +) { + final writer = BufferWriter(); + writer.writeByte(cmdSetOtherParams); + writer.writeByte( + allowAutoAddContacts ? 0x00 : 0x01, + ); // Allow Auto Add Contacts + writer.writeByte(allowTelemetryFlags); // Allow Telemetry Flags + writer.writeByte(advertLocationPolicy); // Advertisement Location Policy + writer.writeByte(multiAcks); // Multi Acknowledgements + return writer.toBytes(); +} diff --git a/lib/helpers/cayenne_lpp.dart b/lib/helpers/cayenne_lpp.dart index bf9b8e7..07909e6 100644 --- a/lib/helpers/cayenne_lpp.dart +++ b/lib/helpers/cayenne_lpp.dart @@ -1,4 +1,6 @@ import 'dart:typed_data'; +import 'package:meshcore_open/utils/app_logger.dart'; + import '../connector/meshcore_protocol.dart'; class CayenneLpp { @@ -84,180 +86,192 @@ class CayenneLpp { static List> parse(Uint8List bytes) { final buffer = BufferReader(bytes); final telemetry = >[]; + try { + while (buffer.remaining >= 2) { + final channel = buffer.readUInt8(); + final type = buffer.readUInt8(); - while (buffer.remaining >= 2) { - final channel = buffer.readUInt8(); - final type = buffer.readUInt8(); + if (channel == 0 && type == 0) { + break; + } - if (channel == 0 && type == 0) { - break; - } - - switch (type) { - case lppGenericSensor: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readUInt32BE(), - }); - break; - case lppLuminosity: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readUInt16BE(), - }); - break; - case lppPresence: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readUInt8(), - }); - break; - case lppTemperature: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readInt16BE() / 10, - }); - break; - case lppRelativeHumidity: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readUInt8() / 2, - }); - break; - case lppBarometricPressure: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readUInt16BE() / 10, - }); - break; - case lppVoltage: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readInt16BE() / 100, - }); - break; - case lppCurrent: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readInt16BE() / 1000, - }); - break; - case lppPercentage: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readUInt8(), - }); - break; - case lppConcentration: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readUInt16BE(), - }); - break; - case lppPower: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readUInt16BE(), - }); - break; - case lppGps: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': { - 'latitude': buffer.readInt24BE() / 10000, - 'longitude': buffer.readInt24BE() / 10000, - 'altitude': buffer.readInt24BE() / 100, - }, - }); - break; - default: - return telemetry; + switch (type) { + case lppGenericSensor: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readUInt32BE(), + }); + break; + case lppLuminosity: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readUInt16BE(), + }); + break; + case lppPresence: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readUInt8(), + }); + break; + case lppTemperature: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readInt16BE() / 10, + }); + break; + case lppRelativeHumidity: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readUInt8() / 2, + }); + break; + case lppBarometricPressure: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readUInt16BE() / 10, + }); + break; + case lppVoltage: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readInt16BE() / 100, + }); + break; + case lppCurrent: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readInt16BE() / 1000, + }); + break; + case lppPercentage: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readUInt8(), + }); + break; + case lppConcentration: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readUInt16BE(), + }); + break; + case lppPower: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readUInt16BE(), + }); + break; + case lppGps: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': { + 'latitude': buffer.readInt24BE() / 10000, + 'longitude': buffer.readInt24BE() / 10000, + 'altitude': buffer.readInt24BE() / 100, + }, + }); + break; + default: + return telemetry; + } } + return telemetry; + } catch (e) { + // Handle parsing errors, possibly due to malformed data + appLogger.error('Error parsing Cayenne LPP data: $e'); + // Return any telemetry parsed so far to preserve partial data + return telemetry; } - - return telemetry; } static List> parseByChannel(Uint8List bytes) { final buffer = BufferReader(bytes); final Map> channels = {}; + try { + while (buffer.remaining >= 2) { + final channel = buffer.readUInt8(); + final type = buffer.readUInt8(); - while (buffer.remaining >= 2) { - final channel = buffer.readUInt8(); - final type = buffer.readUInt8(); + // Optional: stop on padding (00 00) + if (channel == 0 && type == 0) { + break; + } - // Optional: stop on padding (00 00) - if (channel == 0 && type == 0) { - break; + final channelData = channels.putIfAbsent( + channel, + () => {'channel': channel, 'values': {}}, + ); + + switch (type) { + case lppGenericSensor: + channelData['values']['generic'] = buffer.readUInt32BE(); + break; + case lppLuminosity: + channelData['values']['luminosity'] = buffer.readUInt16BE(); + break; + case lppPresence: + channelData['values']['presence'] = buffer.readUInt8() != 0; + break; + case lppTemperature: + channelData['values']['temperature'] = buffer.readInt16BE() / 10.0; + break; + case lppRelativeHumidity: + channelData['values']['humidity'] = buffer.readUInt8() / 2.0; + break; + case lppBarometricPressure: + channelData['values']['pressure'] = buffer.readUInt16BE() / 10.0; + break; + case lppVoltage: + channelData['values']['voltage'] = buffer.readInt16BE() / 100.0; + break; + case lppCurrent: + channelData['values']['current'] = buffer.readInt16BE() / 1000.0; + break; + case lppPercentage: + channelData['values']['percentage'] = buffer.readUInt8(); + break; + case lppConcentration: + channelData['values']['concentration'] = buffer.readUInt16BE(); + break; + case lppPower: + channelData['values']['power'] = buffer.readUInt16BE(); + break; + case lppGps: + channelData['values']['gps'] = { + 'latitude': buffer.readInt24BE() / 10000.0, + 'longitude': buffer.readInt24BE() / 10000.0, + 'altitude': buffer.readInt24BE() / 100.0, + }; + break; + // Add more types as needed... + default: + //Stopped parsing to avoid misalignment + return channels.values.toList(); + } } - final channelData = channels.putIfAbsent( - channel, - () => {'channel': channel, 'values': {}}, - ); - - switch (type) { - case lppGenericSensor: - channelData['values']['generic'] = buffer.readUInt32BE(); - break; - case lppLuminosity: - channelData['values']['luminosity'] = buffer.readUInt16BE(); - break; - case lppPresence: - channelData['values']['presence'] = buffer.readUInt8() != 0; - break; - case lppTemperature: - channelData['values']['temperature'] = buffer.readInt16BE() / 10.0; - break; - case lppRelativeHumidity: - channelData['values']['humidity'] = buffer.readUInt8() / 2.0; - break; - case lppBarometricPressure: - channelData['values']['pressure'] = buffer.readUInt16BE() / 10.0; - break; - case lppVoltage: - channelData['values']['voltage'] = buffer.readInt16BE() / 100.0; - break; - case lppCurrent: - channelData['values']['current'] = buffer.readInt16BE() / 1000.0; - break; - case lppPercentage: - channelData['values']['percentage'] = buffer.readUInt8(); - break; - case lppConcentration: - channelData['values']['concentration'] = buffer.readUInt16BE(); - break; - case lppPower: - channelData['values']['power'] = buffer.readUInt16BE(); - break; - case lppGps: - channelData['values']['gps'] = { - 'latitude': buffer.readInt24BE() / 10000.0, - 'longitude': buffer.readInt24BE() / 10000.0, - 'altitude': buffer.readInt24BE() / 100.0, - }; - break; - // Add more types as needed... - default: - // Unknown type: skip or handle error? - continue; - } + final List> channelsOut = channels.values.toList(); + channelsOut.sort((a, b) => a['channel'].compareTo(b['channel'])); + return channelsOut; + } catch (e) { + // Handle parsing errors, possibly due to malformed data + appLogger.error('Error parsing Cayenne LPP data: $e'); + return < + Map + >[]; // Return an empty list on error to avoid crashing the app } - - final List> channelsOut = channels.values.toList(); - channelsOut.sort((a, b) => a['channel'].compareTo(b['channel'])); - return channelsOut; } } diff --git a/lib/icons/los_icon.dart b/lib/icons/los_icon.dart new file mode 100644 index 0000000..58d75b0 --- /dev/null +++ b/lib/icons/los_icon.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; + +class LosIcon extends StatelessWidget { + final double size; + final Color? color; + + const LosIcon({super.key, this.size = 24, this.color}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final iconTheme = IconTheme.of(context); + final iconColor = + color ?? + iconTheme.color ?? + theme.iconTheme.color ?? + theme.colorScheme.onSurface; + + return Icon(Symbols.elevation, size: size, color: iconColor); + } +} diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index 567c74c..3613e85 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -1,4 +1,12 @@ { + "channels_channelDeleteFailed": "Неуспешно изтриване на канала \"{name}\"", + "@channels_channelDeleteFailed": { + "placeholders": { + "name": { + "type": "String" + } + } + }, "@@locale": "bg", "appTitle": "MeshCore Open", "nav_contacts": "Контакти", @@ -334,6 +342,8 @@ "channels_publicChannel": "Публичен канал", "channels_privateChannel": "Частен канал", "channels_editChannel": "Редактирай канал", + "channels_muteChannel": "Заглуши канала", + "channels_unmuteChannel": "Включи известията на канала", "channels_deleteChannel": "Изтрий канала", "channels_deleteChannelConfirm": "Изтрий \"{name}\"? Това не може да бъде отменено.", "@channels_deleteChannelConfirm": { @@ -1351,12 +1361,12 @@ } } }, - "repeater_neighboursSubtitle": "Преглед на съседни възли с нулев скок.", - "repeater_neighbours": "Съседи", + "repeater_neighborsSubtitle": "Преглед на съседни възли с нулев скок.", + "repeater_neighbors": "Съседи", "neighbors_receivedData": "Получени данни за съседи", "neighbors_requestTimedOut": "Съседите поискат изтичане на време.", "neighbors_errorLoading": "Грешка при зареждане на съседи: {error}", - "neighbors_repeatersNeighbours": "Повторители Съседи", + "neighbors_repeatersNeighbors": "Повторители Съседи", "neighbors_noData": "Няма налични данни за съседи.", "channels_createPrivateChannel": "Създай Частен Канал", "channels_joinPrivateChannel": "Присъедини се към Частен Канал", @@ -1552,6 +1562,8 @@ "contacts_clipboardEmpty": "Клипборда е празна.", "contacts_invalidAdvertFormat": "Невалидни данни за контакт", "appSettings_languageRu": "Руски", + "appSettings_enableMessageTracing": "Разрешаване на проследяване на съобщения", + "appSettings_enableMessageTracingSubtitle": "Показване на подробни метаданни за маршрутизация и синхронизация за съобщения", "contacts_contactImported": "Контактът е импортиран.", "contacts_zeroHopAdvert": "Реклама без скок", "contacts_contactImportFailed": "Контактът не е успешно импортиран.", @@ -1594,7 +1606,152 @@ "scanner_bluetoothOff": "Bluetooth е изключен.", "scanner_enableBluetooth": "Активирайте Bluetooth", "scanner_bluetoothOffMessage": "Моля, активирайте Bluetooth, за да сканирате за устройства.", + "snrIndicator_lastSeen": "Последно видян", + "snrIndicator_nearByRepeaters": "Близки повтарящи се устройства", + "chat_ShowAllPaths": "Покажи всички пътища", "settings_clientRepeatSubtitle": "Позволете на това устройство да предава пакети към мрежата за други устройства.", "settings_clientRepeatFreqWarning": "За повторение извън мрежата са необходими честоти от 433, 869 или 918 MHz.", - "settings_clientRepeat": "Без електричество – повторение" + "settings_clientRepeat": "Без електричество – повторение", + "settings_aboutOpenMeteoAttribution": "Данни за надморска височина на LOS: Open-Meteo (CC BY 4.0)", + "appSettings_unitsTitle": "единици", + "appSettings_unitsMetric": "Метрика (m / km)", + "appSettings_unitsImperial": "Имперска (ft / mi)", + "map_lineOfSight": "Линия на видимост", + "map_losScreenTitle": "Линия на видимост", + "losSelectStartEnd": "Изберете начални и крайни възли за LOS.", + "losRunFailed": "Проверката на пряката видимост е неуспешна: {error}", + "@losRunFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "losClearAllPoints": "Изчистете всички точки", + "losRunToViewElevationProfile": "Стартирайте LOS, за да видите профила на надморската височина", + "losMenuTitle": "LOS меню", + "losMenuSubtitle": "Докоснете възли или натиснете продължително карта за персонализирани точки", + "losShowDisplayNodes": "Показване на възли на дисплея", + "losCustomPoints": "Персонализирани точки", + "losCustomPointLabel": "Персонализирано {index}", + "@losCustomPointLabel": { + "placeholders": { + "index": { + "type": "int" + } + } + }, + "losPointA": "Точка А", + "losPointB": "Точка Б", + "losAntennaA": "Антена A: {value} {unit}", + "@losAntennaA": { + "placeholders": { + "value": { + "type": "String" + }, + "unit": { + "type": "String" + } + } + }, + "losAntennaB": "Антена B: {value} {unit}", + "@losAntennaB": { + "placeholders": { + "value": { + "type": "String" + }, + "unit": { + "type": "String" + } + } + }, + "losRun": "Стартирайте LOS", + "losNoElevationData": "Няма данни за надморска височина", + "losProfileClear": "{distance} {distanceUnit}, чист LOS, минимално разстояние {clearance} {heightUnit}", + "@losProfileClear": { + "placeholders": { + "distance": { + "type": "String" + }, + "distanceUnit": { + "type": "String" + }, + "clearance": { + "type": "String" + }, + "heightUnit": { + "type": "String" + } + } + }, + "losProfileBlocked": "{distance} {distanceUnit}, блокиран от {obstruction} {heightUnit}", + "@losProfileBlocked": { + "placeholders": { + "distance": { + "type": "String" + }, + "distanceUnit": { + "type": "String" + }, + "obstruction": { + "type": "String" + }, + "heightUnit": { + "type": "String" + } + } + }, + "losStatusChecking": "LOS: проверка...", + "losStatusNoData": "LOS: няма данни", + "losStatusSummary": "LOS: {clear}/{total} ясно, {blocked} блокирано, {unknown} неизвестно", + "@losStatusSummary": { + "placeholders": { + "clear": { + "type": "int" + }, + "total": { + "type": "int" + }, + "blocked": { + "type": "int" + }, + "unknown": { + "type": "int" + } + } + }, + "losErrorElevationUnavailable": "Няма налични данни за надморска височина за една или повече проби.", + "losErrorInvalidInput": "Невалидни данни за точки/надморска височина за изчисляване на LOS.", + "losRenameCustomPoint": "Преименувайте персонализирана точка", + "losPointName": "Име на точката", + "losShowPanelTooltip": "Показване на LOS панел", + "losHidePanelTooltip": "Скриване на LOS панела", + "losElevationAttribution": "Данни за надморска височина: Open-Meteo (CC BY 4.0)", + "losLegendRadioHorizon": "Радиохоризонт", + "losLegendLosBeam": "Линия на видимост", + "losLegendTerrain": "Терен", + "losFrequencyLabel": "Честота", + "losFrequencyInfoTooltip": "Преглед на детайли за изчислението", + "losFrequencyDialogTitle": "Изчисляване на радиохоризонта", + "losFrequencyDialogDescription": "Започвайки от k={baselineK} при {baselineFreq} MHz, изчислението коригира k-фактора за текущата {frequencyMHz} MHz лента, която определя границата на извития радиохоризонт.", + "@losFrequencyDialogDescription": { + "description": "Explain how the calculation uses the baseline frequency and derived k-factor.", + "placeholders": { + "baselineK": { + "type": "double" + }, + "baselineFreq": { + "type": "double" + }, + "frequencyMHz": { + "type": "double" + }, + "kFactor": { + "type": "double" + } + } + }, + "listFilter_removeFromFavorites": "Премахване от списъка с любими", + "listFilter_addToFavorites": "Добави към любими", + "listFilter_favorites": "Любими" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 7aba89b..64e8cd3 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1,4 +1,6 @@ { + "channels_channelDeleteFailed": "Kanal {name} konnte nicht gelöscht werden", + "@channels_channelDeleteFailed": { "placeholders": { "name": { "type": "String" } } }, "@@locale": "de", "appTitle": "MeshCore Open", "nav_contacts": "Kontakte", @@ -334,6 +336,8 @@ "channels_publicChannel": "Öffentlicher Kanal", "channels_privateChannel": "Privater Kanal", "channels_editChannel": "Kanal bearbeiten", + "channels_muteChannel": "Kanal stummschalten", + "channels_unmuteChannel": "Kanal Stummschaltung aufheben", "channels_deleteChannel": "Lösche den Kanal", "channels_deleteChannelConfirm": "Löschen von \"{name}\"? Dies kann nicht rückgängig gemacht werden.", "@channels_deleteChannelConfirm": { @@ -1339,6 +1343,9 @@ "listFilter_az": "A-Z", "listFilter_filters": "Filtere", "listFilter_all": "Alle", + "listFilter_favorites": "Favoriten", + "listFilter_addToFavorites": "Zu Favoriten hinzufügen", + "listFilter_removeFromFavorites": "Aus Favoriten entfernen", "listFilter_users": "Benutzer", "listFilter_repeaters": "Repeater", "listFilter_roomServers": "Raumserver", @@ -1351,12 +1358,12 @@ } } }, - "repeater_neighbours": "Nachbarn", - "repeater_neighboursSubtitle": "Anzahl der Hop-Nachbarn anzeigen.", + "repeater_neighbors": "Nachbarn", + "repeater_neighborsSubtitle": "Anzahl der Hop-Nachbarn anzeigen.", "neighbors_receivedData": "Empfangene Nachbarsdaten", "neighbors_requestTimedOut": "Anfrage durch Timeout fehlgeschlagen.", "neighbors_errorLoading": "Fehler beim Laden der Nachbarn: {error}", - "neighbors_repeatersNeighbours": "Nachbarn", + "neighbors_repeatersNeighbors": "Nachbarn", "neighbors_noData": "Keine Nachbarsdaten verfügbar.", "channels_joinPrivateChannel": "Treten Sie einem privaten Kanal bei", "channels_joinPrivateChannelDesc": "Manuelle Eingabe eines geheimen Schlüssels.", @@ -1552,6 +1559,8 @@ "contacts_invalidAdvertFormat": "Ungültige Kontaktdaten", "contacts_clipboardEmpty": "Die Zwischenablage ist leer.", "appSettings_languageUk": "Ukrainisch", + "appSettings_enableMessageTracing": "Nachrichtenverfolgung aktivieren", + "appSettings_enableMessageTracingSubtitle": "Detaillierte Routing- und Timing-Metadaten für Nachrichten anzeigen", "contacts_contactImported": "Kontakt wurde importiert.", "contacts_contactImportFailed": "Kontakt konnte nicht importiert werden", "contacts_zeroHopAdvert": "Zero-Hop-Ankündigung", @@ -1622,7 +1631,149 @@ "scanner_bluetoothOffMessage": "Bitte aktivieren Sie Bluetooth, um nach Geräten zu suchen.", "scanner_bluetoothOff": "Bluetooth ist deaktiviert.", "scanner_enableBluetooth": "Bluetooth aktivieren", + "snrIndicator_lastSeen": "Zuletzt gesehen", + "snrIndicator_nearByRepeaters": "In der Nähe befindliche Repeater", + "chat_ShowAllPaths": "Alle Pfade anzeigen", "settings_clientRepeat": "Wiederholung, ohne Stromanschluss", "settings_clientRepeatFreqWarning": "Die Kommunikation ohne Stromversorgung erfordert Frequenzen von 433, 869 oder 918 MHz.", - "settings_clientRepeatSubtitle": "Ermöglichen Sie diesem Gerät, Mesh-Pakete für andere zu wiederholen." + "settings_clientRepeatSubtitle": "Ermöglichen Sie diesem Gerät, Mesh-Pakete für andere zu wiederholen.", + "settings_aboutOpenMeteoAttribution": "LOS-Höhendaten: Open-Meteo (CC BY 4.0)", + "appSettings_unitsTitle": "Einheiten", + "appSettings_unitsMetric": "Metrisch (m/km)", + "appSettings_unitsImperial": "Imperial (ft/mi)", + "map_lineOfSight": "Sichtlinie", + "map_losScreenTitle": "Sichtlinie", + "losSelectStartEnd": "Wählen Sie Start- und Endknoten für LOS aus.", + "losRunFailed": "Sichtlinienprüfung fehlgeschlagen: {error}", + "@losRunFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "losClearAllPoints": "Löschen Sie alle Punkte", + "losRunToViewElevationProfile": "Führen Sie LOS aus, um das Höhenprofil anzuzeigen", + "losMenuTitle": "LOS-Menü", + "losMenuSubtitle": "Tippen Sie auf Knoten oder drücken Sie lange auf die Karte, um benutzerdefinierte Punkte anzuzeigen", + "losShowDisplayNodes": "Anzeigeknoten anzeigen", + "losCustomPoints": "Benutzerdefinierte Punkte", + "losCustomPointLabel": "Benutzerdefiniert {index}", + "@losCustomPointLabel": { + "placeholders": { + "index": { + "type": "int" + } + } + }, + "losPointA": "Punkt A", + "losPointB": "Punkt B", + "losAntennaA": "Antenne A: {value} {unit}", + "@losAntennaA": { + "placeholders": { + "value": { + "type": "String" + }, + "unit": { + "type": "String" + } + } + }, + "losAntennaB": "Antenne B: {value} {unit}", + "@losAntennaB": { + "placeholders": { + "value": { + "type": "String" + }, + "unit": { + "type": "String" + } + } + }, + "losRun": "Führen Sie LOS aus", + "losNoElevationData": "Keine Höhendaten", + "losProfileClear": "{distance} {distanceUnit}, freie Sichtlinie, Mindestabstand {clearance} {heightUnit}", + "@losProfileClear": { + "placeholders": { + "distance": { + "type": "String" + }, + "distanceUnit": { + "type": "String" + }, + "clearance": { + "type": "String" + }, + "heightUnit": { + "type": "String" + } + } + }, + "losProfileBlocked": "{distance} {distanceUnit}, blockiert durch {obstruction} {heightUnit}", + "@losProfileBlocked": { + "placeholders": { + "distance": { + "type": "String" + }, + "distanceUnit": { + "type": "String" + }, + "obstruction": { + "type": "String" + }, + "heightUnit": { + "type": "String" + } + } + }, + "losStatusChecking": "LOS: Überprüfen...", + "losStatusNoData": "LOS: keine Daten", + "losStatusSummary": "Sichtlinie: {clear}/{total} frei, {blocked} blockiert, {unknown} unbekannt", + "@losStatusSummary": { + "placeholders": { + "clear": { + "type": "int" + }, + "total": { + "type": "int" + }, + "blocked": { + "type": "int" + }, + "unknown": { + "type": "int" + } + } + }, + "losErrorElevationUnavailable": "Für eine oder mehrere Proben sind keine Höhendaten verfügbar.", + "losErrorInvalidInput": "Ungültige Punkte/Höhendaten für die LOS-Berechnung.", + "losRenameCustomPoint": "Benennen Sie den benutzerdefinierten Punkt um", + "losPointName": "Punktname", + "losShowPanelTooltip": "LOS-Panel anzeigen", + "losHidePanelTooltip": "LOS-Panel ausblenden", + "losElevationAttribution": "Höhendaten: Open-Meteo (CC BY 4.0)", + "losLegendRadioHorizon": "Funkhorizont", + "losLegendLosBeam": "Sichtlinie", + "losLegendTerrain": "Gelände", + "losFrequencyLabel": "Frequenz", + "losFrequencyInfoTooltip": "Details zur Berechnung anzeigen", + "losFrequencyDialogTitle": "Berechnung des Funkhorizonts", + "losFrequencyDialogDescription": "Ausgehend von k={baselineK} bei {baselineFreq} MHz passt die Berechnung den k-Faktor für das aktuelle {frequencyMHz} MHz-Band an, das die gekrümmte Funkhorizontobergrenze definiert.", + "@losFrequencyDialogDescription": { + "description": "Explain how the calculation uses the baseline frequency and derived k-factor.", + "placeholders": { + "baselineK": { + "type": "double" + }, + "baselineFreq": { + "type": "double" + }, + "frequencyMHz": { + "type": "double" + }, + "kFactor": { + "type": "double" + } + } + } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index cfd6330..8d9f385 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -131,6 +131,7 @@ }, "settings_aboutLegalese": "2026 MeshCore Open Source Project", "settings_aboutDescription": "An open-source Flutter client for MeshCore LoRa mesh networking devices.", + "settings_aboutOpenMeteoAttribution": "LOS elevation data: Open-Meteo (CC BY 4.0)", "settings_infoName": "Name", "settings_infoId": "ID", "settings_infoStatus": "Status", @@ -182,6 +183,8 @@ "appSettings_languageBg": "Български", "appSettings_languageRu": "Русский", "appSettings_languageUk": "Українська", + "appSettings_enableMessageTracing": "Enable Message Tracing", + "appSettings_enableMessageTracingSubtitle": "Show detailed routing and timing metadata for messages", "appSettings_notifications": "Notifications", "appSettings_enableNotifications": "Enable Notifications", "appSettings_enableNotificationsSubtitle": "Receive notifications for messages and adverts", @@ -242,6 +245,9 @@ "appSettings_last24Hours": "Last 24 hours", "appSettings_lastWeek": "Last week", "appSettings_offlineMapCache": "Offline Map Cache", + "appSettings_unitsTitle": "Units", + "appSettings_unitsMetric": "Metric (m / km)", + "appSettings_unitsImperial": "Imperial (ft / mi)", "appSettings_noAreaSelected": "No area selected", "appSettings_areaSelectedZoom": "Area selected (zoom {minZoom}-{maxZoom})", "@appSettings_areaSelectedZoom": { @@ -348,6 +354,8 @@ "channels_publicChannel": "Public channel", "channels_privateChannel": "Private channel", "channels_editChannel": "Edit channel", + "channels_muteChannel": "Mute channel", + "channels_unmuteChannel": "Unmute channel", "channels_deleteChannel": "Delete channel", "channels_deleteChannelConfirm": "Delete \"{name}\"? This cannot be undone.", "@channels_deleteChannelConfirm": { @@ -357,6 +365,14 @@ } } }, + "channels_channelDeleteFailed": "Failed to delete channel \"{name}\"", + "@channels_channelDeleteFailed": { + "placeholders": { + "name": { + "type": "String" + } + } + }, "channels_channelDeleted": "Channel \"{name}\" deleted", "@channels_channelDeleted": { "placeholders": { @@ -558,6 +574,7 @@ }, "debugFrame_hexDump": "Hex Dump:", "chat_pathManagement": "Path Management", + "chat_ShowAllPaths": "Show all paths", "chat_routingMode": "Routing mode", "chat_autoUseSavedPath": "Auto (use saved path)", "chat_forceFloodMode": "Force Flood Mode", @@ -638,6 +655,8 @@ }, "chat_invalidLink": "Invalid link format", "map_title": "Node Map", + "map_lineOfSight": "Line of Sight", + "map_losScreenTitle": "Line of Sight", "map_noNodesWithLocation": "No nodes with location data", "map_nodesNeedGps": "Nodes need to share their GPS coordinates\nto appear on the map", "map_nodesCount": "Nodes: {count}", @@ -905,8 +924,8 @@ "repeater_telemetrySubtitle": "View telemetry of sensors and system stats", "repeater_cli": "CLI", "repeater_cliSubtitle": "Send commands to the repeater", - "repeater_neighbours": "Neighbors", - "repeater_neighboursSubtitle": "View zero hop neighbors.", + "repeater_neighbors": "Neighbors", + "repeater_neighborsSubtitle": "View zero hop neighbors.", "repeater_settings": "Settings", "repeater_settingsSubtitle": "Configure repeater parameters", "repeater_statusTitle": "Repeater Status", @@ -1266,8 +1285,8 @@ } } }, - "neighbors_receivedData": "Received Neighbours Data", - "neighbors_requestTimedOut": "Neighbours request timed out.", + "neighbors_receivedData": "Received Neighbors Data", + "neighbors_requestTimedOut": "Neighbors request timed out.", "neighbors_errorLoading": "Error loading neighbors: {error}", "@neighbors_errorLoading": { "placeholders": { @@ -1276,8 +1295,8 @@ } } }, - "neighbors_repeatersNeighbours": "Repeaters Neighbours", - "neighbors_noData": "No neighbours data available.", + "neighbors_repeatersNeighbors": "Repeaters Neighbors", + "neighbors_noData": "No neighbors data available.", "neighbors_unknownContact": "Unknown {pubkey}", "@neighbors_unknownContact": { "placeholders": { @@ -1536,6 +1555,9 @@ "listFilter_az": "A-Z", "listFilter_filters": "Filters", "listFilter_all": "All", + "listFilter_favorites": "Favorites", + "listFilter_addToFavorites": "Add to favorites", + "listFilter_removeFromFavorites": "Remove from favorites", "listFilter_users": "Users", "listFilter_repeaters": "Repeaters", "listFilter_roomServers": "Room servers", @@ -1547,6 +1569,139 @@ "pathTrace_refreshTooltip": "Refresh Path Trace.", "pathTrace_someHopsNoLocation": "One or more of the hops is missing a location!", "pathTrace_clearTooltip": "Clear path.", + "losSelectStartEnd": "Select start and end nodes for LOS.", + "losRunFailed": "Line-of-sight check failed: {error}", + "@losRunFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "losClearAllPoints": "Clear all points", + "losRunToViewElevationProfile": "Run LOS to view elevation profile", + "losMenuTitle": "LOS Menu", + "losMenuSubtitle": "Tap nodes or long-press map for custom points", + "losShowDisplayNodes": "Show display nodes", + "losCustomPoints": "Custom points", + "losCustomPointLabel": "Custom {index}", + "@losCustomPointLabel": { + "placeholders": { + "index": { + "type": "int" + } + } + }, + "losPointA": "Point A", + "losPointB": "Point B", + "losAntennaA": "Antenna A: {value} {unit}", + "@losAntennaA": { + "placeholders": { + "value": { + "type": "String" + }, + "unit": { + "type": "String" + } + } + }, + "losAntennaB": "Antenna B: {value} {unit}", + "@losAntennaB": { + "placeholders": { + "value": { + "type": "String" + }, + "unit": { + "type": "String" + } + } + }, + "losRun": "Run LOS", + "losNoElevationData": "No elevation data", + "losProfileClear": "{distance} {distanceUnit}, clear LOS, min clearance {clearance} {heightUnit}", + "@losProfileClear": { + "placeholders": { + "distance": { + "type": "String" + }, + "distanceUnit": { + "type": "String" + }, + "clearance": { + "type": "String" + }, + "heightUnit": { + "type": "String" + } + } + }, + "losProfileBlocked": "{distance} {distanceUnit}, blocked by {obstruction} {heightUnit}", + "@losProfileBlocked": { + "placeholders": { + "distance": { + "type": "String" + }, + "distanceUnit": { + "type": "String" + }, + "obstruction": { + "type": "String" + }, + "heightUnit": { + "type": "String" + } + } + }, + "losStatusChecking": "LOS: checking...", + "losStatusNoData": "LOS: no data", + "losStatusSummary": "LOS: {clear}/{total} clear, {blocked} blocked, {unknown} unknown", + "@losStatusSummary": { + "placeholders": { + "clear": { + "type": "int" + }, + "total": { + "type": "int" + }, + "blocked": { + "type": "int" + }, + "unknown": { + "type": "int" + } + } + }, + "losErrorElevationUnavailable": "Elevation data unavailable for one or more samples.", + "losErrorInvalidInput": "Invalid points/elevation data for LOS calculation.", + "losRenameCustomPoint": "Rename custom point", + "losPointName": "Point name", + "losShowPanelTooltip": "Show LOS panel", + "losHidePanelTooltip": "Hide LOS panel", + "losElevationAttribution": "Elevation data: Open-Meteo (CC BY 4.0)", + "losLegendRadioHorizon": "Radio horizon", + "losLegendLosBeam": "LOS beam", + "losLegendTerrain": "Terrain", + "losFrequencyLabel": "Frequency", + "losFrequencyInfoTooltip": "View calculation details", + "losFrequencyDialogTitle": "Radio horizon calculation", + "losFrequencyDialogDescription": "Starting from k={baselineK} at {baselineFreq} MHz, the calculation adjusts the k-factor for the current {frequencyMHz} MHz band, which defines the curved radio horizon cap.", + "@losFrequencyDialogDescription": { + "description": "Explain how the calculation uses the baseline frequency and derived k-factor.", + "placeholders": { + "baselineK": { + "type": "double" + }, + "baselineFreq": { + "type": "double" + }, + "frequencyMHz": { + "type": "double" + }, + "kFactor": { + "type": "double" + } + } + }, "contacts_pathTrace": "Path Trace", "contacts_ping": "Ping", "contacts_repeaterPathTrace": "Path trace to repeater", @@ -1624,5 +1779,7 @@ "settings_gpxExportChat": "Companion locations", "settings_gpxExportAllContacts": "All contacts locations", "settings_gpxExportShareText": "Map data exported from meshcore-open", - "settings_gpxExportShareSubject": "meshcore-open GPX map data export" + "settings_gpxExportShareSubject": "meshcore-open GPX map data export", + "snrIndicator_nearByRepeaters": "Nearby Repeaters", + "snrIndicator_lastSeen": "Last seen" } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 4250a6f..dd8ce6c 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1,4 +1,12 @@ { + "channels_channelDeleteFailed": "No se pudo eliminar el canal \"{name}\"", + "@channels_channelDeleteFailed": { + "placeholders": { + "name": { + "type": "String" + } + } + }, "@@locale": "es", "appTitle": "MeshCore Open", "nav_contacts": "Contactos", @@ -334,6 +342,8 @@ "channels_publicChannel": "Canal público", "channels_privateChannel": "Canal privado", "channels_editChannel": "Editar canal", + "channels_muteChannel": "Silenciar canal", + "channels_unmuteChannel": "Activar canal", "channels_deleteChannel": "Eliminar canal", "channels_deleteChannelConfirm": "Eliminar \"{name}\"? Esto no se puede deshacer.", "@channels_deleteChannelConfirm": { @@ -1351,12 +1361,12 @@ } } }, - "repeater_neighbours": "Vecinos", - "repeater_neighboursSubtitle": "Ver vecinos de salto cero.", + "repeater_neighbors": "Vecinos", + "repeater_neighborsSubtitle": "Ver vecinos de salto cero.", "neighbors_receivedData": "Recibidas Datos de Vecinos", "neighbors_requestTimedOut": "Los vecinos solicitan que se desconecte.", "neighbors_errorLoading": "Error al cargar vecinos: {error}", - "neighbors_repeatersNeighbours": "Repetidores Vecinos", + "neighbors_repeatersNeighbors": "Repetidores Vecinos", "neighbors_noData": "No hay datos de vecinos disponibles.", "channels_joinPrivateChannel": "Únete a un Canal Privado", "channels_createPrivateChannel": "Crear un Canal Privado", @@ -1551,6 +1561,8 @@ "appSettings_languageUk": "Ucraniano", "contacts_clipboardEmpty": "El portapapeles está vacío.", "appSettings_languageRu": "Ruso", + "appSettings_enableMessageTracing": "Habilitar seguimiento de mensajes", + "appSettings_enableMessageTracingSubtitle": "Mostrar metadatos detallados de enrutamiento y tiempo para los mensajes", "contacts_invalidAdvertFormat": "Datos de contacto no válidos", "contacts_floodAdvert": "Anuncio de inundación", "contacts_contactImported": "El contacto ha sido importado.", @@ -1622,7 +1634,152 @@ "scanner_bluetoothOffMessage": "Por favor, active el Bluetooth para escanear dispositivos.", "scanner_bluetoothOff": "Bluetooth está desactivado.", "scanner_enableBluetooth": "Habilitar Bluetooth", + "snrIndicator_nearByRepeaters": "Repetidores cercanos", + "snrIndicator_lastSeen": "Visto por última vez", + "chat_ShowAllPaths": "Mostrar todos los caminos", "settings_clientRepeatFreqWarning": "Para la comunicación fuera de la red, se requiere una frecuencia de 433, 869 o 918 MHz.", "settings_clientRepeat": "Repetir sin conexión", - "settings_clientRepeatSubtitle": "Permita que este dispositivo repita los paquetes de red para otros usuarios." + "settings_clientRepeatSubtitle": "Permita que este dispositivo repita los paquetes de red para otros usuarios.", + "settings_aboutOpenMeteoAttribution": "Datos de elevación LOS: Open-Meteo (CC BY 4.0)", + "appSettings_unitsTitle": "Unidades", + "appSettings_unitsMetric": "Métrico (m/km)", + "appSettings_unitsImperial": "Imperial (pies/millas)", + "map_lineOfSight": "Línea de visión", + "map_losScreenTitle": "Línea de visión", + "losSelectStartEnd": "Seleccione los nodos de inicio y fin para LOS.", + "losRunFailed": "Error en la comprobación de la línea de visión: {error}", + "@losRunFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "losClearAllPoints": "Borrar todos los puntos", + "losRunToViewElevationProfile": "Ejecute LOS para ver el perfil de elevación", + "losMenuTitle": "Menú LOS", + "losMenuSubtitle": "Toque nodos o mantenga presionado el mapa para puntos personalizados", + "losShowDisplayNodes": "Mostrar nodos de visualización", + "losCustomPoints": "Puntos personalizados", + "losCustomPointLabel": "Personalizado {index}", + "@losCustomPointLabel": { + "placeholders": { + "index": { + "type": "int" + } + } + }, + "losPointA": "Punto A", + "losPointB": "Punto B", + "losAntennaA": "Antena A: {value} {unit}", + "@losAntennaA": { + "placeholders": { + "value": { + "type": "String" + }, + "unit": { + "type": "String" + } + } + }, + "losAntennaB": "Antena B: {value} {unit}", + "@losAntennaB": { + "placeholders": { + "value": { + "type": "String" + }, + "unit": { + "type": "String" + } + } + }, + "losRun": "Ejecutar LOS", + "losNoElevationData": "Sin datos de elevación", + "losProfileClear": "{distance} {distanceUnit}, despejar LOS, autorización mínima {clearance} {heightUnit}", + "@losProfileClear": { + "placeholders": { + "distance": { + "type": "String" + }, + "distanceUnit": { + "type": "String" + }, + "clearance": { + "type": "String" + }, + "heightUnit": { + "type": "String" + } + } + }, + "losProfileBlocked": "{distance} {distanceUnit}, bloqueado por {obstruction} {heightUnit}", + "@losProfileBlocked": { + "placeholders": { + "distance": { + "type": "String" + }, + "distanceUnit": { + "type": "String" + }, + "obstruction": { + "type": "String" + }, + "heightUnit": { + "type": "String" + } + } + }, + "losStatusChecking": "LOS: comprobando...", + "losStatusNoData": "LOS: sin datos", + "losStatusSummary": "LOS: {clear}/{total} claro, {blocked} bloqueado, {unknown} desconocido", + "@losStatusSummary": { + "placeholders": { + "clear": { + "type": "int" + }, + "total": { + "type": "int" + }, + "blocked": { + "type": "int" + }, + "unknown": { + "type": "int" + } + } + }, + "losErrorElevationUnavailable": "Datos de elevación no disponibles para una o más muestras.", + "losErrorInvalidInput": "Datos de puntos/elevación no válidos para el cálculo de LOS.", + "losRenameCustomPoint": "Cambiar el nombre del punto personalizado", + "losPointName": "Nombre del punto", + "losShowPanelTooltip": "Mostrar panel LOS", + "losHidePanelTooltip": "Ocultar panel LOS", + "losElevationAttribution": "Datos de elevación: Open-Meteo (CC BY 4.0)", + "losLegendRadioHorizon": "Horizonte radioeléctrico", + "losLegendLosBeam": "Línea de visión", + "losLegendTerrain": "Terreno", + "losFrequencyLabel": "Frecuencia", + "losFrequencyInfoTooltip": "Ver detalles del cálculo", + "losFrequencyDialogTitle": "Cálculo del horizonte radioeléctrico", + "losFrequencyDialogDescription": "A partir de k={baselineK} en {baselineFreq} MHz, el cálculo ajusta el factor k para la banda actual de {frequencyMHz} MHz, que define el límite curvo del horizonte de radio.", + "@losFrequencyDialogDescription": { + "description": "Explain how the calculation uses the baseline frequency and derived k-factor.", + "placeholders": { + "baselineK": { + "type": "double" + }, + "baselineFreq": { + "type": "double" + }, + "frequencyMHz": { + "type": "double" + }, + "kFactor": { + "type": "double" + } + } + }, + "listFilter_favorites": "Favoritos", + "listFilter_removeFromFavorites": "Eliminar de las favoritas", + "listFilter_addToFavorites": "Añadir a favoritos" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 56d4a41..fe738f7 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1,4 +1,12 @@ { + "channels_channelDeleteFailed": "Échec de la suppression de la chaîne \"{name}\"", + "@channels_channelDeleteFailed": { + "placeholders": { + "name": { + "type": "String" + } + } + }, "@@locale": "fr", "appTitle": "MeshCore Open", "nav_contacts": "Contacts", @@ -262,7 +270,7 @@ } }, "contacts_manageRepeater": "Gérer le répéteur", - "contacts_roomLogin": "Connexion Salle", + "contacts_roomLogin": "Connexion Room Server", "contacts_openChat": "Ouverture du Chat", "contacts_editGroup": "Modifier le groupe", "contacts_deleteGroup": "Supprimer le groupe", @@ -334,6 +342,8 @@ "channels_publicChannel": "Canal public", "channels_privateChannel": "Canal privé", "channels_editChannel": "Modifier le canal", + "channels_muteChannel": "Désactiver les notifications du canal", + "channels_unmuteChannel": "Réactiver les notifications du canal", "channels_deleteChannel": "Supprimer le canal", "channels_deleteChannelConfirm": "Supprimer {name}? Cela ne peut pas être annulé.", "@channels_deleteChannelConfirm": { @@ -796,7 +806,7 @@ "dialog_disconnect": "Déconnecter", "dialog_disconnectConfirm": "Êtes-vous sûr de vouloir vous déconnecter de cet appareil ?", "login_repeaterLogin": "Connexion au répéteur", - "login_roomLogin": "Connexion Salle", + "login_roomLogin": "Connexion Room Server", "login_password": "Mot de passe", "login_enterPassword": "Entrez votre mot de passe", "login_savePassword": "Sauvegarder le mot de passe", @@ -1351,12 +1361,12 @@ } } }, - "repeater_neighbours": "Voisins", - "repeater_neighboursSubtitle": "Afficher les voisins de saut nuls.", + "repeater_neighbors": "Voisins", + "repeater_neighborsSubtitle": "Afficher les voisins de saut nuls.", "neighbors_receivedData": "Données des voisins reçues", "neighbors_requestTimedOut": "Les voisins demandent un délai.", "neighbors_errorLoading": "Erreur lors du chargement des voisins : {error}", - "neighbors_repeatersNeighbours": "Répéteurs Voisins", + "neighbors_repeatersNeighbors": "Répéteurs Voisins", "neighbors_noData": "Aucune donnée concernant les voisins disponible.", "channels_createPrivateChannelDesc": "Sécurisé avec une clé secrète.", "channels_joinPrivateChannel": "Rejoindre un Canal Privé", @@ -1391,7 +1401,7 @@ "settings_locationIntervalSec": "Intervalle de mise-à-jour du GPS (Secondes)", "settings_locationIntervalInvalid": "L'intervalle doit être compris entre 60 et 86400 secondes.", "contacts_manageRoom": "Gérer le Room Server", - "room_management": "Administración del Servidor de Habitación", + "room_management": "Administrattion Room Server", "@community_joinConfirmation": { "placeholders": { "name": { @@ -1551,6 +1561,8 @@ "contacts_invalidAdvertFormat": "Données de contact non valides", "appSettings_languageUk": "Ukrainien", "appSettings_languageRu": "Russe", + "appSettings_enableMessageTracing": "Activer le traçage des messages", + "appSettings_enableMessageTracingSubtitle": "Afficher les métadonnées détaillées de routage et de synchronisation des messages", "contacts_clipboardEmpty": "Le presse-papiers est vide.", "contacts_contactImported": "Le contact a été importé.", "contacts_floodAdvert": "Annonce à tout le réseau", @@ -1594,7 +1606,152 @@ "scanner_bluetoothOffMessage": "Veuillez activer le Bluetooth pour rechercher des appareils.", "scanner_bluetoothOff": "Le Bluetooth est désactivé.", "scanner_enableBluetooth": "Activer le Bluetooth", + "snrIndicator_lastSeen": "Dernière fois vu", + "snrIndicator_nearByRepeaters": "Répéteurs à proximité", + "chat_ShowAllPaths": "Afficher tous les chemins", "settings_clientRepeatFreqWarning": "Pour les transmissions hors réseau, il est nécessaire d'utiliser les fréquences de 433, 869 ou 918 MHz.", "settings_clientRepeatSubtitle": "Permettez à cet appareil de répéter les paquets de données pour les autres.", - "settings_clientRepeat": "Répétition hors réseau" + "settings_clientRepeat": "Répétition hors réseau", + "settings_aboutOpenMeteoAttribution": "Données d'élévation LOS : Open-Meteo (CC BY 4.0)", + "appSettings_unitsTitle": "Unités", + "appSettings_unitsMetric": "Métrique (m/km)", + "appSettings_unitsImperial": "Impérial (ft / mi)", + "map_lineOfSight": "Ligne de vue", + "map_losScreenTitle": "Ligne de vue", + "losSelectStartEnd": "Sélectionnez les nœuds de début et de fin pour LOS.", + "losRunFailed": "Échec de la vérification de la ligne de vue : {error}", + "@losRunFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "losClearAllPoints": "Effacer tous les points", + "losRunToViewElevationProfile": "Exécutez LOS pour afficher le profil d'altitude", + "losMenuTitle": "Menu LOS", + "losMenuSubtitle": "Appuyez sur les nœuds ou appuyez longuement sur la carte pour des points personnalisés", + "losShowDisplayNodes": "Afficher les nœuds d'affichage", + "losCustomPoints": "Points personnalisés", + "losCustomPointLabel": "Personnalisé {index}", + "@losCustomPointLabel": { + "placeholders": { + "index": { + "type": "int" + } + } + }, + "losPointA": "Point A", + "losPointB": "Point B", + "losAntennaA": "Antenne A : {value} {unit}", + "@losAntennaA": { + "placeholders": { + "value": { + "type": "String" + }, + "unit": { + "type": "String" + } + } + }, + "losAntennaB": "Antenne B : {value} {unit}", + "@losAntennaB": { + "placeholders": { + "value": { + "type": "String" + }, + "unit": { + "type": "String" + } + } + }, + "losRun": "Exécuter la LOS", + "losNoElevationData": "Aucune donnée d'altitude", + "losProfileClear": "{distance} {distanceUnit}, LOS clair, clairance minimale {clearance} {heightUnit}", + "@losProfileClear": { + "placeholders": { + "distance": { + "type": "String" + }, + "distanceUnit": { + "type": "String" + }, + "clearance": { + "type": "String" + }, + "heightUnit": { + "type": "String" + } + } + }, + "losProfileBlocked": "{distance} {distanceUnit}, bloqué par {obstruction} {heightUnit}", + "@losProfileBlocked": { + "placeholders": { + "distance": { + "type": "String" + }, + "distanceUnit": { + "type": "String" + }, + "obstruction": { + "type": "String" + }, + "heightUnit": { + "type": "String" + } + } + }, + "losStatusChecking": "LOS : vérification...", + "losStatusNoData": "LOS : aucune donnée", + "losStatusSummary": "LOS : {clear}/{total} clair, {blocked} bloqué, {unknown} inconnu", + "@losStatusSummary": { + "placeholders": { + "clear": { + "type": "int" + }, + "total": { + "type": "int" + }, + "blocked": { + "type": "int" + }, + "unknown": { + "type": "int" + } + } + }, + "losErrorElevationUnavailable": "Données d'altitude indisponibles pour un ou plusieurs échantillons.", + "losErrorInvalidInput": "Données de points/d'altitude non valides pour le calcul de la LOS.", + "losRenameCustomPoint": "Renommer le point personnalisé", + "losPointName": "Nom du point", + "losShowPanelTooltip": "Afficher le panneau LOS", + "losHidePanelTooltip": "Masquer le panneau LOS", + "losElevationAttribution": "Données d’altitude : Open-Meteo (CC BY 4.0)", + "losLegendRadioHorizon": "Horizon radio", + "losLegendLosBeam": "Ligne de visée", + "losLegendTerrain": "Terrain", + "losFrequencyLabel": "Fréquence", + "losFrequencyInfoTooltip": "Voir les détails du calcul", + "losFrequencyDialogTitle": "Calcul de l’horizon radio", + "losFrequencyDialogDescription": "À partir de k={baselineK} à {baselineFreq} MHz, le calcul ajuste le facteur k pour la bande actuelle de {frequencyMHz} MHz, ce qui définit la limite incurvée de l'horizon radio.", + "@losFrequencyDialogDescription": { + "description": "Explain how the calculation uses the baseline frequency and derived k-factor.", + "placeholders": { + "baselineK": { + "type": "double" + }, + "baselineFreq": { + "type": "double" + }, + "frequencyMHz": { + "type": "double" + }, + "kFactor": { + "type": "double" + } + } + }, + "listFilter_addToFavorites": "Ajouter à mes favoris", + "listFilter_removeFromFavorites": "Supprimer des favoris", + "listFilter_favorites": "Préférences" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 239c765..d6c02f0 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1,4 +1,12 @@ { + "channels_channelDeleteFailed": "Impossibile eliminare il canale \"{name}\"", + "@channels_channelDeleteFailed": { + "placeholders": { + "name": { + "type": "String" + } + } + }, "@@locale": "it", "appTitle": "MeshCore Open", "nav_contacts": "Contatti", @@ -334,6 +342,8 @@ "channels_publicChannel": "Canale pubblico", "channels_privateChannel": "Canale privato", "channels_editChannel": "Modifica canale", + "channels_muteChannel": "Silenzia canale", + "channels_unmuteChannel": "Attiva notifiche canale", "channels_deleteChannel": "Elimina canale", "channels_deleteChannelConfirm": "Eliminare \"{name}\"? Non può essere annullato.", "@channels_deleteChannelConfirm": { @@ -1351,12 +1361,12 @@ } } }, - "repeater_neighbours": "Vicini", - "repeater_neighboursSubtitle": "Visualizza vicini di salto pari a zero.", + "repeater_neighbors": "Vicini", + "repeater_neighborsSubtitle": "Visualizza vicini di salto pari a zero.", "neighbors_receivedData": "Ricevute dati vicini", "neighbors_requestTimedOut": "I vicini richiedono un timeout.", "neighbors_errorLoading": "Errore nel caricamento dei vicini: {error}", - "neighbors_repeatersNeighbours": "Ripetitori Vicini", + "neighbors_repeatersNeighbors": "Ripetitori Vicini", "neighbors_noData": "Nessun dato sugli vicini disponibile.", "channels_createPrivateChannel": "Crea un Canale Privato", "channels_createPrivateChannelDesc": "Protetta con una chiave segreta.", @@ -1551,6 +1561,8 @@ "appSettings_languageRu": "Russo", "contacts_invalidAdvertFormat": "Dati di contatto non validi", "appSettings_languageUk": "Ucraino", + "appSettings_enableMessageTracing": "Abilita tracciamento messaggi", + "appSettings_enableMessageTracingSubtitle": "Mostra metadati dettagliati su instradamento e tempi per i messaggi", "contacts_zeroHopAdvert": "Annuncio Zero Hop", "contacts_floodAdvert": "Annuncio alluvionale", "contacts_copyAdvertToClipboard": "Copia Annuncio negli Appunti", @@ -1594,7 +1606,152 @@ "scanner_bluetoothOff": "Il Bluetooth è disattivato.", "scanner_bluetoothOffMessage": "Si prega di attivare il Bluetooth per effettuare la scansione dei dispositivi.", "scanner_enableBluetooth": "Abilita il Bluetooth", + "snrIndicator_nearByRepeaters": "Ripetitori vicini", + "snrIndicator_lastSeen": "Ultimo accesso", + "chat_ShowAllPaths": "Mostra tutti i percorsi", "settings_clientRepeat": "Ripetizione \"fuori dalla rete\"", "settings_clientRepeatFreqWarning": "Per la comunicazione fuori rete, è necessario utilizzare frequenze di 433, 869 o 918 MHz.", - "settings_clientRepeatSubtitle": "Permetti a questo dispositivo di ripetere i pacchetti di rete per gli altri." + "settings_clientRepeatSubtitle": "Permetti a questo dispositivo di ripetere i pacchetti di rete per gli altri.", + "settings_aboutOpenMeteoAttribution": "Dati di elevazione LOS: Open-Meteo (CC BY 4.0)", + "appSettings_unitsTitle": "Unità", + "appSettings_unitsMetric": "Metrico (m/km)", + "appSettings_unitsImperial": "Imperiale (ft / mi)", + "map_lineOfSight": "Linea di vista", + "map_losScreenTitle": "Linea di vista", + "losSelectStartEnd": "Seleziona i nodi iniziali e finali per la LOS.", + "losRunFailed": "Controllo della linea di vista fallito: {error}", + "@losRunFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "losClearAllPoints": "Cancella tutti i punti", + "losRunToViewElevationProfile": "Eseguire LOS per visualizzare il profilo altimetrico", + "losMenuTitle": "Menù LOS", + "losMenuSubtitle": "Tocca i nodi o premi a lungo la mappa per punti personalizzati", + "losShowDisplayNodes": "Mostra i nodi di visualizzazione", + "losCustomPoints": "Punti personalizzati", + "losCustomPointLabel": "Personalizzato {index}", + "@losCustomPointLabel": { + "placeholders": { + "index": { + "type": "int" + } + } + }, + "losPointA": "Punto A", + "losPointB": "Punto B", + "losAntennaA": "Antenna A: {value} {unit}", + "@losAntennaA": { + "placeholders": { + "value": { + "type": "String" + }, + "unit": { + "type": "String" + } + } + }, + "losAntennaB": "Antenna B: {value} {unit}", + "@losAntennaB": { + "placeholders": { + "value": { + "type": "String" + }, + "unit": { + "type": "String" + } + } + }, + "losRun": "Esegui LOS", + "losNoElevationData": "Nessun dato di elevazione", + "losProfileClear": "{distance} {distanceUnit}, libera LOS, distanza minima {clearance} {heightUnit}", + "@losProfileClear": { + "placeholders": { + "distance": { + "type": "String" + }, + "distanceUnit": { + "type": "String" + }, + "clearance": { + "type": "String" + }, + "heightUnit": { + "type": "String" + } + } + }, + "losProfileBlocked": "{distance} {distanceUnit}, bloccato da {obstruction} {heightUnit}", + "@losProfileBlocked": { + "placeholders": { + "distance": { + "type": "String" + }, + "distanceUnit": { + "type": "String" + }, + "obstruction": { + "type": "String" + }, + "heightUnit": { + "type": "String" + } + } + }, + "losStatusChecking": "LOS: controllo...", + "losStatusNoData": "LOS: nessun dato", + "losStatusSummary": "LOS: {clear}/{total} libera, {blocked} bloccato, {unknown} sconosciuto", + "@losStatusSummary": { + "placeholders": { + "clear": { + "type": "int" + }, + "total": { + "type": "int" + }, + "blocked": { + "type": "int" + }, + "unknown": { + "type": "int" + } + } + }, + "losErrorElevationUnavailable": "Dati di elevazione non disponibili per uno o più campioni.", + "losErrorInvalidInput": "Dati punti/elevazione non validi per il calcolo della LOS.", + "losRenameCustomPoint": "Rinomina punto personalizzato", + "losPointName": "Nome del punto", + "losShowPanelTooltip": "Mostra il pannello LOS", + "losHidePanelTooltip": "Nascondi il pannello LOS", + "losElevationAttribution": "Dati di elevazione: Open-Meteo (CC BY 4.0)", + "losLegendRadioHorizon": "Orizzonte radio", + "losLegendLosBeam": "Linea di vista", + "losLegendTerrain": "Terreno", + "losFrequencyLabel": "Frequenza", + "losFrequencyInfoTooltip": "Visualizza i dettagli del calcolo", + "losFrequencyDialogTitle": "Calcolo dell’orizzonte radio", + "losFrequencyDialogDescription": "Partendo da k={baselineK} a {baselineFreq} MHz, il calcolo regola il fattore k per l'attuale banda {frequencyMHz} MHz, che definisce il limite curvo dell'orizzonte radio.", + "@losFrequencyDialogDescription": { + "description": "Explain how the calculation uses the baseline frequency and derived k-factor.", + "placeholders": { + "baselineK": { + "type": "double" + }, + "baselineFreq": { + "type": "double" + }, + "frequencyMHz": { + "type": "double" + }, + "kFactor": { + "type": "double" + } + } + }, + "listFilter_addToFavorites": "Aggiungi ai preferiti", + "listFilter_removeFromFavorites": "Rimuovi dai preferiti", + "listFilter_favorites": "Preferiti" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 7235e90..d64cdb0 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -700,6 +700,12 @@ abstract class AppLocalizations { /// **'An open-source Flutter client for MeshCore LoRa mesh networking devices.'** String get settings_aboutDescription; + /// No description provided for @settings_aboutOpenMeteoAttribution. + /// + /// In en, this message translates to: + /// **'LOS elevation data: Open-Meteo (CC BY 4.0)'** + String get settings_aboutOpenMeteoAttribution; + /// No description provided for @settings_infoName. /// /// In en, this message translates to: @@ -964,6 +970,18 @@ abstract class AppLocalizations { /// **'Українська'** String get appSettings_languageUk; + /// No description provided for @appSettings_enableMessageTracing. + /// + /// In en, this message translates to: + /// **'Enable Message Tracing'** + String get appSettings_enableMessageTracing; + + /// No description provided for @appSettings_enableMessageTracingSubtitle. + /// + /// In en, this message translates to: + /// **'Show detailed routing and timing metadata for messages'** + String get appSettings_enableMessageTracingSubtitle; + /// No description provided for @appSettings_notifications. /// /// In en, this message translates to: @@ -1240,6 +1258,24 @@ abstract class AppLocalizations { /// **'Offline Map Cache'** String get appSettings_offlineMapCache; + /// No description provided for @appSettings_unitsTitle. + /// + /// In en, this message translates to: + /// **'Units'** + String get appSettings_unitsTitle; + + /// No description provided for @appSettings_unitsMetric. + /// + /// In en, this message translates to: + /// **'Metric (m / km)'** + String get appSettings_unitsMetric; + + /// No description provided for @appSettings_unitsImperial. + /// + /// In en, this message translates to: + /// **'Imperial (ft / mi)'** + String get appSettings_unitsImperial; + /// No description provided for @appSettings_noAreaSelected. /// /// In en, this message translates to: @@ -1522,6 +1558,18 @@ abstract class AppLocalizations { /// **'Edit channel'** String get channels_editChannel; + /// No description provided for @channels_muteChannel. + /// + /// In en, this message translates to: + /// **'Mute channel'** + String get channels_muteChannel; + + /// No description provided for @channels_unmuteChannel. + /// + /// In en, this message translates to: + /// **'Unmute channel'** + String get channels_unmuteChannel; + /// No description provided for @channels_deleteChannel. /// /// In en, this message translates to: @@ -1534,6 +1582,12 @@ abstract class AppLocalizations { /// **'Delete \"{name}\"? This cannot be undone.'** String channels_deleteChannelConfirm(String name); + /// No description provided for @channels_channelDeleteFailed. + /// + /// In en, this message translates to: + /// **'Failed to delete channel \"{name}\"'** + String channels_channelDeleteFailed(String name); + /// No description provided for @channels_channelDeleted. /// /// In en, this message translates to: @@ -2032,6 +2086,12 @@ abstract class AppLocalizations { /// **'Path Management'** String get chat_pathManagement; + /// No description provided for @chat_ShowAllPaths. + /// + /// In en, this message translates to: + /// **'Show all paths'** + String get chat_ShowAllPaths; + /// No description provided for @chat_routingMode. /// /// In en, this message translates to: @@ -2284,6 +2344,18 @@ abstract class AppLocalizations { /// **'Node Map'** String get map_title; + /// No description provided for @map_lineOfSight. + /// + /// In en, this message translates to: + /// **'Line of Sight'** + String get map_lineOfSight; + + /// No description provided for @map_losScreenTitle. + /// + /// In en, this message translates to: + /// **'Line of Sight'** + String get map_losScreenTitle; + /// No description provided for @map_noNodesWithLocation. /// /// In en, this message translates to: @@ -3027,17 +3099,17 @@ abstract class AppLocalizations { /// **'Send commands to the repeater'** String get repeater_cliSubtitle; - /// No description provided for @repeater_neighbours. + /// No description provided for @repeater_neighbors. /// /// In en, this message translates to: /// **'Neighbors'** - String get repeater_neighbours; + String get repeater_neighbors; - /// No description provided for @repeater_neighboursSubtitle. + /// No description provided for @repeater_neighborsSubtitle. /// /// In en, this message translates to: /// **'View zero hop neighbors.'** - String get repeater_neighboursSubtitle; + String get repeater_neighborsSubtitle; /// No description provided for @repeater_settings. /// @@ -4181,13 +4253,13 @@ abstract class AppLocalizations { /// No description provided for @neighbors_receivedData. /// /// In en, this message translates to: - /// **'Received Neighbours Data'** + /// **'Received Neighbors Data'** String get neighbors_receivedData; /// No description provided for @neighbors_requestTimedOut. /// /// In en, this message translates to: - /// **'Neighbours request timed out.'** + /// **'Neighbors request timed out.'** String get neighbors_requestTimedOut; /// No description provided for @neighbors_errorLoading. @@ -4196,16 +4268,16 @@ abstract class AppLocalizations { /// **'Error loading neighbors: {error}'** String neighbors_errorLoading(String error); - /// No description provided for @neighbors_repeatersNeighbours. + /// No description provided for @neighbors_repeatersNeighbors. /// /// In en, this message translates to: - /// **'Repeaters Neighbours'** - String get neighbors_repeatersNeighbours; + /// **'Repeaters Neighbors'** + String get neighbors_repeatersNeighbors; /// No description provided for @neighbors_noData. /// /// In en, this message translates to: - /// **'No neighbours data available.'** + /// **'No neighbors data available.'** String get neighbors_noData; /// No description provided for @neighbors_unknownContact. @@ -4700,6 +4772,24 @@ abstract class AppLocalizations { /// **'All'** String get listFilter_all; + /// No description provided for @listFilter_favorites. + /// + /// In en, this message translates to: + /// **'Favorites'** + String get listFilter_favorites; + + /// No description provided for @listFilter_addToFavorites. + /// + /// In en, this message translates to: + /// **'Add to favorites'** + String get listFilter_addToFavorites; + + /// No description provided for @listFilter_removeFromFavorites. + /// + /// In en, this message translates to: + /// **'Remove from favorites'** + String get listFilter_removeFromFavorites; + /// No description provided for @listFilter_users. /// /// In en, this message translates to: @@ -4766,6 +4856,225 @@ abstract class AppLocalizations { /// **'Clear path.'** String get pathTrace_clearTooltip; + /// No description provided for @losSelectStartEnd. + /// + /// In en, this message translates to: + /// **'Select start and end nodes for LOS.'** + String get losSelectStartEnd; + + /// No description provided for @losRunFailed. + /// + /// In en, this message translates to: + /// **'Line-of-sight check failed: {error}'** + String losRunFailed(String error); + + /// No description provided for @losClearAllPoints. + /// + /// In en, this message translates to: + /// **'Clear all points'** + String get losClearAllPoints; + + /// No description provided for @losRunToViewElevationProfile. + /// + /// In en, this message translates to: + /// **'Run LOS to view elevation profile'** + String get losRunToViewElevationProfile; + + /// No description provided for @losMenuTitle. + /// + /// In en, this message translates to: + /// **'LOS Menu'** + String get losMenuTitle; + + /// No description provided for @losMenuSubtitle. + /// + /// In en, this message translates to: + /// **'Tap nodes or long-press map for custom points'** + String get losMenuSubtitle; + + /// No description provided for @losShowDisplayNodes. + /// + /// In en, this message translates to: + /// **'Show display nodes'** + String get losShowDisplayNodes; + + /// No description provided for @losCustomPoints. + /// + /// In en, this message translates to: + /// **'Custom points'** + String get losCustomPoints; + + /// No description provided for @losCustomPointLabel. + /// + /// In en, this message translates to: + /// **'Custom {index}'** + String losCustomPointLabel(int index); + + /// No description provided for @losPointA. + /// + /// In en, this message translates to: + /// **'Point A'** + String get losPointA; + + /// No description provided for @losPointB. + /// + /// In en, this message translates to: + /// **'Point B'** + String get losPointB; + + /// No description provided for @losAntennaA. + /// + /// In en, this message translates to: + /// **'Antenna A: {value} {unit}'** + String losAntennaA(String value, String unit); + + /// No description provided for @losAntennaB. + /// + /// In en, this message translates to: + /// **'Antenna B: {value} {unit}'** + String losAntennaB(String value, String unit); + + /// No description provided for @losRun. + /// + /// In en, this message translates to: + /// **'Run LOS'** + String get losRun; + + /// No description provided for @losNoElevationData. + /// + /// In en, this message translates to: + /// **'No elevation data'** + String get losNoElevationData; + + /// No description provided for @losProfileClear. + /// + /// In en, this message translates to: + /// **'{distance} {distanceUnit}, clear LOS, min clearance {clearance} {heightUnit}'** + String losProfileClear( + String distance, + String distanceUnit, + String clearance, + String heightUnit, + ); + + /// No description provided for @losProfileBlocked. + /// + /// In en, this message translates to: + /// **'{distance} {distanceUnit}, blocked by {obstruction} {heightUnit}'** + String losProfileBlocked( + String distance, + String distanceUnit, + String obstruction, + String heightUnit, + ); + + /// No description provided for @losStatusChecking. + /// + /// In en, this message translates to: + /// **'LOS: checking...'** + String get losStatusChecking; + + /// No description provided for @losStatusNoData. + /// + /// In en, this message translates to: + /// **'LOS: no data'** + String get losStatusNoData; + + /// No description provided for @losStatusSummary. + /// + /// In en, this message translates to: + /// **'LOS: {clear}/{total} clear, {blocked} blocked, {unknown} unknown'** + String losStatusSummary(int clear, int total, int blocked, int unknown); + + /// No description provided for @losErrorElevationUnavailable. + /// + /// In en, this message translates to: + /// **'Elevation data unavailable for one or more samples.'** + String get losErrorElevationUnavailable; + + /// No description provided for @losErrorInvalidInput. + /// + /// In en, this message translates to: + /// **'Invalid points/elevation data for LOS calculation.'** + String get losErrorInvalidInput; + + /// No description provided for @losRenameCustomPoint. + /// + /// In en, this message translates to: + /// **'Rename custom point'** + String get losRenameCustomPoint; + + /// No description provided for @losPointName. + /// + /// In en, this message translates to: + /// **'Point name'** + String get losPointName; + + /// No description provided for @losShowPanelTooltip. + /// + /// In en, this message translates to: + /// **'Show LOS panel'** + String get losShowPanelTooltip; + + /// No description provided for @losHidePanelTooltip. + /// + /// In en, this message translates to: + /// **'Hide LOS panel'** + String get losHidePanelTooltip; + + /// No description provided for @losElevationAttribution. + /// + /// In en, this message translates to: + /// **'Elevation data: Open-Meteo (CC BY 4.0)'** + String get losElevationAttribution; + + /// No description provided for @losLegendRadioHorizon. + /// + /// In en, this message translates to: + /// **'Radio horizon'** + String get losLegendRadioHorizon; + + /// No description provided for @losLegendLosBeam. + /// + /// In en, this message translates to: + /// **'LOS beam'** + String get losLegendLosBeam; + + /// No description provided for @losLegendTerrain. + /// + /// In en, this message translates to: + /// **'Terrain'** + String get losLegendTerrain; + + /// No description provided for @losFrequencyLabel. + /// + /// In en, this message translates to: + /// **'Frequency'** + String get losFrequencyLabel; + + /// No description provided for @losFrequencyInfoTooltip. + /// + /// In en, this message translates to: + /// **'View calculation details'** + String get losFrequencyInfoTooltip; + + /// No description provided for @losFrequencyDialogTitle. + /// + /// In en, this message translates to: + /// **'Radio horizon calculation'** + String get losFrequencyDialogTitle; + + /// Explain how the calculation uses the baseline frequency and derived k-factor. + /// + /// In en, this message translates to: + /// **'Starting from k={baselineK} at {baselineFreq} MHz, the calculation adjusts the k-factor for the current {frequencyMHz} MHz band, which defines the curved radio horizon cap.'** + String losFrequencyDialogDescription( + double baselineK, + double baselineFreq, + double frequencyMHz, + double kFactor, + ); + /// No description provided for @contacts_pathTrace. /// /// In en, this message translates to: @@ -5023,6 +5332,18 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'meshcore-open GPX map data export'** String get settings_gpxExportShareSubject; + + /// No description provided for @snrIndicator_nearByRepeaters. + /// + /// In en, this message translates to: + /// **'Nearby Repeaters'** + String get snrIndicator_nearByRepeaters; + + /// No description provided for @snrIndicator_lastSeen. + /// + /// In en, this message translates to: + /// **'Last seen'** + String get snrIndicator_lastSeen; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index fdf9ec7..f9637aa 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -326,6 +326,10 @@ class AppLocalizationsBg extends AppLocalizations { String get settings_aboutDescription => 'Отворен софтуер за Flutter клиент за MeshCore LoRa мрежови устройства.'; + @override + String get settings_aboutOpenMeteoAttribution => + 'Данни за надморска височина на LOS: Open-Meteo (CC BY 4.0)'; + @override String get settings_infoName => 'Име'; @@ -462,6 +466,14 @@ class AppLocalizationsBg extends AppLocalizations { @override String get appSettings_languageUk => 'Украински'; + @override + String get appSettings_enableMessageTracing => + 'Разрешаване на проследяване на съобщения'; + + @override + String get appSettings_enableMessageTracingSubtitle => + 'Показване на подробни метаданни за маршрутизация и синхронизация за съобщения'; + @override String get appSettings_notifications => 'Уведомления'; @@ -622,6 +634,15 @@ class AppLocalizationsBg extends AppLocalizations { @override String get appSettings_offlineMapCache => 'Кеш на офлайн карти'; + @override + String get appSettings_unitsTitle => 'единици'; + + @override + String get appSettings_unitsMetric => 'Метрика (m / km)'; + + @override + String get appSettings_unitsImperial => 'Имперска (ft / mi)'; + @override String get appSettings_noAreaSelected => 'Няма избрана област'; @@ -785,6 +806,12 @@ class AppLocalizationsBg extends AppLocalizations { @override String get channels_editChannel => 'Редактирай канал'; + @override + String get channels_muteChannel => 'Заглуши канала'; + + @override + String get channels_unmuteChannel => 'Включи известията на канала'; + @override String get channels_deleteChannel => 'Изтрий канала'; @@ -793,6 +820,11 @@ class AppLocalizationsBg extends AppLocalizations { return 'Изтрий \"$name\"? Това не може да бъде отменено.'; } + @override + String channels_channelDeleteFailed(String name) { + return 'Неуспешно изтриване на канала \"$name\"'; + } + @override String channels_channelDeleted(String name) { return 'Каналът \"$name\" е изтрит'; @@ -1080,6 +1112,9 @@ class AppLocalizationsBg extends AppLocalizations { @override String get chat_pathManagement => 'Управление на пътища'; + @override + String get chat_ShowAllPaths => 'Покажи всички пътища'; + @override String get chat_routingMode => 'Режим на маршрутизиране'; @@ -1240,6 +1275,12 @@ class AppLocalizationsBg extends AppLocalizations { @override String get map_title => 'Карта на възлите'; + @override + String get map_lineOfSight => 'Линия на видимост'; + + @override + String get map_losScreenTitle => 'Линия на видимост'; + @override String get map_noNodesWithLocation => 'Няма възли с данни за местоположение.'; @@ -1677,10 +1718,10 @@ class AppLocalizationsBg extends AppLocalizations { String get repeater_cliSubtitle => 'Изпрати команди към ретранслатора'; @override - String get repeater_neighbours => 'Съседи'; + String get repeater_neighbors => 'Съседи'; @override - String get repeater_neighboursSubtitle => + String get repeater_neighborsSubtitle => 'Преглед на съседни възли с нулев скок.'; @override @@ -2380,7 +2421,7 @@ class AppLocalizationsBg extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => 'Повторители Съседи'; + String get neighbors_repeatersNeighbors => 'Повторители Съседи'; @override String get neighbors_noData => 'Няма налични данни за съседи.'; @@ -2687,6 +2728,15 @@ class AppLocalizationsBg extends AppLocalizations { @override String get listFilter_all => 'Всички'; + @override + String get listFilter_favorites => 'Любими'; + + @override + String get listFilter_addToFavorites => 'Добави към любими'; + + @override + String get listFilter_removeFromFavorites => 'Премахване от списъка с любими'; + @override String get listFilter_users => 'Потребители'; @@ -2721,6 +2771,144 @@ class AppLocalizationsBg extends AppLocalizations { @override String get pathTrace_clearTooltip => 'Изчисти пътя'; + @override + String get losSelectStartEnd => 'Изберете начални и крайни възли за LOS.'; + + @override + String losRunFailed(String error) { + return 'Проверката на пряката видимост е неуспешна: $error'; + } + + @override + String get losClearAllPoints => 'Изчистете всички точки'; + + @override + String get losRunToViewElevationProfile => + 'Стартирайте LOS, за да видите профила на надморската височина'; + + @override + String get losMenuTitle => 'LOS меню'; + + @override + String get losMenuSubtitle => + 'Докоснете възли или натиснете продължително карта за персонализирани точки'; + + @override + String get losShowDisplayNodes => 'Показване на възли на дисплея'; + + @override + String get losCustomPoints => 'Персонализирани точки'; + + @override + String losCustomPointLabel(int index) { + return 'Персонализирано $index'; + } + + @override + String get losPointA => 'Точка А'; + + @override + String get losPointB => 'Точка Б'; + + @override + String losAntennaA(String value, String unit) { + return 'Антена A: $value $unit'; + } + + @override + String losAntennaB(String value, String unit) { + return 'Антена B: $value $unit'; + } + + @override + String get losRun => 'Стартирайте LOS'; + + @override + String get losNoElevationData => 'Няма данни за надморска височина'; + + @override + String losProfileClear( + String distance, + String distanceUnit, + String clearance, + String heightUnit, + ) { + return '$distance $distanceUnit, чист LOS, минимално разстояние $clearance $heightUnit'; + } + + @override + String losProfileBlocked( + String distance, + String distanceUnit, + String obstruction, + String heightUnit, + ) { + return '$distance $distanceUnit, блокиран от $obstruction $heightUnit'; + } + + @override + String get losStatusChecking => 'LOS: проверка...'; + + @override + String get losStatusNoData => 'LOS: няма данни'; + + @override + String losStatusSummary(int clear, int total, int blocked, int unknown) { + return 'LOS: $clear/$total ясно, $blocked блокирано, $unknown неизвестно'; + } + + @override + String get losErrorElevationUnavailable => + 'Няма налични данни за надморска височина за една или повече проби.'; + + @override + String get losErrorInvalidInput => + 'Невалидни данни за точки/надморска височина за изчисляване на LOS.'; + + @override + String get losRenameCustomPoint => 'Преименувайте персонализирана точка'; + + @override + String get losPointName => 'Име на точката'; + + @override + String get losShowPanelTooltip => 'Показване на LOS панел'; + + @override + String get losHidePanelTooltip => 'Скриване на LOS панела'; + + @override + String get losElevationAttribution => + 'Данни за надморска височина: Open-Meteo (CC BY 4.0)'; + + @override + String get losLegendRadioHorizon => 'Радиохоризонт'; + + @override + String get losLegendLosBeam => 'Линия на видимост'; + + @override + String get losLegendTerrain => 'Терен'; + + @override + String get losFrequencyLabel => 'Честота'; + + @override + String get losFrequencyInfoTooltip => 'Преглед на детайли за изчислението'; + + @override + String get losFrequencyDialogTitle => 'Изчисляване на радиохоризонта'; + + @override + String losFrequencyDialogDescription( + double baselineK, + double baselineFreq, + double frequencyMHz, + double kFactor, + ) { + return 'Започвайки от k=$baselineK при $baselineFreq MHz, изчислението коригира k-фактора за текущата $frequencyMHz MHz лента, която определя границата на извития радиохоризонт.'; + } + @override String get contacts_pathTrace => 'Пътен проследяване'; @@ -2890,4 +3078,10 @@ class AppLocalizationsBg extends AppLocalizations { @override String get settings_gpxExportShareSubject => 'meshcore-open износ на данни за карта в формат GPX'; + + @override + String get snrIndicator_nearByRepeaters => 'Близки повтарящи се устройства'; + + @override + String get snrIndicator_lastSeen => 'Последно видян'; } diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index c0fc4c8..1574281 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -320,6 +320,10 @@ class AppLocalizationsDe extends AppLocalizations { String get settings_aboutDescription => 'Ein Open-Source-Flutter-Client für MeshCore LoRa-Meshnetzwerkgeräte.'; + @override + String get settings_aboutOpenMeteoAttribution => + 'LOS-Höhendaten: Open-Meteo (CC BY 4.0)'; + @override String get settings_infoName => 'Name'; @@ -456,6 +460,14 @@ class AppLocalizationsDe extends AppLocalizations { @override String get appSettings_languageUk => 'Ukrainisch'; + @override + String get appSettings_enableMessageTracing => + 'Nachrichtenverfolgung aktivieren'; + + @override + String get appSettings_enableMessageTracingSubtitle => + 'Detaillierte Routing- und Timing-Metadaten für Nachrichten anzeigen'; + @override String get appSettings_notifications => 'Benachrichtigungen'; @@ -619,6 +631,15 @@ class AppLocalizationsDe extends AppLocalizations { @override String get appSettings_offlineMapCache => 'Offline-Karten-Cache'; + @override + String get appSettings_unitsTitle => 'Einheiten'; + + @override + String get appSettings_unitsMetric => 'Metrisch (m/km)'; + + @override + String get appSettings_unitsImperial => 'Imperial (ft/mi)'; + @override String get appSettings_noAreaSelected => 'Kein Bereich ausgewählt'; @@ -782,6 +803,12 @@ class AppLocalizationsDe extends AppLocalizations { @override String get channels_editChannel => 'Kanal bearbeiten'; + @override + String get channels_muteChannel => 'Kanal stummschalten'; + + @override + String get channels_unmuteChannel => 'Kanal Stummschaltung aufheben'; + @override String get channels_deleteChannel => 'Lösche den Kanal'; @@ -790,6 +817,11 @@ class AppLocalizationsDe extends AppLocalizations { return 'Löschen von \"$name\"? Dies kann nicht rückgängig gemacht werden.'; } + @override + String channels_channelDeleteFailed(String name) { + return 'Kanal $name konnte nicht gelöscht werden'; + } + @override String channels_channelDeleted(String name) { return 'Kanal \"$name\" gelöscht'; @@ -1080,6 +1112,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get chat_pathManagement => 'Pfadverwaltung'; + @override + String get chat_ShowAllPaths => 'Alle Pfade anzeigen'; + @override String get chat_routingMode => 'Routenmodus'; @@ -1239,6 +1274,12 @@ class AppLocalizationsDe extends AppLocalizations { @override String get map_title => 'Karte'; + @override + String get map_lineOfSight => 'Sichtlinie'; + + @override + String get map_losScreenTitle => 'Sichtlinie'; + @override String get map_noNodesWithLocation => 'Keine Knoten mit Standortdaten'; @@ -1676,10 +1717,10 @@ class AppLocalizationsDe extends AppLocalizations { String get repeater_cliSubtitle => 'Sende Befehle an den Repeater'; @override - String get repeater_neighbours => 'Nachbarn'; + String get repeater_neighbors => 'Nachbarn'; @override - String get repeater_neighboursSubtitle => 'Anzahl der Hop-Nachbarn anzeigen.'; + String get repeater_neighborsSubtitle => 'Anzahl der Hop-Nachbarn anzeigen.'; @override String get repeater_settings => 'Einstellungen'; @@ -2382,7 +2423,7 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => 'Nachbarn'; + String get neighbors_repeatersNeighbors => 'Nachbarn'; @override String get neighbors_noData => 'Keine Nachbarsdaten verfügbar.'; @@ -2692,6 +2733,15 @@ class AppLocalizationsDe extends AppLocalizations { @override String get listFilter_all => 'Alle'; + @override + String get listFilter_favorites => 'Favoriten'; + + @override + String get listFilter_addToFavorites => 'Zu Favoriten hinzufügen'; + + @override + String get listFilter_removeFromFavorites => 'Aus Favoriten entfernen'; + @override String get listFilter_users => 'Benutzer'; @@ -2726,6 +2776,145 @@ class AppLocalizationsDe extends AppLocalizations { @override String get pathTrace_clearTooltip => 'Pfad löschen'; + @override + String get losSelectStartEnd => + 'Wählen Sie Start- und Endknoten für LOS aus.'; + + @override + String losRunFailed(String error) { + return 'Sichtlinienprüfung fehlgeschlagen: $error'; + } + + @override + String get losClearAllPoints => 'Löschen Sie alle Punkte'; + + @override + String get losRunToViewElevationProfile => + 'Führen Sie LOS aus, um das Höhenprofil anzuzeigen'; + + @override + String get losMenuTitle => 'LOS-Menü'; + + @override + String get losMenuSubtitle => + 'Tippen Sie auf Knoten oder drücken Sie lange auf die Karte, um benutzerdefinierte Punkte anzuzeigen'; + + @override + String get losShowDisplayNodes => 'Anzeigeknoten anzeigen'; + + @override + String get losCustomPoints => 'Benutzerdefinierte Punkte'; + + @override + String losCustomPointLabel(int index) { + return 'Benutzerdefiniert $index'; + } + + @override + String get losPointA => 'Punkt A'; + + @override + String get losPointB => 'Punkt B'; + + @override + String losAntennaA(String value, String unit) { + return 'Antenne A: $value $unit'; + } + + @override + String losAntennaB(String value, String unit) { + return 'Antenne B: $value $unit'; + } + + @override + String get losRun => 'Führen Sie LOS aus'; + + @override + String get losNoElevationData => 'Keine Höhendaten'; + + @override + String losProfileClear( + String distance, + String distanceUnit, + String clearance, + String heightUnit, + ) { + return '$distance $distanceUnit, freie Sichtlinie, Mindestabstand $clearance $heightUnit'; + } + + @override + String losProfileBlocked( + String distance, + String distanceUnit, + String obstruction, + String heightUnit, + ) { + return '$distance $distanceUnit, blockiert durch $obstruction $heightUnit'; + } + + @override + String get losStatusChecking => 'LOS: Überprüfen...'; + + @override + String get losStatusNoData => 'LOS: keine Daten'; + + @override + String losStatusSummary(int clear, int total, int blocked, int unknown) { + return 'Sichtlinie: $clear/$total frei, $blocked blockiert, $unknown unbekannt'; + } + + @override + String get losErrorElevationUnavailable => + 'Für eine oder mehrere Proben sind keine Höhendaten verfügbar.'; + + @override + String get losErrorInvalidInput => + 'Ungültige Punkte/Höhendaten für die LOS-Berechnung.'; + + @override + String get losRenameCustomPoint => + 'Benennen Sie den benutzerdefinierten Punkt um'; + + @override + String get losPointName => 'Punktname'; + + @override + String get losShowPanelTooltip => 'LOS-Panel anzeigen'; + + @override + String get losHidePanelTooltip => 'LOS-Panel ausblenden'; + + @override + String get losElevationAttribution => 'Höhendaten: Open-Meteo (CC BY 4.0)'; + + @override + String get losLegendRadioHorizon => 'Funkhorizont'; + + @override + String get losLegendLosBeam => 'Sichtlinie'; + + @override + String get losLegendTerrain => 'Gelände'; + + @override + String get losFrequencyLabel => 'Frequenz'; + + @override + String get losFrequencyInfoTooltip => 'Details zur Berechnung anzeigen'; + + @override + String get losFrequencyDialogTitle => 'Berechnung des Funkhorizonts'; + + @override + String losFrequencyDialogDescription( + double baselineK, + double baselineFreq, + double frequencyMHz, + double kFactor, + ) { + return 'Ausgehend von k=$baselineK bei $baselineFreq MHz passt die Berechnung den k-Faktor für das aktuelle $frequencyMHz MHz-Band an, das die gekrümmte Funkhorizontobergrenze definiert.'; + } + @override String get contacts_pathTrace => 'Pfadverfolgung'; @@ -2898,4 +3087,10 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settings_gpxExportShareSubject => 'GPX-Kartendaten aus meshcore-open exportieren'; + + @override + String get snrIndicator_nearByRepeaters => 'In der Nähe befindliche Repeater'; + + @override + String get snrIndicator_lastSeen => 'Zuletzt gesehen'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 4f0bed1..039ad22 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -318,6 +318,10 @@ class AppLocalizationsEn extends AppLocalizations { String get settings_aboutDescription => 'An open-source Flutter client for MeshCore LoRa mesh networking devices.'; + @override + String get settings_aboutOpenMeteoAttribution => + 'LOS elevation data: Open-Meteo (CC BY 4.0)'; + @override String get settings_infoName => 'Name'; @@ -454,6 +458,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get appSettings_languageUk => 'Українська'; + @override + String get appSettings_enableMessageTracing => 'Enable Message Tracing'; + + @override + String get appSettings_enableMessageTracingSubtitle => + 'Show detailed routing and timing metadata for messages'; + @override String get appSettings_notifications => 'Notifications'; @@ -614,6 +625,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get appSettings_offlineMapCache => 'Offline Map Cache'; + @override + String get appSettings_unitsTitle => 'Units'; + + @override + String get appSettings_unitsMetric => 'Metric (m / km)'; + + @override + String get appSettings_unitsImperial => 'Imperial (ft / mi)'; + @override String get appSettings_noAreaSelected => 'No area selected'; @@ -774,6 +794,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get channels_editChannel => 'Edit channel'; + @override + String get channels_muteChannel => 'Mute channel'; + + @override + String get channels_unmuteChannel => 'Unmute channel'; + @override String get channels_deleteChannel => 'Delete channel'; @@ -782,6 +808,11 @@ class AppLocalizationsEn extends AppLocalizations { return 'Delete \"$name\"? This cannot be undone.'; } + @override + String channels_channelDeleteFailed(String name) { + return 'Failed to delete channel \"$name\"'; + } + @override String channels_channelDeleted(String name) { return 'Channel \"$name\" deleted'; @@ -1065,6 +1096,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get chat_pathManagement => 'Path Management'; + @override + String get chat_ShowAllPaths => 'Show all paths'; + @override String get chat_routingMode => 'Routing mode'; @@ -1219,6 +1253,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get map_title => 'Node Map'; + @override + String get map_lineOfSight => 'Line of Sight'; + + @override + String get map_losScreenTitle => 'Line of Sight'; + @override String get map_noNodesWithLocation => 'No nodes with location data'; @@ -1650,10 +1690,10 @@ class AppLocalizationsEn extends AppLocalizations { String get repeater_cliSubtitle => 'Send commands to the repeater'; @override - String get repeater_neighbours => 'Neighbors'; + String get repeater_neighbors => 'Neighbors'; @override - String get repeater_neighboursSubtitle => 'View zero hop neighbors.'; + String get repeater_neighborsSubtitle => 'View zero hop neighbors.'; @override String get repeater_settings => 'Settings'; @@ -2329,10 +2369,10 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String get neighbors_receivedData => 'Received Neighbours Data'; + String get neighbors_receivedData => 'Received Neighbors Data'; @override - String get neighbors_requestTimedOut => 'Neighbours request timed out.'; + String get neighbors_requestTimedOut => 'Neighbors request timed out.'; @override String neighbors_errorLoading(String error) { @@ -2340,10 +2380,10 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => 'Repeaters Neighbours'; + String get neighbors_repeatersNeighbors => 'Repeaters Neighbors'; @override - String get neighbors_noData => 'No neighbours data available.'; + String get neighbors_noData => 'No neighbors data available.'; @override String neighbors_unknownContact(String pubkey) { @@ -2646,6 +2686,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get listFilter_all => 'All'; + @override + String get listFilter_favorites => 'Favorites'; + + @override + String get listFilter_addToFavorites => 'Add to favorites'; + + @override + String get listFilter_removeFromFavorites => 'Remove from favorites'; + @override String get listFilter_users => 'Users'; @@ -2680,6 +2729,143 @@ class AppLocalizationsEn extends AppLocalizations { @override String get pathTrace_clearTooltip => 'Clear path.'; + @override + String get losSelectStartEnd => 'Select start and end nodes for LOS.'; + + @override + String losRunFailed(String error) { + return 'Line-of-sight check failed: $error'; + } + + @override + String get losClearAllPoints => 'Clear all points'; + + @override + String get losRunToViewElevationProfile => + 'Run LOS to view elevation profile'; + + @override + String get losMenuTitle => 'LOS Menu'; + + @override + String get losMenuSubtitle => 'Tap nodes or long-press map for custom points'; + + @override + String get losShowDisplayNodes => 'Show display nodes'; + + @override + String get losCustomPoints => 'Custom points'; + + @override + String losCustomPointLabel(int index) { + return 'Custom $index'; + } + + @override + String get losPointA => 'Point A'; + + @override + String get losPointB => 'Point B'; + + @override + String losAntennaA(String value, String unit) { + return 'Antenna A: $value $unit'; + } + + @override + String losAntennaB(String value, String unit) { + return 'Antenna B: $value $unit'; + } + + @override + String get losRun => 'Run LOS'; + + @override + String get losNoElevationData => 'No elevation data'; + + @override + String losProfileClear( + String distance, + String distanceUnit, + String clearance, + String heightUnit, + ) { + return '$distance $distanceUnit, clear LOS, min clearance $clearance $heightUnit'; + } + + @override + String losProfileBlocked( + String distance, + String distanceUnit, + String obstruction, + String heightUnit, + ) { + return '$distance $distanceUnit, blocked by $obstruction $heightUnit'; + } + + @override + String get losStatusChecking => 'LOS: checking...'; + + @override + String get losStatusNoData => 'LOS: no data'; + + @override + String losStatusSummary(int clear, int total, int blocked, int unknown) { + return 'LOS: $clear/$total clear, $blocked blocked, $unknown unknown'; + } + + @override + String get losErrorElevationUnavailable => + 'Elevation data unavailable for one or more samples.'; + + @override + String get losErrorInvalidInput => + 'Invalid points/elevation data for LOS calculation.'; + + @override + String get losRenameCustomPoint => 'Rename custom point'; + + @override + String get losPointName => 'Point name'; + + @override + String get losShowPanelTooltip => 'Show LOS panel'; + + @override + String get losHidePanelTooltip => 'Hide LOS panel'; + + @override + String get losElevationAttribution => + 'Elevation data: Open-Meteo (CC BY 4.0)'; + + @override + String get losLegendRadioHorizon => 'Radio horizon'; + + @override + String get losLegendLosBeam => 'LOS beam'; + + @override + String get losLegendTerrain => 'Terrain'; + + @override + String get losFrequencyLabel => 'Frequency'; + + @override + String get losFrequencyInfoTooltip => 'View calculation details'; + + @override + String get losFrequencyDialogTitle => 'Radio horizon calculation'; + + @override + String losFrequencyDialogDescription( + double baselineK, + double baselineFreq, + double frequencyMHz, + double kFactor, + ) { + return 'Starting from k=$baselineK at $baselineFreq MHz, the calculation adjusts the k-factor for the current $frequencyMHz MHz band, which defines the curved radio horizon cap.'; + } + @override String get contacts_pathTrace => 'Path Trace'; @@ -2845,4 +3031,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settings_gpxExportShareSubject => 'meshcore-open GPX map data export'; + + @override + String get snrIndicator_nearByRepeaters => 'Nearby Repeaters'; + + @override + String get snrIndicator_lastSeen => 'Last seen'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index f56e4e4..8961b18 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -323,6 +323,10 @@ class AppLocalizationsEs extends AppLocalizations { String get settings_aboutDescription => 'Un cliente de código abierto de Flutter para dispositivos de red mesh LoRa de MeshCore.'; + @override + String get settings_aboutOpenMeteoAttribution => + 'Datos de elevación LOS: Open-Meteo (CC BY 4.0)'; + @override String get settings_infoName => 'Nombre'; @@ -459,6 +463,14 @@ class AppLocalizationsEs extends AppLocalizations { @override String get appSettings_languageUk => 'Ucraniano'; + @override + String get appSettings_enableMessageTracing => + 'Habilitar seguimiento de mensajes'; + + @override + String get appSettings_enableMessageTracingSubtitle => + 'Mostrar metadatos detallados de enrutamiento y tiempo para los mensajes'; + @override String get appSettings_notifications => 'Notificaciones'; @@ -620,6 +632,15 @@ class AppLocalizationsEs extends AppLocalizations { @override String get appSettings_offlineMapCache => 'Caché de Mapa Offline'; + @override + String get appSettings_unitsTitle => 'Unidades'; + + @override + String get appSettings_unitsMetric => 'Métrico (m/km)'; + + @override + String get appSettings_unitsImperial => 'Imperial (pies/millas)'; + @override String get appSettings_noAreaSelected => 'No se ha seleccionado ningún área'; @@ -783,6 +804,12 @@ class AppLocalizationsEs extends AppLocalizations { @override String get channels_editChannel => 'Editar canal'; + @override + String get channels_muteChannel => 'Silenciar canal'; + + @override + String get channels_unmuteChannel => 'Activar canal'; + @override String get channels_deleteChannel => 'Eliminar canal'; @@ -791,6 +818,11 @@ class AppLocalizationsEs extends AppLocalizations { return 'Eliminar \"$name\"? Esto no se puede deshacer.'; } + @override + String channels_channelDeleteFailed(String name) { + return 'No se pudo eliminar el canal \"$name\"'; + } + @override String channels_channelDeleted(String name) { return 'Canal \"$name\" eliminado'; @@ -1079,6 +1111,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get chat_pathManagement => 'Gestión de Rutas'; + @override + String get chat_ShowAllPaths => 'Mostrar todos los caminos'; + @override String get chat_routingMode => 'Modo de enrutamiento'; @@ -1237,6 +1272,12 @@ class AppLocalizationsEs extends AppLocalizations { @override String get map_title => 'Mapa de Nodos'; + @override + String get map_lineOfSight => 'Línea de visión'; + + @override + String get map_losScreenTitle => 'Línea de visión'; + @override String get map_noNodesWithLocation => 'No hay nodos con datos de ubicación'; @@ -1674,10 +1715,10 @@ class AppLocalizationsEs extends AppLocalizations { String get repeater_cliSubtitle => 'Enviar comandos al repetidor'; @override - String get repeater_neighbours => 'Vecinos'; + String get repeater_neighbors => 'Vecinos'; @override - String get repeater_neighboursSubtitle => 'Ver vecinos de salto cero.'; + String get repeater_neighborsSubtitle => 'Ver vecinos de salto cero.'; @override String get repeater_settings => 'Configuración'; @@ -2376,7 +2417,7 @@ class AppLocalizationsEs extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => 'Repetidores Vecinos'; + String get neighbors_repeatersNeighbors => 'Repetidores Vecinos'; @override String get neighbors_noData => 'No hay datos de vecinos disponibles.'; @@ -2685,6 +2726,15 @@ class AppLocalizationsEs extends AppLocalizations { @override String get listFilter_all => 'Todas'; + @override + String get listFilter_favorites => 'Favoritos'; + + @override + String get listFilter_addToFavorites => 'Añadir a favoritos'; + + @override + String get listFilter_removeFromFavorites => 'Eliminar de las favoritas'; + @override String get listFilter_users => 'Usuarios'; @@ -2719,6 +2769,146 @@ class AppLocalizationsEs extends AppLocalizations { @override String get pathTrace_clearTooltip => 'Borrar ruta'; + @override + String get losSelectStartEnd => + 'Seleccione los nodos de inicio y fin para LOS.'; + + @override + String losRunFailed(String error) { + return 'Error en la comprobación de la línea de visión: $error'; + } + + @override + String get losClearAllPoints => 'Borrar todos los puntos'; + + @override + String get losRunToViewElevationProfile => + 'Ejecute LOS para ver el perfil de elevación'; + + @override + String get losMenuTitle => 'Menú LOS'; + + @override + String get losMenuSubtitle => + 'Toque nodos o mantenga presionado el mapa para puntos personalizados'; + + @override + String get losShowDisplayNodes => 'Mostrar nodos de visualización'; + + @override + String get losCustomPoints => 'Puntos personalizados'; + + @override + String losCustomPointLabel(int index) { + return 'Personalizado $index'; + } + + @override + String get losPointA => 'Punto A'; + + @override + String get losPointB => 'Punto B'; + + @override + String losAntennaA(String value, String unit) { + return 'Antena A: $value $unit'; + } + + @override + String losAntennaB(String value, String unit) { + return 'Antena B: $value $unit'; + } + + @override + String get losRun => 'Ejecutar LOS'; + + @override + String get losNoElevationData => 'Sin datos de elevación'; + + @override + String losProfileClear( + String distance, + String distanceUnit, + String clearance, + String heightUnit, + ) { + return '$distance $distanceUnit, despejar LOS, autorización mínima $clearance $heightUnit'; + } + + @override + String losProfileBlocked( + String distance, + String distanceUnit, + String obstruction, + String heightUnit, + ) { + return '$distance $distanceUnit, bloqueado por $obstruction $heightUnit'; + } + + @override + String get losStatusChecking => 'LOS: comprobando...'; + + @override + String get losStatusNoData => 'LOS: sin datos'; + + @override + String losStatusSummary(int clear, int total, int blocked, int unknown) { + return 'LOS: $clear/$total claro, $blocked bloqueado, $unknown desconocido'; + } + + @override + String get losErrorElevationUnavailable => + 'Datos de elevación no disponibles para una o más muestras.'; + + @override + String get losErrorInvalidInput => + 'Datos de puntos/elevación no válidos para el cálculo de LOS.'; + + @override + String get losRenameCustomPoint => + 'Cambiar el nombre del punto personalizado'; + + @override + String get losPointName => 'Nombre del punto'; + + @override + String get losShowPanelTooltip => 'Mostrar panel LOS'; + + @override + String get losHidePanelTooltip => 'Ocultar panel LOS'; + + @override + String get losElevationAttribution => + 'Datos de elevación: Open-Meteo (CC BY 4.0)'; + + @override + String get losLegendRadioHorizon => 'Horizonte radioeléctrico'; + + @override + String get losLegendLosBeam => 'Línea de visión'; + + @override + String get losLegendTerrain => 'Terreno'; + + @override + String get losFrequencyLabel => 'Frecuencia'; + + @override + String get losFrequencyInfoTooltip => 'Ver detalles del cálculo'; + + @override + String get losFrequencyDialogTitle => 'Cálculo del horizonte radioeléctrico'; + + @override + String losFrequencyDialogDescription( + double baselineK, + double baselineFreq, + double frequencyMHz, + double kFactor, + ) { + return 'A partir de k=$baselineK en $baselineFreq MHz, el cálculo ajusta el factor k para la banda actual de $frequencyMHz MHz, que define el límite curvo del horizonte de radio.'; + } + @override String get contacts_pathTrace => 'Rastreo de caminos'; @@ -2889,4 +3079,10 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settings_gpxExportShareSubject => 'meshcore-open exportación de datos de mapa GPX'; + + @override + String get snrIndicator_nearByRepeaters => 'Repetidores cercanos'; + + @override + String get snrIndicator_lastSeen => 'Visto por última vez'; } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index e1325da..3737f46 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -324,6 +324,10 @@ class AppLocalizationsFr extends AppLocalizations { String get settings_aboutDescription => 'Un client Flutter open source pour les appareils de réseau mesh MeshCore LoRa.'; + @override + String get settings_aboutOpenMeteoAttribution => + 'Données d\'élévation LOS : Open-Meteo (CC BY 4.0)'; + @override String get settings_infoName => 'Nom'; @@ -460,6 +464,14 @@ class AppLocalizationsFr extends AppLocalizations { @override String get appSettings_languageUk => 'Ukrainien'; + @override + String get appSettings_enableMessageTracing => + 'Activer le traçage des messages'; + + @override + String get appSettings_enableMessageTracingSubtitle => + 'Afficher les métadonnées détaillées de routage et de synchronisation des messages'; + @override String get appSettings_notifications => 'Notifications'; @@ -622,6 +634,15 @@ class AppLocalizationsFr extends AppLocalizations { @override String get appSettings_offlineMapCache => 'Cache de Carte Hors Ligne'; + @override + String get appSettings_unitsTitle => 'Unités'; + + @override + String get appSettings_unitsMetric => 'Métrique (m/km)'; + + @override + String get appSettings_unitsImperial => 'Impérial (ft / mi)'; + @override String get appSettings_noAreaSelected => 'Aucune zone sélectionnée'; @@ -683,7 +704,7 @@ class AppLocalizationsFr extends AppLocalizations { String get contacts_manageRoom => 'Gérer le Room Server'; @override - String get contacts_roomLogin => 'Connexion Salle'; + String get contacts_roomLogin => 'Connexion Room Server'; @override String get contacts_openChat => 'Ouverture du Chat'; @@ -785,6 +806,12 @@ class AppLocalizationsFr extends AppLocalizations { @override String get channels_editChannel => 'Modifier le canal'; + @override + String get channels_muteChannel => 'Désactiver les notifications du canal'; + + @override + String get channels_unmuteChannel => 'Réactiver les notifications du canal'; + @override String get channels_deleteChannel => 'Supprimer le canal'; @@ -793,6 +820,11 @@ class AppLocalizationsFr extends AppLocalizations { return 'Supprimer $name? Cela ne peut pas être annulé.'; } + @override + String channels_channelDeleteFailed(String name) { + return 'Échec de la suppression de la chaîne \"$name\"'; + } + @override String channels_channelDeleted(String name) { return 'Le canal \"$name\" a été supprimé'; @@ -1082,6 +1114,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get chat_pathManagement => 'Gestion des chemins'; + @override + String get chat_ShowAllPaths => 'Afficher tous les chemins'; + @override String get chat_routingMode => 'Mode de routage'; @@ -1243,6 +1278,12 @@ class AppLocalizationsFr extends AppLocalizations { @override String get map_title => 'Carte des nœuds'; + @override + String get map_lineOfSight => 'Ligne de vue'; + + @override + String get map_losScreenTitle => 'Ligne de vue'; + @override String get map_noNodesWithLocation => 'Aucun nœud avec des données de localisation'; @@ -1531,7 +1572,7 @@ class AppLocalizationsFr extends AppLocalizations { String get login_repeaterLogin => 'Connexion au répéteur'; @override - String get login_roomLogin => 'Connexion Salle'; + String get login_roomLogin => 'Connexion Room Server'; @override String get login_password => 'Mot de passe'; @@ -1656,7 +1697,7 @@ class AppLocalizationsFr extends AppLocalizations { String get repeater_management => 'Gestion des répéteurs'; @override - String get room_management => 'Administración del Servidor de Habitación'; + String get room_management => 'Administrattion Room Server'; @override String get repeater_managementTools => 'Outils de Gestion'; @@ -1682,11 +1723,10 @@ class AppLocalizationsFr extends AppLocalizations { String get repeater_cliSubtitle => 'Envoyer des commandes au répéteur'; @override - String get repeater_neighbours => 'Voisins'; + String get repeater_neighbors => 'Voisins'; @override - String get repeater_neighboursSubtitle => - 'Afficher les voisins de saut nuls.'; + String get repeater_neighborsSubtitle => 'Afficher les voisins de saut nuls.'; @override String get repeater_settings => 'Paramètres'; @@ -2391,7 +2431,7 @@ class AppLocalizationsFr extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => 'Répéteurs Voisins'; + String get neighbors_repeatersNeighbors => 'Répéteurs Voisins'; @override String get neighbors_noData => @@ -2702,6 +2742,15 @@ class AppLocalizationsFr extends AppLocalizations { @override String get listFilter_all => 'Tout'; + @override + String get listFilter_favorites => 'Préférences'; + + @override + String get listFilter_addToFavorites => 'Ajouter à mes favoris'; + + @override + String get listFilter_removeFromFavorites => 'Supprimer des favoris'; + @override String get listFilter_users => 'Utilisateurs'; @@ -2736,6 +2785,145 @@ class AppLocalizationsFr extends AppLocalizations { @override String get pathTrace_clearTooltip => 'Effacer le chemin'; + @override + String get losSelectStartEnd => + 'Sélectionnez les nœuds de début et de fin pour LOS.'; + + @override + String losRunFailed(String error) { + return 'Échec de la vérification de la ligne de vue : $error'; + } + + @override + String get losClearAllPoints => 'Effacer tous les points'; + + @override + String get losRunToViewElevationProfile => + 'Exécutez LOS pour afficher le profil d\'altitude'; + + @override + String get losMenuTitle => 'Menu LOS'; + + @override + String get losMenuSubtitle => + 'Appuyez sur les nœuds ou appuyez longuement sur la carte pour des points personnalisés'; + + @override + String get losShowDisplayNodes => 'Afficher les nœuds d\'affichage'; + + @override + String get losCustomPoints => 'Points personnalisés'; + + @override + String losCustomPointLabel(int index) { + return 'Personnalisé $index'; + } + + @override + String get losPointA => 'Point A'; + + @override + String get losPointB => 'Point B'; + + @override + String losAntennaA(String value, String unit) { + return 'Antenne A : $value $unit'; + } + + @override + String losAntennaB(String value, String unit) { + return 'Antenne B : $value $unit'; + } + + @override + String get losRun => 'Exécuter la LOS'; + + @override + String get losNoElevationData => 'Aucune donnée d\'altitude'; + + @override + String losProfileClear( + String distance, + String distanceUnit, + String clearance, + String heightUnit, + ) { + return '$distance $distanceUnit, LOS clair, clairance minimale $clearance $heightUnit'; + } + + @override + String losProfileBlocked( + String distance, + String distanceUnit, + String obstruction, + String heightUnit, + ) { + return '$distance $distanceUnit, bloqué par $obstruction $heightUnit'; + } + + @override + String get losStatusChecking => 'LOS : vérification...'; + + @override + String get losStatusNoData => 'LOS : aucune donnée'; + + @override + String losStatusSummary(int clear, int total, int blocked, int unknown) { + return 'LOS : $clear/$total clair, $blocked bloqué, $unknown inconnu'; + } + + @override + String get losErrorElevationUnavailable => + 'Données d\'altitude indisponibles pour un ou plusieurs échantillons.'; + + @override + String get losErrorInvalidInput => + 'Données de points/d\'altitude non valides pour le calcul de la LOS.'; + + @override + String get losRenameCustomPoint => 'Renommer le point personnalisé'; + + @override + String get losPointName => 'Nom du point'; + + @override + String get losShowPanelTooltip => 'Afficher le panneau LOS'; + + @override + String get losHidePanelTooltip => 'Masquer le panneau LOS'; + + @override + String get losElevationAttribution => + 'Données d’altitude : Open-Meteo (CC BY 4.0)'; + + @override + String get losLegendRadioHorizon => 'Horizon radio'; + + @override + String get losLegendLosBeam => 'Ligne de visée'; + + @override + String get losLegendTerrain => 'Terrain'; + + @override + String get losFrequencyLabel => 'Fréquence'; + + @override + String get losFrequencyInfoTooltip => 'Voir les détails du calcul'; + + @override + String get losFrequencyDialogTitle => 'Calcul de l’horizon radio'; + + @override + String losFrequencyDialogDescription( + double baselineK, + double baselineFreq, + double frequencyMHz, + double kFactor, + ) { + return 'À partir de k=$baselineK à $baselineFreq MHz, le calcul ajuste le facteur k pour la bande actuelle de $frequencyMHz MHz, ce qui définit la limite incurvée de l\'horizon radio.'; + } + @override String get contacts_pathTrace => 'Traçage de chemin'; @@ -2913,4 +3101,10 @@ class AppLocalizationsFr extends AppLocalizations { @override String get settings_gpxExportShareSubject => 'meshcore-open exporter les données de carte GPX'; + + @override + String get snrIndicator_nearByRepeaters => 'Répéteurs à proximité'; + + @override + String get snrIndicator_lastSeen => 'Dernière fois vu'; } diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 15d5354..b64ec6d 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -322,6 +322,10 @@ class AppLocalizationsIt extends AppLocalizations { String get settings_aboutDescription => 'Un client Flutter open-source per i dispositivi di rete mesh LoRa Core di MeshCore.'; + @override + String get settings_aboutOpenMeteoAttribution => + 'Dati di elevazione LOS: Open-Meteo (CC BY 4.0)'; + @override String get settings_infoName => 'Nome'; @@ -458,6 +462,14 @@ class AppLocalizationsIt extends AppLocalizations { @override String get appSettings_languageUk => 'Ucraino'; + @override + String get appSettings_enableMessageTracing => + 'Abilita tracciamento messaggi'; + + @override + String get appSettings_enableMessageTracingSubtitle => + 'Mostra metadati dettagliati su instradamento e tempi per i messaggi'; + @override String get appSettings_notifications => 'Notifiche'; @@ -619,6 +631,15 @@ class AppLocalizationsIt extends AppLocalizations { @override String get appSettings_offlineMapCache => 'Cache Mappa Offline'; + @override + String get appSettings_unitsTitle => 'Unità'; + + @override + String get appSettings_unitsMetric => 'Metrico (m/km)'; + + @override + String get appSettings_unitsImperial => 'Imperiale (ft / mi)'; + @override String get appSettings_noAreaSelected => 'Nessun\'area selezionata'; @@ -781,6 +802,12 @@ class AppLocalizationsIt extends AppLocalizations { @override String get channels_editChannel => 'Modifica canale'; + @override + String get channels_muteChannel => 'Silenzia canale'; + + @override + String get channels_unmuteChannel => 'Attiva notifiche canale'; + @override String get channels_deleteChannel => 'Elimina canale'; @@ -789,6 +816,11 @@ class AppLocalizationsIt extends AppLocalizations { return 'Eliminare \"$name\"? Non può essere annullato.'; } + @override + String channels_channelDeleteFailed(String name) { + return 'Impossibile eliminare il canale \"$name\"'; + } + @override String channels_channelDeleted(String name) { return 'Canale \"$name\" eliminato'; @@ -1077,6 +1109,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get chat_pathManagement => 'Gestione Percorsi'; + @override + String get chat_ShowAllPaths => 'Mostra tutti i percorsi'; + @override String get chat_routingMode => 'Modalità di routing'; @@ -1236,6 +1271,12 @@ class AppLocalizationsIt extends AppLocalizations { @override String get map_title => 'Mappa Nodi'; + @override + String get map_lineOfSight => 'Linea di vista'; + + @override + String get map_losScreenTitle => 'Linea di vista'; + @override String get map_noNodesWithLocation => 'Nessun nodo con dati di posizione'; @@ -1672,10 +1713,10 @@ class AppLocalizationsIt extends AppLocalizations { String get repeater_cliSubtitle => 'Invia comandi al ripetitore'; @override - String get repeater_neighbours => 'Vicini'; + String get repeater_neighbors => 'Vicini'; @override - String get repeater_neighboursSubtitle => + String get repeater_neighborsSubtitle => 'Visualizza vicini di salto pari a zero.'; @override @@ -2376,7 +2417,7 @@ class AppLocalizationsIt extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => 'Ripetitori Vicini'; + String get neighbors_repeatersNeighbors => 'Ripetitori Vicini'; @override String get neighbors_noData => 'Nessun dato sugli vicini disponibile.'; @@ -2685,6 +2726,15 @@ class AppLocalizationsIt extends AppLocalizations { @override String get listFilter_all => 'Tutti'; + @override + String get listFilter_favorites => 'Preferiti'; + + @override + String get listFilter_addToFavorites => 'Aggiungi ai preferiti'; + + @override + String get listFilter_removeFromFavorites => 'Rimuovi dai preferiti'; + @override String get listFilter_users => 'Utenti'; @@ -2720,6 +2770,145 @@ class AppLocalizationsIt extends AppLocalizations { @override String get pathTrace_clearTooltip => 'Pulisci percorso'; + @override + String get losSelectStartEnd => + 'Seleziona i nodi iniziali e finali per la LOS.'; + + @override + String losRunFailed(String error) { + return 'Controllo della linea di vista fallito: $error'; + } + + @override + String get losClearAllPoints => 'Cancella tutti i punti'; + + @override + String get losRunToViewElevationProfile => + 'Eseguire LOS per visualizzare il profilo altimetrico'; + + @override + String get losMenuTitle => 'Menù LOS'; + + @override + String get losMenuSubtitle => + 'Tocca i nodi o premi a lungo la mappa per punti personalizzati'; + + @override + String get losShowDisplayNodes => 'Mostra i nodi di visualizzazione'; + + @override + String get losCustomPoints => 'Punti personalizzati'; + + @override + String losCustomPointLabel(int index) { + return 'Personalizzato $index'; + } + + @override + String get losPointA => 'Punto A'; + + @override + String get losPointB => 'Punto B'; + + @override + String losAntennaA(String value, String unit) { + return 'Antenna A: $value $unit'; + } + + @override + String losAntennaB(String value, String unit) { + return 'Antenna B: $value $unit'; + } + + @override + String get losRun => 'Esegui LOS'; + + @override + String get losNoElevationData => 'Nessun dato di elevazione'; + + @override + String losProfileClear( + String distance, + String distanceUnit, + String clearance, + String heightUnit, + ) { + return '$distance $distanceUnit, libera LOS, distanza minima $clearance $heightUnit'; + } + + @override + String losProfileBlocked( + String distance, + String distanceUnit, + String obstruction, + String heightUnit, + ) { + return '$distance $distanceUnit, bloccato da $obstruction $heightUnit'; + } + + @override + String get losStatusChecking => 'LOS: controllo...'; + + @override + String get losStatusNoData => 'LOS: nessun dato'; + + @override + String losStatusSummary(int clear, int total, int blocked, int unknown) { + return 'LOS: $clear/$total libera, $blocked bloccato, $unknown sconosciuto'; + } + + @override + String get losErrorElevationUnavailable => + 'Dati di elevazione non disponibili per uno o più campioni.'; + + @override + String get losErrorInvalidInput => + 'Dati punti/elevazione non validi per il calcolo della LOS.'; + + @override + String get losRenameCustomPoint => 'Rinomina punto personalizzato'; + + @override + String get losPointName => 'Nome del punto'; + + @override + String get losShowPanelTooltip => 'Mostra il pannello LOS'; + + @override + String get losHidePanelTooltip => 'Nascondi il pannello LOS'; + + @override + String get losElevationAttribution => + 'Dati di elevazione: Open-Meteo (CC BY 4.0)'; + + @override + String get losLegendRadioHorizon => 'Orizzonte radio'; + + @override + String get losLegendLosBeam => 'Linea di vista'; + + @override + String get losLegendTerrain => 'Terreno'; + + @override + String get losFrequencyLabel => 'Frequenza'; + + @override + String get losFrequencyInfoTooltip => 'Visualizza i dettagli del calcolo'; + + @override + String get losFrequencyDialogTitle => 'Calcolo dell’orizzonte radio'; + + @override + String losFrequencyDialogDescription( + double baselineK, + double baselineFreq, + double frequencyMHz, + double kFactor, + ) { + return 'Partendo da k=$baselineK a $baselineFreq MHz, il calcolo regola il fattore k per l\'attuale banda $frequencyMHz MHz, che definisce il limite curvo dell\'orizzonte radio.'; + } + @override String get contacts_pathTrace => 'Traccia Percorso'; @@ -2893,4 +3082,10 @@ class AppLocalizationsIt extends AppLocalizations { @override String get settings_gpxExportShareSubject => 'meshcore-open esportazione dati mappa GPX'; + + @override + String get snrIndicator_nearByRepeaters => 'Ripetitori vicini'; + + @override + String get snrIndicator_lastSeen => 'Ultimo accesso'; } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 17b3bce..523cd6e 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -320,6 +320,10 @@ class AppLocalizationsNl extends AppLocalizations { String get settings_aboutDescription => 'Een open-source Flutter client voor MeshCore LoRa mesh netwerkapparaten.'; + @override + String get settings_aboutOpenMeteoAttribution => + 'LOS-hoogtegegevens: Open-Meteo (CC BY 4.0)'; + @override String get settings_infoName => 'Naam'; @@ -456,6 +460,13 @@ class AppLocalizationsNl extends AppLocalizations { @override String get appSettings_languageUk => 'Oekraïens'; + @override + String get appSettings_enableMessageTracing => 'Berichttracking inschakelen'; + + @override + String get appSettings_enableMessageTracingSubtitle => + 'Gedetailleerde routerings- en timing-metadata voor berichten weergeven'; + @override String get appSettings_notifications => 'Notificaties'; @@ -617,6 +628,15 @@ class AppLocalizationsNl extends AppLocalizations { @override String get appSettings_offlineMapCache => 'Offline Kaarten Cache'; + @override + String get appSettings_unitsTitle => 'Eenheden'; + + @override + String get appSettings_unitsMetric => 'Metrisch (m / km)'; + + @override + String get appSettings_unitsImperial => 'Imperiaal (ft / mi)'; + @override String get appSettings_noAreaSelected => 'Geen gebied geselecteerd'; @@ -779,6 +799,12 @@ class AppLocalizationsNl extends AppLocalizations { @override String get channels_editChannel => 'Kanaal bewerken'; + @override + String get channels_muteChannel => 'Kanaal dempen'; + + @override + String get channels_unmuteChannel => 'Kanaal dempen opheffen'; + @override String get channels_deleteChannel => 'Kanaal verwijderen'; @@ -787,6 +813,11 @@ class AppLocalizationsNl extends AppLocalizations { return 'Verwijderen \"$name\"? Dit kan niet worden teruggedraaid.'; } + @override + String channels_channelDeleteFailed(String name) { + return 'Kan kanaal $name niet verwijderen'; + } + @override String channels_channelDeleted(String name) { return 'Kanaal \"$name\" verwijderd'; @@ -1074,6 +1105,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get chat_pathManagement => 'Beheer van Paden'; + @override + String get chat_ShowAllPaths => 'Toon alle paden'; + @override String get chat_routingMode => 'Routeerwijze'; @@ -1232,6 +1266,12 @@ class AppLocalizationsNl extends AppLocalizations { @override String get map_title => 'Node Map'; + @override + String get map_lineOfSight => 'Zichtlijn'; + + @override + String get map_losScreenTitle => 'Zichtlijn'; + @override String get map_noNodesWithLocation => 'Geen nodes met locatiegegevens'; @@ -1668,10 +1708,10 @@ class AppLocalizationsNl extends AppLocalizations { String get repeater_cliSubtitle => 'Verzend commando\'s naar de repeater'; @override - String get repeater_neighbours => 'Buren'; + String get repeater_neighbors => 'Buren'; @override - String get repeater_neighboursSubtitle => 'Bekijk nul hops buren.'; + String get repeater_neighborsSubtitle => 'Bekijk nul hops buren.'; @override String get repeater_settings => 'Instellingen'; @@ -2367,7 +2407,7 @@ class AppLocalizationsNl extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => 'Herhalingen Buren'; + String get neighbors_repeatersNeighbors => 'Herhalingen Buren'; @override String get neighbors_noData => 'Geen gegevens van buren beschikbaar.'; @@ -2677,6 +2717,15 @@ class AppLocalizationsNl extends AppLocalizations { @override String get listFilter_all => 'Alles'; + @override + String get listFilter_favorites => 'Favorieten'; + + @override + String get listFilter_addToFavorites => 'Toevoegen aan favorieten'; + + @override + String get listFilter_removeFromFavorites => 'Verwijderen uit favorieten'; + @override String get listFilter_users => 'Gebruikers'; @@ -2711,6 +2760,145 @@ class AppLocalizationsNl extends AppLocalizations { @override String get pathTrace_clearTooltip => 'Weg wissen'; + @override + String get losSelectStartEnd => + 'Selecteer begin- en eindknooppunten voor LOS.'; + + @override + String losRunFailed(String error) { + return 'Zichtlijncontrole mislukt: $error'; + } + + @override + String get losClearAllPoints => 'Wis alle punten'; + + @override + String get losRunToViewElevationProfile => + 'Voer LOS uit om het hoogteprofiel te bekijken'; + + @override + String get losMenuTitle => 'LOS-menu'; + + @override + String get losMenuSubtitle => + 'Tik op knooppunten of druk lang op de kaart voor aangepaste punten'; + + @override + String get losShowDisplayNodes => 'Toon weergaveknooppunten'; + + @override + String get losCustomPoints => 'Aangepaste punten'; + + @override + String losCustomPointLabel(int index) { + return 'Aangepast $index'; + } + + @override + String get losPointA => 'Punt A'; + + @override + String get losPointB => 'Punt B'; + + @override + String losAntennaA(String value, String unit) { + return 'Antenne A: $value $unit'; + } + + @override + String losAntennaB(String value, String unit) { + return 'Antenne B: $value $unit'; + } + + @override + String get losRun => 'Voer LOS uit'; + + @override + String get losNoElevationData => 'Geen hoogtegegevens'; + + @override + String losProfileClear( + String distance, + String distanceUnit, + String clearance, + String heightUnit, + ) { + return '$distance $distanceUnit, vrije LOS, min. vrije ruimte $clearance $heightUnit'; + } + + @override + String losProfileBlocked( + String distance, + String distanceUnit, + String obstruction, + String heightUnit, + ) { + return '$distance $distanceUnit, geblokkeerd door $obstruction $heightUnit'; + } + + @override + String get losStatusChecking => 'LOS: controleren...'; + + @override + String get losStatusNoData => 'LOS: geen gegevens'; + + @override + String losStatusSummary(int clear, int total, int blocked, int unknown) { + return 'LOS: $clear/$total gewist, $blocked geblokkeerd, $unknown onbekend'; + } + + @override + String get losErrorElevationUnavailable => + 'Hoogtegegevens niet beschikbaar voor een of meer monsters.'; + + @override + String get losErrorInvalidInput => + 'Ongeldige punten/hoogtegegevens voor LOS-berekening.'; + + @override + String get losRenameCustomPoint => 'Hernoem aangepast punt'; + + @override + String get losPointName => 'Puntnaam'; + + @override + String get losShowPanelTooltip => 'Toon LOS-paneel'; + + @override + String get losHidePanelTooltip => 'LOS-paneel verbergen'; + + @override + String get losElevationAttribution => + 'Hoogtegegevens: Open-Meteo (CC BY 4.0)'; + + @override + String get losLegendRadioHorizon => 'Radiohorizon'; + + @override + String get losLegendLosBeam => 'Zichtlijn'; + + @override + String get losLegendTerrain => 'Terrein'; + + @override + String get losFrequencyLabel => 'Frequentie'; + + @override + String get losFrequencyInfoTooltip => 'Bekijk details van de berekening'; + + @override + String get losFrequencyDialogTitle => 'Berekening van de radiohorizon'; + + @override + String losFrequencyDialogDescription( + double baselineK, + double baselineFreq, + double frequencyMHz, + double kFactor, + ) { + return 'Beginnend met k=$baselineK bij $baselineFreq MHz, wordt bij de berekening de k-factor aangepast voor de huidige $frequencyMHz MHz-band, die de gebogen radiohorizonkap definieert.'; + } + @override String get contacts_pathTrace => 'Pad Traceren'; @@ -2881,4 +3069,10 @@ class AppLocalizationsNl extends AppLocalizations { @override String get settings_gpxExportShareSubject => 'meshcore-open GPX kaartgegevens exporteren'; + + @override + String get snrIndicator_nearByRepeaters => 'Nabije herhalingseenheden'; + + @override + String get snrIndicator_lastSeen => 'Laatst gezien'; } diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 147e160..1639600 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -323,6 +323,10 @@ class AppLocalizationsPl extends AppLocalizations { String get settings_aboutDescription => 'Otwarty kod źródłowy klient Flutter dla urządzeń do sieci mesh LoRa MeshCore.'; + @override + String get settings_aboutOpenMeteoAttribution => + 'Dane wysokościowe LOS: Open-Meteo (CC BY 4.0)'; + @override String get settings_infoName => 'Imię'; @@ -460,6 +464,13 @@ class AppLocalizationsPl extends AppLocalizations { @override String get appSettings_languageUk => 'Ukraińska'; + @override + String get appSettings_enableMessageTracing => 'Włącz śledzenie wiadomości'; + + @override + String get appSettings_enableMessageTracingSubtitle => + 'Pokaż szczegółowe metadane trasowania i czasu dla wiadomości'; + @override String get appSettings_notifications => 'Powiadomienia'; @@ -621,6 +632,15 @@ class AppLocalizationsPl extends AppLocalizations { @override String get appSettings_offlineMapCache => 'Bufor Map Offline'; + @override + String get appSettings_unitsTitle => 'Jednostki'; + + @override + String get appSettings_unitsMetric => 'Metryczne (m / km)'; + + @override + String get appSettings_unitsImperial => 'Imperialne (ft / mi)'; + @override String get appSettings_noAreaSelected => 'Nie zaznaczono żadnej powierzchni.'; @@ -784,6 +804,12 @@ class AppLocalizationsPl extends AppLocalizations { @override String get channels_editChannel => 'Edytuj kanał'; + @override + String get channels_muteChannel => 'Wycisz kanał'; + + @override + String get channels_unmuteChannel => 'Wyłącz wyciszenie kanału'; + @override String get channels_deleteChannel => 'Usuń kanał'; @@ -792,6 +818,11 @@ class AppLocalizationsPl extends AppLocalizations { return 'Usuń \"$name\"? Nie można tego cofnąć.'; } + @override + String channels_channelDeleteFailed(String name) { + return 'Nie udało się usunąć kanału \"$name\"'; + } + @override String channels_channelDeleted(String name) { return 'Kanał \"$name\" usunięto'; @@ -1079,6 +1110,9 @@ class AppLocalizationsPl extends AppLocalizations { @override String get chat_pathManagement => 'Zarządzanie ścieżkami'; + @override + String get chat_ShowAllPaths => 'Pokaż wszystkie ścieżki'; + @override String get chat_routingMode => 'Tryb routingu'; @@ -1238,6 +1272,12 @@ class AppLocalizationsPl extends AppLocalizations { @override String get map_title => 'Mapa węzłów'; + @override + String get map_lineOfSight => 'Linia wzroku'; + + @override + String get map_losScreenTitle => 'Linia wzroku'; + @override String get map_noNodesWithLocation => 'Brak węzłów z danymi lokalizacyjnymi'; @@ -1676,10 +1716,10 @@ class AppLocalizationsPl extends AppLocalizations { String get repeater_cliSubtitle => 'Wyślij polecenia do powielacza'; @override - String get repeater_neighbours => 'Sąsiedzi'; + String get repeater_neighbors => 'Sąsiedzi'; @override - String get repeater_neighboursSubtitle => + String get repeater_neighborsSubtitle => 'Wyświetl sąsiedztwo zerowych hopów.'; @override @@ -2375,7 +2415,7 @@ class AppLocalizationsPl extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => 'Powtarzacze Sąsiedzi'; + String get neighbors_repeatersNeighbors => 'Powtarzacze Sąsiedzi'; @override String get neighbors_noData => 'Brak danych dotyczących sąsiadów.'; @@ -2684,6 +2724,15 @@ class AppLocalizationsPl extends AppLocalizations { @override String get listFilter_all => 'Wszystko'; + @override + String get listFilter_favorites => 'Ulubione'; + + @override + String get listFilter_addToFavorites => 'Dodaj do ulubionych'; + + @override + String get listFilter_removeFromFavorites => 'Usuń z ulubionych'; + @override String get listFilter_users => 'Użytkownicy'; @@ -2718,6 +2767,144 @@ class AppLocalizationsPl extends AppLocalizations { @override String get pathTrace_clearTooltip => 'Wyczyść ścieżkę'; + @override + String get losSelectStartEnd => 'Wybierz węzły początkowe i końcowe dla LOS.'; + + @override + String losRunFailed(String error) { + return 'Sprawdzenie pola widzenia nie powiodło się: $error'; + } + + @override + String get losClearAllPoints => 'Wyczyść wszystkie punkty'; + + @override + String get losRunToViewElevationProfile => + 'Uruchom LOS, aby wyświetlić profil wysokości'; + + @override + String get losMenuTitle => 'Menu LOS'; + + @override + String get losMenuSubtitle => + 'Stuknij węzły lub naciśnij i przytrzymaj mapę, aby uzyskać niestandardowe punkty'; + + @override + String get losShowDisplayNodes => 'Pokaż węzły wyświetlające'; + + @override + String get losCustomPoints => 'Punkty niestandardowe'; + + @override + String losCustomPointLabel(int index) { + return 'Niestandardowe $index'; + } + + @override + String get losPointA => 'Punkt A'; + + @override + String get losPointB => 'Punkt B'; + + @override + String losAntennaA(String value, String unit) { + return 'Antena A: $value $unit'; + } + + @override + String losAntennaB(String value, String unit) { + return 'Antena B: $value $unit'; + } + + @override + String get losRun => 'Uruchom LOS-a'; + + @override + String get losNoElevationData => 'Brak danych o wysokości'; + + @override + String losProfileClear( + String distance, + String distanceUnit, + String clearance, + String heightUnit, + ) { + return '$distance $distanceUnit, czysty LOS, minimalny prześwit $clearance $heightUnit'; + } + + @override + String losProfileBlocked( + String distance, + String distanceUnit, + String obstruction, + String heightUnit, + ) { + return '$distance $distanceUnit, zablokowane przez $obstruction $heightUnit'; + } + + @override + String get losStatusChecking => 'LOS: sprawdzam...'; + + @override + String get losStatusNoData => 'LOS: brak danych'; + + @override + String losStatusSummary(int clear, int total, int blocked, int unknown) { + return 'LOS: $clear/$total jasne, $blocked zablokowane, $unknown nieznane'; + } + + @override + String get losErrorElevationUnavailable => + 'Dane dotyczące wysokości są niedostępne dla jednej lub większej liczby próbek.'; + + @override + String get losErrorInvalidInput => + 'Nieprawidłowe dane punktów/wysokości do obliczenia LOS.'; + + @override + String get losRenameCustomPoint => 'Zmień nazwę punktu niestandardowego'; + + @override + String get losPointName => 'Nazwa punktu'; + + @override + String get losShowPanelTooltip => 'Pokaż panel LOS'; + + @override + String get losHidePanelTooltip => 'Ukryj panel LOS'; + + @override + String get losElevationAttribution => + 'Dane dotyczące wysokości: Open-Meteo (CC BY 4.0)'; + + @override + String get losLegendRadioHorizon => 'Horyzont radiowy'; + + @override + String get losLegendLosBeam => 'Linia widoczności'; + + @override + String get losLegendTerrain => 'Teren'; + + @override + String get losFrequencyLabel => 'Częstotliwość'; + + @override + String get losFrequencyInfoTooltip => 'Zobacz szczegóły obliczenia'; + + @override + String get losFrequencyDialogTitle => 'Obliczanie horyzontu radiowego'; + + @override + String losFrequencyDialogDescription( + double baselineK, + double baselineFreq, + double frequencyMHz, + double kFactor, + ) { + return 'Zaczynając od k=$baselineK przy $baselineFreq MHz, obliczenia korygują współczynnik k dla bieżącego pasma $frequencyMHz MHz, które definiuje zakrzywiony limit horyzontu radiowego.'; + } + @override String get contacts_pathTrace => 'Śledzenie Ścieżek'; @@ -2895,4 +3082,10 @@ class AppLocalizationsPl extends AppLocalizations { @override String get settings_gpxExportShareSubject => 'Eksport danych mapy GPX meshcore-open'; + + @override + String get snrIndicator_nearByRepeaters => 'Nadajniki w pobliżu'; + + @override + String get snrIndicator_lastSeen => 'Ostatnio widziany'; } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index b481775..c59a4f5 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -324,6 +324,10 @@ class AppLocalizationsPt extends AppLocalizations { String get settings_aboutDescription => 'Um cliente Flutter de código aberto para dispositivos de rede mesh LoRa Core da MeshCore.'; + @override + String get settings_aboutOpenMeteoAttribution => + 'Dados de elevação LOS: Open-Meteo (CC BY 4.0)'; + @override String get settings_infoName => 'Nome'; @@ -460,6 +464,14 @@ class AppLocalizationsPt extends AppLocalizations { @override String get appSettings_languageUk => 'Ucraniano'; + @override + String get appSettings_enableMessageTracing => + 'Ativar rastreamento de mensagens'; + + @override + String get appSettings_enableMessageTracingSubtitle => + 'Mostrar metadados detalhados de roteamento e tempo para as mensagens'; + @override String get appSettings_notifications => 'Notificações'; @@ -620,6 +632,15 @@ class AppLocalizationsPt extends AppLocalizations { @override String get appSettings_offlineMapCache => 'Cache de Mapa Offline'; + @override + String get appSettings_unitsTitle => 'Unidades'; + + @override + String get appSettings_unitsMetric => 'Métrico (m/km)'; + + @override + String get appSettings_unitsImperial => 'Imperial (ft/mi)'; + @override String get appSettings_noAreaSelected => 'Nenhuma área selecionada'; @@ -784,6 +805,12 @@ class AppLocalizationsPt extends AppLocalizations { @override String get channels_editChannel => 'Editar canal'; + @override + String get channels_muteChannel => 'Silenciar canal'; + + @override + String get channels_unmuteChannel => 'Ativar canal'; + @override String get channels_deleteChannel => 'Excluir canal'; @@ -792,6 +819,11 @@ class AppLocalizationsPt extends AppLocalizations { return 'Excluir \"$name\"? Não pode ser desfeito.'; } + @override + String channels_channelDeleteFailed(String name) { + return 'Falha ao excluir o canal \"$name\"'; + } + @override String channels_channelDeleted(String name) { return 'Canal \"$name\" excluído'; @@ -1079,6 +1111,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get chat_pathManagement => 'Gerenciamento de Caminhos'; + @override + String get chat_ShowAllPaths => 'Mostrar todos os caminhos'; + @override String get chat_routingMode => 'Modo de roteamento'; @@ -1237,6 +1272,12 @@ class AppLocalizationsPt extends AppLocalizations { @override String get map_title => 'Mapa de Nós'; + @override + String get map_lineOfSight => 'Linha de visão'; + + @override + String get map_losScreenTitle => 'Linha de visão'; + @override String get map_noNodesWithLocation => 'Não existem nós com dados de localização.'; @@ -1674,11 +1715,10 @@ class AppLocalizationsPt extends AppLocalizations { String get repeater_cliSubtitle => 'Enviar comandos ao repetidor'; @override - String get repeater_neighbours => 'Vizinhos'; + String get repeater_neighbors => 'Vizinhos'; @override - String get repeater_neighboursSubtitle => - 'Visualizar vizinhos de salto zero.'; + String get repeater_neighborsSubtitle => 'Visualizar vizinhos de salto zero.'; @override String get repeater_settings => 'Configurações'; @@ -2377,7 +2417,7 @@ class AppLocalizationsPt extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => 'Repetidores Vizinhos'; + String get neighbors_repeatersNeighbors => 'Repetidores Vizinhos'; @override String get neighbors_noData => 'Não estão disponíveis dados de vizinhos.'; @@ -2687,6 +2727,15 @@ class AppLocalizationsPt extends AppLocalizations { @override String get listFilter_all => 'Tudo'; + @override + String get listFilter_favorites => 'Favoritos'; + + @override + String get listFilter_addToFavorites => 'Adicionar aos favoritos'; + + @override + String get listFilter_removeFromFavorites => 'Remover da lista de favoritos'; + @override String get listFilter_users => 'Usuários'; @@ -2721,6 +2770,144 @@ class AppLocalizationsPt extends AppLocalizations { @override String get pathTrace_clearTooltip => 'Limpar caminho'; + @override + String get losSelectStartEnd => 'Selecione nós iniciais e finais para LOS.'; + + @override + String losRunFailed(String error) { + return 'Falha na verificação da linha de visão: $error'; + } + + @override + String get losClearAllPoints => 'Limpe todos os pontos'; + + @override + String get losRunToViewElevationProfile => + 'Execute o LOS para visualizar o perfil de elevação'; + + @override + String get losMenuTitle => 'Menu LOS'; + + @override + String get losMenuSubtitle => + 'Toque nos nós ou mantenha pressionado o mapa para obter pontos personalizados'; + + @override + String get losShowDisplayNodes => 'Mostrar nós de exibição'; + + @override + String get losCustomPoints => 'Pontos personalizados'; + + @override + String losCustomPointLabel(int index) { + return '$index personalizado'; + } + + @override + String get losPointA => 'Ponto A'; + + @override + String get losPointB => 'Ponto B'; + + @override + String losAntennaA(String value, String unit) { + return 'Antena A: $value $unit'; + } + + @override + String losAntennaB(String value, String unit) { + return 'Antena B: $value $unit'; + } + + @override + String get losRun => 'Executar LOS'; + + @override + String get losNoElevationData => 'Sem dados de elevação'; + + @override + String losProfileClear( + String distance, + String distanceUnit, + String clearance, + String heightUnit, + ) { + return '$distance $distanceUnit, limpar LOS, liberação mínima $clearance $heightUnit'; + } + + @override + String losProfileBlocked( + String distance, + String distanceUnit, + String obstruction, + String heightUnit, + ) { + return '$distance $distanceUnit, bloqueado por $obstruction $heightUnit'; + } + + @override + String get losStatusChecking => 'LOS: verificando...'; + + @override + String get losStatusNoData => 'LOS: sem dados'; + + @override + String losStatusSummary(int clear, int total, int blocked, int unknown) { + return 'LOS: $clear/$total limpo, $blocked bloqueado, $unknown desconhecido'; + } + + @override + String get losErrorElevationUnavailable => + 'Dados de elevação indisponíveis para uma ou mais amostras.'; + + @override + String get losErrorInvalidInput => + 'Dados de pontos/elevação inválidos para cálculo de LOS.'; + + @override + String get losRenameCustomPoint => 'Renomear ponto personalizado'; + + @override + String get losPointName => 'Nome do ponto'; + + @override + String get losShowPanelTooltip => 'Mostrar painel LOS'; + + @override + String get losHidePanelTooltip => 'Ocultar painel LOS'; + + @override + String get losElevationAttribution => + 'Dados de elevação: Open-Meteo (CC BY 4.0)'; + + @override + String get losLegendRadioHorizon => 'Horizonte de rádio'; + + @override + String get losLegendLosBeam => 'Linha de visada'; + + @override + String get losLegendTerrain => 'Terreno'; + + @override + String get losFrequencyLabel => 'Frequência'; + + @override + String get losFrequencyInfoTooltip => 'Ver detalhes do cálculo'; + + @override + String get losFrequencyDialogTitle => 'Cálculo do horizonte de rádio'; + + @override + String losFrequencyDialogDescription( + double baselineK, + double baselineFreq, + double frequencyMHz, + double kFactor, + ) { + return 'Começando em k=$baselineK em $baselineFreq MHz, o cálculo ajusta o fator k para a banda atual de $frequencyMHz MHz, que define o limite do horizonte de rádio curvo.'; + } + @override String get contacts_pathTrace => 'Traçado de Caminho'; @@ -2890,4 +3077,10 @@ class AppLocalizationsPt extends AppLocalizations { @override String get settings_gpxExportShareSubject => 'meshcore-open exportação de dados de mapa GPX'; + + @override + String get snrIndicator_nearByRepeaters => 'Repetidores Próximos'; + + @override + String get snrIndicator_lastSeen => 'Visto pela última vez'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index e5875b1..69d6044 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -321,6 +321,10 @@ class AppLocalizationsRu extends AppLocalizations { String get settings_aboutDescription => 'Открытое клиентское приложение на Flutter для устройств MeshCore с LoRa-сетями.'; + @override + String get settings_aboutOpenMeteoAttribution => + 'Данные о высоте LOS: Open-Meteo (CC BY 4.0)'; + @override String get settings_infoName => 'Имя'; @@ -458,6 +462,14 @@ class AppLocalizationsRu extends AppLocalizations { @override String get appSettings_languageUk => 'Українська'; + @override + String get appSettings_enableMessageTracing => + 'Включить трассировку сообщений'; + + @override + String get appSettings_enableMessageTracingSubtitle => + 'Показывать подробные метаданные о маршрутизации и времени для сообщений'; + @override String get appSettings_notifications => 'Уведомления'; @@ -620,6 +632,15 @@ class AppLocalizationsRu extends AppLocalizations { @override String get appSettings_offlineMapCache => 'Кэш офлайн-карты'; + @override + String get appSettings_unitsTitle => 'Единицы'; + + @override + String get appSettings_unitsMetric => 'Метрическая (м/км)'; + + @override + String get appSettings_unitsImperial => 'Имперская (ft / mi)'; + @override String get appSettings_noAreaSelected => 'Область не выбрана'; @@ -782,6 +803,12 @@ class AppLocalizationsRu extends AppLocalizations { @override String get channels_editChannel => 'Изменить канал'; + @override + String get channels_muteChannel => 'Отключить уведомления канала'; + + @override + String get channels_unmuteChannel => 'Включить уведомления канала'; + @override String get channels_deleteChannel => 'Удалить канал'; @@ -790,6 +817,11 @@ class AppLocalizationsRu extends AppLocalizations { return 'Удалить \"$name\"? Это действие нельзя отменить.'; } + @override + String channels_channelDeleteFailed(String name) { + return 'Не удалось удалить канал $name.'; + } + @override String channels_channelDeleted(String name) { return 'Канал \"$name\" удалён'; @@ -1077,6 +1109,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get chat_pathManagement => 'Управление маршрутами'; + @override + String get chat_ShowAllPaths => 'Показать все пути'; + @override String get chat_routingMode => 'Режим маршрутизации'; @@ -1239,6 +1274,12 @@ class AppLocalizationsRu extends AppLocalizations { @override String get map_title => 'Карта нод'; + @override + String get map_lineOfSight => 'Линия видимости'; + + @override + String get map_losScreenTitle => 'Линия видимости'; + @override String get map_noNodesWithLocation => 'Нет нод с данными о местоположении'; @@ -1676,10 +1717,10 @@ class AppLocalizationsRu extends AppLocalizations { String get repeater_cliSubtitle => 'Отправка команд репитеру'; @override - String get repeater_neighbours => 'Соседи'; + String get repeater_neighbors => 'Соседи'; @override - String get repeater_neighboursSubtitle => 'Просмотр соседей на нулевом хопе.'; + String get repeater_neighborsSubtitle => 'Просмотр соседей на нулевом хопе.'; @override String get repeater_settings => 'Настройки'; @@ -2379,7 +2420,7 @@ class AppLocalizationsRu extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => 'Соседи репитеров'; + String get neighbors_repeatersNeighbors => 'Соседи репитеров'; @override String get neighbors_noData => 'Данные о соседях недоступны.'; @@ -2689,6 +2730,15 @@ class AppLocalizationsRu extends AppLocalizations { @override String get listFilter_all => 'Все'; + @override + String get listFilter_favorites => 'Избранное'; + + @override + String get listFilter_addToFavorites => 'Добавить в избранное'; + + @override + String get listFilter_removeFromFavorites => 'Удалить из избранного'; + @override String get listFilter_users => 'Пользователи'; @@ -2723,6 +2773,144 @@ class AppLocalizationsRu extends AppLocalizations { @override String get pathTrace_clearTooltip => 'Очистить путь'; + @override + String get losSelectStartEnd => 'Выберите начальный и конечный узлы для LOS.'; + + @override + String losRunFailed(String error) { + return 'Проверка прямой видимости не удалась: $error'; + } + + @override + String get losClearAllPoints => 'Очистить все точки'; + + @override + String get losRunToViewElevationProfile => + 'Запустите LOS, чтобы просмотреть профиль высот.'; + + @override + String get losMenuTitle => 'ЛОС Меню'; + + @override + String get losMenuSubtitle => + 'Коснитесь узлов или нажмите и удерживайте карту для выбора пользовательских точек.'; + + @override + String get losShowDisplayNodes => 'Показать узлы отображения'; + + @override + String get losCustomPoints => 'Пользовательские точки'; + + @override + String losCustomPointLabel(int index) { + return 'Пользовательский $index'; + } + + @override + String get losPointA => 'Точка А'; + + @override + String get losPointB => 'Точка Б'; + + @override + String losAntennaA(String value, String unit) { + return 'Антенна А: $value $unit'; + } + + @override + String losAntennaB(String value, String unit) { + return 'Антенна Б: $value $unit'; + } + + @override + String get losRun => 'Запустить ЛОС'; + + @override + String get losNoElevationData => 'Нет данных о высоте'; + + @override + String losProfileClear( + String distance, + String distanceUnit, + String clearance, + String heightUnit, + ) { + return '$distance $distanceUnit, свободная зона видимости, минимальный зазор $clearance $heightUnit'; + } + + @override + String losProfileBlocked( + String distance, + String distanceUnit, + String obstruction, + String heightUnit, + ) { + return '$distance $distanceUnit, заблокирован $obstruction $heightUnit'; + } + + @override + String get losStatusChecking => 'ЛОС: проверяю...'; + + @override + String get losStatusNoData => 'ЛОС: нет данных'; + + @override + String losStatusSummary(int clear, int total, int blocked, int unknown) { + return 'LOS: $clear/$total очищено, $blocked заблокировано, $unknown неизвестно.'; + } + + @override + String get losErrorElevationUnavailable => + 'Данные о высоте недоступны для одного или нескольких образцов.'; + + @override + String get losErrorInvalidInput => + 'Неверные данные о точках/высоте для расчета LOS.'; + + @override + String get losRenameCustomPoint => 'Переименовать пользовательскую точку'; + + @override + String get losPointName => 'Имя точки'; + + @override + String get losShowPanelTooltip => 'Показать панель LOS'; + + @override + String get losHidePanelTooltip => 'Скрыть панель LOS'; + + @override + String get losElevationAttribution => + 'Данные о высоте: Open-Meteo (CC BY 4.0)'; + + @override + String get losLegendRadioHorizon => 'Радиогоризонт'; + + @override + String get losLegendLosBeam => 'Линия прямой видимости'; + + @override + String get losLegendTerrain => 'Рельеф'; + + @override + String get losFrequencyLabel => 'Частота'; + + @override + String get losFrequencyInfoTooltip => 'Просмотреть детали расчёта'; + + @override + String get losFrequencyDialogTitle => 'Расчёт радиогоризонта'; + + @override + String losFrequencyDialogDescription( + double baselineK, + double baselineFreq, + double frequencyMHz, + double kFactor, + ) { + return 'Начиная с k=$baselineK на частоте $baselineFreq МГц, расчет корректирует коэффициент k для текущего диапазона $frequencyMHz МГц, который определяет изогнутую границу радиогоризонта.'; + } + @override String get contacts_pathTrace => 'Трассировка пути'; @@ -2901,4 +3089,10 @@ class AppLocalizationsRu extends AppLocalizations { @override String get settings_gpxExportShareSubject => 'meshcore-open экспорт данных карты GPX'; + + @override + String get snrIndicator_nearByRepeaters => 'Ближайшие ретрансляторы'; + + @override + String get snrIndicator_lastSeen => 'Последний раз видели'; } diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 4e8b4cb..17cbd7b 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -320,6 +320,10 @@ class AppLocalizationsSk extends AppLocalizations { String get settings_aboutDescription => 'Otvorený zdrojový Flutter klient pre MeshCore LoRa sieťové zariadenia.'; + @override + String get settings_aboutOpenMeteoAttribution => + 'Údaje o nadmorskej výške LOS: Open-Meteo (CC BY 4.0)'; + @override String get settings_infoName => 'Meno'; @@ -456,6 +460,13 @@ class AppLocalizationsSk extends AppLocalizations { @override String get appSettings_languageUk => 'Ukrajinská'; + @override + String get appSettings_enableMessageTracing => 'Povoliť sledovanie správ'; + + @override + String get appSettings_enableMessageTracingSubtitle => + 'Zobraziť podrobné metadáta o smerovaní a časovaní správ'; + @override String get appSettings_notifications => 'Upozornenia'; @@ -614,6 +625,15 @@ class AppLocalizationsSk extends AppLocalizations { @override String get appSettings_offlineMapCache => 'Offline Mapa Pamäť'; + @override + String get appSettings_unitsTitle => 'Jednotky'; + + @override + String get appSettings_unitsMetric => 'Metrické (m / km)'; + + @override + String get appSettings_unitsImperial => 'Imperiálne (ft / mi)'; + @override String get appSettings_noAreaSelected => 'Neoznačila sa žiadna oblasť'; @@ -779,6 +799,12 @@ class AppLocalizationsSk extends AppLocalizations { @override String get channels_editChannel => 'Upraviť kanál'; + @override + String get channels_muteChannel => 'Stlmiť kanál'; + + @override + String get channels_unmuteChannel => 'Zrušiť stlmenie kanála'; + @override String get channels_deleteChannel => 'Odstrániť kanál'; @@ -787,6 +813,11 @@ class AppLocalizationsSk extends AppLocalizations { return 'Odstrániť \"$name\"? To sa nedá zrušiť.'; } + @override + String channels_channelDeleteFailed(String name) { + return 'Kanál \"$name\" sa nepodarilo odstrániť'; + } + @override String channels_channelDeleted(String name) { return 'Kanál \"$name\" bol odstránený'; @@ -1074,6 +1105,9 @@ class AppLocalizationsSk extends AppLocalizations { @override String get chat_pathManagement => 'Správa ciest'; + @override + String get chat_ShowAllPaths => 'Zobraziť všetky cesty'; + @override String get chat_routingMode => 'Režim trasy'; @@ -1233,6 +1267,12 @@ class AppLocalizationsSk extends AppLocalizations { @override String get map_title => 'Mapa uzlov'; + @override + String get map_lineOfSight => 'Line of Sight'; + + @override + String get map_losScreenTitle => 'Line of Sight'; + @override String get map_noNodesWithLocation => 'Žiadne uzly s údajmi o polohe'; @@ -1669,10 +1709,10 @@ class AppLocalizationsSk extends AppLocalizations { String get repeater_cliSubtitle => 'Pošlite príkazy opakovaču'; @override - String get repeater_neighbours => 'Súsezný'; + String get repeater_neighbors => 'Súsezný'; @override - String get repeater_neighboursSubtitle => 'Zobraziť susedné body bez skokov.'; + String get repeater_neighborsSubtitle => 'Zobraziť susedné body bez skokov.'; @override String get repeater_settings => 'Nastavenia'; @@ -2363,7 +2403,7 @@ class AppLocalizationsSk extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => 'Opakovadlá Súsezná'; + String get neighbors_repeatersNeighbors => 'Opakovadlá Súsezná'; @override String get neighbors_noData => @@ -2672,6 +2712,15 @@ class AppLocalizationsSk extends AppLocalizations { @override String get listFilter_all => 'Všetko'; + @override + String get listFilter_favorites => 'Obľúbené'; + + @override + String get listFilter_addToFavorites => 'Pridaj do obľúbených'; + + @override + String get listFilter_removeFromFavorites => 'Odstrániť z označení'; + @override String get listFilter_users => 'Používatelia'; @@ -2706,6 +2755,144 @@ class AppLocalizationsSk extends AppLocalizations { @override String get pathTrace_clearTooltip => 'Zmazať cestu'; + @override + String get losSelectStartEnd => 'Vyberte počiatočný a koncový uzol pre LOS.'; + + @override + String losRunFailed(String error) { + return 'Kontrola priamej viditeľnosti zlyhala: $error'; + } + + @override + String get losClearAllPoints => 'Vymazať všetky body'; + + @override + String get losRunToViewElevationProfile => + 'Ak chcete zobraziť výškový profil, spustite LOS'; + + @override + String get losMenuTitle => 'Menu LOS'; + + @override + String get losMenuSubtitle => + 'Klepnutím na uzly alebo dlhým stlačením mapy získate vlastné body'; + + @override + String get losShowDisplayNodes => 'Zobraziť uzly zobrazenia'; + + @override + String get losCustomPoints => 'Vlastné body'; + + @override + String losCustomPointLabel(int index) { + return 'Vlastné $index'; + } + + @override + String get losPointA => 'Bod A'; + + @override + String get losPointB => 'Bod B'; + + @override + String losAntennaA(String value, String unit) { + return 'Anténa A: $value $unit'; + } + + @override + String losAntennaB(String value, String unit) { + return 'Anténa B: $value $unit'; + } + + @override + String get losRun => 'Spustite LOS'; + + @override + String get losNoElevationData => 'Žiadne údaje o nadmorskej výške'; + + @override + String losProfileClear( + String distance, + String distanceUnit, + String clearance, + String heightUnit, + ) { + return '$distance $distanceUnit, vymazať LOS, min. vôľa $clearance $heightUnit'; + } + + @override + String losProfileBlocked( + String distance, + String distanceUnit, + String obstruction, + String heightUnit, + ) { + return '$distance $distanceUnit, blokovaný $obstruction $heightUnit'; + } + + @override + String get losStatusChecking => 'LOS: kontrolujem...'; + + @override + String get losStatusNoData => 'LOS: žiadne údaje'; + + @override + String losStatusSummary(int clear, int total, int blocked, int unknown) { + return 'LOS: $clear/$total vymazané, $blocked blokované, $unknown neznáme'; + } + + @override + String get losErrorElevationUnavailable => + 'Údaje o nadmorskej výške nie sú k dispozícii pre jednu alebo viacero vzoriek.'; + + @override + String get losErrorInvalidInput => + 'Neplatné body/údaje o nadmorskej výške pre výpočet LOS.'; + + @override + String get losRenameCustomPoint => 'Premenovať vlastný bod'; + + @override + String get losPointName => 'Názov bodu'; + + @override + String get losShowPanelTooltip => 'Zobraziť panel LOS'; + + @override + String get losHidePanelTooltip => 'Skryť panel LOS'; + + @override + String get losElevationAttribution => + 'Údaje o nadmorskej výške: Open-Meteo (CC BY 4.0)'; + + @override + String get losLegendRadioHorizon => 'Rádiový horizont'; + + @override + String get losLegendLosBeam => 'Priama viditeľnosť'; + + @override + String get losLegendTerrain => 'Terén'; + + @override + String get losFrequencyLabel => 'Frekvencia'; + + @override + String get losFrequencyInfoTooltip => 'Zobraziť podrobnosti výpočtu'; + + @override + String get losFrequencyDialogTitle => 'Výpočet rádiového horizontu'; + + @override + String losFrequencyDialogDescription( + double baselineK, + double baselineFreq, + double frequencyMHz, + double kFactor, + ) { + return 'Počnúc od k=$baselineK pri $baselineFreq MHz výpočet upraví k-faktor pre aktuálne pásmo $frequencyMHz MHz, ktorý definuje zakrivený strop rádiového horizontu.'; + } + @override String get contacts_pathTrace => 'Sledovanie lúčov'; @@ -2877,4 +3064,10 @@ class AppLocalizationsSk extends AppLocalizations { @override String get settings_gpxExportShareSubject => 'meshcore-open export dát GPX mapových údajov'; + + @override + String get snrIndicator_nearByRepeaters => 'Miestne opakovače'; + + @override + String get snrIndicator_lastSeen => 'Naposledy videný'; } diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index e01151e..6478b2b 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -319,6 +319,10 @@ class AppLocalizationsSl extends AppLocalizations { String get settings_aboutDescription => 'Odprtokodni Flutter klient za naprave za LoRa omrežje MeshCore.'; + @override + String get settings_aboutOpenMeteoAttribution => + 'Podatki o višini LOS: Open-Meteo (CC BY 4.0)'; + @override String get settings_infoName => 'Ime'; @@ -455,6 +459,13 @@ class AppLocalizationsSl extends AppLocalizations { @override String get appSettings_languageUk => 'Ukrajinsko'; + @override + String get appSettings_enableMessageTracing => 'Omogoči sledenje sporočilom'; + + @override + String get appSettings_enableMessageTracingSubtitle => + 'Prikaži podrobne metapodatke o usmerjanju in časovnem usklajevanju sporočil'; + @override String get appSettings_notifications => 'Obvestila'; @@ -615,6 +626,15 @@ class AppLocalizationsSl extends AppLocalizations { @override String get appSettings_offlineMapCache => 'Shramba zemljevidov brez povezave'; + @override + String get appSettings_unitsTitle => 'Enote'; + + @override + String get appSettings_unitsMetric => 'Metrična (m/km)'; + + @override + String get appSettings_unitsImperial => 'Imperialno (ft / mi)'; + @override String get appSettings_noAreaSelected => 'Območje ni izbrano'; @@ -777,6 +797,12 @@ class AppLocalizationsSl extends AppLocalizations { @override String get channels_editChannel => 'Uredi kanal'; + @override + String get channels_muteChannel => 'Utišaj kanal'; + + @override + String get channels_unmuteChannel => 'Vklopi obvestila kanala'; + @override String get channels_deleteChannel => 'Pošlji kanal'; @@ -785,6 +811,11 @@ class AppLocalizationsSl extends AppLocalizations { return 'Izbrišem \"$name\"? To se ne da povrniti.'; } + @override + String channels_channelDeleteFailed(String name) { + return 'Kanala $name ni bilo mogoče izbrisati'; + } + @override String channels_channelDeleted(String name) { return 'Kanal \"$name\" izbrisan.'; @@ -1072,6 +1103,9 @@ class AppLocalizationsSl extends AppLocalizations { @override String get chat_pathManagement => 'Upravljanje poti'; + @override + String get chat_ShowAllPaths => 'Prikaži vse poti'; + @override String get chat_routingMode => 'Navodilo za usmerjevalni način'; @@ -1228,6 +1262,12 @@ class AppLocalizationsSl extends AppLocalizations { @override String get map_title => 'Mapa omrežja'; + @override + String get map_lineOfSight => 'Linija vida'; + + @override + String get map_losScreenTitle => 'Linija vida'; + @override String get map_noNodesWithLocation => 'Nihče od notranjih elementov nima podatkov o lokaciji.'; @@ -1668,10 +1708,10 @@ class AppLocalizationsSl extends AppLocalizations { 'Pošlji ukazne povelje na ponovitveno enoto.'; @override - String get repeater_neighbours => 'Sosedi'; + String get repeater_neighbors => 'Sosedi'; @override - String get repeater_neighboursSubtitle => 'Pogledati nič sosednjih hopjev.'; + String get repeater_neighborsSubtitle => 'Pogledati nič sosednjih hopjev.'; @override String get repeater_settings => 'Nastavitve'; @@ -2367,7 +2407,7 @@ class AppLocalizationsSl extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => 'Ponovitve Sosedi'; + String get neighbors_repeatersNeighbors => 'Ponovitve Sosedi'; @override String get neighbors_noData => 'Niso na voljo podatki o sosedih.'; @@ -2675,6 +2715,15 @@ class AppLocalizationsSl extends AppLocalizations { @override String get listFilter_all => 'Vse'; + @override + String get listFilter_favorites => 'Priljubljene'; + + @override + String get listFilter_addToFavorites => 'Dodaj v priljubljene'; + + @override + String get listFilter_removeFromFavorites => 'Odstrani iz priljubljenih'; + @override String get listFilter_users => 'Uporabniki'; @@ -2709,6 +2758,144 @@ class AppLocalizationsSl extends AppLocalizations { @override String get pathTrace_clearTooltip => 'Počisti pot'; + @override + String get losSelectStartEnd => 'Izberite začetno in končno vozlišče za LOS.'; + + @override + String losRunFailed(String error) { + return 'Preverjanje vidnega polja ni uspelo: $error'; + } + + @override + String get losClearAllPoints => 'Počisti vse točke'; + + @override + String get losRunToViewElevationProfile => + 'Zaženite LOS za ogled višinskega profila'; + + @override + String get losMenuTitle => 'LOS meni'; + + @override + String get losMenuSubtitle => + 'Tapnite vozlišča ali dolgo pritisnite na zemljevid za točke po meri'; + + @override + String get losShowDisplayNodes => 'Pokaži prikazna vozlišča'; + + @override + String get losCustomPoints => 'Točke po meri'; + + @override + String losCustomPointLabel(int index) { + return 'Po meri $index'; + } + + @override + String get losPointA => 'Točka A'; + + @override + String get losPointB => 'Točka B'; + + @override + String losAntennaA(String value, String unit) { + return 'Antena A: $value $unit'; + } + + @override + String losAntennaB(String value, String unit) { + return 'Antena B: $value $unit'; + } + + @override + String get losRun => 'Zaženi LOS'; + + @override + String get losNoElevationData => 'Ni podatkov o višini'; + + @override + String losProfileClear( + String distance, + String distanceUnit, + String clearance, + String heightUnit, + ) { + return '$distance $distanceUnit, čisti LOS, najmanjša razdalja $clearance $heightUnit'; + } + + @override + String losProfileBlocked( + String distance, + String distanceUnit, + String obstruction, + String heightUnit, + ) { + return '$distance $distanceUnit, blokiral $obstruction $heightUnit'; + } + + @override + String get losStatusChecking => 'LOS: preverjam ...'; + + @override + String get losStatusNoData => 'LOS: ni podatkov'; + + @override + String losStatusSummary(int clear, int total, int blocked, int unknown) { + return 'LOS: $clear/$total jasno, $blocked blokirano, $unknown neznano'; + } + + @override + String get losErrorElevationUnavailable => + 'Podatki o nadmorski višini niso na voljo za enega ali več vzorcev.'; + + @override + String get losErrorInvalidInput => + 'Neveljavni podatki o točkah/višini za izračun LOS.'; + + @override + String get losRenameCustomPoint => 'Preimenujte točko po meri'; + + @override + String get losPointName => 'Ime točke'; + + @override + String get losShowPanelTooltip => 'Pokaži ploščo LOS'; + + @override + String get losHidePanelTooltip => 'Skrij ploščo LOS'; + + @override + String get losElevationAttribution => + 'Podatki o višini: Open-Meteo (CC BY 4.0)'; + + @override + String get losLegendRadioHorizon => 'Radijski horizont'; + + @override + String get losLegendLosBeam => 'Linija vidnosti'; + + @override + String get losLegendTerrain => 'Teren'; + + @override + String get losFrequencyLabel => 'Frekvenca'; + + @override + String get losFrequencyInfoTooltip => 'Prikaži podrobnosti izračuna'; + + @override + String get losFrequencyDialogTitle => 'Izračun radijskega horizonta'; + + @override + String losFrequencyDialogDescription( + double baselineK, + double baselineFreq, + double frequencyMHz, + double kFactor, + ) { + return 'Začenši od k=$baselineK pri $baselineFreq MHz, izračun prilagodi k-faktor za trenutni pas $frequencyMHz MHz, ki določa ukrivljeno zgornjo mejo radijskega horizonta.'; + } + @override String get contacts_pathTrace => 'Sledenje poti'; @@ -2882,4 +3069,10 @@ class AppLocalizationsSl extends AppLocalizations { @override String get settings_gpxExportShareSubject => 'meshcore-open izvoz podatkov GPX karte'; + + @override + String get snrIndicator_nearByRepeaters => 'Bližnji ponovitelji'; + + @override + String get snrIndicator_lastSeen => 'Zadnjič videno'; } diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index f081711..54b998d 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -317,6 +317,10 @@ class AppLocalizationsSv extends AppLocalizations { String get settings_aboutDescription => 'En öppen källkods Flutter-klient för MeshCore LoRa meshnätverksenheter.'; + @override + String get settings_aboutOpenMeteoAttribution => + 'LOS-höjddata: Open-Meteo (CC BY 4.0)'; + @override String get settings_infoName => 'Namn'; @@ -453,6 +457,13 @@ class AppLocalizationsSv extends AppLocalizations { @override String get appSettings_languageUk => 'Ukrainska'; + @override + String get appSettings_enableMessageTracing => 'Aktivera meddelandespårning'; + + @override + String get appSettings_enableMessageTracingSubtitle => + 'Visa detaljerade metadata om dirigering och tidsinställningar för meddelanden'; + @override String get appSettings_notifications => 'Meddelanden'; @@ -610,6 +621,15 @@ class AppLocalizationsSv extends AppLocalizations { @override String get appSettings_offlineMapCache => 'Offline Kartcache'; + @override + String get appSettings_unitsTitle => 'Enheter'; + + @override + String get appSettings_unitsMetric => 'Metriskt (m/km)'; + + @override + String get appSettings_unitsImperial => 'Imperialt (ft / mi)'; + @override String get appSettings_noAreaSelected => 'Ingen area markerad'; @@ -773,6 +793,12 @@ class AppLocalizationsSv extends AppLocalizations { @override String get channels_editChannel => 'Redigera kanal'; + @override + String get channels_muteChannel => 'Tysta kanal'; + + @override + String get channels_unmuteChannel => 'Slå på ljud för kanal'; + @override String get channels_deleteChannel => 'Ta bort kanal'; @@ -781,6 +807,11 @@ class AppLocalizationsSv extends AppLocalizations { return 'Radera \"$name\"? Detta kan inte ångras.'; } + @override + String channels_channelDeleteFailed(String name) { + return 'Det gick inte att ta bort kanalen \"$name\"'; + } + @override String channels_channelDeleted(String name) { return 'Kanalen \"$name\" raderad'; @@ -1069,6 +1100,9 @@ class AppLocalizationsSv extends AppLocalizations { @override String get chat_pathManagement => 'Stigarhantering'; + @override + String get chat_ShowAllPaths => 'Visa alla vägar'; + @override String get chat_routingMode => 'Ruttläge'; @@ -1225,6 +1259,12 @@ class AppLocalizationsSv extends AppLocalizations { @override String get map_title => 'Nodkarta'; + @override + String get map_lineOfSight => 'Synlinje'; + + @override + String get map_losScreenTitle => 'Synlinje'; + @override String get map_noNodesWithLocation => 'Inga noder med platsinformation'; @@ -1658,10 +1698,10 @@ class AppLocalizationsSv extends AppLocalizations { String get repeater_cliSubtitle => 'Skicka kommandon till repetitorn'; @override - String get repeater_neighbours => 'Grannar'; + String get repeater_neighbors => 'Grannar'; @override - String get repeater_neighboursSubtitle => 'Visa noll hoppgrannar.'; + String get repeater_neighborsSubtitle => 'Visa noll hoppgrannar.'; @override String get repeater_settings => 'Inställningar'; @@ -2352,7 +2392,7 @@ class AppLocalizationsSv extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => 'Upprepar grannar'; + String get neighbors_repeatersNeighbors => 'Upprepar grannar'; @override String get neighbors_noData => 'Inga grannuppgifter finns tillgängliga.'; @@ -2660,6 +2700,15 @@ class AppLocalizationsSv extends AppLocalizations { @override String get listFilter_all => 'Alla'; + @override + String get listFilter_favorites => 'Favoriter'; + + @override + String get listFilter_addToFavorites => 'Lägg till i favoriter'; + + @override + String get listFilter_removeFromFavorites => 'Ta bort från favoriter'; + @override String get listFilter_users => 'Användare'; @@ -2694,6 +2743,142 @@ class AppLocalizationsSv extends AppLocalizations { @override String get pathTrace_clearTooltip => 'Rensa väg'; + @override + String get losSelectStartEnd => 'Välj start- och slutnoder för LOS.'; + + @override + String losRunFailed(String error) { + return 'Synlinjekontroll misslyckades: $error'; + } + + @override + String get losClearAllPoints => 'Rensa alla punkter'; + + @override + String get losRunToViewElevationProfile => 'Kör LOS för att se höjdprofil'; + + @override + String get losMenuTitle => 'LOS-menyn'; + + @override + String get losMenuSubtitle => + 'Tryck på noder eller tryck länge på kartan för anpassade punkter'; + + @override + String get losShowDisplayNodes => 'Visa displaynoder'; + + @override + String get losCustomPoints => 'Anpassade poäng'; + + @override + String losCustomPointLabel(int index) { + return 'Anpassad $index'; + } + + @override + String get losPointA => 'Punkt A'; + + @override + String get losPointB => 'Punkt B'; + + @override + String losAntennaA(String value, String unit) { + return 'Antenn A: $value $unit'; + } + + @override + String losAntennaB(String value, String unit) { + return 'Antenn B: $value $unit'; + } + + @override + String get losRun => 'Kör LOS'; + + @override + String get losNoElevationData => 'Inga höjddata'; + + @override + String losProfileClear( + String distance, + String distanceUnit, + String clearance, + String heightUnit, + ) { + return '$distance $distanceUnit, rensa LOS, min clearance $clearance $heightUnit'; + } + + @override + String losProfileBlocked( + String distance, + String distanceUnit, + String obstruction, + String heightUnit, + ) { + return '$distance $distanceUnit, blockerad av $obstruction $heightUnit'; + } + + @override + String get losStatusChecking => 'LOS: kollar...'; + + @override + String get losStatusNoData => 'LOS: inga data'; + + @override + String losStatusSummary(int clear, int total, int blocked, int unknown) { + return 'LOS: $clear/$total rensa, $blocked blockerad, $unknown okänd'; + } + + @override + String get losErrorElevationUnavailable => + 'Höjddata är inte tillgänglig för ett eller flera prover.'; + + @override + String get losErrorInvalidInput => + 'Ogiltiga poäng/höjddata för LOS-beräkning.'; + + @override + String get losRenameCustomPoint => 'Byt namn på anpassad punkt'; + + @override + String get losPointName => 'Punktnamn'; + + @override + String get losShowPanelTooltip => 'Visa LOS-panelen'; + + @override + String get losHidePanelTooltip => 'Dölj LOS-panelen'; + + @override + String get losElevationAttribution => 'Höjddata: Open-Meteo (CC BY 4.0)'; + + @override + String get losLegendRadioHorizon => 'Radiohorisont'; + + @override + String get losLegendLosBeam => 'Siktlinje'; + + @override + String get losLegendTerrain => 'Terräng'; + + @override + String get losFrequencyLabel => 'Frekvens'; + + @override + String get losFrequencyInfoTooltip => 'Visa detaljer om beräkningen'; + + @override + String get losFrequencyDialogTitle => 'Beräkning av radiohorisonten'; + + @override + String losFrequencyDialogDescription( + double baselineK, + double baselineFreq, + double frequencyMHz, + double kFactor, + ) { + return 'Med start från k=$baselineK vid $baselineFreq MHz, justerar beräkningen k-faktorn för det aktuella $frequencyMHz MHz-bandet, som definierar den böjda radiohorisonten.'; + } + @override String get contacts_pathTrace => 'Path Trace'; @@ -2862,4 +3047,10 @@ class AppLocalizationsSv extends AppLocalizations { @override String get settings_gpxExportShareSubject => 'meshcore-open export av GPX-kartdata'; + + @override + String get snrIndicator_nearByRepeaters => 'Närliggande uppreparstationer'; + + @override + String get snrIndicator_lastSeen => 'Senast sedd'; } diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 847c3e5..e3564f1 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -322,6 +322,10 @@ class AppLocalizationsUk extends AppLocalizations { String get settings_aboutDescription => 'Клієнт Flutter з відкритим вихідним кодом для пристроїв мережі MeshCore LoRa.'; + @override + String get settings_aboutOpenMeteoAttribution => + 'Дані про висоту LOS: Open-Meteo (CC BY 4.0)'; + @override String get settings_infoName => 'Ім\'я'; @@ -458,6 +462,14 @@ class AppLocalizationsUk extends AppLocalizations { @override String get appSettings_languageUk => 'Українська'; + @override + String get appSettings_enableMessageTracing => + 'Увімкнути відстеження повідомлень'; + + @override + String get appSettings_enableMessageTracingSubtitle => + 'Показувати детальні метадані про маршрутизацію та час для повідомлень'; + @override String get appSettings_notifications => 'Сповіщення'; @@ -618,6 +630,15 @@ class AppLocalizationsUk extends AppLocalizations { @override String get appSettings_offlineMapCache => 'Офлайн-кеш карти'; + @override + String get appSettings_unitsTitle => 'одиниці'; + + @override + String get appSettings_unitsMetric => 'Метричний (м / км)'; + + @override + String get appSettings_unitsImperial => 'Імперська (ft / mi)'; + @override String get appSettings_noAreaSelected => 'Область не вибрано'; @@ -780,6 +801,12 @@ class AppLocalizationsUk extends AppLocalizations { @override String get channels_editChannel => 'Редагувати канал'; + @override + String get channels_muteChannel => 'Вимкнути сповіщення каналу'; + + @override + String get channels_unmuteChannel => 'Увімкнути сповіщення каналу'; + @override String get channels_deleteChannel => 'Видалити канал'; @@ -788,6 +815,11 @@ class AppLocalizationsUk extends AppLocalizations { return 'Видалити $name? Це не можна скасувати.'; } + @override + String channels_channelDeleteFailed(String name) { + return 'Не вдалося видалити канал \"$name\"'; + } + @override String channels_channelDeleted(String name) { return 'Канал «$name» видалено'; @@ -1075,6 +1107,9 @@ class AppLocalizationsUk extends AppLocalizations { @override String get chat_pathManagement => 'Керування шляхами'; + @override + String get chat_ShowAllPaths => 'Показати всі шляхи'; + @override String get chat_routingMode => 'Режим маршрутизації'; @@ -1237,6 +1272,12 @@ class AppLocalizationsUk extends AppLocalizations { @override String get map_title => 'Карта вузлів'; + @override + String get map_lineOfSight => 'Пряма видимість'; + + @override + String get map_losScreenTitle => 'Пряма видимість'; + @override String get map_noNodesWithLocation => 'Немає вузлів з даними про розташування'; @@ -1675,10 +1716,10 @@ class AppLocalizationsUk extends AppLocalizations { String get repeater_cliSubtitle => 'Надіслати команди ретранслятору'; @override - String get repeater_neighbours => 'Сусіди'; + String get repeater_neighbors => 'Сусіди'; @override - String get repeater_neighboursSubtitle => + String get repeater_neighborsSubtitle => 'Показати сусідів нульового стрибка.'; @override @@ -2380,7 +2421,7 @@ class AppLocalizationsUk extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => 'Ретранслятори-сусіди'; + String get neighbors_repeatersNeighbors => 'Ретранслятори-сусіди'; @override String get neighbors_noData => 'Дані про сусідів недоступні.'; @@ -2696,6 +2737,15 @@ class AppLocalizationsUk extends AppLocalizations { @override String get listFilter_all => 'Все'; + @override + String get listFilter_favorites => 'Улюблені'; + + @override + String get listFilter_addToFavorites => 'Додати до улюблених'; + + @override + String get listFilter_removeFromFavorites => 'Видалити зі списку улюблених'; + @override String get listFilter_users => 'Користувачі'; @@ -2730,6 +2780,145 @@ class AppLocalizationsUk extends AppLocalizations { @override String get pathTrace_clearTooltip => 'Очистити шлях'; + @override + String get losSelectStartEnd => + 'Виберіть початковий і кінцевий вузли для LOS.'; + + @override + String losRunFailed(String error) { + return 'Помилка перевірки прямої видимості: $error'; + } + + @override + String get losClearAllPoints => 'Очистити всі пункти'; + + @override + String get losRunToViewElevationProfile => + 'Запустіть LOS, щоб переглянути профіль висоти'; + + @override + String get losMenuTitle => 'Меню LOS'; + + @override + String get losMenuSubtitle => + 'Торкніться вузлів або утримуйте карту, щоб отримати власні точки'; + + @override + String get losShowDisplayNodes => 'Показати вузли відображення'; + + @override + String get losCustomPoints => 'Користувальницькі точки'; + + @override + String losCustomPointLabel(int index) { + return 'Спеціальний $index'; + } + + @override + String get losPointA => 'Точка А'; + + @override + String get losPointB => 'Точка Б'; + + @override + String losAntennaA(String value, String unit) { + return 'Антена A: $value $unit'; + } + + @override + String losAntennaB(String value, String unit) { + return 'Антена B: $value $unit'; + } + + @override + String get losRun => 'Запустіть LOS'; + + @override + String get losNoElevationData => 'Немає даних про висоту'; + + @override + String losProfileClear( + String distance, + String distanceUnit, + String clearance, + String heightUnit, + ) { + return '$distance $distanceUnit, чистий LOS, мінімальний зазор $clearance $heightUnit'; + } + + @override + String losProfileBlocked( + String distance, + String distanceUnit, + String obstruction, + String heightUnit, + ) { + return '$distance $distanceUnit, заблоковано $obstruction $heightUnit'; + } + + @override + String get losStatusChecking => 'LOS: перевірка...'; + + @override + String get losStatusNoData => 'LOS: немає даних'; + + @override + String losStatusSummary(int clear, int total, int blocked, int unknown) { + return 'LOS: $clear/$total очищено, $blocked заблоковано, $unknown невідомо'; + } + + @override + String get losErrorElevationUnavailable => + 'Дані про висоту недоступні для одного чи кількох зразків.'; + + @override + String get losErrorInvalidInput => + 'Недійсні дані про точки/висоту для розрахунку LOS.'; + + @override + String get losRenameCustomPoint => 'Перейменуйте спеціальну точку'; + + @override + String get losPointName => 'Назва точки'; + + @override + String get losShowPanelTooltip => 'Показати панель LOS'; + + @override + String get losHidePanelTooltip => 'Приховати панель LOS'; + + @override + String get losElevationAttribution => + 'Дані про висоту: Open-Meteo (CC BY 4.0)'; + + @override + String get losLegendRadioHorizon => 'Радіогоризонт'; + + @override + String get losLegendLosBeam => 'Лінія прямої видимості'; + + @override + String get losLegendTerrain => 'Рельєф'; + + @override + String get losFrequencyLabel => 'Частота'; + + @override + String get losFrequencyInfoTooltip => 'Переглянути деталі розрахунку'; + + @override + String get losFrequencyDialogTitle => 'Розрахунок радіогоризонту'; + + @override + String losFrequencyDialogDescription( + double baselineK, + double baselineFreq, + double frequencyMHz, + double kFactor, + ) { + return 'Починаючи з k=$baselineK на $baselineFreq МГц, обчислення коригує k-фактор для поточного діапазону $frequencyMHz МГц, який визначає викривлену межу радіогоризонту.'; + } + @override String get contacts_pathTrace => 'Трасування шляхів'; @@ -2907,4 +3096,10 @@ class AppLocalizationsUk extends AppLocalizations { @override String get settings_gpxExportShareSubject => 'експорт даних карти meshcore-open у форматі GPX'; + + @override + String get snrIndicator_nearByRepeaters => 'Ближні ретранслятори'; + + @override + String get snrIndicator_lastSeen => 'Останній раз бачили'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index fdc4531..b55c376 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -12,7 +12,7 @@ class AppLocalizationsZh extends AppLocalizations { String get appTitle => 'MeshCore Open'; @override - String get nav_contacts => '联系方式'; + String get nav_contacts => '联系人'; @override String get nav_channels => '频道'; @@ -24,7 +24,7 @@ class AppLocalizationsZh extends AppLocalizations { String get common_cancel => '取消'; @override - String get common_ok => '好的'; + String get common_ok => '确定'; @override String get common_connect => '连接'; @@ -54,13 +54,13 @@ class AppLocalizationsZh extends AppLocalizations { String get common_disconnect => '断开'; @override - String get common_connected => '连接'; + String get common_connected => '已连接'; @override - String get common_disconnected => '断开'; + String get common_disconnected => '已断开'; @override - String get common_create => '创造'; + String get common_create => '创建'; @override String get common_continue => '继续'; @@ -87,7 +87,7 @@ class AppLocalizationsZh extends AppLocalizations { String get common_disable => '禁用'; @override - String get common_reboot => '重新启动'; + String get common_reboot => '重启'; @override String get common_loading => '正在加载...'; @@ -106,7 +106,7 @@ class AppLocalizationsZh extends AppLocalizations { } @override - String get scanner_title => 'MeshCore 开放'; + String get scanner_title => '连接设备'; @override String get scanner_scanning => '正在搜索设备...'; @@ -129,11 +129,11 @@ class AppLocalizationsZh extends AppLocalizations { String get scanner_searchingDevices => '正在搜索 MeshCore 设备...'; @override - String get scanner_tapToScan => '点击“扫描”功能,以查找 MeshCore 设备。'; + String get scanner_tapToScan => '点击“扫描”按钮以查找 MeshCore 设备。'; @override String scanner_connectionFailed(String error) { - return 'Connection failed: $error'; + return '连接失败:$error'; } @override @@ -146,7 +146,7 @@ class AppLocalizationsZh extends AppLocalizations { String get scanner_bluetoothOff => '蓝牙已关闭'; @override - String get scanner_bluetoothOffMessage => '请打开蓝牙功能,以便搜索设备。'; + String get scanner_bluetoothOffMessage => '请开启蓝牙以搜索设备'; @override String get scanner_enableBluetooth => '启用蓝牙'; @@ -155,7 +155,7 @@ class AppLocalizationsZh extends AppLocalizations { String get device_quickSwitch => '快速切换'; @override - String get device_meshcore => '网格核心'; + String get device_meshcore => 'MeshCore'; @override String get settings_title => '设置'; @@ -182,19 +182,19 @@ class AppLocalizationsZh extends AppLocalizations { String get settings_nodeNameHint => '请输入节点名称'; @override - String get settings_nodeNameUpdated => '姓名已更新'; + String get settings_nodeNameUpdated => '节点名称已更新'; @override - String get settings_radioSettings => '收音机设置'; + String get settings_radioSettings => '无线电设置'; @override String get settings_radioSettingsSubtitle => '频率、功率、扩频因子'; @override - String get settings_radioSettingsUpdated => '收音机设置已更新'; + String get settings_radioSettingsUpdated => '无线电设置已更新'; @override - String get settings_location => '地点'; + String get settings_location => '位置'; @override String get settings_locationSubtitle => 'GPS 坐标'; @@ -203,19 +203,19 @@ class AppLocalizationsZh extends AppLocalizations { String get settings_locationUpdated => '位置和 GPS 设置已更新'; @override - String get settings_locationBothRequired => '请输入经度和纬度。'; + String get settings_locationBothRequired => '请输入经度和纬度'; @override - String get settings_locationInvalid => '无效的经度和纬度。'; + String get settings_locationInvalid => '无效的经度和纬度'; @override - String get settings_locationGPSEnable => '开启 GPS 功能'; + String get settings_locationGPSEnable => '启用 GPS'; @override - String get settings_locationGPSEnableSubtitle => '使 GPS 能够自动更新位置。'; + String get settings_locationGPSEnableSubtitle => '启用 GPS 以自动更新位置。'; @override - String get settings_locationIntervalSec => 'GPS 间隔时间(秒)'; + String get settings_locationIntervalSec => 'GPS 间隔(秒)'; @override String get settings_locationIntervalInvalid => '间隔时间必须至少为 60 秒,但不超过 86400 秒。'; @@ -233,7 +233,7 @@ class AppLocalizationsZh extends AppLocalizations { String get settings_privacyModeSubtitle => '在广告中隐藏姓名/位置'; @override - String get settings_privacyModeToggle => '切换隐私模式,以隐藏您的姓名和位置,从而在广告中保护您的个人信息。'; + String get settings_privacyModeToggle => '切换隐私模式以在广告中隐藏姓名和位置,保护个人信息。'; @override String get settings_privacyModeEnabled => '隐私模式已启用'; @@ -242,16 +242,16 @@ class AppLocalizationsZh extends AppLocalizations { String get settings_privacyModeDisabled => '隐私模式已关闭'; @override - String get settings_actions => '行动'; + String get settings_actions => '操作'; @override - String get settings_sendAdvertisement => '发布广告'; + String get settings_sendAdvertisement => '发送广播'; @override - String get settings_sendAdvertisementSubtitle => '现已开始进行广播节目'; + String get settings_sendAdvertisementSubtitle => '立即发送广播'; @override - String get settings_advertisementSent => '已发送广告'; + String get settings_advertisementSent => '已发送广播'; @override String get settings_syncTime => '同步时间'; @@ -260,22 +260,22 @@ class AppLocalizationsZh extends AppLocalizations { String get settings_syncTimeSubtitle => '将设备时钟设置为与手机时间一致'; @override - String get settings_timeSynchronized => '时间同步'; + String get settings_timeSynchronized => '时间已同步'; @override String get settings_refreshContacts => '刷新联系人'; @override - String get settings_refreshContactsSubtitle => '从设备中重新加载联系人列表'; + String get settings_refreshContactsSubtitle => '从设备重新加载联系人列表'; @override String get settings_rebootDevice => '重启设备'; @override - String get settings_rebootDeviceSubtitle => '重新启动 MeshCore 设备'; + String get settings_rebootDeviceSubtitle => '重启 MeshCore 设备'; @override - String get settings_rebootDeviceConfirm => '您确定要重启设备吗?这将导致您与设备断开连接。'; + String get settings_rebootDeviceConfirm => '确定要重启设备吗?这将断开与设备的连接。'; @override String get settings_debug => '调试'; @@ -287,10 +287,10 @@ class AppLocalizationsZh extends AppLocalizations { String get settings_bleDebugLogSubtitle => 'BLE 命令、响应和原始数据'; @override - String get settings_appDebugLog => '应用程序调试日志'; + String get settings_appDebugLog => '应用调试日志'; @override - String get settings_appDebugLogSubtitle => '应用程序调试消息'; + String get settings_appDebugLogSubtitle => '应用调试消息'; @override String get settings_about => '关于'; @@ -308,10 +308,14 @@ class AppLocalizationsZh extends AppLocalizations { '一个开源的 Flutter 客户端,用于 MeshCore LoRa 无线网络设备。'; @override - String get settings_infoName => '姓名'; + String get settings_aboutOpenMeteoAttribution => + 'LOS 高程数据:Open-Meteo (CC BY 4.0)'; @override - String get settings_infoId => 'ID'; + String get settings_infoName => '名称'; + + @override + String get settings_infoId => 'MAC ID'; @override String get settings_infoStatus => '状态'; @@ -326,7 +330,7 @@ class AppLocalizationsZh extends AppLocalizations { String get settings_infoContactsCount => '联系人数量'; @override - String get settings_infoChannelCount => '通道数量'; + String get settings_infoChannelCount => '频道数量'; @override String get settings_presets => '预设'; @@ -338,19 +342,19 @@ class AppLocalizationsZh extends AppLocalizations { String get settings_frequencyHelper => '300.0 - 2500.0'; @override - String get settings_frequencyInvalid => '无效频率(300-2500 MHz)'; + String get settings_frequencyInvalid => '无效频率范围(300-2500 MHz)'; @override String get settings_bandwidth => '带宽'; @override - String get settings_spreadingFactor => '传播系数'; + String get settings_spreadingFactor => '扩频因子'; @override String get settings_codingRate => '编码速率'; @override - String get settings_txPower => 'TX 功率(dBm)'; + String get settings_txPower => 'TX 功率 (dBm)'; @override String get settings_txPowerHelper => '0 - 22'; @@ -370,7 +374,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String settings_error(String message) { - return '[保存:$message]\n错误:$message'; + return '错误:$message'; } @override @@ -383,19 +387,19 @@ class AppLocalizationsZh extends AppLocalizations { String get appSettings_theme => '主题'; @override - String get appSettings_themeSystem => '系统默认设置'; + String get appSettings_themeSystem => '跟随系统'; @override - String get appSettings_themeLight => '光'; + String get appSettings_themeLight => '浅色'; @override - String get appSettings_themeDark => '黑暗'; + String get appSettings_themeDark => '深色'; @override String get appSettings_language => '语言'; @override - String get appSettings_languageSystem => '系统默认设置'; + String get appSettings_languageSystem => '跟随系统'; @override String get appSettings_languageEn => '英语'; @@ -413,7 +417,7 @@ class AppLocalizationsZh extends AppLocalizations { String get appSettings_languagePl => '波兰语'; @override - String get appSettings_languageSl => '斯洛文语'; + String get appSettings_languageSl => '斯洛文尼亚语'; @override String get appSettings_languagePt => '葡萄牙语'; @@ -434,13 +438,19 @@ class AppLocalizationsZh extends AppLocalizations { String get appSettings_languageSk => '斯洛伐克语'; @override - String get appSettings_languageBg => '保加利亚'; + String get appSettings_languageBg => '保加利亚语'; @override String get appSettings_languageRu => '俄语'; @override - String get appSettings_languageUk => '乌克兰'; + String get appSettings_languageUk => '乌克兰语'; + + @override + String get appSettings_enableMessageTracing => '启用消息追踪'; + + @override + String get appSettings_enableMessageTracingSubtitle => '显示消息的详细路由和时间元数据'; @override String get appSettings_notifications => '通知'; @@ -449,7 +459,7 @@ class AppLocalizationsZh extends AppLocalizations { String get appSettings_enableNotifications => '启用通知'; @override - String get appSettings_enableNotificationsSubtitle => '接收消息和广告的通知'; + String get appSettings_enableNotificationsSubtitle => '接收消息和广播的通知'; @override String get appSettings_notificationPermissionDenied => '权限被拒绝'; @@ -464,41 +474,40 @@ class AppLocalizationsZh extends AppLocalizations { String get appSettings_messageNotifications => '消息通知'; @override - String get appSettings_messageNotificationsSubtitle => '在收到新消息时显示通知'; + String get appSettings_messageNotificationsSubtitle => '收到新消息时显示通知'; @override String get appSettings_channelMessageNotifications => '频道消息通知'; @override - String get appSettings_channelMessageNotificationsSubtitle => - '在收到频道消息时,显示通知。'; + String get appSettings_channelMessageNotificationsSubtitle => '收到频道消息时显示通知'; @override - String get appSettings_advertisementNotifications => '广告通知'; + String get appSettings_advertisementNotifications => '广播通知'; @override - String get appSettings_advertisementNotificationsSubtitle => '在发现新的节点时,显示通知。'; + String get appSettings_advertisementNotificationsSubtitle => '发现新节点时显示通知'; @override - String get appSettings_messaging => '信息传递'; + String get appSettings_messaging => '消息'; @override - String get appSettings_clearPathOnMaxRetry => '关于“最大重试”的清晰说明'; + String get appSettings_clearPathOnMaxRetry => '达到最大重试次数时清除路径'; @override - String get appSettings_clearPathOnMaxRetrySubtitle => '在尝试发送失败后 5 次,重置联系路径。'; + String get appSettings_clearPathOnMaxRetrySubtitle => '在5次发送失败后重置联系路径。'; @override - String get appSettings_pathsWillBeCleared => '如果尝试 5 次后仍然失败,则将重新规划路径。'; + String get appSettings_pathsWillBeCleared => '5次失败后将重新路由'; @override - String get appSettings_pathsWillNotBeCleared => '路径不会自动清除。'; + String get appSettings_pathsWillNotBeCleared => '路径不会自动清除'; @override String get appSettings_autoRouteRotation => '自动路径轮换'; @override - String get appSettings_autoRouteRotationSubtitle => '在最佳路径和防洪模式之间切换'; + String get appSettings_autoRouteRotationSubtitle => '在最佳路径和泛洪模式之间切换'; @override String get appSettings_autoRouteRotationEnabled => '自动路径轮换已启用'; @@ -510,7 +519,7 @@ class AppLocalizationsZh extends AppLocalizations { String get appSettings_battery => '电池'; @override - String get appSettings_batteryChemistry => '电池化学'; + String get appSettings_batteryChemistry => '电池类型'; @override String appSettings_batteryChemistryPerDevice(String deviceName) { @@ -518,25 +527,25 @@ class AppLocalizationsZh extends AppLocalizations { } @override - String get appSettings_batteryChemistryConnectFirst => '连接到设备以进行选择'; + String get appSettings_batteryChemistryConnectFirst => '请先连接设备'; @override - String get appSettings_batteryNmc => '18650 型号,NMC 电池(3.0-4.2V)'; + String get appSettings_batteryNmc => '18650 NMC 电池 (3.0-4.2V)'; @override String get appSettings_batteryLifepo4 => '磷酸铁锂 (2.6-3.65V)'; @override - String get appSettings_batteryLipo => '锂离子电池 (3.0-4.2V)'; + String get appSettings_batteryLipo => '锂聚合物电池 (3.0-4.2V)'; @override - String get appSettings_mapDisplay => '地图展示'; + String get appSettings_mapDisplay => '地图显示'; @override - String get appSettings_showRepeaters => '显示重复'; + String get appSettings_showRepeaters => '显示转发节点'; @override - String get appSettings_showRepeatersSubtitle => '在地图上显示重复节点'; + String get appSettings_showRepeatersSubtitle => '在地图上显示转发节点'; @override String get appSettings_showChatNodes => '显示聊天节点'; @@ -558,14 +567,14 @@ class AppLocalizationsZh extends AppLocalizations { @override String appSettings_timeFilterShowLast(int hours) { - return 'Show nodes from last $hours hours'; + return '显示过去 $hours 小时内的节点'; } @override String get appSettings_mapTimeFilter => '地图时间筛选'; @override - String get appSettings_showNodesDiscoveredWithin => '显示在以下范围内发现的节点:'; + String get appSettings_showNodesDiscoveredWithin => '显示在此时间段内发现的节点:'; @override String get appSettings_allTime => '所有时间'; @@ -585,57 +594,66 @@ class AppLocalizationsZh extends AppLocalizations { @override String get appSettings_offlineMapCache => '离线地图缓存'; + @override + String get appSettings_unitsTitle => '单位'; + + @override + String get appSettings_unitsMetric => '公制(米/公里)'; + + @override + String get appSettings_unitsImperial => '英制 (ft / mi)'; + @override String get appSettings_noAreaSelected => '未选择任何区域'; @override String appSettings_areaSelectedZoom(int minZoom, int maxZoom) { - return '已选择区域(缩放至 $minZoom - $maxZoom)'; + return '已选择区域(缩放 $minZoom - $maxZoom)'; } @override String get appSettings_debugCard => '调试'; @override - String get appSettings_appDebugLogging => '应用程序调试日志'; + String get appSettings_appDebugLogging => '应用调试日志'; @override - String get appSettings_appDebugLoggingSubtitle => '用于故障排除的日志应用程序调试消息'; + String get appSettings_appDebugLoggingSubtitle => '记录应用调试消息以进行故障排除。'; @override String get appSettings_appDebugLoggingEnabled => '调试日志已启用'; @override - String get appSettings_appDebugLoggingDisabled => '应用程序调试日志已禁用'; + String get appSettings_appDebugLoggingDisabled => '应用调试日志已禁用'; @override - String get contacts_title => '联系方式'; + String get contacts_title => '联系人'; @override - String get contacts_noContacts => '目前还没有联系人'; + String get contacts_noContacts => '暂无联系人'; @override - String get contacts_contactsWillAppear => '当设备发布广告时,联系方式会显示。'; + String get contacts_contactsWillAppear => '当设备发送广播时,联系人将显示。'; @override String get contacts_searchContacts => '搜索联系人...'; @override - String get contacts_noUnreadContacts => '没有未读通讯'; + String get contacts_noUnreadContacts => '没有未读内容'; @override - String get contacts_noContactsFound => '未找到任何联系人或群组'; + String get contacts_noContactsFound => '未找到任何联系人或群聊'; @override String get contacts_deleteContact => '删除联系人'; @override String contacts_removeConfirm(String contactName) { - return 'Remove $contactName from contacts?'; + return '从联系人中移除 $contactName?'; } @override - String get contacts_manageRepeater => '管理重复器'; + String get contacts_manageRepeater => '管理转发节点'; @override String get contacts_manageRoom => '管理房间服务器'; @@ -644,64 +662,64 @@ class AppLocalizationsZh extends AppLocalizations { String get contacts_roomLogin => '服务器登录'; @override - String get contacts_openChat => '开放聊天'; + String get contacts_openChat => '打开聊天'; @override - String get contacts_editGroup => '编辑小组'; + String get contacts_editGroup => '编辑群聊'; @override - String get contacts_deleteGroup => '删除群组'; + String get contacts_deleteGroup => '删除群聊'; @override String contacts_deleteGroupConfirm(String groupName) { - return '删除\"$groupName\"?'; + return '删除群聊 \"$groupName\"?'; } @override - String get contacts_newGroup => '新的团体'; + String get contacts_newGroup => '新建群聊'; @override - String get contacts_groupName => '团体名称'; + String get contacts_groupName => '群聊名称'; @override - String get contacts_groupNameRequired => '需要提供组名称'; + String get contacts_groupNameRequired => '请输入群聊名称'; @override String contacts_groupAlreadyExists(String name) { - return '名为\"$name\"的组已经存在'; + return '名为 \"$name\" 的群聊已存在'; } @override String get contacts_filterContacts => '筛选联系人...'; @override - String get contacts_noContactsMatchFilter => '未找到符合您筛选条件的联系人'; + String get contacts_noContactsMatchFilter => '没有符合条件的联系人'; @override - String get contacts_noMembers => '没有会员'; + String get contacts_noMembers => '暂无成员'; @override - String get contacts_lastSeenNow => '最后一次被看到的时间'; + String get contacts_lastSeenNow => '刚刚'; @override String contacts_lastSeenMinsAgo(int minutes) { - return 'Last seen $minutes mins ago'; + return '最后在线 $minutes 分钟前'; } @override - String get contacts_lastSeenHourAgo => '最后一次被看到的时间:1小时前'; + String get contacts_lastSeenHourAgo => '最后在线 1小时前'; @override String contacts_lastSeenHoursAgo(int hours) { - return 'Last seen $hours hours ago'; + return '最后在线 $hours 小时前'; } @override - String get contacts_lastSeenDayAgo => '最后一次被看到的时间是1天前'; + String get contacts_lastSeenDayAgo => '最后在线 1天前'; @override String contacts_lastSeenDaysAgo(int days) { - return 'Last seen $days days ago'; + return '最后在线 $days 天前'; } @override @@ -725,34 +743,45 @@ class AppLocalizationsZh extends AppLocalizations { } @override - String get channels_hashtagChannel => '话题标签频道'; + String get channels_hashtagChannel => '标签频道'; @override - String get channels_public => '公众'; + String get channels_public => '公共'; @override - String get channels_private => '私人'; + String get channels_private => '私有'; @override String get channels_publicChannel => '公共频道'; @override - String get channels_privateChannel => '私密频道'; + String get channels_privateChannel => '私有频道'; @override String get channels_editChannel => '编辑频道'; + @override + String get channels_muteChannel => '静音频道'; + + @override + String get channels_unmuteChannel => '取消静音频道'; + @override String get channels_deleteChannel => '删除频道'; @override String channels_deleteChannelConfirm(String name) { - return 'Delete \"$name\"? This cannot be undone.'; + return '删除频道 \"$name\"?此操作不可撤销。'; + } + + @override + String channels_channelDeleteFailed(String name) { + return '无法删除频道 \"$name\"'; } @override String channels_channelDeleted(String name) { - return '删除频道 \"$name\"'; + return '已删除频道 \"$name\"'; } @override @@ -768,23 +797,23 @@ class AppLocalizationsZh extends AppLocalizations { String get channels_usePublicChannel => '使用公共频道'; @override - String get channels_standardPublicPsk => '标准公共PSK'; + String get channels_standardPublicPsk => '标准公共 PSK'; @override String get channels_pskHex => 'PSK (十六进制)'; @override - String get channels_generateRandomPsk => '生成随机的PSK(正交相移键控)'; + String get channels_generateRandomPsk => '生成随机 PSK'; @override - String get channels_enterChannelName => '请在此处输入频道名称'; + String get channels_enterChannelName => '请输入频道名称'; @override - String get channels_pskMustBe32Hex => 'PSK 必须包含 32 个十六进制字符。'; + String get channels_pskMustBe32Hex => 'PSK 必须为 32 个十六进制字符'; @override String channels_channelAdded(String name) { - return '添加频道 \"$name\"'; + return '已添加频道 \"$name\"'; } @override @@ -804,13 +833,13 @@ class AppLocalizationsZh extends AppLocalizations { String get channels_publicChannelAdded => '已添加公共频道'; @override - String get channels_sortBy => '按排序'; + String get channels_sortBy => '排序方式'; @override - String get channels_sortManual => '手册'; + String get channels_sortManual => '手动'; @override - String get channels_sortAZ => 'A 到 Z'; + String get channels_sortAZ => 'A-Z'; @override String get channels_sortLatestMessages => '最新消息'; @@ -819,13 +848,13 @@ class AppLocalizationsZh extends AppLocalizations { String get channels_sortUnread => '未读'; @override - String get channels_createPrivateChannel => '创建私密频道'; + String get channels_createPrivateChannel => '创建私有频道'; @override - String get channels_createPrivateChannelDesc => '使用秘密密钥进行保护。'; + String get channels_createPrivateChannelDesc => '使用密钥保护。'; @override - String get channels_joinPrivateChannel => '加入私密频道'; + String get channels_joinPrivateChannel => '加入私有频道'; @override String get channels_joinPrivateChannelDesc => '手动输入密钥。'; @@ -834,19 +863,19 @@ class AppLocalizationsZh extends AppLocalizations { String get channels_joinPublicChannel => '加入公共频道'; @override - String get channels_joinPublicChannelDesc => '任何人都可以加入这个频道。'; + String get channels_joinPublicChannelDesc => '任何人都可以加入。'; @override - String get channels_joinHashtagChannel => '加入一个带有特定标签的频道'; + String get channels_joinHashtagChannel => '加入标签频道'; @override - String get channels_joinHashtagChannelDesc => '任何人都可以加入带有特定标签的频道。'; + String get channels_joinHashtagChannelDesc => '任何人都可以加入标签频道。'; @override String get channels_scanQrCode => '扫描二维码'; @override - String get channels_scanQrCodeComingSoon => '即将发布'; + String get channels_scanQrCodeComingSoon => '即将推出'; @override String get channels_enterHashtag => '输入标签'; @@ -855,30 +884,30 @@ class AppLocalizationsZh extends AppLocalizations { String get channels_hashtagHint => '例如:#团队'; @override - String get chat_noMessages => '目前还没有收到任何消息。'; + String get chat_noMessages => '暂无消息'; @override - String get chat_sendMessageToStart => '发送消息以开始'; + String get chat_sendMessageToStart => '发送消息开始对话'; @override - String get chat_originalMessageNotFound => '无法找到原始消息'; + String get chat_originalMessageNotFound => '找不到原始消息'; @override String chat_replyingTo(String name) { - return 'Replying to $name'; + return '正在回复 $name'; } @override String chat_replyTo(String name) { - return 'Reply to $name'; + return '回复 $name'; } @override - String get chat_location => '地点'; + String get chat_location => '位置'; @override String chat_sendMessageTo(String contactName) { - return 'Send a message to $contactName'; + return '发送消息给 $contactName'; } @override @@ -886,7 +915,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String chat_messageTooLong(int maxBytes) { - return '消息内容过长(最大 $maxBytes 字节)。'; + return '消息过长(最多 $maxBytes 字节)'; } @override @@ -896,60 +925,60 @@ class AppLocalizationsZh extends AppLocalizations { String get chat_messageDeleted => '消息已删除'; @override - String get chat_retryingMessage => '重试消息'; + String get chat_retryingMessage => '正在重试消息'; @override String chat_retryCount(int current, int max) { - return 'Retry $current/$max'; + return '重试 $current/$max'; } @override - String get chat_sendGif => '发送 GIF 动画'; + String get chat_sendGif => '发送 GIF'; @override String get chat_reply => '回复'; @override - String get chat_addReaction => '添加评论'; + String get chat_addReaction => '添加表情'; @override String get chat_me => '我'; @override - String get emojiCategorySmileys => '表情符号'; + String get emojiCategorySmileys => '表情'; @override String get emojiCategoryGestures => '手势'; @override - String get emojiCategoryHearts => '心脏'; + String get emojiCategoryHearts => '爱心'; @override - String get emojiCategoryObjects => '物体'; + String get emojiCategoryObjects => '物品'; @override - String get gifPicker_title => '选择一个 GIF 动画'; + String get gifPicker_title => '选择 GIF'; @override - String get gifPicker_searchHint => '搜索 GIF 动画...'; + String get gifPicker_searchHint => '搜索 GIF...'; @override - String get gifPicker_poweredBy => '由 GIPHY 提供支持'; + String get gifPicker_poweredBy => '由 GIPHY 提供'; @override - String get gifPicker_noGifsFound => '未找到 GIF 动画'; + String get gifPicker_noGifsFound => '未找到 GIF'; @override - String get gifPicker_failedLoad => '无法加载 GIF 动画'; + String get gifPicker_failedLoad => '加载 GIF 失败'; @override - String get gifPicker_failedSearch => '未能搜索 GIF 动画'; + String get gifPicker_failedSearch => '搜索 GIF 失败'; @override - String get gifPicker_noInternet => '没有互联网连接'; + String get gifPicker_noInternet => '无网络连接'; @override - String get debugLog_appTitle => '应用程序调试日志'; + String get debugLog_appTitle => '应用调试日志'; @override String get debugLog_bleTitle => 'BLE 调试日志'; @@ -958,7 +987,7 @@ class AppLocalizationsZh extends AppLocalizations { String get debugLog_copyLog => '复制日志'; @override - String get debugLog_clearLog => '清晰的日志'; + String get debugLog_clearLog => '清除日志'; @override String get debugLog_copied => '调试日志已复制'; @@ -967,19 +996,19 @@ class AppLocalizationsZh extends AppLocalizations { String get debugLog_bleCopied => 'BLE 日志已复制'; @override - String get debugLog_noEntries => '目前还没有调试日志'; + String get debugLog_noEntries => '暂无调试日志'; @override - String get debugLog_enableInSettings => '在设置中启用应用程序调试日志功能。'; + String get debugLog_enableInSettings => '请在设置中启用应用调试日志。'; @override - String get debugLog_frames => '框架'; + String get debugLog_frames => '帧'; @override - String get debugLog_rawLogRx => '原始日志-RX'; + String get debugLog_rawLogRx => '原始日志 RX'; @override - String get debugLog_noBleActivity => '目前尚未有蓝牙低功耗(BLE)活动。'; + String get debugLog_noBleActivity => '暂无 BLE 活动'; @override String debugFrame_length(int count) { @@ -992,7 +1021,7 @@ class AppLocalizationsZh extends AppLocalizations { } @override - String get debugFrame_textMessageHeader => '短信模板:'; + String get debugFrame_textMessageHeader => '文本消息:'; @override String debugFrame_destinationPubKey(String pubKey) { @@ -1001,7 +1030,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String debugFrame_timestamp(int timestamp) { - return '- Timestamp: $timestamp'; + return '- 时间戳:$timestamp'; } @override @@ -1011,14 +1040,14 @@ class AppLocalizationsZh extends AppLocalizations { @override String debugFrame_textType(int type, String label) { - return '- Text Type: $type ($label)'; + return '- 文本类型:$type ($label)'; } @override - String get debugFrame_textTypeCli => '命令行界面'; + String get debugFrame_textTypeCli => '命令行'; @override - String get debugFrame_textTypePlain => '简单'; + String get debugFrame_textTypePlain => '纯文本'; @override String debugFrame_text(String text) { @@ -1031,46 +1060,43 @@ class AppLocalizationsZh extends AppLocalizations { @override String get chat_pathManagement => '路径管理'; + @override + String get chat_ShowAllPaths => '显示所有路径'; + @override String get chat_routingMode => '路由模式'; @override - String get chat_autoUseSavedPath => '自动(使用已保存的路径)'; + String get chat_autoUseSavedPath => '自动(使用保存的路径)'; @override - String get chat_forceFloodMode => '强制洪水模式'; + String get chat_forceFloodMode => '强制泛洪模式'; @override String get chat_recentAckPaths => '最近使用的 ACK 路径(点击使用):'; @override - String get chat_pathHistoryFull => '路径历史已满。删除条目以添加新的条目。'; + String get chat_pathHistoryFull => '路径历史已满,请删除后再添加。'; @override - String get chat_hopSingular => '跳跃'; + String get chat_hopSingular => '跳'; @override - String get chat_hopPlural => '啤酒花'; + String get chat_hopPlural => '跳'; @override String chat_hopsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'hops', - one: 'hop', - ); - return '$count $_temp0'; + return '$count 跳'; } @override String get chat_successes => '成功'; @override - String get chat_removePath => '删除路径'; + String get chat_removePath => '移除路径'; @override - String get chat_noPathHistoryYet => '目前还没有历史记录。\n发送消息以查找路径。'; + String get chat_noPathHistoryYet => '暂无路径历史。\n发送消息以探索路径。'; @override String get chat_pathActions => '路径操作:'; @@ -1082,45 +1108,39 @@ class AppLocalizationsZh extends AppLocalizations { String get chat_setCustomPathSubtitle => '手动指定路由路径'; @override - String get chat_clearPath => '明确的道路'; + String get chat_clearPath => '清除路径'; @override - String get chat_clearPathSubtitle => '在下一次发送时,重新尝试。'; + String get chat_clearPathSubtitle => '清除当前路径,下次发送将重新尝试。'; @override - String get chat_pathCleared => '路径已清理。下一条消息将重新确定路线。'; + String get chat_pathCleared => '路径已清除。下一条消息将重新路由。'; @override - String get chat_floodModeSubtitle => '使用应用程序栏中的路由切换功能'; + String get chat_floodModeSubtitle => '在应用栏中切换路由模式。'; @override - String get chat_floodModeEnabled => '防洪模式已启用。通过应用程序栏中的路由图标进行切换。'; + String get chat_floodModeEnabled => '泛洪模式已启用。可通过应用栏的路由图标切换。'; @override String get chat_fullPath => '完整路径'; @override - String get chat_pathDetailsNotAvailable => '路径信息尚未提供。请尝试发送消息以刷新。'; + String get chat_pathDetailsNotAvailable => '路径信息暂不可用,请尝试发送消息刷新。'; @override String chat_pathSetHops(int hopCount, String status) { - String _temp0 = intl.Intl.pluralLogic( - hopCount, - locale: localeName, - other: 'hops', - one: 'hop', - ); - return 'Path set: $hopCount $_temp0 - $status'; + return '路径设置:$hopCount 跳 - $status'; } @override - String get chat_pathSavedLocally => '已本地保存。连接以进行同步。'; + String get chat_pathSavedLocally => '已本地保存,连接设备后可同步。'; @override String get chat_pathDeviceConfirmed => '设备已确认。'; @override - String get chat_pathDeviceNotConfirmed => '该设备尚未得到确认。'; + String get chat_pathDeviceNotConfirmed => '设备尚未确认。'; @override String get chat_type => '类型'; @@ -1135,71 +1155,77 @@ class AppLocalizationsZh extends AppLocalizations { String get chat_compressOutgoingMessages => '压缩发送的消息'; @override - String get chat_floodForced => '洪水(被迫)'; + String get chat_floodForced => '泛洪(强制)'; @override - String get chat_directForced => '直接(强制性的)'; + String get chat_directForced => '直连(强制)'; @override String chat_hopsForced(int count) { - return '$count 根啤酒花(人工种植)'; + return '$count 跳(强制)'; } @override - String get chat_floodAuto => '自动洪水'; + String get chat_floodAuto => '自动泛洪'; @override - String get chat_direct => '直接'; + String get chat_direct => '直连'; @override String get chat_poiShared => '共享位置'; @override String chat_unread(int count) { - return 'Unread: $count'; + return '未读:$count'; } @override String get chat_openLink => '打开链接?'; @override - String get chat_openLinkConfirmation => '您想用浏览器打开这个链接吗?'; + String get chat_openLinkConfirmation => '是否使用浏览器打开此链接?'; @override - String get chat_open => '开放'; + String get chat_open => '打开'; @override String chat_couldNotOpenLink(String url) { - return '[保存:$url]\n无法打开链接:$url'; + return '无法打开链接:$url'; } @override String get chat_invalidLink => '无效的链接格式'; @override - String get map_title => '节点图'; + String get map_title => '节点地图'; + + @override + String get map_lineOfSight => '视线'; + + @override + String get map_losScreenTitle => '视线'; @override String get map_noNodesWithLocation => '没有包含位置信息的节点'; @override - String get map_nodesNeedGps => '节点需要共享其 GPS 坐标,以便在地图上显示'; + String get map_nodesNeedGps => '节点需要共享 GPS 坐标才能在地图上显示'; @override String map_nodesCount(int count) { - return 'Nodes: $count'; + return '节点:$count'; } @override String map_pinsCount(int count) { - return 'Pins: $count'; + return '标记:$count'; } @override String get map_chat => '聊天'; @override - String get map_repeater => '重复器'; + String get map_repeater => '转发节点'; @override String get map_room => '房间'; @@ -1208,28 +1234,28 @@ class AppLocalizationsZh extends AppLocalizations { String get map_sensor => '传感器'; @override - String get map_pinDm => 'PIN (直接消息)'; + String get map_pinDm => '标记(私信)'; @override - String get map_pinPrivate => '私密'; + String get map_pinPrivate => '私有'; @override - String get map_pinPublic => '公开'; + String get map_pinPublic => '公共'; @override - String get map_lastSeen => '最后一次被看到'; + String get map_lastSeen => '最后在线'; @override - String get map_disconnectConfirm => '您确定要断开与此设备的连接吗?'; + String get map_disconnectConfirm => '确定要断开与此设备的连接吗?'; @override - String get map_from => '从'; + String get map_from => '来自'; @override String get map_source => '来源'; @override - String get map_flags => '旗帜'; + String get map_flags => '标志'; @override String get map_shareMarkerHere => '在此分享标记'; @@ -1241,10 +1267,10 @@ class AppLocalizationsZh extends AppLocalizations { String get map_label => '标签'; @override - String get map_pointOfInterest => '值得参观的地方'; + String get map_pointOfInterest => '兴趣点'; @override - String get map_sendToContact => '发送给联系'; + String get map_sendToContact => '发送给联系人'; @override String get map_sendToChannel => '发送到频道'; @@ -1253,11 +1279,11 @@ class AppLocalizationsZh extends AppLocalizations { String get map_noChannelsAvailable => '没有可用的频道'; @override - String get map_publicLocationShare => '公共场所共享'; + String get map_publicLocationShare => '公共位置共享'; @override String map_publicLocationShareConfirm(String channelLabel) { - return '[保存:$channelLabel]\n您即将分享一个位置,该位置位于 $channelLabel。 此频道是公开的,任何拥有 PSK 的人都可以看到它。'; + return '您即将在 $channelLabel 上分享一个位置。此频道是公开的,任何拥有 PSK 的人都可以看到。'; } @override @@ -1273,19 +1299,19 @@ class AppLocalizationsZh extends AppLocalizations { String get map_chatNodes => '聊天节点'; @override - String get map_repeaters => '重复器'; + String get map_repeaters => '转发节点'; @override String get map_otherNodes => '其他节点'; @override - String get map_keyPrefix => '关键前缀'; + String get map_keyPrefix => '关键字前缀'; @override - String get map_filterByKeyPrefix => '按关键前缀筛选'; + String get map_filterByKeyPrefix => '按关键字前缀筛选'; @override - String get map_publicKeyPrefix => '公钥前缀'; + String get map_publicKeyPrefix => '关键字前缀'; @override String get map_markers => '标记'; @@ -1294,44 +1320,44 @@ class AppLocalizationsZh extends AppLocalizations { String get map_showSharedMarkers => '显示共享标记'; @override - String get map_lastSeenTime => '最后一次被看到的时间'; + String get map_lastSeenTime => '最后在线时间'; @override - String get map_sharedPin => '共享密码'; + String get map_sharedPin => '共享标记'; @override String get map_joinRoom => '加入房间'; @override - String get map_manageRepeater => '管理重复器'; + String get map_manageRepeater => '管理转发节点'; @override - String get map_tapToAdd => '点击节点将其添加到路径中'; + String get map_tapToAdd => '点击节点以添加到路径'; @override - String get map_runTrace => '运行路径跟踪'; + String get map_runTrace => '运行路径追踪'; @override - String get map_removeLast => '删除最后一个'; + String get map_removeLast => '移除最后一个'; @override - String get map_pathTraceCancelled => '路径跟踪已取消'; + String get map_pathTraceCancelled => '路径追踪已取消'; @override String get mapCache_title => '离线地图缓存'; @override - String get mapCache_selectAreaFirst => '选择一个用于缓存的区域'; + String get mapCache_selectAreaFirst => '请先选择要缓存的区域'; @override - String get mapCache_noTilesToDownload => '此区域没有可下载的瓦片。'; + String get mapCache_noTilesToDownload => '此区域没有可下载的瓦片'; @override - String get mapCache_downloadTilesTitle => '下载瓷砖'; + String get mapCache_downloadTilesTitle => '下载瓦片'; @override String mapCache_downloadTilesPrompt(int count) { - return '[保存:$count]\n下载 $count 个图片用于离线使用?'; + return '这需要下载 $count 个瓦片'; } @override @@ -1339,12 +1365,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String mapCache_cachedTiles(int count) { - return '缓存 $count 个瓦片'; + return '已缓存 $count 个瓦片'; } @override String mapCache_cachedTilesWithFailed(int downloaded, int failed) { - return 'Cached $downloaded tiles ($failed failed)'; + return '已缓存 $downloaded 个瓦片($failed 个失败)'; } @override @@ -1357,7 +1383,7 @@ class AppLocalizationsZh extends AppLocalizations { String get mapCache_offlineCacheCleared => '离线缓存已清除'; @override - String get mapCache_noAreaSelected => '未选择任何区域'; + String get mapCache_noAreaSelected => '未选择区域'; @override String get mapCache_cacheArea => '缓存区域'; @@ -1366,27 +1392,27 @@ class AppLocalizationsZh extends AppLocalizations { String get mapCache_useCurrentView => '使用当前视图'; @override - String get mapCache_zoomRange => '变焦范围'; + String get mapCache_zoomRange => '缩放范围'; @override String mapCache_estimatedTiles(int count) { - return 'Estimated tiles: $count'; + return '估计瓦片数:$count'; } @override String mapCache_downloadedTiles(int completed, int total) { - return 'Downloaded $completed / $total'; + return '已下载 $completed/$total'; } @override - String get mapCache_downloadTilesButton => '下载瓷砖'; + String get mapCache_downloadTilesButton => '下载瓦片'; @override String get mapCache_clearCacheButton => '清除缓存'; @override String mapCache_failedDownloads(int count) { - return 'Failed downloads: $count'; + return '下载失败:$count'; } @override @@ -1396,7 +1422,7 @@ class AppLocalizationsZh extends AppLocalizations { String east, String west, ) { - return 'N $north, S $south, E $east, W $west'; + return '北 $north, 南 $south, 东 $east, 西 $west'; } @override @@ -1404,12 +1430,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String time_minutesAgo(int minutes) { - return '${minutes}m ago'; + return '$minutes分钟前'; } @override String time_hoursAgo(int hours) { - return '${hours}h ago'; + return '$hours小时前'; } @override @@ -1424,22 +1450,22 @@ class AppLocalizationsZh extends AppLocalizations { String get time_hours => '小时'; @override - String get time_day => '一天'; + String get time_day => '天'; @override String get time_days => '天'; @override - String get time_week => '一周'; + String get time_week => '周'; @override String get time_weeks => '周'; @override - String get time_month => '月份'; + String get time_month => '月'; @override - String get time_months => '月份'; + String get time_months => '月'; @override String get time_minutes => '分钟'; @@ -1451,13 +1477,13 @@ class AppLocalizationsZh extends AppLocalizations { String get dialog_disconnect => '断开'; @override - String get dialog_disconnectConfirm => '您确定要断开与此设备的连接吗?'; + String get dialog_disconnectConfirm => '确定要断开与此设备的连接吗?'; @override - String get login_repeaterLogin => '重复登录'; + String get login_repeaterLogin => '转发节点登录'; @override - String get login_roomLogin => '服务器登录'; + String get login_roomLogin => '房间服务器登录'; @override String get login_password => '密码'; @@ -1469,13 +1495,13 @@ class AppLocalizationsZh extends AppLocalizations { String get login_savePassword => '保存密码'; @override - String get login_savePasswordSubtitle => '密码将安全地存储在 данном设备上'; + String get login_savePasswordSubtitle => '密码将安全地存储在此设备上'; @override - String get login_repeaterDescription => '输入重复器密码,即可访问设置和状态。'; + String get login_repeaterDescription => '输入转发节点密码以访问设置和状态。'; @override - String get login_roomDescription => '输入密码进入房间,即可访问设置和状态。'; + String get login_roomDescription => '输入房间服务器密码以访问设置和状态。'; @override String get login_routing => '路由'; @@ -1484,10 +1510,10 @@ class AppLocalizationsZh extends AppLocalizations { String get login_routingMode => '路由模式'; @override - String get login_autoUseSavedPath => '自动(使用已保存的路径)'; + String get login_autoUseSavedPath => '自动(使用保存的路径)'; @override - String get login_forceFloodMode => '强制洪水模式'; + String get login_forceFloodMode => '强制泛洪模式'; @override String get login_managePaths => '管理路径'; @@ -1497,37 +1523,31 @@ class AppLocalizationsZh extends AppLocalizations { @override String login_attempt(int current, int max) { - return 'Attempt $current/$max'; + return '尝试 $current/$max'; } @override String login_failed(String error) { - return 'Login failed: $error'; + return '登录失败:$error'; } @override - String get login_failedMessage => '登录失败。可能是密码错误,也可能是无法连接到服务器。'; + String get login_failedMessage => '登录失败。可能是密码错误或无法连接到服务器。'; @override String get common_reload => '重新加载'; @override - String get common_clear => '清晰'; + String get common_clear => '清除'; @override String path_currentPath(String path) { - return 'Current path: $path'; + return '当前路径:$path'; } @override String path_usingHopsPath(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'hops', - one: 'hop', - ); - return '使用 $count $_temp0 条路径'; + return '使用 $count 跳路径'; } @override @@ -1537,42 +1557,42 @@ class AppLocalizationsZh extends AppLocalizations { String get path_currentPathLabel => '当前路径'; @override - String get path_hexPrefixInstructions => '请输入每个跳跃步骤的 2 个字符的十六进制前缀,用逗号分隔。'; + String get path_hexPrefixInstructions => '请输入每个中继节点的2字符十六进制前缀,用逗号分隔。'; @override - String get path_hexPrefixExample => '例如:A1, F2, 3C (每个节点使用其公钥的第一字节)'; + String get path_hexPrefixExample => '例如:A1, F2, 3C(每个节点使用其公钥的第一字节)'; @override String get path_labelHexPrefixes => '路径(十六进制前缀)'; @override - String get path_helperMaxHops => '最大 64 个“hop”(跳跃)。每个前缀由 2 个十六进制字符(1 字节)组成。'; + String get path_helperMaxHops => '最多 64 跳。每个前缀由 2 个十六进制字符(1 字节)组成。'; @override - String get path_selectFromContacts => '或者从联系人列表中选择:'; + String get path_selectFromContacts => '或从联系人列表中选择:'; @override - String get path_noRepeatersFound => '未找到任何重复设备或房间服务器。'; + String get path_noRepeatersFound => '未找到任何转发节点或房间服务器。'; @override - String get path_customPathsRequire => '自定义路径需要中间节点,这些节点可以转发消息。'; + String get path_customPathsRequire => '自定义路径需要中间节点转发消息。'; @override String path_invalidHexPrefixes(String prefixes) { - return 'Invalid hex prefixes: $prefixes'; + return '无效的十六进制前缀:$prefixes'; } @override - String get path_tooLong => '路径太长。允许的最大跳跃次数为 64 次。'; + String get path_tooLong => '路径过长,最多允许 64 跳。'; @override String get path_setPath => '设置路径'; @override - String get repeater_management => '重复器管理'; + String get repeater_management => '转发节点管理'; @override - String get room_management => '服务器管理'; + String get room_management => '房间服务器管理'; @override String get repeater_managementTools => '管理工具'; @@ -1581,56 +1601,56 @@ class AppLocalizationsZh extends AppLocalizations { String get repeater_status => '状态'; @override - String get repeater_statusSubtitle => '查看重复器状态、统计信息和邻居'; + String get repeater_statusSubtitle => '查看转发节点状态、统计和邻居'; @override - String get repeater_telemetry => '远程监控'; + String get repeater_telemetry => '遥测'; @override - String get repeater_telemetrySubtitle => '查看传感器和系统状态的数据。'; + String get repeater_telemetrySubtitle => '查看传感器和系统状态数据'; @override - String get repeater_cli => '命令行界面'; + String get repeater_cli => '命令行'; @override - String get repeater_cliSubtitle => '向复用器发送指令'; + String get repeater_cliSubtitle => '向转发节点发送命令'; @override - String get repeater_neighbours => '邻居'; + String get repeater_neighbors => '邻居'; @override - String get repeater_neighboursSubtitle => '查看邻居节点(无需中间节点)。'; + String get repeater_neighborsSubtitle => '查看邻居节点(零跳)'; @override String get repeater_settings => '设置'; @override - String get repeater_settingsSubtitle => '配置重复器参数'; + String get repeater_settingsSubtitle => '配置转发节点参数'; @override - String get repeater_statusTitle => '重复器状态'; + String get repeater_statusTitle => '转发节点状态'; @override String get repeater_routingMode => '路由模式'; @override - String get repeater_autoUseSavedPath => '自动(使用已保存的路径)'; + String get repeater_autoUseSavedPath => '自动(使用保存的路径)'; @override - String get repeater_forceFloodMode => '强制洪水模式'; + String get repeater_forceFloodMode => '强制泛洪模式'; @override String get repeater_pathManagement => '路径管理'; @override - String get repeater_refresh => '更新'; + String get repeater_refresh => '刷新'; @override - String get repeater_statusRequestTimeout => '状态请求超时。'; + String get repeater_statusRequestTimeout => '状态请求超时'; @override String repeater_errorLoadingStatus(String error) { - return 'Error loading status: $error'; + return '加载状态时出错:$error'; } @override @@ -1640,34 +1660,34 @@ class AppLocalizationsZh extends AppLocalizations { String get repeater_battery => '电池'; @override - String get repeater_clockAtLogin => '登录时的时间'; + String get repeater_clockAtLogin => '登录时的时钟'; @override - String get repeater_uptime => '正常运行时间'; + String get repeater_uptime => '运行时间'; @override - String get repeater_queueLength => '排队长度'; + String get repeater_queueLength => '队列长度'; @override String get repeater_debugFlags => '调试标志'; @override - String get repeater_radioStatistics => '广播统计'; + String get repeater_radioStatistics => '无线电统计'; @override - String get repeater_lastRssi => '上次的 RSSI 值'; + String get repeater_lastRssi => '上次 RSSI'; @override - String get repeater_lastSnr => '最后一次信噪比'; + String get repeater_lastSnr => '上次 SNR'; @override - String get repeater_noiseFloor => '噪声水平'; + String get repeater_noiseFloor => '底噪'; @override - String get repeater_txAirtime => 'TX 频道预留时间'; + String get repeater_txAirtime => '发送空中时间'; @override - String get repeater_rxAirtime => 'RX 空时'; + String get repeater_rxAirtime => '接收空中时间'; @override String get repeater_packetStatistics => '数据包统计'; @@ -1676,7 +1696,7 @@ class AppLocalizationsZh extends AppLocalizations { String get repeater_sent => '发送'; @override - String get repeater_received => '已收到'; + String get repeater_received => '接收'; @override String get repeater_duplicates => '重复'; @@ -1693,35 +1713,35 @@ class AppLocalizationsZh extends AppLocalizations { @override String repeater_packetTxTotal(int total, String flood, String direct) { - return 'Total: $total, Flood: $flood, Direct: $direct'; + return '总计:$total,泛洪:$flood,直连:$direct'; } @override String repeater_packetRxTotal(int total, String flood, String direct) { - return 'Total: $total, Flood: $flood, Direct: $direct'; + return '总计:$total,泛洪:$flood,直连:$direct'; } @override String repeater_duplicatesFloodDirect(String flood, String direct) { - return 'Flood: $flood, Direct: $direct'; + return '泛洪:$flood,直连:$direct'; } @override String repeater_duplicatesTotal(int total) { - return 'Total: $total'; + return '总计:$total'; } @override - String get repeater_settingsTitle => '重复器设置'; + String get repeater_settingsTitle => '转发节点设置'; @override String get repeater_basicSettings => '基本设置'; @override - String get repeater_repeaterName => '重复器名称'; + String get repeater_repeaterName => '转发节点名称'; @override - String get repeater_repeaterNameHelper => '此复播器的显示名称'; + String get repeater_repeaterNameHelper => '此转发节点的显示名称'; @override String get repeater_adminPassword => '管理员密码'; @@ -1736,13 +1756,13 @@ class AppLocalizationsZh extends AppLocalizations { String get repeater_guestPasswordHelper => '只读访问密码'; @override - String get repeater_radioSettings => '收音机设置'; + String get repeater_radioSettings => '无线电设置'; @override String get repeater_frequencyMhz => '频率 (MHz)'; @override - String get repeater_frequencyHelper => '300-2500 兆赫'; + String get repeater_frequencyHelper => '300-2500 MHz'; @override String get repeater_txPower => 'TX 功率'; @@ -1754,7 +1774,7 @@ class AppLocalizationsZh extends AppLocalizations { String get repeater_bandwidth => '带宽'; @override - String get repeater_spreadingFactor => '传播系数'; + String get repeater_spreadingFactor => '扩频因子'; @override String get repeater_codingRate => '编码速率'; @@ -1766,68 +1786,68 @@ class AppLocalizationsZh extends AppLocalizations { String get repeater_latitude => '纬度'; @override - String get repeater_latitudeHelper => '十进制度(例如:37.7749)'; + String get repeater_latitudeHelper => '十进制,例如 37.7749'; @override String get repeater_longitude => '经度'; @override - String get repeater_longitudeHelper => '十进制度(例如:-122.4194)'; + String get repeater_longitudeHelper => '十进制,例如 -122.4194'; @override - String get repeater_features => '特点'; + String get repeater_features => '功能'; @override String get repeater_packetForwarding => '数据包转发'; @override - String get repeater_packetForwardingSubtitle => '启用重复器,使其能够转发数据包'; + String get repeater_packetForwardingSubtitle => '启用转发节点转发数据包'; @override String get repeater_guestAccess => '访客访问'; @override - String get repeater_guestAccessSubtitle => '允许访客仅限读取权限'; + String get repeater_guestAccessSubtitle => '允许访客只读权限'; @override String get repeater_privacyMode => '隐私模式'; @override - String get repeater_privacyModeSubtitle => '在广告中隐藏姓名/位置'; + String get repeater_privacyModeSubtitle => '在广播中隐藏姓名/位置'; @override - String get repeater_advertisementSettings => '广告设置'; + String get repeater_advertisementSettings => '广播设置'; @override - String get repeater_localAdvertInterval => '本地广告投放时间段'; + String get repeater_localAdvertInterval => '本地广播间隔'; @override String repeater_localAdvertIntervalMinutes(int minutes) { - return '$minutes minutes'; + return '$minutes 分钟'; } @override - String get repeater_floodAdvertInterval => '洪水广告播放间隔'; + String get repeater_floodAdvertInterval => '泛洪广播间隔'; @override String repeater_floodAdvertIntervalHours(int hours) { - return '$hours hours'; + return '$hours 小时'; } @override - String get repeater_encryptedAdvertInterval => '加密的广告投放时间段'; + String get repeater_encryptedAdvertInterval => '加密广播间隔'; @override - String get repeater_dangerZone => '危险区域'; + String get repeater_dangerZone => '危险设置'; @override - String get repeater_rebootRepeater => '重启重复器'; + String get repeater_rebootRepeater => '重启转发节点'; @override - String get repeater_rebootRepeaterSubtitle => '重新启动重复器设备'; + String get repeater_rebootRepeaterSubtitle => '重启转发节点设备'; @override - String get repeater_rebootRepeaterConfirm => '您确定要重新启动这个中继器吗?'; + String get repeater_rebootRepeaterConfirm => '确定要重启此转发节点吗?'; @override String get repeater_regenerateIdentityKey => '重新生成身份密钥'; @@ -1836,77 +1856,77 @@ class AppLocalizationsZh extends AppLocalizations { String get repeater_regenerateIdentityKeySubtitle => '生成新的公钥/私钥对'; @override - String get repeater_regenerateIdentityKeyConfirm => '这将为复用器生成一个新的身份。继续吗?'; + String get repeater_regenerateIdentityKeyConfirm => '这将为转发节点生成新身份,继续吗?'; @override - String get repeater_eraseFileSystem => '删除文件系统'; + String get repeater_eraseFileSystem => '擦除文件系统'; @override - String get repeater_eraseFileSystemSubtitle => '格式化重复文件系统'; + String get repeater_eraseFileSystemSubtitle => '格式化转发节点文件系统'; @override - String get repeater_eraseFileSystemConfirm => '警告:此操作将清除复用器上的所有数据。 无法恢复!'; + String get repeater_eraseFileSystemConfirm => '警告:此操作将清除转发节点上的所有数据,且无法恢复!'; @override - String get repeater_eraseSerialOnly => '“Erase”功能仅可通过串行控制台使用。'; + String get repeater_eraseSerialOnly => '擦除功能仅可通过串行控制台使用。'; @override String repeater_commandSent(String command) { - return 'Command sent: $command'; + return '命令已发送:$command'; } @override String repeater_errorSendingCommand(String error) { - return 'Error sending command: $error'; + return '发送命令时出错:$error'; } @override String get repeater_confirm => '确认'; @override - String get repeater_settingsSaved => '设置已成功保存'; + String get repeater_settingsSaved => '设置保存成功'; @override String repeater_errorSavingSettings(String error) { - return 'Error saving settings: $error'; + return '保存设置时出错:$error'; } @override - String get repeater_refreshBasicSettings => '重置基本设置'; + String get repeater_refreshBasicSettings => '刷新基本设置'; @override - String get repeater_refreshRadioSettings => '重置收音机设置'; + String get repeater_refreshRadioSettings => '刷新无线电设置'; @override - String get repeater_refreshTxPower => '重置 TX 电源'; + String get repeater_refreshTxPower => '刷新 TX 功率'; @override - String get repeater_refreshLocationSettings => '重置位置设置'; + String get repeater_refreshLocationSettings => '刷新位置设置'; @override String get repeater_refreshPacketForwarding => '刷新包转发'; @override - String get repeater_refreshGuestAccess => '重新获取访客访问权限'; + String get repeater_refreshGuestAccess => '刷新访客权限'; @override - String get repeater_refreshPrivacyMode => '重置隐私模式'; + String get repeater_refreshPrivacyMode => '刷新隐私模式'; @override - String get repeater_refreshAdvertisementSettings => '重置广告设置'; + String get repeater_refreshAdvertisementSettings => '刷新广播设置'; @override String repeater_refreshed(String label) { - return '$label refreshed'; + return '$label 已刷新'; } @override String repeater_errorRefreshing(String label) { - return '[保存:$label]\n刷新 $label 时出错'; + return '刷新 $label 时出错'; } @override - String get repeater_cliTitle => '重复器命令行界面'; + String get repeater_cliTitle => '转发节点命令行'; @override String get repeater_debugNextCommand => '调试下一条命令'; @@ -1915,39 +1935,39 @@ class AppLocalizationsZh extends AppLocalizations { String get repeater_commandHelp => '帮助'; @override - String get repeater_clearHistory => '清晰的历史'; + String get repeater_clearHistory => '清除历史'; @override - String get repeater_noCommandsSent => '尚未发送任何指令'; + String get repeater_noCommandsSent => '尚未发送命令'; @override - String get repeater_typeCommandOrUseQuick => '在下方输入命令,或使用快捷命令。'; + String get repeater_typeCommandOrUseQuick => '输入命令或使用快捷命令'; @override String get repeater_enterCommandHint => '输入命令...'; @override - String get repeater_previousCommand => '之前的命令'; + String get repeater_previousCommand => '上一条命令'; @override - String get repeater_nextCommand => '下一个指令'; + String get repeater_nextCommand => '下一条命令'; @override - String get repeater_enterCommandFirst => '首先输入一个命令'; + String get repeater_enterCommandFirst => '请先输入命令'; @override - String get repeater_cliCommandFrameTitle => 'CLI 命令框架'; + String get repeater_cliCommandFrameTitle => 'CLI 命令帧'; @override String repeater_cliCommandError(String error) { - return 'Error: $error'; + return '错误:$error'; } @override - String get repeater_cliQuickGetName => '获取姓名'; + String get repeater_cliQuickGetName => '获取名称'; @override - String get repeater_cliQuickGetRadio => '收听广播'; + String get repeater_cliQuickGetRadio => '获取无线电设置'; @override String get repeater_cliQuickGetTx => '获取 TX'; @@ -1959,199 +1979,179 @@ class AppLocalizationsZh extends AppLocalizations { String get repeater_cliQuickVersion => '版本'; @override - String get repeater_cliQuickAdvertise => '发布广告'; + String get repeater_cliQuickAdvertise => '发送广播'; @override String get repeater_cliQuickClock => '时钟'; @override - String get repeater_cliHelpAdvert => '发送广告资料包'; + String get repeater_cliHelpAdvert => '发送广播包'; @override - String get repeater_cliHelpReboot => '重置设备。 (请注意,您可能会收到“超时”错误,这是正常的现象)'; + String get repeater_cliHelpReboot => '重启设备。(注意:可能会收到超时错误,属于正常现象)'; @override - String get repeater_cliHelpClock => '显示每个设备的当前时间。'; + String get repeater_cliHelpClock => '显示设备当前时间'; @override - String get repeater_cliHelpPassword => '为设备设置新的管理员密码。'; + String get repeater_cliHelpPassword => '设置新的管理员密码'; @override - String get repeater_cliHelpVersion => '显示设备版本和固件构建日期。'; + String get repeater_cliHelpVersion => '显示设备版本和固件构建日期'; @override - String get repeater_cliHelpClearStats => '重置各种统计指标,将其设置为零。'; + String get repeater_cliHelpClearStats => '重置各种统计数据'; @override - String get repeater_cliHelpSetAf => '设置时间因素。'; + String get repeater_cliHelpSetAf => '设置时间因子'; @override - String get repeater_cliHelpSetTx => - '设置 LoRa 传输功率,单位为 dBm (相对于参考值)。 (重启以应用更改)'; + String get repeater_cliHelpSetTx => '设置 LoRa 发射功率 (dBm)(重启生效)'; @override - String get repeater_cliHelpSetRepeat => '启用或禁用此节点的重复器功能。'; + String get repeater_cliHelpSetRepeat => '启用或禁用此节点的转发功能'; @override String get repeater_cliHelpSetAllowReadOnly => - '(房间服务器)如果设置为“开启”,则允许使用空密码登录,但无法向房间发送消息(只能进行读取)。'; + '(房间服务器)设为“开”则允许空密码登录,但只能读(不能发送)'; @override - String get repeater_cliHelpSetFloodMax => '设置最大传入数据包的跳数(如果大于或等于最大值,则不进行转发)。'; + String get repeater_cliHelpSetFloodMax => '设置最大传入数据包跳数(≥该值则不转发)'; @override - String get repeater_cliHelpSetIntThresh => - '设置干扰阈值(以dB为单位)。默认值为14。将设置为0以禁用频道干扰检测。'; + String get repeater_cliHelpSetIntThresh => '设置干扰阈值 (dB),默认14,设为0禁用'; @override - String get repeater_cliHelpSetAgcResetInterval => - '设置间隔时间,用于重置自动增益控制器。设置为 0 以禁用。'; + String get repeater_cliHelpSetAgcResetInterval => '设置 AGC 重置间隔(秒),设为0禁用'; @override - String get repeater_cliHelpSetMultiAcks => '启用或禁用“双重确认”功能。'; + String get repeater_cliHelpSetMultiAcks => '启用或禁用“多重确认”功能'; @override - String get repeater_cliHelpSetAdvertInterval => - '设置定时器间隔,单位为分钟,用于发送本地(无中继)的广告数据包。 将设置为 0 以禁用。'; + String get repeater_cliHelpSetAdvertInterval => '设置本地广播间隔(分钟),设为0禁用'; @override - String get repeater_cliHelpSetFloodAdvertInterval => - '设置定时器间隔时间为小时,以便发送广告信息包。将设置为 0 以禁用。'; + String get repeater_cliHelpSetFloodAdvertInterval => '设置泛洪广播间隔(小时),设为0禁用'; @override - String get repeater_cliHelpSetGuestPassword => - '设置/更新访客密码。 (对于访客,登录请求可以发送“获取统计”请求)'; + String get repeater_cliHelpSetGuestPassword => '设置/更新访客密码'; @override - String get repeater_cliHelpSetName => '设置广告名称。'; + String get repeater_cliHelpSetName => '设置广播名称'; @override - String get repeater_cliHelpSetLat => '设置广告地图的纬度。(以十进制表示)'; + String get repeater_cliHelpSetLat => '设置广播纬度(十进制)'; @override - String get repeater_cliHelpSetLon => '设置广告地图的经度。 (十进制度)'; + String get repeater_cliHelpSetLon => '设置广播经度(十进制)'; @override - String get repeater_cliHelpSetRadio => '完全重新设置无线电参数,并保存到偏好设置。需要执行“重启”命令才能生效。'; + String get repeater_cliHelpSetRadio => '完全重设无线电参数并保存,需重启生效'; @override - String get repeater_cliHelpSetRxDelay => - '设置(实验性):设置一个基础值(必须大于1才能生效),用于对接收到的数据包进行轻微延迟处理,该延迟值基于信号强度/评分。将该值设置为0以禁用。'; + String get repeater_cliHelpSetRxDelay => '(实验性)设置接收延迟基数,设为0禁用'; @override - String get repeater_cliHelpSetTxDelay => - '通过将一个因子与“浮动模式”数据包的时间在空中停留时间相乘,并结合随机的“时隙”系统,来延迟其转发,从而降低数据包冲突的概率。'; + String get repeater_cliHelpSetTxDelay => '通过因子和随机时隙延迟泛洪数据包转发,降低冲突'; @override - String get repeater_cliHelpSetDirectTxDelay => - '与txdelay相同,但用于对直接模式数据包的转发进行随机延迟。'; + String get repeater_cliHelpSetDirectTxDelay => '同 txdelay,用于直连模式数据包'; @override - String get repeater_cliHelpSetBridgeEnabled => '启用/禁用桥接。'; + String get repeater_cliHelpSetBridgeEnabled => '启用/禁用桥接'; @override - String get repeater_cliHelpSetBridgeDelay => '在重新发送数据包之前,设置延迟时间。'; + String get repeater_cliHelpSetBridgeDelay => '设置桥接转发延迟'; @override - String get repeater_cliHelpSetBridgeSource => '选择桥接器是否会转发收到的数据包,还是转发发送的数据包。'; + String get repeater_cliHelpSetBridgeSource => '选择桥接器转发接收或发送的数据包'; @override - String get repeater_cliHelpSetBridgeBaud => '为 RS232 桥接设置串行链路的波特率。'; + String get repeater_cliHelpSetBridgeBaud => '设置 RS232 桥接串口波特率'; @override - String get repeater_cliHelpSetBridgeSecret => '设置 ESPNOW 桥的秘密。'; + String get repeater_cliHelpSetBridgeSecret => '设置 ESPNOW 桥接密钥'; @override - String get repeater_cliHelpSetAdcMultiplier => - '设置自定义因子,用于调整报告的电池电压(仅在特定板上支持)。'; + String get repeater_cliHelpSetAdcMultiplier => '设置电池电压校正系数(特定板支持)'; @override - String get repeater_cliHelpTempRadio => - '设置临时收音机参数,持续指定分钟数,之后恢复到原始收音机参数。(不保存到偏好设置)。'; + String get repeater_cliHelpTempRadio => '临时设置无线电参数指定分钟,之后恢复(不保存)'; @override - String get repeater_cliHelpSetPerm => - '修改 ACL。如果 \"permissions\" 的值为 0,则删除与 pubkey 相关的条目。如果 pubkey-hex 完整且当前不在 ACL 中,则添加新的条目。通过匹配 pubkey 相关的前缀来更新条目。不同固件角色的权限位有所不同,但低 2 位分别对应:0 (访客)、1 (只读)、2 (读写)、3 (管理员)。'; + String get repeater_cliHelpSetPerm => '修改 ACL,权限位:0访客、1只读、2读写、3管理员'; @override - String get repeater_cliHelpGetBridgeType => '支持桥接模式、RS232、ESPNOW。'; + String get repeater_cliHelpGetBridgeType => '支持桥接模式:RS232、ESPNOW'; @override - String get repeater_cliHelpLogStart => '开始将数据包记录到文件系统。'; + String get repeater_cliHelpLogStart => '开始记录数据包到文件系统'; @override - String get repeater_cliHelpLogStop => '停止将数据包记录写入文件系统。'; + String get repeater_cliHelpLogStop => '停止记录数据包'; @override - String get repeater_cliHelpLogErase => '从文件系统中删除所有已记录的包信息。'; + String get repeater_cliHelpLogErase => '删除所有记录的数据包'; @override - String get repeater_cliHelpNeighbors => - '显示了通过零跳广告收到的其他复用节点列表。 每行包含:id-前缀-十六进制:时间戳:信噪比(4次)'; + String get repeater_cliHelpNeighbors => '显示零跳广播收到的其他转发节点列表'; @override - String get repeater_cliHelpNeighborRemove => - '从邻居列表中删除第一个匹配项(通过十六进制的 pubkey 前缀)。'; + String get repeater_cliHelpNeighborRemove => '从邻居列表删除第一个匹配项(通过公钥前缀)'; @override - String get repeater_cliHelpRegion => '(仅限序列)列出所有已定义的区域以及当前的防洪许可。'; + String get repeater_cliHelpRegion => '(仅串口)列出所有定义区域及当前泛洪权限'; @override - String get repeater_cliHelpRegionLoad => - '请注意:这是一个特殊的、包含多个命令的调用方式。 之后的每个命令都是一个区域名称(使用空格进行缩进,以表示父级关系,至少需要一个空格)。 结束方式是通过发送一个空行/命令。'; + String get repeater_cliHelpRegionLoad => '特殊多命令调用,以空行结束'; @override - String get repeater_cliHelpRegionGet => - '搜索具有指定名称前缀的区域(或使用“*”表示全局范围)。 返回结果为“-> region-name (parent-name) \'F\'”'; + String get repeater_cliHelpRegionGet => '搜索指定前缀的区域'; @override - String get repeater_cliHelpRegionPut => '添加或更新一个区域定义,并指定其名称。'; + String get repeater_cliHelpRegionPut => '添加或更新区域定义'; @override - String get repeater_cliHelpRegionRemove => - '删除具有指定名称的区域定义。 (必须与指定名称完全匹配,且不能有子区域)'; + String get repeater_cliHelpRegionRemove => '删除指定区域定义'; @override - String get repeater_cliHelpRegionAllowf => '为指定区域设置“洪水”权限。(“*”表示全局/旧版本范围)'; + String get repeater_cliHelpRegionAllowf => '为区域设置“泛洪”权限'; @override - String get repeater_cliHelpRegionDenyf => - '移除指定区域的“洪水”权限。(请注意:目前不建议在全局/旧版本中使用此功能!!)'; + String get repeater_cliHelpRegionDenyf => '移除区域的“泛洪”权限'; @override - String get repeater_cliHelpRegionHome => '回复当前“主区域”。(此功能尚未应用,仅供未来使用)'; + String get repeater_cliHelpRegionHome => '返回当前“主区域”(预留)'; @override - String get repeater_cliHelpRegionHomeSet => '设置“主”区域。'; + String get repeater_cliHelpRegionHomeSet => '设置“主”区域'; @override - String get repeater_cliHelpRegionSave => '将区域列表/地图保存到存储中。'; + String get repeater_cliHelpRegionSave => '保存区域列表到存储'; @override - String get repeater_cliHelpGps => - '显示 GPS 状态。当 GPS 处于关闭状态时,它只会显示“关闭”;当 GPS 处于开启状态时,它会显示“开启”、“状态”、“定位”、“卫星数量”等信息。'; + String get repeater_cliHelpGps => '显示 GPS 状态'; @override - String get repeater_cliHelpGpsOnOff => '切换 GPS 设备的电源状态。'; + String get repeater_cliHelpGpsOnOff => '切换 GPS 电源'; @override - String get repeater_cliHelpGpsSync => '将节点时间与 GPS 钟同步。'; + String get repeater_cliHelpGpsSync => '将节点时间与 GPS 同步'; @override - String get repeater_cliHelpGpsSetLoc => '将节点的坐标设置为 GPS 坐标,并保存设置。'; + String get repeater_cliHelpGpsSetLoc => '将节点坐标设为 GPS 坐标并保存'; @override - String get repeater_cliHelpGpsAdvert => - '设置节点的位置广告配置:\n- none:不将位置信息包含在广告中\n- share:共享 GPS 位置(从 SensorManager 获取)\n- prefs:在偏好设置中展示的位置'; + String get repeater_cliHelpGpsAdvert => '设置位置广播配置:none/share/prefs'; @override - String get repeater_cliHelpGpsAdvertSet => '设置广告的位置配置。'; + String get repeater_cliHelpGpsAdvertSet => '设置广播位置配置'; @override String get repeater_commandsListTitle => '命令列表'; @override - String get repeater_commandsListNote => '请注意:对于各种“set ...”命令,也存在“get ...”命令。'; + String get repeater_commandsListNote => '注意:多数 set 命令也有对应的 get 命令'; @override String get repeater_general => '通用'; @@ -2160,39 +2160,39 @@ class AppLocalizationsZh extends AppLocalizations { String get repeater_settingsCategory => '设置'; @override - String get repeater_bridge => '桥'; + String get repeater_bridge => '桥接'; @override - String get repeater_logging => '记录'; + String get repeater_logging => '日志'; @override - String get repeater_neighborsRepeaterOnly => '邻居(仅限重复功能)'; + String get repeater_neighborsRepeaterOnly => '邻居(仅转发节点)'; @override - String get repeater_regionManagementRepeaterOnly => '区域管理(仅限重复站点)'; + String get repeater_regionManagementRepeaterOnly => '区域管理(仅转发节点)'; @override - String get repeater_regionNote => '区域命令已引入,用于管理区域定义和权限。'; + String get repeater_regionNote => '区域命令用于管理区域定义和权限'; @override String get repeater_gpsManagement => 'GPS 管理'; @override - String get repeater_gpsNote => '已引入 GPS 命令,用于管理与位置相关的任务。'; + String get repeater_gpsNote => 'GPS 命令用于位置相关任务'; @override String get telemetry_receivedData => '接收到的遥测数据'; @override - String get telemetry_requestTimeout => '遥测请求超时。'; + String get telemetry_requestTimeout => '遥测请求超时'; @override String telemetry_errorLoading(String error) { - return 'Error loading telemetry: $error'; + return '加载遥测数据时出错:$error'; } @override - String get telemetry_noData => '没有可用的 telemetry 数据。'; + String get telemetry_noData => '暂无遥测数据'; @override String telemetry_channelTitle(int channel) { @@ -2206,13 +2206,13 @@ class AppLocalizationsZh extends AppLocalizations { String get telemetry_voltageLabel => '电压'; @override - String get telemetry_mcuTemperatureLabel => 'MCU 的温度'; + String get telemetry_mcuTemperatureLabel => 'MCU 温度'; @override String get telemetry_temperatureLabel => '温度'; @override - String get telemetry_currentLabel => '当前'; + String get telemetry_currentLabel => '电流'; @override String telemetry_batteryValue(int percent, String volts) { @@ -2235,30 +2235,30 @@ class AppLocalizationsZh extends AppLocalizations { } @override - String get neighbors_receivedData => '已收到邻居信息'; + String get neighbors_receivedData => '已接收邻居信息'; @override - String get neighbors_requestTimedOut => '邻居要求停止干扰。'; + String get neighbors_requestTimedOut => '邻居请求超时'; @override String neighbors_errorLoading(String error) { - return 'Error loading neighbors: $error'; + return '加载邻居时出错:$error'; } @override - String get neighbors_repeatersNeighbours => '重复使用的邻居'; + String get neighbors_repeatersNeighbors => '转发节点的邻居'; @override - String get neighbors_noData => '没有可用的邻居信息。'; + String get neighbors_noData => '暂无邻居信息'; @override String neighbors_unknownContact(String pubkey) { - return 'Unknown $pubkey'; + return '未知 $pubkey'; } @override String neighbors_heardAgo(String time) { - return 'Heard: $time ago'; + return '听到:$time前'; } @override @@ -2271,16 +2271,16 @@ class AppLocalizationsZh extends AppLocalizations { String get channelPath_otherObservedPaths => '其他观察到的路径'; @override - String get channelPath_repeaterHops => '复用跳跃'; + String get channelPath_repeaterHops => '转发节点跳数'; @override - String get channelPath_noHopDetails => '对于此包,未提供详细信息。'; + String get channelPath_noHopDetails => '此数据包未提供详细信息'; @override String get channelPath_messageDetails => '消息详情'; @override - String get channelPath_senderLabel => '发件人'; + String get channelPath_senderLabel => '发送者'; @override String get channelPath_timeLabel => '时间'; @@ -2298,11 +2298,11 @@ class AppLocalizationsZh extends AppLocalizations { @override String channelPath_observedPathTitle(int index, String hops) { - return 'Observed path $index • $hops'; + return '观察到的路径 $index • $hops'; } @override - String get channelPath_noLocationData => '没有位置信息'; + String get channelPath_noLocationData => '无位置信息'; @override String channelPath_timeWithDate(int day, int month, String time) { @@ -2318,37 +2318,37 @@ class AppLocalizationsZh extends AppLocalizations { String get channelPath_unknownPath => '未知'; @override - String get channelPath_floodPath => '洪水'; + String get channelPath_floodPath => '泛洪'; @override - String get channelPath_directPath => '直接'; + String get channelPath_directPath => '直连'; @override String channelPath_observedZeroOf(int total) { - return '0 of $total hops'; + return '0 / $total 跳'; } @override String channelPath_observedSomeOf(int observed, int total) { - return '$observed of $total hops'; + return '$observed / $total 跳'; } @override - String get channelPath_mapTitle => '路线图'; + String get channelPath_mapTitle => '路径地图'; @override - String get channelPath_noRepeaterLocations => '这条路径上没有可用的中继器位置。'; + String get channelPath_noRepeaterLocations => '此路径上没有可用的转发节点位置信息'; @override String channelPath_primaryPath(int index) { - return '路径 $index (主要路径)'; + return '路径 $index(主要)'; } @override String get channelPath_pathLabelTitle => '路径'; @override - String get channelPath_observedPathHeader => '观察路径'; + String get channelPath_observedPathHeader => '观察到的路径'; @override String channelPath_selectedPathLabel(String label, String prefixes) { @@ -2356,19 +2356,19 @@ class AppLocalizationsZh extends AppLocalizations { } @override - String get channelPath_noHopDetailsAvailable => '对于此包裹,尚无详细信息。'; + String get channelPath_noHopDetailsAvailable => '此数据包暂无详细信息'; @override - String get channelPath_unknownRepeater => '未知的重复设备'; + String get channelPath_unknownRepeater => '未知转发节点'; @override String get community_title => '社区'; @override - String get community_create => '建立社区'; + String get community_create => '创建社区'; @override - String get community_createDesc => '创建一个新的社群,并通过二维码进行分享。'; + String get community_createDesc => '创建新社区并通过二维码分享。'; @override String get community_join => '加入'; @@ -2378,23 +2378,23 @@ class AppLocalizationsZh extends AppLocalizations { @override String community_joinConfirmation(String name) { - return 'Do you want to join the community \"$name\"?'; + return '是否加入社区 \"$name\"?'; } @override String get community_scanQr => '扫描社区二维码'; @override - String get community_scanInstructions => '将相机对准社区的二维码。'; + String get community_scanInstructions => '将摄像头对准社区的二维码'; @override String get community_showQr => '显示二维码'; @override - String get community_publicChannel => '社区公共'; + String get community_publicChannel => '社区公共频道'; @override - String get community_hashtagChannel => '社区标签'; + String get community_hashtagChannel => '社区标签频道'; @override String get community_name => '社区名称'; @@ -2404,12 +2404,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String community_created(String name) { - return 'Community \"$name\" created'; + return '社区 \"$name\" 已创建'; } @override String community_joined(String name) { - return 'Joined community \"$name\"'; + return '已加入社区 \"$name\"'; } @override @@ -2417,34 +2417,34 @@ class AppLocalizationsZh extends AppLocalizations { @override String community_qrInstructions(String name) { - return 'Scan this QR code to join \"$name\"'; + return '扫描此二维码加入 \"$name\"'; } @override - String get community_hashtagPrivacyHint => '仅社区成员才能加入社区话题标签的频道。'; + String get community_hashtagPrivacyHint => '仅社区成员可加入社区标签频道。'; @override String get community_invalidQrCode => '无效的社区二维码'; @override - String get community_alreadyMember => '已经是会员'; + String get community_alreadyMember => '已是成员'; @override String community_alreadyMemberMessage(String name) { - return 'You are already a member of \"$name\".'; + return '您已是 \"$name\" 的成员。'; } @override String get community_addPublicChannel => '添加公共频道'; @override - String get community_addPublicChannelHint => '自动添加该社区的公共频道'; + String get community_addPublicChannelHint => '自动添加此社区的公共频道'; @override - String get community_noCommunities => '目前还没有任何社区加入。'; + String get community_noCommunities => '尚未加入任何社区。'; @override - String get community_scanOrCreate => '扫描二维码或创建社群,即可开始。'; + String get community_scanOrCreate => '扫描二维码或创建社区以开始。'; @override String get community_manageCommunities => '管理社区'; @@ -2454,7 +2454,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String community_deleteConfirm(String name) { - return '是否要删除\"$name\"?'; + return '是否退出 \"$name\"?'; } @override @@ -2464,52 +2464,52 @@ class AppLocalizationsZh extends AppLocalizations { @override String community_deleted(String name) { - return 'Left community \"$name\"'; + return '已退出社区 \"$name\"'; } @override - String get community_regenerateSecret => '恢复秘密'; + String get community_regenerateSecret => '重新生成密钥'; @override String community_regenerateSecretConfirm(String name) { - return '[保存:$name]\n是否需要重新生成\"$name\"的密钥?所有成员都需要扫描新的二维码才能继续进行通信。'; + return '是否为 \"$name\" 重新生成密钥?所有成员需扫描新的二维码才能继续通信。'; } @override - String get community_regenerate => '再生'; + String get community_regenerate => '重新生成'; @override String community_secretRegenerated(String name) { - return '[保护对象:$name]\n秘密已恢复至\"$name\"'; + return '已为 \"$name\" 重新生成密钥'; } @override - String get community_updateSecret => '更新秘密'; + String get community_updateSecret => '更新密钥'; @override String community_secretUpdated(String name) { - return '“$name”的秘密已更新'; + return '“$name”的密钥已更新'; } @override String community_scanToUpdateSecret(String name) { - return 'Scan the new QR code to update the secret for \"$name\"'; + return '扫描新二维码以更新 \"$name\" 的密钥'; } @override - String get community_addHashtagChannel => '添加社区标签'; + String get community_addHashtagChannel => '添加标签频道'; @override - String get community_addHashtagChannelDesc => '为这个社区创建一个带有话题标签的频道'; + String get community_addHashtagChannelDesc => '为此社区创建标签频道'; @override String get community_selectCommunity => '选择社区'; @override - String get community_regularHashtag => '常用标签'; + String get community_regularHashtag => '普通标签'; @override - String get community_regularHashtagDesc => '公共话题标签(任何人都可以参与)'; + String get community_regularHashtagDesc => '公共标签频道(任何人都可参与)'; @override String get community_communityHashtag => '社区标签'; @@ -2519,47 +2519,56 @@ class AppLocalizationsZh extends AppLocalizations { @override String community_forCommunity(String name) { - return 'For $name'; + return '为 $name'; } @override - String get listFilter_tooltip => '筛选和排序'; + String get listFilter_tooltip => '筛选与排序'; @override - String get listFilter_sortBy => '按排序'; + String get listFilter_sortBy => '排序方式'; @override String get listFilter_latestMessages => '最新消息'; @override - String get listFilter_heardRecently => '最近听到的'; + String get listFilter_heardRecently => '最近听到'; @override - String get listFilter_az => 'A 到 Z'; + String get listFilter_az => 'A-Z'; @override - String get listFilter_filters => '过滤器'; + String get listFilter_filters => '筛选'; @override String get listFilter_all => '全部'; + @override + String get listFilter_favorites => '收藏'; + + @override + String get listFilter_addToFavorites => '添加到收藏'; + + @override + String get listFilter_removeFromFavorites => '从收藏中移除'; + @override String get listFilter_users => '用户'; @override - String get listFilter_repeaters => '重复器'; + String get listFilter_repeaters => '转发节点'; @override String get listFilter_roomServers => '房间服务器'; @override - String get listFilter_unreadOnly => '仅显示未读消息'; + String get listFilter_unreadOnly => '仅显示未读'; @override - String get listFilter_newGroup => '新的团体'; + String get listFilter_newGroup => '新建群聊'; @override - String get pathTrace_you => '您'; + String get pathTrace_you => '我自己'; @override String get pathTrace_failed => '路径追踪失败。'; @@ -2568,81 +2577,214 @@ class AppLocalizationsZh extends AppLocalizations { String get pathTrace_notAvailable => '无法获取路径信息。'; @override - String get pathTrace_refreshTooltip => '重新绘制路径。'; + String get pathTrace_refreshTooltip => '刷新路径追踪'; @override - String get pathTrace_someHopsNoLocation => '其中一个或多个啤酒花缺少位置!'; + String get pathTrace_someHopsNoLocation => '某些跳缺少位置信息!'; @override String get pathTrace_clearTooltip => '清除路径'; @override - String get contacts_pathTrace => '路径追踪'; + String get losSelectStartEnd => '选择 LOS 的起始节点和结束节点。'; @override - String get contacts_ping => '乒'; - - @override - String get contacts_repeaterPathTrace => '追踪路径至中继器'; - - @override - String get contacts_repeaterPing => '中继器'; - - @override - String get contacts_roomPathTrace => '追踪到房间服务器'; - - @override - String get contacts_roomPing => '会议室服务器'; - - @override - String get contacts_chatTraceRoute => '路径跟踪路线'; - - @override - String contacts_pathTraceTo(String name) { - return '追踪路径至 $name'; + String losRunFailed(String error) { + return '视线检查失败:$error'; } @override - String get contacts_clipboardEmpty => '剪贴板为空。'; + String get losClearAllPoints => '清除所有点'; @override - String get contacts_invalidAdvertFormat => '无效的联系信息'; + String get losRunToViewElevationProfile => '运行 LOS 查看高程剖面'; @override - String get contacts_contactImported => '已建立联系。'; + String get losMenuTitle => '服务水平菜单'; @override - String get contacts_contactImportFailed => '未能导入联系人。'; + String get losMenuSubtitle => '点击节点或长按地图以获取自定义点'; @override - String get contacts_zeroHopAdvert => '零跳广告'; + String get losShowDisplayNodes => '显示显示节点'; @override - String get contacts_floodAdvert => '防洪广告'; + String get losCustomPoints => '自定义积分'; @override - String get contacts_copyAdvertToClipboard => '复制广告到剪贴板'; + String losCustomPointLabel(int index) { + return '自定义 $index'; + } + + @override + String get losPointA => 'A点'; + + @override + String get losPointB => 'B点'; + + @override + String losAntennaA(String value, String unit) { + return '天线 A: $value $unit'; + } + + @override + String losAntennaB(String value, String unit) { + return '天线 B:$value $unit'; + } + + @override + String get losRun => '运行视距'; + + @override + String get losNoElevationData => '无海拔数据'; + + @override + String losProfileClear( + String distance, + String distanceUnit, + String clearance, + String heightUnit, + ) { + return '$distance $distanceUnit,清除 LOS,最小间隙 $clearance $heightUnit'; + } + + @override + String losProfileBlocked( + String distance, + String distanceUnit, + String obstruction, + String heightUnit, + ) { + return '$distance $distanceUnit,被 $obstruction $heightUnit 阻止'; + } + + @override + String get losStatusChecking => '洛斯:正在检查...'; + + @override + String get losStatusNoData => 'LOS:无数据'; + + @override + String losStatusSummary(int clear, int total, int blocked, int unknown) { + return 'LOS:$clear/$total 清除,$blocked 阻塞,$unknown 未知'; + } + + @override + String get losErrorElevationUnavailable => '一个或多个样本的海拔数据不可用。'; + + @override + String get losErrorInvalidInput => '用于 LOS 计算的点/高程数据无效。'; + + @override + String get losRenameCustomPoint => '重命名自定义点'; + + @override + String get losPointName => '点名称'; + + @override + String get losShowPanelTooltip => '显示 LOS 面板'; + + @override + String get losHidePanelTooltip => '隐藏 LOS 面板'; + + @override + String get losElevationAttribution => '高程数据:Open-Meteo (CC BY 4.0)'; + + @override + String get losLegendRadioHorizon => '无线电地平线'; + + @override + String get losLegendLosBeam => '视距波束'; + + @override + String get losLegendTerrain => '地形'; + + @override + String get losFrequencyLabel => '频率'; + + @override + String get losFrequencyInfoTooltip => '查看计算详情'; + + @override + String get losFrequencyDialogTitle => '无线电地平线计算'; + + @override + String losFrequencyDialogDescription( + double baselineK, + double baselineFreq, + double frequencyMHz, + double kFactor, + ) { + return '从 $baselineFreq MHz 处的 k=$baselineK 开始,计算调整当前 $frequencyMHz MHz 频段的 k 因子,该因子定义了弯曲的无线电范围上限。'; + } + + @override + String get contacts_pathTrace => '路径追踪'; + + @override + String get contacts_ping => 'Ping'; + + @override + String get contacts_repeaterPathTrace => 'Trace 转发节点'; + + @override + String get contacts_repeaterPing => 'Ping 转发节点'; + + @override + String get contacts_roomPathTrace => 'Trace 房间服务器'; + + @override + String get contacts_roomPing => 'Ping 房间服务器'; + + @override + String get contacts_chatTraceRoute => '路由追踪'; + + @override + String contacts_pathTraceTo(String name) { + return '追踪至 $name 的路径'; + } + + @override + String get contacts_clipboardEmpty => '剪贴板为空'; + + @override + String get contacts_invalidAdvertFormat => '无效的联系人信息格式'; + + @override + String get contacts_contactImported => '联系人已导入'; + + @override + String get contacts_contactImportFailed => '导入联系人失败。'; + + @override + String get contacts_zeroHopAdvert => '发送零跳广播'; + + @override + String get contacts_floodAdvert => '发送泛洪广播'; + + @override + String get contacts_copyAdvertToClipboard => '复制广播到剪贴板'; @override String get contacts_addContactFromClipboard => '从剪贴板添加联系人'; @override - String get contacts_ShareContact => '复制联系方式到剪贴板'; + String get contacts_ShareContact => '复制联系人信息到剪贴板'; @override - String get contacts_ShareContactZeroHop => '通过广告分享联系方式'; + String get contacts_ShareContactZeroHop => '通过广播分享联系人'; @override - String get contacts_zeroHopContactAdvertSent => '通过广告获取联系方式。'; + String get contacts_zeroHopContactAdvertSent => '零跳广播已发送'; @override - String get contacts_zeroHopContactAdvertFailed => '发送联系方式失败。'; + String get contacts_zeroHopContactAdvertFailed => '发送联系人广播失败。'; @override - String get contacts_contactAdvertCopied => '广告内容已复制到剪贴板。'; + String get contacts_contactAdvertCopied => '广播已复制到剪贴板。'; @override - String get contacts_contactAdvertCopyFailed => '将广告复制到剪贴板操作失败。'; + String get contacts_contactAdvertCopyFailed => '复制广播到剪贴板失败。'; @override String get notification_activityTitle => 'MeshCore 活动'; @@ -2671,47 +2813,53 @@ class AppLocalizationsZh extends AppLocalizations { String get notification_receivedNewMessage => '收到新消息'; @override - String get settings_gpxExportRepeaters => '导出重复器/房间服务器到GPX'; + String get settings_gpxExportRepeaters => '导出转发节点/房间服务器到 GPX'; @override - String get settings_gpxExportRepeatersSubtitle => '导出带有位置的重复器/房间服务器到GPX文件。'; + String get settings_gpxExportRepeatersSubtitle => '导出带位置的转发节点/房间服务器到 GPX 文件'; @override - String get settings_gpxExportContacts => '导出伴侣到GPX'; + String get settings_gpxExportContacts => '导出伙伴到 GPX'; @override - String get settings_gpxExportContactsSubtitle => '导出带有位置的伙伴到GPX文件。'; + String get settings_gpxExportContactsSubtitle => '导出带位置的伙伴到 GPX 文件'; @override - String get settings_gpxExportAll => '导出所有联系人到GPX'; + String get settings_gpxExportAll => '导出所有联系人到 GPX'; @override - String get settings_gpxExportAllSubtitle => '导出所有带有位置的联系人到GPX文件。'; + String get settings_gpxExportAllSubtitle => '导出所有带位置的联系人到 GPX 文件'; @override - String get settings_gpxExportSuccess => '成功导出GPX文件'; + String get settings_gpxExportSuccess => 'GPX 文件导出成功'; @override - String get settings_gpxExportNoContacts => '没有联系人可导出'; + String get settings_gpxExportNoContacts => '没有可导出的联系人'; @override String get settings_gpxExportNotAvailable => '您的设备/操作系统不支持'; @override - String get settings_gpxExportError => '导出时发生错误'; + String get settings_gpxExportError => '导出时出错'; @override - String get settings_gpxExportRepeatersRoom => '重复器和房间服务器位置'; + String get settings_gpxExportRepeatersRoom => '转发节点与房间服务器位置'; @override - String get settings_gpxExportChat => '伴侣位置'; + String get settings_gpxExportChat => '伙伴位置'; @override String get settings_gpxExportAllContacts => '所有联系人位置'; @override - String get settings_gpxExportShareText => '来自meshcore-open的导出地图数据'; + String get settings_gpxExportShareText => '来自 MeshCore Open 的地图数据导出'; @override - String get settings_gpxExportShareSubject => 'meshcore-open GPX 地图数据导出'; + String get settings_gpxExportShareSubject => 'MeshCore Open GPX 地图数据导出'; + + @override + String get snrIndicator_nearByRepeaters => '附近的重复器'; + + @override + String get snrIndicator_lastSeen => '最近访问'; } diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 7c397b4..fee1f22 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1,4 +1,12 @@ { + "channels_channelDeleteFailed": "Kan kanaal {name} niet verwijderen", + "@channels_channelDeleteFailed": { + "placeholders": { + "name": { + "type": "String" + } + } + }, "@@locale": "nl", "appTitle": "MeshCore Open", "nav_contacts": "Contacten", @@ -334,6 +342,8 @@ "channels_publicChannel": "Open kanaal", "channels_privateChannel": "Private kanaal", "channels_editChannel": "Kanaal bewerken", + "channels_muteChannel": "Kanaal dempen", + "channels_unmuteChannel": "Kanaal dempen opheffen", "channels_deleteChannel": "Kanaal verwijderen", "channels_deleteChannelConfirm": "Verwijderen \"{name}\"? Dit kan niet worden teruggedraaid.", "@channels_deleteChannelConfirm": { @@ -1351,12 +1361,12 @@ } } }, - "repeater_neighbours": "Buren", - "repeater_neighboursSubtitle": "Bekijk nul hops buren.", + "repeater_neighbors": "Buren", + "repeater_neighborsSubtitle": "Bekijk nul hops buren.", "neighbors_receivedData": "Ontvangen Buurdata", "neighbors_requestTimedOut": "Buren vragen om tijdelijk uitgeschakeld.", "neighbors_errorLoading": "Fout bij het laden van buren: {error}", - "neighbors_repeatersNeighbours": "Herhalingen Buren", + "neighbors_repeatersNeighbors": "Herhalingen Buren", "neighbors_noData": "Geen gegevens van buren beschikbaar.", "channels_createPrivateChannelDesc": "Beveiligd met een geheime sleutel.", "channels_createPrivateChannel": "Maak een Privé Kanaal", @@ -1555,6 +1565,8 @@ "contacts_floodAdvert": "Overstromingsadvertentie", "contacts_copyAdvertToClipboard": "Advert naar klembord kopiëren", "appSettings_languageRu": "Russisch", + "appSettings_enableMessageTracing": "Berichttracking inschakelen", + "appSettings_enableMessageTracingSubtitle": "Gedetailleerde routerings- en timing-metadata voor berichten weergeven", "contacts_clipboardEmpty": "Knipbord is leeg.", "contacts_addContactFromClipboard": "Contact uit klembord toevoegen", "contacts_contactImported": "Contact is geïmporteerd.", @@ -1594,7 +1606,152 @@ "scanner_enableBluetooth": "Activeer Bluetooth", "scanner_bluetoothOffMessage": "Zorg ervoor dat Bluetooth is ingeschakeld om naar apparaten te zoeken.", "scanner_bluetoothOff": "Bluetooth is uitgeschakeld", + "snrIndicator_lastSeen": "Laatst gezien", + "snrIndicator_nearByRepeaters": "Nabije herhalingseenheden", + "chat_ShowAllPaths": "Toon alle paden", "settings_clientRepeat": "Herhalen: Afgekoppeld", "settings_clientRepeatSubtitle": "Laat dit apparaat de mesh-pakketten opnieuw verzenden voor andere apparaten.", - "settings_clientRepeatFreqWarning": "Om een signaal buiten het netwerk te versturen, zijn frequenties van 433, 869 of 918 MHz vereist." + "settings_clientRepeatFreqWarning": "Om een signaal buiten het netwerk te versturen, zijn frequenties van 433, 869 of 918 MHz vereist.", + "settings_aboutOpenMeteoAttribution": "LOS-hoogtegegevens: Open-Meteo (CC BY 4.0)", + "appSettings_unitsTitle": "Eenheden", + "appSettings_unitsMetric": "Metrisch (m / km)", + "appSettings_unitsImperial": "Imperiaal (ft / mi)", + "map_lineOfSight": "Zichtlijn", + "map_losScreenTitle": "Zichtlijn", + "losSelectStartEnd": "Selecteer begin- en eindknooppunten voor LOS.", + "losRunFailed": "Zichtlijncontrole mislukt: {error}", + "@losRunFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "losClearAllPoints": "Wis alle punten", + "losRunToViewElevationProfile": "Voer LOS uit om het hoogteprofiel te bekijken", + "losMenuTitle": "LOS-menu", + "losMenuSubtitle": "Tik op knooppunten of druk lang op de kaart voor aangepaste punten", + "losShowDisplayNodes": "Toon weergaveknooppunten", + "losCustomPoints": "Aangepaste punten", + "losCustomPointLabel": "Aangepast {index}", + "@losCustomPointLabel": { + "placeholders": { + "index": { + "type": "int" + } + } + }, + "losPointA": "Punt A", + "losPointB": "Punt B", + "losAntennaA": "Antenne A: {value} {unit}", + "@losAntennaA": { + "placeholders": { + "value": { + "type": "String" + }, + "unit": { + "type": "String" + } + } + }, + "losAntennaB": "Antenne B: {value} {unit}", + "@losAntennaB": { + "placeholders": { + "value": { + "type": "String" + }, + "unit": { + "type": "String" + } + } + }, + "losRun": "Voer LOS uit", + "losNoElevationData": "Geen hoogtegegevens", + "losProfileClear": "{distance} {distanceUnit}, vrije LOS, min. vrije ruimte {clearance} {heightUnit}", + "@losProfileClear": { + "placeholders": { + "distance": { + "type": "String" + }, + "distanceUnit": { + "type": "String" + }, + "clearance": { + "type": "String" + }, + "heightUnit": { + "type": "String" + } + } + }, + "losProfileBlocked": "{distance} {distanceUnit}, geblokkeerd door {obstruction} {heightUnit}", + "@losProfileBlocked": { + "placeholders": { + "distance": { + "type": "String" + }, + "distanceUnit": { + "type": "String" + }, + "obstruction": { + "type": "String" + }, + "heightUnit": { + "type": "String" + } + } + }, + "losStatusChecking": "LOS: controleren...", + "losStatusNoData": "LOS: geen gegevens", + "losStatusSummary": "LOS: {clear}/{total} gewist, {blocked} geblokkeerd, {unknown} onbekend", + "@losStatusSummary": { + "placeholders": { + "clear": { + "type": "int" + }, + "total": { + "type": "int" + }, + "blocked": { + "type": "int" + }, + "unknown": { + "type": "int" + } + } + }, + "losErrorElevationUnavailable": "Hoogtegegevens niet beschikbaar voor een of meer monsters.", + "losErrorInvalidInput": "Ongeldige punten/hoogtegegevens voor LOS-berekening.", + "losRenameCustomPoint": "Hernoem aangepast punt", + "losPointName": "Puntnaam", + "losShowPanelTooltip": "Toon LOS-paneel", + "losHidePanelTooltip": "LOS-paneel verbergen", + "losElevationAttribution": "Hoogtegegevens: Open-Meteo (CC BY 4.0)", + "losLegendRadioHorizon": "Radiohorizon", + "losLegendLosBeam": "Zichtlijn", + "losLegendTerrain": "Terrein", + "losFrequencyLabel": "Frequentie", + "losFrequencyInfoTooltip": "Bekijk details van de berekening", + "losFrequencyDialogTitle": "Berekening van de radiohorizon", + "losFrequencyDialogDescription": "Beginnend met k={baselineK} bij {baselineFreq} MHz, wordt bij de berekening de k-factor aangepast voor de huidige {frequencyMHz} MHz-band, die de gebogen radiohorizonkap definieert.", + "@losFrequencyDialogDescription": { + "description": "Explain how the calculation uses the baseline frequency and derived k-factor.", + "placeholders": { + "baselineK": { + "type": "double" + }, + "baselineFreq": { + "type": "double" + }, + "frequencyMHz": { + "type": "double" + }, + "kFactor": { + "type": "double" + } + } + }, + "listFilter_removeFromFavorites": "Verwijderen uit favorieten", + "listFilter_favorites": "Favorieten", + "listFilter_addToFavorites": "Toevoegen aan favorieten" } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 5ebeebf..8096bb4 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1,4 +1,12 @@ { + "channels_channelDeleteFailed": "Nie udało się usunąć kanału \"{name}\"", + "@channels_channelDeleteFailed": { + "placeholders": { + "name": { + "type": "String" + } + } + }, "@@locale": "pl", "appTitle": "MeshCore Open", "nav_contacts": "Kontakty", @@ -334,6 +342,8 @@ "channels_publicChannel": "Kanał publiczny", "channels_privateChannel": "Prywatny kanał", "channels_editChannel": "Edytuj kanał", + "channels_muteChannel": "Wycisz kanał", + "channels_unmuteChannel": "Wyłącz wyciszenie kanału", "channels_deleteChannel": "Usuń kanał", "channels_deleteChannelConfirm": "Usuń \"{name}\"? Nie można tego cofnąć.", "@channels_deleteChannelConfirm": { @@ -1351,12 +1361,12 @@ } } }, - "repeater_neighbours": "Sąsiedzi", - "repeater_neighboursSubtitle": "Wyświetl sąsiedztwo zerowych hopów.", + "repeater_neighbors": "Sąsiedzi", + "repeater_neighborsSubtitle": "Wyświetl sąsiedztwo zerowych hopów.", "neighbors_receivedData": "Otrzymano dane sąsiedztwa", "neighbors_requestTimedOut": "Sąsiedzi proszą o wyłączenie timingu.", "neighbors_errorLoading": "Błąd podczas ładowania sąsiadów: {error}", - "neighbors_repeatersNeighbours": "Powtarzacze Sąsiedzi", + "neighbors_repeatersNeighbors": "Powtarzacze Sąsiedzi", "neighbors_noData": "Brak danych dotyczących sąsiadów.", "channels_joinPrivateChannelDesc": "Ręcznie wprowadź klucz tajny.", "channels_createPrivateChannel": "Utwórz Prywatny Kanał", @@ -1550,6 +1560,8 @@ "contacts_chatTraceRoute": "Śledź trasę promienia", "appSettings_languageRu": "Rosyjski", "appSettings_languageUk": "Ukraińska", + "appSettings_enableMessageTracing": "Włącz śledzenie wiadomości", + "appSettings_enableMessageTracingSubtitle": "Pokaż szczegółowe metadane trasowania i czasu dla wiadomości", "contacts_contactImportFailed": "Kontakt nie został zaimportowany.", "contacts_zeroHopAdvert": "Reklama Zero Hop", "contacts_floodAdvert": "Reklama powodziowa", @@ -1594,7 +1606,152 @@ "scanner_bluetoothOffMessage": "Prosimy włączyć Bluetooth, aby przeskanować urządzenia.", "scanner_bluetoothOff": "Bluetooth jest wyłączony", "scanner_enableBluetooth": "Włącz Bluetooth", + "snrIndicator_lastSeen": "Ostatnio widziany", + "snrIndicator_nearByRepeaters": "Nadajniki w pobliżu", + "chat_ShowAllPaths": "Pokaż wszystkie ścieżki", "settings_clientRepeatSubtitle": "Pozwól temu urządzeniu powtarzać pakiety danych dla innych urządzeń.", "settings_clientRepeat": "Powtórzenie: Niezależne od sieci", - "settings_clientRepeatFreqWarning": "Powtórka poza siecią wymaga częstotliwości 433, 869 lub 918 MHz." + "settings_clientRepeatFreqWarning": "Powtórka poza siecią wymaga częstotliwości 433, 869 lub 918 MHz.", + "settings_aboutOpenMeteoAttribution": "Dane wysokościowe LOS: Open-Meteo (CC BY 4.0)", + "appSettings_unitsTitle": "Jednostki", + "appSettings_unitsMetric": "Metryczne (m / km)", + "appSettings_unitsImperial": "Imperialne (ft / mi)", + "map_lineOfSight": "Linia wzroku", + "map_losScreenTitle": "Linia wzroku", + "losSelectStartEnd": "Wybierz węzły początkowe i końcowe dla LOS.", + "losRunFailed": "Sprawdzenie pola widzenia nie powiodło się: {error}", + "@losRunFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "losClearAllPoints": "Wyczyść wszystkie punkty", + "losRunToViewElevationProfile": "Uruchom LOS, aby wyświetlić profil wysokości", + "losMenuTitle": "Menu LOS", + "losMenuSubtitle": "Stuknij węzły lub naciśnij i przytrzymaj mapę, aby uzyskać niestandardowe punkty", + "losShowDisplayNodes": "Pokaż węzły wyświetlające", + "losCustomPoints": "Punkty niestandardowe", + "losCustomPointLabel": "Niestandardowe {index}", + "@losCustomPointLabel": { + "placeholders": { + "index": { + "type": "int" + } + } + }, + "losPointA": "Punkt A", + "losPointB": "Punkt B", + "losAntennaA": "Antena A: {value} {unit}", + "@losAntennaA": { + "placeholders": { + "value": { + "type": "String" + }, + "unit": { + "type": "String" + } + } + }, + "losAntennaB": "Antena B: {value} {unit}", + "@losAntennaB": { + "placeholders": { + "value": { + "type": "String" + }, + "unit": { + "type": "String" + } + } + }, + "losRun": "Uruchom LOS-a", + "losNoElevationData": "Brak danych o wysokości", + "losProfileClear": "{distance} {distanceUnit}, czysty LOS, minimalny prześwit {clearance} {heightUnit}", + "@losProfileClear": { + "placeholders": { + "distance": { + "type": "String" + }, + "distanceUnit": { + "type": "String" + }, + "clearance": { + "type": "String" + }, + "heightUnit": { + "type": "String" + } + } + }, + "losProfileBlocked": "{distance} {distanceUnit}, zablokowane przez {obstruction} {heightUnit}", + "@losProfileBlocked": { + "placeholders": { + "distance": { + "type": "String" + }, + "distanceUnit": { + "type": "String" + }, + "obstruction": { + "type": "String" + }, + "heightUnit": { + "type": "String" + } + } + }, + "losStatusChecking": "LOS: sprawdzam...", + "losStatusNoData": "LOS: brak danych", + "losStatusSummary": "LOS: {clear}/{total} jasne, {blocked} zablokowane, {unknown} nieznane", + "@losStatusSummary": { + "placeholders": { + "clear": { + "type": "int" + }, + "total": { + "type": "int" + }, + "blocked": { + "type": "int" + }, + "unknown": { + "type": "int" + } + } + }, + "losErrorElevationUnavailable": "Dane dotyczące wysokości są niedostępne dla jednej lub większej liczby próbek.", + "losErrorInvalidInput": "Nieprawidłowe dane punktów/wysokości do obliczenia LOS.", + "losRenameCustomPoint": "Zmień nazwę punktu niestandardowego", + "losPointName": "Nazwa punktu", + "losShowPanelTooltip": "Pokaż panel LOS", + "losHidePanelTooltip": "Ukryj panel LOS", + "losElevationAttribution": "Dane dotyczące wysokości: Open-Meteo (CC BY 4.0)", + "losLegendRadioHorizon": "Horyzont radiowy", + "losLegendLosBeam": "Linia widoczności", + "losLegendTerrain": "Teren", + "losFrequencyLabel": "Częstotliwość", + "losFrequencyInfoTooltip": "Zobacz szczegóły obliczenia", + "losFrequencyDialogTitle": "Obliczanie horyzontu radiowego", + "losFrequencyDialogDescription": "Zaczynając od k={baselineK} przy {baselineFreq} MHz, obliczenia korygują współczynnik k dla bieżącego pasma {frequencyMHz} MHz, które definiuje zakrzywiony limit horyzontu radiowego.", + "@losFrequencyDialogDescription": { + "description": "Explain how the calculation uses the baseline frequency and derived k-factor.", + "placeholders": { + "baselineK": { + "type": "double" + }, + "baselineFreq": { + "type": "double" + }, + "frequencyMHz": { + "type": "double" + }, + "kFactor": { + "type": "double" + } + } + }, + "listFilter_removeFromFavorites": "Usuń z ulubionych", + "listFilter_addToFavorites": "Dodaj do ulubionych", + "listFilter_favorites": "Ulubione" } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index a88e038..53f8f93 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1,4 +1,12 @@ { + "channels_channelDeleteFailed": "Falha ao excluir o canal \"{name}\"", + "@channels_channelDeleteFailed": { + "placeholders": { + "name": { + "type": "String" + } + } + }, "@@locale": "pt", "appTitle": "MeshCore Open", "nav_contacts": "Contactos", @@ -334,6 +342,8 @@ "channels_publicChannel": "Canal público", "channels_privateChannel": "Canal privado", "channels_editChannel": "Editar canal", + "channels_muteChannel": "Silenciar canal", + "channels_unmuteChannel": "Ativar canal", "channels_deleteChannel": "Excluir canal", "channels_deleteChannelConfirm": "Excluir \"{name}\"? Não pode ser desfeito.", "@channels_deleteChannelConfirm": { @@ -1351,12 +1361,12 @@ } } }, - "repeater_neighbours": "Vizinhos", + "repeater_neighbors": "Vizinhos", "neighbors_receivedData": "Dados dos Vizinhos Recebidos", - "repeater_neighboursSubtitle": "Visualizar vizinhos de salto zero.", + "repeater_neighborsSubtitle": "Visualizar vizinhos de salto zero.", "neighbors_requestTimedOut": "Vizinhos solicitam tempo limite esgotado.", "neighbors_errorLoading": "Erro ao carregar vizinhos: {error}", - "neighbors_repeatersNeighbours": "Repetidores Vizinhos", + "neighbors_repeatersNeighbors": "Repetidores Vizinhos", "neighbors_noData": "Não estão disponíveis dados de vizinhos.", "channels_createPrivateChannelDesc": "Protegido com uma chave secreta.", "channels_joinPrivateChannelDesc": "Inserir uma chave secreta manualmente.", @@ -1556,6 +1566,8 @@ "contacts_copyAdvertToClipboard": "Copiar Anúncio para Área de Transferência", "contacts_addContactFromClipboard": "Adicionar Contato da Área de Transferência", "appSettings_languageRu": "Russo", + "appSettings_enableMessageTracing": "Ativar rastreamento de mensagens", + "appSettings_enableMessageTracingSubtitle": "Mostrar metadados detalhados de roteamento e tempo para as mensagens", "contacts_ShareContact": "Copiar contato para Área de Transferência", "contacts_contactImportFailed": "Contato falhou ao ser importado.", "contacts_zeroHopContactAdvertSent": "Enviou contato por anúncio.", @@ -1594,7 +1606,152 @@ "scanner_enableBluetooth": "Ative o Bluetooth", "scanner_bluetoothOff": "Bluetooth está desativado", "scanner_bluetoothOffMessage": "Por favor, ative o Bluetooth para escanear por dispositivos.", + "snrIndicator_nearByRepeaters": "Repetidores Próximos", + "snrIndicator_lastSeen": "Visto pela última vez", + "chat_ShowAllPaths": "Mostrar todos os caminhos", "settings_clientRepeatFreqWarning": "A repetição fora da rede requer frequências de 433, 869 ou 918 MHz.", "settings_clientRepeat": "Repetição sem rede", - "settings_clientRepeatSubtitle": "Permita que este dispositivo repita pacotes de rede para outros dispositivos." + "settings_clientRepeatSubtitle": "Permita que este dispositivo repita pacotes de rede para outros dispositivos.", + "settings_aboutOpenMeteoAttribution": "Dados de elevação LOS: Open-Meteo (CC BY 4.0)", + "appSettings_unitsTitle": "Unidades", + "appSettings_unitsMetric": "Métrico (m/km)", + "appSettings_unitsImperial": "Imperial (ft/mi)", + "map_lineOfSight": "Linha de visão", + "map_losScreenTitle": "Linha de visão", + "losSelectStartEnd": "Selecione nós iniciais e finais para LOS.", + "losRunFailed": "Falha na verificação da linha de visão: {error}", + "@losRunFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "losClearAllPoints": "Limpe todos os pontos", + "losRunToViewElevationProfile": "Execute o LOS para visualizar o perfil de elevação", + "losMenuTitle": "Menu LOS", + "losMenuSubtitle": "Toque nos nós ou mantenha pressionado o mapa para obter pontos personalizados", + "losShowDisplayNodes": "Mostrar nós de exibição", + "losCustomPoints": "Pontos personalizados", + "losCustomPointLabel": "{index} personalizado", + "@losCustomPointLabel": { + "placeholders": { + "index": { + "type": "int" + } + } + }, + "losPointA": "Ponto A", + "losPointB": "Ponto B", + "losAntennaA": "Antena A: {value} {unit}", + "@losAntennaA": { + "placeholders": { + "value": { + "type": "String" + }, + "unit": { + "type": "String" + } + } + }, + "losAntennaB": "Antena B: {value} {unit}", + "@losAntennaB": { + "placeholders": { + "value": { + "type": "String" + }, + "unit": { + "type": "String" + } + } + }, + "losRun": "Executar LOS", + "losNoElevationData": "Sem dados de elevação", + "losProfileClear": "{distance} {distanceUnit}, limpar LOS, liberação mínima {clearance} {heightUnit}", + "@losProfileClear": { + "placeholders": { + "distance": { + "type": "String" + }, + "distanceUnit": { + "type": "String" + }, + "clearance": { + "type": "String" + }, + "heightUnit": { + "type": "String" + } + } + }, + "losProfileBlocked": "{distance} {distanceUnit}, bloqueado por {obstruction} {heightUnit}", + "@losProfileBlocked": { + "placeholders": { + "distance": { + "type": "String" + }, + "distanceUnit": { + "type": "String" + }, + "obstruction": { + "type": "String" + }, + "heightUnit": { + "type": "String" + } + } + }, + "losStatusChecking": "LOS: verificando...", + "losStatusNoData": "LOS: sem dados", + "losStatusSummary": "LOS: {clear}/{total} limpo, {blocked} bloqueado, {unknown} desconhecido", + "@losStatusSummary": { + "placeholders": { + "clear": { + "type": "int" + }, + "total": { + "type": "int" + }, + "blocked": { + "type": "int" + }, + "unknown": { + "type": "int" + } + } + }, + "losErrorElevationUnavailable": "Dados de elevação indisponíveis para uma ou mais amostras.", + "losErrorInvalidInput": "Dados de pontos/elevação inválidos para cálculo de LOS.", + "losRenameCustomPoint": "Renomear ponto personalizado", + "losPointName": "Nome do ponto", + "losShowPanelTooltip": "Mostrar painel LOS", + "losHidePanelTooltip": "Ocultar painel LOS", + "losElevationAttribution": "Dados de elevação: Open-Meteo (CC BY 4.0)", + "losLegendRadioHorizon": "Horizonte de rádio", + "losLegendLosBeam": "Linha de visada", + "losLegendTerrain": "Terreno", + "losFrequencyLabel": "Frequência", + "losFrequencyInfoTooltip": "Ver detalhes do cálculo", + "losFrequencyDialogTitle": "Cálculo do horizonte de rádio", + "losFrequencyDialogDescription": "Começando em k={baselineK} em {baselineFreq} MHz, o cálculo ajusta o fator k para a banda atual de {frequencyMHz} MHz, que define o limite do horizonte de rádio curvo.", + "@losFrequencyDialogDescription": { + "description": "Explain how the calculation uses the baseline frequency and derived k-factor.", + "placeholders": { + "baselineK": { + "type": "double" + }, + "baselineFreq": { + "type": "double" + }, + "frequencyMHz": { + "type": "double" + }, + "kFactor": { + "type": "double" + } + } + }, + "listFilter_addToFavorites": "Adicionar aos favoritos", + "listFilter_removeFromFavorites": "Remover da lista de favoritos", + "listFilter_favorites": "Favoritos" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index fc17eee..b4d7b59 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1,4 +1,12 @@ { + "channels_channelDeleteFailed": "Не удалось удалить канал {name}.", + "@channels_channelDeleteFailed": { + "placeholders": { + "name": { + "type": "String" + } + } + }, "@@locale": "ru", "appTitle": "MeshCore Open", "nav_contacts": "Контакты", @@ -226,6 +234,8 @@ "channels_publicChannel": "Публичный канал", "channels_privateChannel": "Приватный канал", "channels_editChannel": "Изменить канал", + "channels_muteChannel": "Отключить уведомления канала", + "channels_unmuteChannel": "Включить уведомления канала", "channels_deleteChannel": "Удалить канал", "channels_deleteChannelConfirm": "Удалить \"{name}\"? Это действие нельзя отменить.", "channels_channelDeleted": "Канал \"{name}\" удалён", @@ -467,8 +477,8 @@ "repeater_telemetrySubtitle": "Просмотр телеметрии датчиков и системной статистики", "repeater_cli": "CLI", "repeater_cliSubtitle": "Отправка команд репитеру", - "repeater_neighbours": "Соседи", - "repeater_neighboursSubtitle": "Просмотр соседей на нулевом хопе.", + "repeater_neighbors": "Соседи", + "repeater_neighborsSubtitle": "Просмотр соседей на нулевом хопе.", "repeater_settings": "Настройки", "repeater_settingsSubtitle": "Настройка параметров репитера", "repeater_statusTitle": "Статус репитера", @@ -661,7 +671,7 @@ "neighbors_receivedData": "Полученные данные о соседях", "neighbors_requestTimedOut": "Время ожидания данных о соседях истекло.", "neighbors_errorLoading": "Ошибка загрузки соседей: {error}", - "neighbors_repeatersNeighbours": "Соседи репитеров", + "neighbors_repeatersNeighbors": "Соседи репитеров", "neighbors_noData": "Данные о соседях недоступны.", "neighbors_unknownContact": "Неизвестный {pubkey}", "neighbors_heardA ago": "Слышали: {time} назад", @@ -794,6 +804,8 @@ "contacts_invalidAdvertFormat": "Недействительные контактные данные", "contacts_zeroHopAdvert": "Реклама Zero Hop", "appSettings_languageUk": "Українська", + "appSettings_enableMessageTracing": "Включить трассировку сообщений", + "appSettings_enableMessageTracingSubtitle": "Показывать подробные метаданные о маршрутизации и времени для сообщений", "contacts_floodAdvert": "Рекламный поток", "contacts_clipboardEmpty": "Буфер обмена пуст.", "contacts_copyAdvertToClipboard": "Копировать рекламу в буфер обмена", @@ -834,7 +846,152 @@ "scanner_enableBluetooth": "Включите Bluetooth", "scanner_bluetoothOff": "Bluetooth выключен", "scanner_bluetoothOffMessage": "Пожалуйста, включите Bluetooth, чтобы найти устройства.", + "snrIndicator_nearByRepeaters": "Ближайшие ретрансляторы", + "snrIndicator_lastSeen": "Последний раз видели", + "chat_ShowAllPaths": "Показать все пути", "settings_clientRepeatFreqWarning": "Для работы в режиме \"без подключения к сети\" требуется частота 433, 869 или 918 МГц.", "settings_clientRepeatSubtitle": "Позвольте этому устройству повторять пакеты данных для других устройств.", - "settings_clientRepeat": "Повторение \"вне сети\"" + "settings_clientRepeat": "Повторение \"вне сети\"", + "settings_aboutOpenMeteoAttribution": "Данные о высоте LOS: Open-Meteo (CC BY 4.0)", + "appSettings_unitsTitle": "Единицы", + "appSettings_unitsMetric": "Метрическая (м/км)", + "appSettings_unitsImperial": "Имперская (ft / mi)", + "map_lineOfSight": "Линия видимости", + "map_losScreenTitle": "Линия видимости", + "losSelectStartEnd": "Выберите начальный и конечный узлы для LOS.", + "losRunFailed": "Проверка прямой видимости не удалась: {error}", + "@losRunFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "losClearAllPoints": "Очистить все точки", + "losRunToViewElevationProfile": "Запустите LOS, чтобы просмотреть профиль высот.", + "losMenuTitle": "ЛОС Меню", + "losMenuSubtitle": "Коснитесь узлов или нажмите и удерживайте карту для выбора пользовательских точек.", + "losShowDisplayNodes": "Показать узлы отображения", + "losCustomPoints": "Пользовательские точки", + "losCustomPointLabel": "Пользовательский {index}", + "@losCustomPointLabel": { + "placeholders": { + "index": { + "type": "int" + } + } + }, + "losPointA": "Точка А", + "losPointB": "Точка Б", + "losAntennaA": "Антенна А: {value} {unit}", + "@losAntennaA": { + "placeholders": { + "value": { + "type": "String" + }, + "unit": { + "type": "String" + } + } + }, + "losAntennaB": "Антенна Б: {value} {unit}", + "@losAntennaB": { + "placeholders": { + "value": { + "type": "String" + }, + "unit": { + "type": "String" + } + } + }, + "losRun": "Запустить ЛОС", + "losNoElevationData": "Нет данных о высоте", + "losProfileClear": "{distance} {distanceUnit}, свободная зона видимости, минимальный зазор {clearance} {heightUnit}", + "@losProfileClear": { + "placeholders": { + "distance": { + "type": "String" + }, + "distanceUnit": { + "type": "String" + }, + "clearance": { + "type": "String" + }, + "heightUnit": { + "type": "String" + } + } + }, + "losProfileBlocked": "{distance} {distanceUnit}, заблокирован {obstruction} {heightUnit}", + "@losProfileBlocked": { + "placeholders": { + "distance": { + "type": "String" + }, + "distanceUnit": { + "type": "String" + }, + "obstruction": { + "type": "String" + }, + "heightUnit": { + "type": "String" + } + } + }, + "losStatusChecking": "ЛОС: проверяю...", + "losStatusNoData": "ЛОС: нет данных", + "losStatusSummary": "LOS: {clear}/{total} очищено, {blocked} заблокировано, {unknown} неизвестно.", + "@losStatusSummary": { + "placeholders": { + "clear": { + "type": "int" + }, + "total": { + "type": "int" + }, + "blocked": { + "type": "int" + }, + "unknown": { + "type": "int" + } + } + }, + "losErrorElevationUnavailable": "Данные о высоте недоступны для одного или нескольких образцов.", + "losErrorInvalidInput": "Неверные данные о точках/высоте для расчета LOS.", + "losRenameCustomPoint": "Переименовать пользовательскую точку", + "losPointName": "Имя точки", + "losShowPanelTooltip": "Показать панель LOS", + "losHidePanelTooltip": "Скрыть панель LOS", + "losElevationAttribution": "Данные о высоте: Open-Meteo (CC BY 4.0)", + "losLegendRadioHorizon": "Радиогоризонт", + "losLegendLosBeam": "Линия прямой видимости", + "losLegendTerrain": "Рельеф", + "losFrequencyLabel": "Частота", + "losFrequencyInfoTooltip": "Просмотреть детали расчёта", + "losFrequencyDialogTitle": "Расчёт радиогоризонта", + "losFrequencyDialogDescription": "Начиная с k={baselineK} на частоте {baselineFreq} МГц, расчет корректирует коэффициент k для текущего диапазона {frequencyMHz} МГц, который определяет изогнутую границу радиогоризонта.", + "@losFrequencyDialogDescription": { + "description": "Explain how the calculation uses the baseline frequency and derived k-factor.", + "placeholders": { + "baselineK": { + "type": "double" + }, + "baselineFreq": { + "type": "double" + }, + "frequencyMHz": { + "type": "double" + }, + "kFactor": { + "type": "double" + } + } + }, + "listFilter_addToFavorites": "Добавить в избранное", + "listFilter_favorites": "Избранное", + "listFilter_removeFromFavorites": "Удалить из избранного" } diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index 14cd3ec..23a0aea 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -1,4 +1,12 @@ { + "channels_channelDeleteFailed": "Kanál \"{name}\" sa nepodarilo odstrániť", + "@channels_channelDeleteFailed": { + "placeholders": { + "name": { + "type": "String" + } + } + }, "@@locale": "sk", "appTitle": "MeshCore Open", "nav_contacts": "Kontakty", @@ -334,6 +342,8 @@ "channels_publicChannel": "Veľké verejne kanály", "channels_privateChannel": "Osobné kanál", "channels_editChannel": "Upraviť kanál", + "channels_muteChannel": "Stlmiť kanál", + "channels_unmuteChannel": "Zrušiť stlmenie kanála", "channels_deleteChannel": "Odstrániť kanál", "channels_deleteChannelConfirm": "Odstrániť \"{name}\"? To sa nedá zrušiť.", "@channels_deleteChannelConfirm": { @@ -1351,12 +1361,12 @@ } } }, - "repeater_neighboursSubtitle": "Zobraziť susedné body bez skokov.", + "repeater_neighborsSubtitle": "Zobraziť susedné body bez skokov.", "neighbors_requestTimedOut": "Súďia žiadajú o časové ukončenie.", "neighbors_receivedData": "Obdielo dáta suseda", - "repeater_neighbours": "Súsezný", + "repeater_neighbors": "Súsezný", "neighbors_errorLoading": "Chyba pri načítaní susedov: {error}", - "neighbors_repeatersNeighbours": "Opakovadlá Súsezná", + "neighbors_repeatersNeighbors": "Opakovadlá Súsezná", "neighbors_noData": "Nie je dostupná žiadna informácia o susedoch.", "channels_createPrivateChannel": "Vytvorte súkromný kanál", "channels_joinPrivateChannel": "Pripojiť sa k súkromnému kanálu", @@ -1556,6 +1566,8 @@ "contacts_copyAdvertToClipboard": "Kopírovať reklamu do schránky", "contacts_invalidAdvertFormat": "Neplatné kontaktné údaje", "appSettings_languageRu": "Ruština", + "appSettings_enableMessageTracing": "Povoliť sledovanie správ", + "appSettings_enableMessageTracingSubtitle": "Zobraziť podrobné metadáta o smerovaní a časovaní správ", "contacts_addContactFromClipboard": "Pridať kontakt z schránky", "contacts_contactImported": "Kontakt bol importovaný.", "contacts_zeroHopContactAdvertSent": "Poslal kontakt cez inzerát.", @@ -1594,7 +1606,152 @@ "scanner_bluetoothOffMessage": "Prosím, zapnite Bluetooth, aby ste mohli skenovať pre zariadenia.", "scanner_bluetoothOff": "Bluetooth je vypnutý", "scanner_enableBluetooth": "Povolte Bluetooth", + "snrIndicator_lastSeen": "Naposledy videný", + "snrIndicator_nearByRepeaters": "Miestne opakovače", + "chat_ShowAllPaths": "Zobraziť všetky cesty", "settings_clientRepeat": "Opätovné použitie bez elektrickej siete", "settings_clientRepeatFreqWarning": "Použitie off-grid systému vyžaduje frekvencie 433, 869 alebo 918 MHz.", - "settings_clientRepeatSubtitle": "Umožnite, aby toto zariadenie opakovávalo siete pre ostatných." + "settings_clientRepeatSubtitle": "Umožnite, aby toto zariadenie opakovávalo siete pre ostatných.", + "settings_aboutOpenMeteoAttribution": "Údaje o nadmorskej výške LOS: Open-Meteo (CC BY 4.0)", + "appSettings_unitsTitle": "Jednotky", + "appSettings_unitsMetric": "Metrické (m / km)", + "appSettings_unitsImperial": "Imperiálne (ft / mi)", + "map_lineOfSight": "Line of Sight", + "map_losScreenTitle": "Line of Sight", + "losSelectStartEnd": "Vyberte počiatočný a koncový uzol pre LOS.", + "losRunFailed": "Kontrola priamej viditeľnosti zlyhala: {error}", + "@losRunFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "losClearAllPoints": "Vymazať všetky body", + "losRunToViewElevationProfile": "Ak chcete zobraziť výškový profil, spustite LOS", + "losMenuTitle": "Menu LOS", + "losMenuSubtitle": "Klepnutím na uzly alebo dlhým stlačením mapy získate vlastné body", + "losShowDisplayNodes": "Zobraziť uzly zobrazenia", + "losCustomPoints": "Vlastné body", + "losCustomPointLabel": "Vlastné {index}", + "@losCustomPointLabel": { + "placeholders": { + "index": { + "type": "int" + } + } + }, + "losPointA": "Bod A", + "losPointB": "Bod B", + "losAntennaA": "Anténa A: {value} {unit}", + "@losAntennaA": { + "placeholders": { + "value": { + "type": "String" + }, + "unit": { + "type": "String" + } + } + }, + "losAntennaB": "Anténa B: {value} {unit}", + "@losAntennaB": { + "placeholders": { + "value": { + "type": "String" + }, + "unit": { + "type": "String" + } + } + }, + "losRun": "Spustite LOS", + "losNoElevationData": "Žiadne údaje o nadmorskej výške", + "losProfileClear": "{distance} {distanceUnit}, vymazať LOS, min. vôľa {clearance} {heightUnit}", + "@losProfileClear": { + "placeholders": { + "distance": { + "type": "String" + }, + "distanceUnit": { + "type": "String" + }, + "clearance": { + "type": "String" + }, + "heightUnit": { + "type": "String" + } + } + }, + "losProfileBlocked": "{distance} {distanceUnit}, blokovaný {obstruction} {heightUnit}", + "@losProfileBlocked": { + "placeholders": { + "distance": { + "type": "String" + }, + "distanceUnit": { + "type": "String" + }, + "obstruction": { + "type": "String" + }, + "heightUnit": { + "type": "String" + } + } + }, + "losStatusChecking": "LOS: kontrolujem...", + "losStatusNoData": "LOS: žiadne údaje", + "losStatusSummary": "LOS: {clear}/{total} vymazané, {blocked} blokované, {unknown} neznáme", + "@losStatusSummary": { + "placeholders": { + "clear": { + "type": "int" + }, + "total": { + "type": "int" + }, + "blocked": { + "type": "int" + }, + "unknown": { + "type": "int" + } + } + }, + "losErrorElevationUnavailable": "Údaje o nadmorskej výške nie sú k dispozícii pre jednu alebo viacero vzoriek.", + "losErrorInvalidInput": "Neplatné body/údaje o nadmorskej výške pre výpočet LOS.", + "losRenameCustomPoint": "Premenovať vlastný bod", + "losPointName": "Názov bodu", + "losShowPanelTooltip": "Zobraziť panel LOS", + "losHidePanelTooltip": "Skryť panel LOS", + "losElevationAttribution": "Údaje o nadmorskej výške: Open-Meteo (CC BY 4.0)", + "losLegendRadioHorizon": "Rádiový horizont", + "losLegendLosBeam": "Priama viditeľnosť", + "losLegendTerrain": "Terén", + "losFrequencyLabel": "Frekvencia", + "losFrequencyInfoTooltip": "Zobraziť podrobnosti výpočtu", + "losFrequencyDialogTitle": "Výpočet rádiového horizontu", + "losFrequencyDialogDescription": "Počnúc od k={baselineK} pri {baselineFreq} MHz výpočet upraví k-faktor pre aktuálne pásmo {frequencyMHz} MHz, ktorý definuje zakrivený strop rádiového horizontu.", + "@losFrequencyDialogDescription": { + "description": "Explain how the calculation uses the baseline frequency and derived k-factor.", + "placeholders": { + "baselineK": { + "type": "double" + }, + "baselineFreq": { + "type": "double" + }, + "frequencyMHz": { + "type": "double" + }, + "kFactor": { + "type": "double" + } + } + }, + "listFilter_removeFromFavorites": "Odstrániť z označení", + "listFilter_addToFavorites": "Pridaj do obľúbených", + "listFilter_favorites": "Obľúbené" } diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index e633965..6b64f68 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -1,4 +1,12 @@ { + "channels_channelDeleteFailed": "Kanala {name} ni bilo mogoče izbrisati", + "@channels_channelDeleteFailed": { + "placeholders": { + "name": { + "type": "String" + } + } + }, "@@locale": "sl", "appTitle": "MeshCore Open", "nav_contacts": "Stiki", @@ -334,6 +342,8 @@ "channels_publicChannel": "Javni kanal", "channels_privateChannel": "Zasebni kanal", "channels_editChannel": "Uredi kanal", + "channels_muteChannel": "Utišaj kanal", + "channels_unmuteChannel": "Vklopi obvestila kanala", "channels_deleteChannel": "Pošlji kanal", "channels_deleteChannelConfirm": "Izbrišem \"{name}\"? To se ne da povrniti.", "@channels_deleteChannelConfirm": { @@ -1351,12 +1361,12 @@ } } }, - "repeater_neighboursSubtitle": "Pogledati nič sosednjih hopjev.", - "repeater_neighbours": "Sosedi", + "repeater_neighborsSubtitle": "Pogledati nič sosednjih hopjev.", + "repeater_neighbors": "Sosedi", "neighbors_receivedData": "Prejeto podatke o sosedih", "neighbors_requestTimedOut": "Sosedi zahtevajo izklop po dogovoru.", "neighbors_errorLoading": "Napaka pri obnašanju sosedov: {error}", - "neighbors_repeatersNeighbours": "Ponovitve Sosedi", + "neighbors_repeatersNeighbors": "Ponovitve Sosedi", "neighbors_noData": "Niso na voljo podatki o sosedih.", "channels_joinPrivateChannel": "Pridružite se zasebni skupini", "channels_createPrivateChannelDesc": "Varno zaklenjeno s skrivnim ključem.", @@ -1550,6 +1560,8 @@ "contacts_pathTraceTo": "Trace route to {name}", "appSettings_languageRu": "Ruščina", "appSettings_languageUk": "Ukrajinsko", + "appSettings_enableMessageTracing": "Omogoči sledenje sporočilom", + "appSettings_enableMessageTracingSubtitle": "Prikaži podrobne metapodatke o usmerjanju in časovnem usklajevanju sporočil", "contacts_contactImported": "Kontakt je bil uvožen.", "contacts_contactImportFailed": "Kontakt ni bil uspešno uvožen.", "contacts_zeroHopAdvert": "Reklama brez posrednikov", @@ -1594,7 +1606,152 @@ "scanner_enableBluetooth": "Omogočite Bluetooth", "scanner_bluetoothOffMessage": "Prosimo, vklopite Bluetooth, da lahko poiščete naprave.", "scanner_bluetoothOff": "Bluetooth je izklopljen", + "snrIndicator_lastSeen": "Zadnjič videno", + "snrIndicator_nearByRepeaters": "Bližnji ponovitelji", + "chat_ShowAllPaths": "Prikaži vse poti", "settings_clientRepeatFreqWarning": "Za ponovni prenos na brezžični način so potrebne frekvence 433, 869 ali 918 MHz.", "settings_clientRepeatSubtitle": "Omogočite temu naprave, da ponavlja paketne sporočila za druge.", - "settings_clientRepeat": "Neovadno ponavljanje" + "settings_clientRepeat": "Neovadno ponavljanje", + "settings_aboutOpenMeteoAttribution": "Podatki o višini LOS: Open-Meteo (CC BY 4.0)", + "appSettings_unitsTitle": "Enote", + "appSettings_unitsMetric": "Metrična (m/km)", + "appSettings_unitsImperial": "Imperialno (ft / mi)", + "map_lineOfSight": "Linija vida", + "map_losScreenTitle": "Linija vida", + "losSelectStartEnd": "Izberite začetno in končno vozlišče za LOS.", + "losRunFailed": "Preverjanje vidnega polja ni uspelo: {error}", + "@losRunFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "losClearAllPoints": "Počisti vse točke", + "losRunToViewElevationProfile": "Zaženite LOS za ogled višinskega profila", + "losMenuTitle": "LOS meni", + "losMenuSubtitle": "Tapnite vozlišča ali dolgo pritisnite na zemljevid za točke po meri", + "losShowDisplayNodes": "Pokaži prikazna vozlišča", + "losCustomPoints": "Točke po meri", + "losCustomPointLabel": "Po meri {index}", + "@losCustomPointLabel": { + "placeholders": { + "index": { + "type": "int" + } + } + }, + "losPointA": "Točka A", + "losPointB": "Točka B", + "losAntennaA": "Antena A: {value} {unit}", + "@losAntennaA": { + "placeholders": { + "value": { + "type": "String" + }, + "unit": { + "type": "String" + } + } + }, + "losAntennaB": "Antena B: {value} {unit}", + "@losAntennaB": { + "placeholders": { + "value": { + "type": "String" + }, + "unit": { + "type": "String" + } + } + }, + "losRun": "Zaženi LOS", + "losNoElevationData": "Ni podatkov o višini", + "losProfileClear": "{distance} {distanceUnit}, čisti LOS, najmanjša razdalja {clearance} {heightUnit}", + "@losProfileClear": { + "placeholders": { + "distance": { + "type": "String" + }, + "distanceUnit": { + "type": "String" + }, + "clearance": { + "type": "String" + }, + "heightUnit": { + "type": "String" + } + } + }, + "losProfileBlocked": "{distance} {distanceUnit}, blokiral {obstruction} {heightUnit}", + "@losProfileBlocked": { + "placeholders": { + "distance": { + "type": "String" + }, + "distanceUnit": { + "type": "String" + }, + "obstruction": { + "type": "String" + }, + "heightUnit": { + "type": "String" + } + } + }, + "losStatusChecking": "LOS: preverjam ...", + "losStatusNoData": "LOS: ni podatkov", + "losStatusSummary": "LOS: {clear}/{total} jasno, {blocked} blokirano, {unknown} neznano", + "@losStatusSummary": { + "placeholders": { + "clear": { + "type": "int" + }, + "total": { + "type": "int" + }, + "blocked": { + "type": "int" + }, + "unknown": { + "type": "int" + } + } + }, + "losErrorElevationUnavailable": "Podatki o nadmorski višini niso na voljo za enega ali več vzorcev.", + "losErrorInvalidInput": "Neveljavni podatki o točkah/višini za izračun LOS.", + "losRenameCustomPoint": "Preimenujte točko po meri", + "losPointName": "Ime točke", + "losShowPanelTooltip": "Pokaži ploščo LOS", + "losHidePanelTooltip": "Skrij ploščo LOS", + "losElevationAttribution": "Podatki o višini: Open-Meteo (CC BY 4.0)", + "losLegendRadioHorizon": "Radijski horizont", + "losLegendLosBeam": "Linija vidnosti", + "losLegendTerrain": "Teren", + "losFrequencyLabel": "Frekvenca", + "losFrequencyInfoTooltip": "Prikaži podrobnosti izračuna", + "losFrequencyDialogTitle": "Izračun radijskega horizonta", + "losFrequencyDialogDescription": "Začenši od k={baselineK} pri {baselineFreq} MHz, izračun prilagodi k-faktor za trenutni pas {frequencyMHz} MHz, ki določa ukrivljeno zgornjo mejo radijskega horizonta.", + "@losFrequencyDialogDescription": { + "description": "Explain how the calculation uses the baseline frequency and derived k-factor.", + "placeholders": { + "baselineK": { + "type": "double" + }, + "baselineFreq": { + "type": "double" + }, + "frequencyMHz": { + "type": "double" + }, + "kFactor": { + "type": "double" + } + } + }, + "listFilter_favorites": "Priljubljene", + "listFilter_removeFromFavorites": "Odstrani iz priljubljenih", + "listFilter_addToFavorites": "Dodaj v priljubljene" } diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index 4e50409..3b96df7 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -1,4 +1,12 @@ { + "channels_channelDeleteFailed": "Det gick inte att ta bort kanalen \"{name}\"", + "@channels_channelDeleteFailed": { + "placeholders": { + "name": { + "type": "String" + } + } + }, "@@locale": "sv", "appTitle": "MeshCore Open", "nav_contacts": "Kontakter", @@ -334,6 +342,8 @@ "channels_publicChannel": "Allmänt kanal", "channels_privateChannel": "Privat kanal", "channels_editChannel": "Redigera kanal", + "channels_muteChannel": "Tysta kanal", + "channels_unmuteChannel": "Slå på ljud för kanal", "channels_deleteChannel": "Ta bort kanal", "channels_deleteChannelConfirm": "Radera \"{name}\"? Detta kan inte ångras.", "@channels_deleteChannelConfirm": { @@ -1351,12 +1361,12 @@ } } }, - "repeater_neighbours": "Grannar", - "repeater_neighboursSubtitle": "Visa noll hoppgrannar.", + "repeater_neighbors": "Grannar", + "repeater_neighborsSubtitle": "Visa noll hoppgrannar.", "neighbors_receivedData": "Mottagna grannars data", "neighbors_requestTimedOut": "Grannar begär tidsinställd utskick.", "neighbors_errorLoading": "Fel vid inläsning av grannar: {error}", - "neighbors_repeatersNeighbours": "Upprepar grannar", + "neighbors_repeatersNeighbors": "Upprepar grannar", "neighbors_noData": "Inga grannuppgifter finns tillgängliga.", "channels_createPrivateChannel": "Skapa en privat kanal", "channels_joinPrivateChannel": "Gå med i en Privat Kanal", @@ -1556,6 +1566,8 @@ "contacts_copyAdvertToClipboard": "Kopiera annons till urklipp", "contacts_invalidAdvertFormat": "Ogiltiga kontaktuppgifter", "appSettings_languageUk": "Ukrainska", + "appSettings_enableMessageTracing": "Aktivera meddelandespårning", + "appSettings_enableMessageTracingSubtitle": "Visa detaljerade metadata om dirigering och tidsinställningar för meddelanden", "contacts_addContactFromClipboard": "Lägg till kontakt från urklipp", "contacts_contactImported": "Kontakt har importerats.", "contacts_zeroHopContactAdvertSent": "Skickat kontakt via annons.", @@ -1594,7 +1606,152 @@ "scanner_enableBluetooth": "Aktivera Bluetooth", "scanner_bluetoothOffMessage": "Vänligen aktivera Bluetooth för att söka efter enheter.", "scanner_bluetoothOff": "Bluetooth är avstängt", + "snrIndicator_lastSeen": "Senast sedd", + "snrIndicator_nearByRepeaters": "Närliggande uppreparstationer", + "chat_ShowAllPaths": "Visa alla vägar", "settings_clientRepeatSubtitle": "Låt enheten repetera nätpaket för andra användare.", "settings_clientRepeat": "Upprepa utan elnät", - "settings_clientRepeatFreqWarning": "För att kunna kommunicera utanför elnätet krävs frekvenserna 433, 869 eller 918 MHz." + "settings_clientRepeatFreqWarning": "För att kunna kommunicera utanför elnätet krävs frekvenserna 433, 869 eller 918 MHz.", + "settings_aboutOpenMeteoAttribution": "LOS-höjddata: Open-Meteo (CC BY 4.0)", + "appSettings_unitsTitle": "Enheter", + "appSettings_unitsMetric": "Metriskt (m/km)", + "appSettings_unitsImperial": "Imperialt (ft / mi)", + "map_lineOfSight": "Synlinje", + "map_losScreenTitle": "Synlinje", + "losSelectStartEnd": "Välj start- och slutnoder för LOS.", + "losRunFailed": "Synlinjekontroll misslyckades: {error}", + "@losRunFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "losClearAllPoints": "Rensa alla punkter", + "losRunToViewElevationProfile": "Kör LOS för att se höjdprofil", + "losMenuTitle": "LOS-menyn", + "losMenuSubtitle": "Tryck på noder eller tryck länge på kartan för anpassade punkter", + "losShowDisplayNodes": "Visa displaynoder", + "losCustomPoints": "Anpassade poäng", + "losCustomPointLabel": "Anpassad {index}", + "@losCustomPointLabel": { + "placeholders": { + "index": { + "type": "int" + } + } + }, + "losPointA": "Punkt A", + "losPointB": "Punkt B", + "losAntennaA": "Antenn A: {value} {unit}", + "@losAntennaA": { + "placeholders": { + "value": { + "type": "String" + }, + "unit": { + "type": "String" + } + } + }, + "losAntennaB": "Antenn B: {value} {unit}", + "@losAntennaB": { + "placeholders": { + "value": { + "type": "String" + }, + "unit": { + "type": "String" + } + } + }, + "losRun": "Kör LOS", + "losNoElevationData": "Inga höjddata", + "losProfileClear": "{distance} {distanceUnit}, rensa LOS, min clearance {clearance} {heightUnit}", + "@losProfileClear": { + "placeholders": { + "distance": { + "type": "String" + }, + "distanceUnit": { + "type": "String" + }, + "clearance": { + "type": "String" + }, + "heightUnit": { + "type": "String" + } + } + }, + "losProfileBlocked": "{distance} {distanceUnit}, blockerad av {obstruction} {heightUnit}", + "@losProfileBlocked": { + "placeholders": { + "distance": { + "type": "String" + }, + "distanceUnit": { + "type": "String" + }, + "obstruction": { + "type": "String" + }, + "heightUnit": { + "type": "String" + } + } + }, + "losStatusChecking": "LOS: kollar...", + "losStatusNoData": "LOS: inga data", + "losStatusSummary": "LOS: {clear}/{total} rensa, {blocked} blockerad, {unknown} okänd", + "@losStatusSummary": { + "placeholders": { + "clear": { + "type": "int" + }, + "total": { + "type": "int" + }, + "blocked": { + "type": "int" + }, + "unknown": { + "type": "int" + } + } + }, + "losErrorElevationUnavailable": "Höjddata är inte tillgänglig för ett eller flera prover.", + "losErrorInvalidInput": "Ogiltiga poäng/höjddata för LOS-beräkning.", + "losRenameCustomPoint": "Byt namn på anpassad punkt", + "losPointName": "Punktnamn", + "losShowPanelTooltip": "Visa LOS-panelen", + "losHidePanelTooltip": "Dölj LOS-panelen", + "losElevationAttribution": "Höjddata: Open-Meteo (CC BY 4.0)", + "losLegendRadioHorizon": "Radiohorisont", + "losLegendLosBeam": "Siktlinje", + "losLegendTerrain": "Terräng", + "losFrequencyLabel": "Frekvens", + "losFrequencyInfoTooltip": "Visa detaljer om beräkningen", + "losFrequencyDialogTitle": "Beräkning av radiohorisonten", + "losFrequencyDialogDescription": "Med start från k={baselineK} vid {baselineFreq} MHz, justerar beräkningen k-faktorn för det aktuella {frequencyMHz} MHz-bandet, som definierar den böjda radiohorisonten.", + "@losFrequencyDialogDescription": { + "description": "Explain how the calculation uses the baseline frequency and derived k-factor.", + "placeholders": { + "baselineK": { + "type": "double" + }, + "baselineFreq": { + "type": "double" + }, + "frequencyMHz": { + "type": "double" + }, + "kFactor": { + "type": "double" + } + } + }, + "listFilter_removeFromFavorites": "Ta bort från favoriter", + "listFilter_addToFavorites": "Lägg till i favoriter", + "listFilter_favorites": "Favoriter" } diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index afa1179..78b4a90 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -1,4 +1,12 @@ { + "channels_channelDeleteFailed": "Не вдалося видалити канал \"{name}\"", + "@channels_channelDeleteFailed": { + "placeholders": { + "name": { + "type": "String" + } + } + }, "@@locale": "uk", "appTitle": "MeshCore Open", "nav_contacts": "Контакти", @@ -335,6 +343,8 @@ "channels_publicChannel": "Публічний канал", "channels_privateChannel": "Приватний канал", "channels_editChannel": "Редагувати канал", + "channels_muteChannel": "Вимкнути сповіщення каналу", + "channels_unmuteChannel": "Увімкнути сповіщення каналу", "channels_deleteChannel": "Видалити канал", "channels_deleteChannelConfirm": "Видалити {name}? Це не можна скасувати.", "@channels_deleteChannelConfirm": { @@ -1352,12 +1362,12 @@ } } }, - "repeater_neighbours": "Сусіди", - "repeater_neighboursSubtitle": "Показати сусідів нульового стрибка.", + "repeater_neighbors": "Сусіди", + "repeater_neighborsSubtitle": "Показати сусідів нульового стрибка.", "neighbors_receivedData": "Дані сусідів отримано", "neighbors_requestTimedOut": "Час запиту сусідів вичерпано.", "neighbors_errorLoading": "Помилка завантаження сусідів: {error}", - "neighbors_repeatersNeighbours": "Ретранслятори-сусіди", + "neighbors_repeatersNeighbors": "Ретранслятори-сусіди", "neighbors_noData": "Дані про сусідів недоступні.", "channels_createPrivateChannelDesc": "Захищено секретним ключем.", "channels_joinPrivateChannel": "Приєднатися до приватного каналу", @@ -1557,6 +1567,8 @@ "contacts_copyAdvertToClipboard": "Копіювати оголошення в буфер обміну", "contacts_clipboardEmpty": "Буфер обміну порожній", "appSettings_languageRu": "Російська", + "appSettings_enableMessageTracing": "Увімкнути відстеження повідомлень", + "appSettings_enableMessageTracingSubtitle": "Показувати детальні метадані про маршрутизацію та час для повідомлень", "contacts_ShareContact": "Копіювати контакт у буфер обміну", "contacts_zeroHopContactAdvertFailed": "Не вдалося надіслати контакт.", "contacts_contactAdvertCopied": "Рекламу скопійовано до буфера обміну.", @@ -1594,7 +1606,152 @@ "scanner_enableBluetooth": "Увімкніть Bluetooth", "scanner_bluetoothOffMessage": "Будь ласка, увімкніть Bluetooth, щоб сканувати пристрої.", "scanner_bluetoothOff": "Bluetooth вимкнено", + "snrIndicator_lastSeen": "Останній раз бачили", + "snrIndicator_nearByRepeaters": "Ближні ретранслятори", + "chat_ShowAllPaths": "Показати всі шляхи", "settings_clientRepeatFreqWarning": "Повтор без підключення до мережі вимагає частоти 433, 869 або 918 МГц.", "settings_clientRepeatSubtitle": "Дозвольте цьому пристрою повторювати пакети даних для інших пристроїв.", - "settings_clientRepeat": "Автономна система" + "settings_clientRepeat": "Автономна система", + "settings_aboutOpenMeteoAttribution": "Дані про висоту LOS: Open-Meteo (CC BY 4.0)", + "appSettings_unitsTitle": "одиниці", + "appSettings_unitsMetric": "Метричний (м / км)", + "appSettings_unitsImperial": "Імперська (ft / mi)", + "map_lineOfSight": "Пряма видимість", + "map_losScreenTitle": "Пряма видимість", + "losSelectStartEnd": "Виберіть початковий і кінцевий вузли для LOS.", + "losRunFailed": "Помилка перевірки прямої видимості: {error}", + "@losRunFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "losClearAllPoints": "Очистити всі пункти", + "losRunToViewElevationProfile": "Запустіть LOS, щоб переглянути профіль висоти", + "losMenuTitle": "Меню LOS", + "losMenuSubtitle": "Торкніться вузлів або утримуйте карту, щоб отримати власні точки", + "losShowDisplayNodes": "Показати вузли відображення", + "losCustomPoints": "Користувальницькі точки", + "losCustomPointLabel": "Спеціальний {index}", + "@losCustomPointLabel": { + "placeholders": { + "index": { + "type": "int" + } + } + }, + "losPointA": "Точка А", + "losPointB": "Точка Б", + "losAntennaA": "Антена A: {value} {unit}", + "@losAntennaA": { + "placeholders": { + "value": { + "type": "String" + }, + "unit": { + "type": "String" + } + } + }, + "losAntennaB": "Антена B: {value} {unit}", + "@losAntennaB": { + "placeholders": { + "value": { + "type": "String" + }, + "unit": { + "type": "String" + } + } + }, + "losRun": "Запустіть LOS", + "losNoElevationData": "Немає даних про висоту", + "losProfileClear": "{distance} {distanceUnit}, чистий LOS, мінімальний зазор {clearance} {heightUnit}", + "@losProfileClear": { + "placeholders": { + "distance": { + "type": "String" + }, + "distanceUnit": { + "type": "String" + }, + "clearance": { + "type": "String" + }, + "heightUnit": { + "type": "String" + } + } + }, + "losProfileBlocked": "{distance} {distanceUnit}, заблоковано {obstruction} {heightUnit}", + "@losProfileBlocked": { + "placeholders": { + "distance": { + "type": "String" + }, + "distanceUnit": { + "type": "String" + }, + "obstruction": { + "type": "String" + }, + "heightUnit": { + "type": "String" + } + } + }, + "losStatusChecking": "LOS: перевірка...", + "losStatusNoData": "LOS: немає даних", + "losStatusSummary": "LOS: {clear}/{total} очищено, {blocked} заблоковано, {unknown} невідомо", + "@losStatusSummary": { + "placeholders": { + "clear": { + "type": "int" + }, + "total": { + "type": "int" + }, + "blocked": { + "type": "int" + }, + "unknown": { + "type": "int" + } + } + }, + "losErrorElevationUnavailable": "Дані про висоту недоступні для одного чи кількох зразків.", + "losErrorInvalidInput": "Недійсні дані про точки/висоту для розрахунку LOS.", + "losRenameCustomPoint": "Перейменуйте спеціальну точку", + "losPointName": "Назва точки", + "losShowPanelTooltip": "Показати панель LOS", + "losHidePanelTooltip": "Приховати панель LOS", + "losElevationAttribution": "Дані про висоту: Open-Meteo (CC BY 4.0)", + "losLegendRadioHorizon": "Радіогоризонт", + "losLegendLosBeam": "Лінія прямої видимості", + "losLegendTerrain": "Рельєф", + "losFrequencyLabel": "Частота", + "losFrequencyInfoTooltip": "Переглянути деталі розрахунку", + "losFrequencyDialogTitle": "Розрахунок радіогоризонту", + "losFrequencyDialogDescription": "Починаючи з k={baselineK} на {baselineFreq} МГц, обчислення коригує k-фактор для поточного діапазону {frequencyMHz} МГц, який визначає викривлену межу радіогоризонту.", + "@losFrequencyDialogDescription": { + "description": "Explain how the calculation uses the baseline frequency and derived k-factor.", + "placeholders": { + "baselineK": { + "type": "double" + }, + "baselineFreq": { + "type": "double" + }, + "frequencyMHz": { + "type": "double" + }, + "kFactor": { + "type": "double" + } + } + }, + "listFilter_removeFromFavorites": "Видалити зі списку улюблених", + "listFilter_addToFavorites": "Додати до улюблених", + "listFilter_favorites": "Улюблені" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index f69e0db..6b8fc09 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1,4 +1,12 @@ { + "channels_channelDeleteFailed": "无法删除频道 \"{name}\"", + "@channels_channelDeleteFailed": { + "placeholders": { + "name": { + "type": "String" + } + } + }, "@@locale": "zh", "appTitle": "MeshCore Open", "nav_contacts": "联系人", @@ -72,7 +80,7 @@ "scanner_scan": "扫描", "device_quickSwitch": "快速切换", "device_meshcore": "MeshCore", - "settings_title": " ", + "settings_title": "设置", "settings_deviceInfo": "设备信息", "settings_appSettings": "应用设置", "settings_appSettingsSubtitle": "通知、消息和地图偏好", @@ -181,6 +189,8 @@ "appSettings_languageBg": "保加利亚语", "appSettings_languageRu": "俄语", "appSettings_languageUk": "乌克兰语", + "appSettings_enableMessageTracing": "启用消息追踪", + "appSettings_enableMessageTracingSubtitle": "显示消息的详细路由和时间元数据", "appSettings_notifications": "通知", "appSettings_enableNotifications": "启用通知", "appSettings_enableNotificationsSubtitle": "接收消息和广播的通知", @@ -258,7 +268,7 @@ "appSettings_appDebugLoggingSubtitle": "记录应用调试消息以进行故障排除。", "appSettings_appDebugLoggingEnabled": "调试日志已启用", "appSettings_appDebugLoggingDisabled": "应用调试日志已禁用", - "contacts_title": " ", + "contacts_title": "联系人", "contacts_noContacts": "暂无联系人", "contacts_contactsWillAppear": "当设备发送广播时,联系人将显示。", "contacts_searchContacts": "搜索联系人...", @@ -328,7 +338,7 @@ } } }, - "channels_title": " ", + "channels_title": "频道", "channels_noChannelsConfigured": "未配置任何频道", "channels_addPublicChannel": "添加公共频道", "channels_searchChannels": "搜索频道...", @@ -347,6 +357,8 @@ "channels_publicChannel": "公共频道", "channels_privateChannel": "私有频道", "channels_editChannel": "编辑频道", + "channels_muteChannel": "静音频道", + "channels_unmuteChannel": "取消静音频道", "channels_deleteChannel": "删除频道", "channels_deleteChannelConfirm": "删除频道 \"{name}\"?此操作不可撤销。", "@channels_deleteChannelConfirm": { @@ -636,7 +648,7 @@ } }, "chat_invalidLink": "无效的链接格式", - "map_title": " ", + "map_title": "节点地图", "map_noNodesWithLocation": "没有包含位置信息的节点", "map_nodesNeedGps": "节点需要共享 GPS 坐标才能在地图上显示", "map_nodesCount": "节点:{count}", @@ -900,8 +912,8 @@ "repeater_telemetrySubtitle": "查看传感器和系统状态数据", "repeater_cli": "命令行", "repeater_cliSubtitle": "向转发节点发送命令", - "repeater_neighbours": "邻居", - "repeater_neighboursSubtitle": "查看邻居节点(零跳)", + "repeater_neighbors": "邻居", + "repeater_neighborsSubtitle": "查看邻居节点(零跳)", "repeater_settings": "设置", "repeater_settingsSubtitle": "配置转发节点参数", "repeater_statusTitle": "转发节点状态", @@ -1271,7 +1283,7 @@ } } }, - "neighbors_repeatersNeighbours": "转发节点的邻居", + "neighbors_repeatersNeighbors": "转发节点的邻居", "neighbors_noData": "暂无邻居信息", "neighbors_unknownContact": "未知 {pubkey}", "@neighbors_unknownContact": { @@ -1599,7 +1611,152 @@ "scanner_bluetoothOffMessage": "请开启蓝牙以搜索设备", "scanner_bluetoothOff": "蓝牙已关闭", "scanner_enableBluetooth": "启用蓝牙", + "snrIndicator_lastSeen": "最近访问", + "snrIndicator_nearByRepeaters": "附近的重复器", + "chat_ShowAllPaths": "显示所有路径", "settings_clientRepeat": "离网重复", "settings_clientRepeatSubtitle": "允许此设备重复发送网状数据包给其他设备", - "settings_clientRepeatFreqWarning": "离网重复通信需要使用 433、869 或 918 兆赫兹的频率。" + "settings_clientRepeatFreqWarning": "离网重复通信需要使用 433、869 或 918 兆赫兹的频率。", + "settings_aboutOpenMeteoAttribution": "LOS 高程数据:Open-Meteo (CC BY 4.0)", + "appSettings_unitsTitle": "单位", + "appSettings_unitsMetric": "公制(米/公里)", + "appSettings_unitsImperial": "英制 (ft / mi)", + "map_lineOfSight": "视线", + "map_losScreenTitle": "视线", + "losSelectStartEnd": "选择 LOS 的起始节点和结束节点。", + "losRunFailed": "视线检查失败:{error}", + "@losRunFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "losClearAllPoints": "清除所有点", + "losRunToViewElevationProfile": "运行 LOS 查看高程剖面", + "losMenuTitle": "服务水平菜单", + "losMenuSubtitle": "点击节点或长按地图以获取自定义点", + "losShowDisplayNodes": "显示显示节点", + "losCustomPoints": "自定义积分", + "losCustomPointLabel": "自定义 {index}", + "@losCustomPointLabel": { + "placeholders": { + "index": { + "type": "int" + } + } + }, + "losPointA": "A点", + "losPointB": "B点", + "losAntennaA": "天线 A: {value} {unit}", + "@losAntennaA": { + "placeholders": { + "value": { + "type": "String" + }, + "unit": { + "type": "String" + } + } + }, + "losAntennaB": "天线 B:{value} {unit}", + "@losAntennaB": { + "placeholders": { + "value": { + "type": "String" + }, + "unit": { + "type": "String" + } + } + }, + "losRun": "运行视距", + "losNoElevationData": "无海拔数据", + "losProfileClear": "{distance} {distanceUnit},清除 LOS,最小间隙 {clearance} {heightUnit}", + "@losProfileClear": { + "placeholders": { + "distance": { + "type": "String" + }, + "distanceUnit": { + "type": "String" + }, + "clearance": { + "type": "String" + }, + "heightUnit": { + "type": "String" + } + } + }, + "losProfileBlocked": "{distance} {distanceUnit},被 {obstruction} {heightUnit} 阻止", + "@losProfileBlocked": { + "placeholders": { + "distance": { + "type": "String" + }, + "distanceUnit": { + "type": "String" + }, + "obstruction": { + "type": "String" + }, + "heightUnit": { + "type": "String" + } + } + }, + "losStatusChecking": "洛斯:正在检查...", + "losStatusNoData": "LOS:无数据", + "losStatusSummary": "LOS:{clear}/{total} 清除,{blocked} 阻塞,{unknown} 未知", + "@losStatusSummary": { + "placeholders": { + "clear": { + "type": "int" + }, + "total": { + "type": "int" + }, + "blocked": { + "type": "int" + }, + "unknown": { + "type": "int" + } + } + }, + "losErrorElevationUnavailable": "一个或多个样本的海拔数据不可用。", + "losErrorInvalidInput": "用于 LOS 计算的点/高程数据无效。", + "losRenameCustomPoint": "重命名自定义点", + "losPointName": "点名称", + "losShowPanelTooltip": "显示 LOS 面板", + "losHidePanelTooltip": "隐藏 LOS 面板", + "losElevationAttribution": "高程数据:Open-Meteo (CC BY 4.0)", + "losLegendRadioHorizon": "无线电地平线", + "losLegendLosBeam": "视距波束", + "losLegendTerrain": "地形", + "losFrequencyLabel": "频率", + "losFrequencyInfoTooltip": "查看计算详情", + "losFrequencyDialogTitle": "无线电地平线计算", + "losFrequencyDialogDescription": "从 {baselineFreq} MHz 处的 k={baselineK} 开始,计算调整当前 {frequencyMHz} MHz 频段的 k 因子,该因子定义了弯曲的无线电范围上限。", + "@losFrequencyDialogDescription": { + "description": "Explain how the calculation uses the baseline frequency and derived k-factor.", + "placeholders": { + "baselineK": { + "type": "double" + }, + "baselineFreq": { + "type": "double" + }, + "frequencyMHz": { + "type": "double" + }, + "kFactor": { + "type": "double" + } + } + }, + "listFilter_favorites": "收藏", + "listFilter_addToFavorites": "添加到收藏", + "listFilter_removeFromFavorites": "从收藏中移除" } diff --git a/lib/main.dart b/lib/main.dart index 8ee0ca4..5a11188 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter/foundation.dart'; import 'l10n/app_localizations.dart'; import 'package:provider/provider.dart'; @@ -14,6 +15,7 @@ import 'services/ble_debug_log_service.dart'; import 'services/app_debug_log_service.dart'; import 'services/background_service.dart'; import 'services/map_tile_cache_service.dart'; +import 'services/chat_text_scale_service.dart'; import 'storage/prefs_manager.dart'; import 'utils/app_logger.dart'; @@ -33,6 +35,7 @@ void main() async { final appDebugLogService = AppDebugLogService(); final backgroundService = BackgroundService(); final mapTileCacheService = MapTileCacheService(); + final chatTextScaleService = ChatTextScaleService(); // Load settings await appSettingsService.loadSettings(); @@ -47,6 +50,9 @@ void main() async { final notificationService = NotificationService(); await notificationService.initialize(); await backgroundService.initialize(); + _registerThirdPartyLicenses(); + + await chatTextScaleService.initialize(); // Wire up connector with services connector.initialize( @@ -76,10 +82,32 @@ void main() async { bleDebugLogService: bleDebugLogService, appDebugLogService: appDebugLogService, mapTileCacheService: mapTileCacheService, + chatTextScaleService: chatTextScaleService, ), ); } +void _registerThirdPartyLicenses() { + LicenseRegistry.addLicense(() async* { + yield const LicenseEntryWithLineBreaks( + ['Open-Meteo Elevation API Data'], + ''' +Data used by LOS elevation lookups is provided by Open-Meteo. + +Open-Meteo terms and attribution: +https://open-meteo.com/en/terms + +Elevation API: +https://open-meteo.com/en/docs/elevation-api + +Attribution license reference: +Creative Commons Attribution 4.0 International (CC BY 4.0) +https://creativecommons.org/licenses/by/4.0/ +''', + ); + }); +} + class MeshCoreApp extends StatelessWidget { final MeshCoreConnector connector; final MessageRetryService retryService; @@ -89,6 +117,7 @@ class MeshCoreApp extends StatelessWidget { final BleDebugLogService bleDebugLogService; final AppDebugLogService appDebugLogService; final MapTileCacheService mapTileCacheService; + final ChatTextScaleService chatTextScaleService; const MeshCoreApp({ super.key, @@ -100,6 +129,7 @@ class MeshCoreApp extends StatelessWidget { required this.bleDebugLogService, required this.appDebugLogService, required this.mapTileCacheService, + required this.chatTextScaleService, }); @override @@ -112,6 +142,7 @@ class MeshCoreApp extends StatelessWidget { ChangeNotifierProvider.value(value: appSettingsService), ChangeNotifierProvider.value(value: bleDebugLogService), ChangeNotifierProvider.value(value: appDebugLogService), + ChangeNotifierProvider.value(value: chatTextScaleService), Provider.value(value: storage), Provider.value(value: mapTileCacheService), ], diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index 3edb68f..62ba9ca 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -1,3 +1,16 @@ +enum UnitSystem { metric, imperial } + +extension UnitSystemValue on UnitSystem { + String get value { + switch (this) { + case UnitSystem.imperial: + return 'imperial'; + case UnitSystem.metric: + return 'metric'; + } + } +} + class AppSettings { static const Object _unset = Object(); @@ -9,6 +22,7 @@ class AppSettings { final bool mapKeyPrefixEnabled; final String mapKeyPrefix; final bool mapShowMarkers; + final bool enableMessageTracing; final Map? mapCacheBounds; final int mapCacheMinZoom; final int mapCacheMaxZoom; @@ -21,6 +35,9 @@ class AppSettings { final String? languageOverride; // null = system default final bool appDebugLogEnabled; final Map batteryChemistryByDeviceId; + final Map batteryChemistryByRepeaterId; + final UnitSystem unitSystem; + final Set mutedChannels; AppSettings({ this.clearPathOnMaxRetry = false, @@ -31,6 +48,7 @@ class AppSettings { this.mapKeyPrefixEnabled = false, this.mapKeyPrefix = '', this.mapShowMarkers = true, + this.enableMessageTracing = false, this.mapCacheBounds, this.mapCacheMinZoom = 10, this.mapCacheMaxZoom = 15, @@ -43,7 +61,12 @@ class AppSettings { this.languageOverride, this.appDebugLogEnabled = false, Map? batteryChemistryByDeviceId, - }) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {}; + Map? batteryChemistryByRepeaterId, + this.unitSystem = UnitSystem.metric, + Set? mutedChannels, + }) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {}, + batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {}, + mutedChannels = mutedChannels ?? {}; Map toJson() { return { @@ -55,6 +78,7 @@ class AppSettings { 'map_key_prefix_enabled': mapKeyPrefixEnabled, 'map_key_prefix': mapKeyPrefix, 'map_show_markers': mapShowMarkers, + 'enable_message_tracing': enableMessageTracing, 'map_cache_bounds': mapCacheBounds, 'map_cache_min_zoom': mapCacheMinZoom, 'map_cache_max_zoom': mapCacheMaxZoom, @@ -67,10 +91,20 @@ class AppSettings { 'language_override': languageOverride, 'app_debug_log_enabled': appDebugLogEnabled, 'battery_chemistry_by_device_id': batteryChemistryByDeviceId, + 'battery_chemistry_by_repeater_id': batteryChemistryByRepeaterId, + 'unit_system': unitSystem.value, + 'muted_channels': mutedChannels.toList(), }; } factory AppSettings.fromJson(Map json) { + UnitSystem parseUnitSystem(dynamic value) { + if (value is String && value.toLowerCase() == 'imperial') { + return UnitSystem.imperial; + } + return UnitSystem.metric; + } + return AppSettings( clearPathOnMaxRetry: json['clear_path_on_max_retry'] as bool? ?? false, mapShowRepeaters: json['map_show_repeaters'] as bool? ?? true, @@ -81,6 +115,7 @@ class AppSettings { mapKeyPrefixEnabled: json['map_key_prefix_enabled'] as bool? ?? false, mapKeyPrefix: json['map_key_prefix'] as String? ?? '', mapShowMarkers: json['map_show_markers'] as bool? ?? true, + enableMessageTracing: json['enable_message_tracing'] as bool? ?? false, mapCacheBounds: (json['map_cache_bounds'] as Map?)?.map( (key, value) => MapEntry(key.toString(), (value as num).toDouble()), ), @@ -101,6 +136,17 @@ class AppSettings { (key, value) => MapEntry(key.toString(), value.toString()), ) ?? {}, + batteryChemistryByRepeaterId: + (json['battery_chemistry_by_repeater_id'] as Map?)?.map( + (key, value) => MapEntry(key.toString(), value.toString()), + ) ?? + {}, + unitSystem: parseUnitSystem(json['unit_system']), + mutedChannels: + ((json['muted_channels'] as List?) + ?.map((e) => e.toString()) + .toSet()) ?? + {}, ); } @@ -113,6 +159,7 @@ class AppSettings { bool? mapKeyPrefixEnabled, String? mapKeyPrefix, bool? mapShowMarkers, + bool? enableMessageTracing, Object? mapCacheBounds = _unset, int? mapCacheMinZoom, int? mapCacheMaxZoom, @@ -125,6 +172,9 @@ class AppSettings { Object? languageOverride = _unset, bool? appDebugLogEnabled, Map? batteryChemistryByDeviceId, + Map? batteryChemistryByRepeaterId, + UnitSystem? unitSystem, + Set? mutedChannels, }) { return AppSettings( clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry, @@ -135,6 +185,7 @@ class AppSettings { mapKeyPrefixEnabled: mapKeyPrefixEnabled ?? this.mapKeyPrefixEnabled, mapKeyPrefix: mapKeyPrefix ?? this.mapKeyPrefix, mapShowMarkers: mapShowMarkers ?? this.mapShowMarkers, + enableMessageTracing: enableMessageTracing ?? this.enableMessageTracing, mapCacheBounds: mapCacheBounds == _unset ? this.mapCacheBounds : mapCacheBounds as Map?, @@ -154,6 +205,10 @@ class AppSettings { appDebugLogEnabled: appDebugLogEnabled ?? this.appDebugLogEnabled, batteryChemistryByDeviceId: batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId, + batteryChemistryByRepeaterId: + batteryChemistryByRepeaterId ?? this.batteryChemistryByRepeaterId, + unitSystem: unitSystem ?? this.unitSystem, + mutedChannels: mutedChannels ?? this.mutedChannels, ); } } diff --git a/lib/models/contact.dart b/lib/models/contact.dart index a98580f..5e532e6 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -5,6 +5,7 @@ class Contact { final Uint8List publicKey; final String name; final int type; + final int flags; final int pathLength; // -1 = flood, 0+ = direct hops (from device) final Uint8List path; // Path bytes from device final int? @@ -19,6 +20,7 @@ class Contact { required this.publicKey, required this.name, required this.type, + this.flags = 0, required this.pathLength, required this.path, this.pathOverride, @@ -58,11 +60,13 @@ class Contact { } bool get hasLocation => latitude != null && longitude != null; + bool get isFavorite => (flags & contactFlagFavorite) != 0; Contact copyWith({ Uint8List? publicKey, String? name, int? type, + int? flags, int? pathLength, Uint8List? path, int? pathOverride, @@ -77,6 +81,7 @@ class Contact { publicKey: publicKey ?? this.publicKey, name: name ?? this.name, type: type ?? this.type, + flags: flags ?? this.flags, pathLength: pathLength ?? this.pathLength, path: path ?? this.path, pathOverride: clearPathOverride @@ -119,7 +124,7 @@ class Contact { final pathBytes = _pathBytesForDisplay; Uint8List? traceBytes; - if (pathLength <= 0) { + if (pathBytes.isEmpty) { traceBytes = Uint8List(1); traceBytes[0] = publicKey[0]; return traceBytes; @@ -160,43 +165,49 @@ class Contact { } static Contact? fromFrame(Uint8List data) { - if (data.length < contactFrameSize) return null; + if (data.isEmpty) return null; if (data[0] != respCodeContact) return null; + try { + final pubKey = Uint8List.fromList( + data.sublist(contactPubKeyOffset, contactPubKeyOffset + pubKeySize), + ); + final type = data[contactTypeOffset]; + final flags = data[contactFlagsOffset]; + final pathLen = data[contactPathLenOffset].toSigned(8); + final safePathLen = pathLen > 0 + ? (pathLen > maxPathSize ? maxPathSize : pathLen) + : 0; + final pathBytes = safePathLen > 0 + ? Uint8List.fromList( + data.sublist(contactPathOffset, contactPathOffset + safePathLen), + ) + : Uint8List(0); + final name = readCString(data, contactNameOffset, maxNameSize); + final lastmod = readUint32LE(data, contactLastmodOffset); - final pubKey = Uint8List.fromList( - data.sublist(contactPubKeyOffset, contactPubKeyOffset + pubKeySize), - ); - final type = data[contactTypeOffset]; - final pathLen = data[contactPathLenOffset].toSigned(8); - final safePathLen = pathLen > 0 - ? (pathLen > maxPathSize ? maxPathSize : pathLen) - : 0; - final pathBytes = safePathLen > 0 - ? Uint8List.fromList( - data.sublist(contactPathOffset, contactPathOffset + safePathLen), - ) - : Uint8List(0); - final name = readCString(data, contactNameOffset, maxNameSize); - final lastmod = readUint32LE(data, contactLastmodOffset); + double? lat, lon; + final latRaw = readInt32LE(data, contactLatOffset); + final lonRaw = readInt32LE(data, contactLonOffset); + if (latRaw != 0 || lonRaw != 0) { + lat = latRaw / 1e6; + lon = lonRaw / 1e6; + } - double? lat, lon; - final latRaw = readInt32LE(data, contactLatOffset); - final lonRaw = readInt32LE(data, contactLonOffset); - if (latRaw != 0 || lonRaw != 0) { - lat = latRaw / 1e6; - lon = lonRaw / 1e6; + return Contact( + publicKey: pubKey, + name: name.isEmpty ? 'Unknown' : name, + type: type, + flags: flags, + pathLength: pathLen, + path: pathBytes, + latitude: lat, + longitude: lon, + lastSeen: DateTime.fromMillisecondsSinceEpoch(lastmod * 1000), + ); + } catch (e) { + // If parsing fails, return null + return null; } - - return Contact( - publicKey: pubKey, - name: name.isEmpty ? 'Unknown' : name, - type: type, - pathLength: pathLen, - path: pathBytes, - latitude: lat, - longitude: lon, - lastSeen: DateTime.fromMillisecondsSinceEpoch(lastmod * 1000), - ); } @override diff --git a/lib/screens/app_debug_log_screen.dart b/lib/screens/app_debug_log_screen.dart index 5372ea8..4877038 100644 --- a/lib/screens/app_debug_log_screen.dart +++ b/lib/screens/app_debug_log_screen.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../l10n/l10n.dart'; import '../services/app_debug_log_service.dart'; +import '../widgets/adaptive_app_bar_title.dart'; class AppDebugLogScreen extends StatelessWidget { const AppDebugLogScreen({super.key}); @@ -17,7 +18,7 @@ class AppDebugLogScreen extends StatelessWidget { return Scaffold( appBar: AppBar( - title: Text(context.l10n.debugLog_appTitle), + title: AdaptiveAppBarTitle(context.l10n.debugLog_appTitle), centerTitle: true, actions: [ IconButton( diff --git a/lib/screens/app_settings_screen.dart b/lib/screens/app_settings_screen.dart index 4e31733..a2c920e 100644 --- a/lib/screens/app_settings_screen.dart +++ b/lib/screens/app_settings_screen.dart @@ -3,8 +3,10 @@ import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; import '../l10n/l10n.dart'; +import '../models/app_settings.dart'; import '../services/app_settings_service.dart'; import '../services/notification_service.dart'; +import '../widgets/adaptive_app_bar_title.dart'; import 'map_cache_screen.dart'; class AppSettingsScreen extends StatelessWidget { @@ -14,7 +16,7 @@ class AppSettingsScreen extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text(context.l10n.appSettings_title), + title: AdaptiveAppBarTitle(context.l10n.appSettings_title), centerTitle: true, ), body: SafeArea( @@ -80,6 +82,18 @@ class AppSettingsScreen extends StatelessWidget { trailing: const Icon(Icons.chevron_right), onTap: () => _showLanguageDialog(context, settingsService), ), + const Divider(height: 1), + SwitchListTile( + secondary: const Icon(Icons.location_searching), + title: Text(context.l10n.appSettings_enableMessageTracing), + subtitle: Text( + context.l10n.appSettings_enableMessageTracingSubtitle, + ), + value: settingsService.settings.enableMessageTracing, + onChanged: (value) { + settingsService.setEnableMessageTracing(value); + }, + ), ], ), ); @@ -360,6 +374,18 @@ class AppSettingsScreen extends StatelessWidget { onTap: () => _showTimeFilterDialog(context, settingsService), ), const Divider(height: 1), + ListTile( + leading: const Icon(Icons.straighten), + title: Text(context.l10n.appSettings_unitsTitle), + subtitle: Text( + settingsService.settings.unitSystem == UnitSystem.imperial + ? context.l10n.appSettings_unitsImperial + : context.l10n.appSettings_unitsMetric, + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showUnitsDialog(context, settingsService), + ), + const Divider(height: 1), ListTile( leading: const Icon(Icons.download_outlined), title: Text(context.l10n.appSettings_offlineMapCache), @@ -706,6 +732,46 @@ class AppSettingsScreen extends StatelessWidget { ); } + void _showUnitsDialog( + BuildContext context, + AppSettingsService settingsService, + ) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(context.l10n.appSettings_unitsTitle), + content: RadioGroup( + groupValue: settingsService.settings.unitSystem, + onChanged: (value) { + if (value != null) { + settingsService.setUnitSystem(value); + Navigator.pop(context); + } + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text(context.l10n.appSettings_unitsMetric), + leading: const Radio(value: UnitSystem.metric), + ), + ListTile( + title: Text(context.l10n.appSettings_unitsImperial), + leading: const Radio(value: UnitSystem.imperial), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(context.l10n.common_close), + ), + ], + ), + ); + } + Widget _buildDebugCard( BuildContext context, AppSettingsService settingsService, diff --git a/lib/screens/ble_debug_log_screen.dart b/lib/screens/ble_debug_log_screen.dart index 7cebb76..88f734b 100644 --- a/lib/screens/ble_debug_log_screen.dart +++ b/lib/screens/ble_debug_log_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import '../l10n/l10n.dart'; import '../services/ble_debug_log_service.dart'; import '../connector/meshcore_protocol.dart'; +import '../widgets/adaptive_app_bar_title.dart'; enum _BleLogView { frames, rawLogRx } @@ -29,7 +30,7 @@ class _BleDebugLogScreenState extends State { : rawEntries.isNotEmpty; return Scaffold( appBar: AppBar( - title: Text(context.l10n.debugLog_bleTitle), + title: AdaptiveAppBarTitle(context.l10n.debugLog_bleTitle), actions: [ IconButton( tooltip: context.l10n.debugLog_copyLog, diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index c82356d..4e3743d 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -16,11 +17,15 @@ import '../helpers/utf8_length_limiter.dart'; import '../l10n/l10n.dart'; import '../models/channel.dart'; import '../models/channel_message.dart'; +import '../services/app_settings_service.dart'; +import '../services/chat_text_scale_service.dart'; import '../utils/emoji_utils.dart'; +import '../widgets/chat_zoom_wrapper.dart'; import '../widgets/emoji_picker.dart'; import '../widgets/gif_message.dart'; import '../widgets/jump_to_bottom_button.dart'; import '../widgets/gif_picker.dart'; +import '../widgets/message_status_icon.dart'; import 'channel_message_path_screen.dart'; import 'map_screen.dart'; @@ -216,37 +221,50 @@ class _ChannelChatScreenState extends State { return Stack( children: [ - ListView.builder( - reverse: true, // List grows from bottom up - controller: _scrollController, - padding: const EdgeInsets.all(8), - itemCount: itemCount, - itemBuilder: (context, index) { - // Loading indicator now appears at end (bottom) of reversed list - if (_isLoadingOlder && index == itemCount - 1) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: Center( - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, + ChatZoomWrapper( + child: ListView.builder( + reverse: true, // List grows from bottom up + controller: _scrollController, + padding: const EdgeInsets.all(8), + itemCount: itemCount, + itemBuilder: (context, index) { + // Loading indicator now appears at end (bottom) of reversed list + if (_isLoadingOlder && index == itemCount - 1) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), ), ), + ); + } + final messageIndex = index; + final message = reversedMessages[messageIndex]; + if (!_messageKeys.containsKey(message.messageId)) { + _messageKeys[message.messageId] = GlobalKey(); + } + return Container( + key: _messageKeys[message.messageId]!, + child: Builder( + builder: (context) { + final textScale = context + .select( + (service) => service.scale, + ); + return _buildMessageBubble( + message, + textScale, + ); + }, ), ); - } - final messageIndex = index; - final message = reversedMessages[messageIndex]; - if (!_messageKeys.containsKey(message.messageId)) { - _messageKeys[message.messageId] = GlobalKey(); - } - return Container( - key: _messageKeys[message.messageId]!, - child: _buildMessageBubble(message), - ); - }, + }, + ), ), JumpToBottomButton(scrollController: _scrollController), ], @@ -261,7 +279,9 @@ class _ChannelChatScreenState extends State { ); } - Widget _buildMessageBubble(ChannelMessage message) { + Widget _buildMessageBubble(ChannelMessage message, double textScale) { + final settingsService = context.watch(); + final enableTracing = settingsService.settings.enableMessageTracing; final isOutgoing = message.isOutgoing; final gifId = _parseGifId(message.text); final poi = _parsePoiMessage(message.text); @@ -271,107 +291,184 @@ class _ChannelChatScreenState extends State { ? message.pathVariants.first : Uint8List(0)); - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), - child: Column( - crossAxisAlignment: isOutgoing - ? CrossAxisAlignment.end - : CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: isOutgoing - ? MainAxisAlignment.end - : MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!isOutgoing) ...[ - _buildAvatar(message.senderName), - const SizedBox(width: 8), - ], - Flexible( - child: GestureDetector( - onTap: () => _showMessagePathInfo(message), - onLongPress: () => _showMessageActions(message), - child: Container( - padding: gifId != null - ? const EdgeInsets.all(4) - : const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, + const maxSwipeOffset = 64.0; + const replySwipeThreshold = 64.0; + const bodyFontSize = 14.0; + final messageBody = Column( + crossAxisAlignment: isOutgoing + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: isOutgoing + ? MainAxisAlignment.end + : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isOutgoing) ...[ + _buildAvatar(message.senderName), + const SizedBox(width: 8), + ], + Flexible( + child: GestureDetector( + onTap: () => _showMessagePathInfo(message), + onLongPress: () => _showMessageActions(message), + child: Container( + padding: gifId != null + ? const EdgeInsets.all(4) + : const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.65, + ), + decoration: BoxDecoration( + color: isOutgoing + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isOutgoing) ...[ + Padding( + padding: gifId != null + ? const EdgeInsets.only( + left: 8, + top: 4, + bottom: 4, + ) + : EdgeInsets.zero, + child: Text( + message.senderName, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), ), - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.65, - ), - decoration: BoxDecoration( - color: isOutgoing - ? Theme.of(context).colorScheme.primaryContainer - : Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!isOutgoing) ...[ - Padding( - padding: gifId != null - ? const EdgeInsets.only( - left: 8, - top: 4, - bottom: 4, - ) - : EdgeInsets.zero, - child: Text( - message.senderName, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, + ), + if (gifId == null) const SizedBox(height: 4), + ], + if (message.replyToMessageId != null) ...[ + _buildReplyPreview(message, textScale), + const SizedBox(height: 8), + ], + if (poi != null) + _buildPoiMessage( + context, + poi, + isOutgoing, + textScale, + trailing: (!enableTracing && isOutgoing) + ? Padding( + padding: const EdgeInsets.only(bottom: 2), + child: MessageStatusIcon( + isAcked: + message.status == + ChannelMessageStatus.sent && + displayPath.isNotEmpty, + isFailed: + message.status == + ChannelMessageStatus.failed, + ), + ) + : null, + ) + else if (gifId != null) + Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: GifMessage( + url: + 'https://media.giphy.com/media/$gifId/giphy.gif', + backgroundColor: Colors.transparent, + fallbackTextColor: isOutgoing + ? Theme.of(context) + .colorScheme + .onPrimaryContainer + .withValues(alpha: 0.7) + : Theme.of(context).colorScheme.onSurface + .withValues(alpha: 0.6), ), ), - ), - if (gifId == null) const SizedBox(height: 4), - ], - if (message.replyToMessageId != null) ...[ - _buildReplyPreview(message), - const SizedBox(height: 8), - ], - if (poi != null) - _buildPoiMessage(context, poi, isOutgoing) - else if (gifId != null) - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: GifMessage( - url: - 'https://media.giphy.com/media/$gifId/giphy.gif', - backgroundColor: Colors.transparent, - fallbackTextColor: isOutgoing - ? Theme.of(context) - .colorScheme - .onPrimaryContainer - .withValues(alpha: 0.7) - : Theme.of(context).colorScheme.onSurface - .withValues(alpha: 0.6), + if (!enableTracing && isOutgoing) + Positioned( + top: 0, + right: 0, + child: Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: isOutgoing + ? Theme.of( + context, + ).colorScheme.primaryContainer + : Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(10), + topRight: Radius.circular(8), + ), + ), + child: MessageStatusIcon( + isAcked: + message.status == + ChannelMessageStatus.sent && + displayPath.isNotEmpty, + isFailed: + message.status == + ChannelMessageStatus.failed, + ), + ), + ), + ], + ) + else + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Flexible( + child: Linkify( + text: message.text, + style: TextStyle( + fontSize: bodyFontSize * textScale, + ), + linkStyle: TextStyle( + fontSize: bodyFontSize * textScale, + color: Colors.green, + decoration: TextDecoration.underline, + ), + options: const LinkifyOptions( + humanize: false, + defaultToHttps: false, + ), + linkifiers: const [UrlLinkifier()], + onOpen: (link) => LinkHandler.handleLinkTap( + context, + link.url, + ), + ), ), - ) - else - Linkify( - text: message.text, - style: const TextStyle(fontSize: 14), - linkStyle: const TextStyle( - fontSize: 14, - color: Colors.green, - decoration: TextDecoration.underline, - ), - options: const LinkifyOptions( - humanize: false, - defaultToHttps: false, - ), - linkifiers: const [UrlLinkifier()], - onOpen: (link) => - LinkHandler.handleLinkTap(context, link.url), - ), + if (!enableTracing && isOutgoing) ...[ + const SizedBox(width: 4), + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: MessageStatusIcon( + isAcked: + message.status == + ChannelMessageStatus.sent && + displayPath.isNotEmpty, + isFailed: + message.status == + ChannelMessageStatus.failed, + ), + ), + ], + ], + ), + if (enableTracing) ...[ if (displayPath.isNotEmpty) ...[ const SizedBox(height: 4), Padding( @@ -443,25 +540,81 @@ class _ChannelChatScreenState extends State { ), ), ], - ), + ], ), ), ), - ], - ), - if (message.reactions.isNotEmpty) ...[ - const SizedBox(height: 4), - Padding( - padding: EdgeInsets.only(left: isOutgoing ? 0 : 48), - child: _buildReactionsDisplay(message), ), ], + ), + if (message.reactions.isNotEmpty) ...[ + const SizedBox(height: 4), + Padding( + padding: EdgeInsets.only(left: isOutgoing ? 0 : 48), + child: _buildReactionsDisplay(message), + ), ], - ), + ], + ); + + if (!isOutgoing) { + return _SwipeReplyBubble( + maxSwipeOffset: maxSwipeOffset, + replySwipeThreshold: replySwipeThreshold, + onReplyTriggered: () => _setReplyingTo(message), + hintBuilder: ({required isStart}) => + _buildReplySwipeHint(isStart: isStart), + child: messageBody, + ); + } else { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: messageBody, + ); + } + } + + Widget _buildReplySwipeHint({required bool isStart}) { + final colorScheme = Theme.of(context).colorScheme; + final content = Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.reply, color: colorScheme.primary), + const SizedBox(width: 6), + Text( + context.l10n.chat_reply, + style: TextStyle( + color: colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ], + ); + + return Container( + alignment: isStart ? Alignment.centerLeft : Alignment.centerRight, + padding: const EdgeInsets.symmetric(horizontal: 16), + color: colorScheme.primary.withValues(alpha: 0.08), + child: isStart + ? content + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + context.l10n.chat_reply, + style: TextStyle( + color: colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 6), + Icon(Icons.reply, color: colorScheme.primary), + ], + ), ); } - Widget _buildReplyPreview(ChannelMessage message) { + Widget _buildReplyPreview(ChannelMessage message, double textScale) { final connector = context.read(); final isOwnNode = message.replyToSenderName == connector.selfName; final replyText = message.replyToText ?? ''; @@ -489,7 +642,7 @@ class _ChannelChatScreenState extends State { const SizedBox(width: 4), Text( context.l10n.chat_location, - style: TextStyle(fontSize: 12, color: previewTextColor), + style: TextStyle(fontSize: 12 * textScale, color: previewTextColor), ), ], ); @@ -499,7 +652,7 @@ class _ChannelChatScreenState extends State { maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( - fontSize: 12, + fontSize: 12 * textScale, color: previewTextColor, fontStyle: FontStyle.italic, ), @@ -523,7 +676,7 @@ class _ChannelChatScreenState extends State { Text( context.l10n.chat_replyTo(message.replyToSenderName ?? ''), style: TextStyle( - fontSize: 11, + fontSize: 11 * textScale, fontWeight: FontWeight.bold, color: isOwnNode ? Theme.of(context).colorScheme.primary @@ -599,7 +752,13 @@ class _ChannelChatScreenState extends State { return _PoiInfo(lat: lat, lon: lon, label: label); } - Widget _buildPoiMessage(BuildContext context, _PoiInfo poi, bool isOutgoing) { + Widget _buildPoiMessage( + BuildContext context, + _PoiInfo poi, + bool isOutgoing, + double textScale, { + Widget? trailing, + }) { final colorScheme = Theme.of(context).colorScheme; final textColor = isOutgoing ? colorScheme.onPrimaryContainer @@ -635,16 +794,21 @@ class _ChannelChatScreenState extends State { children: [ Text( context.l10n.chat_poiShared, - style: TextStyle(color: textColor, fontWeight: FontWeight.w600), + style: TextStyle( + color: textColor, + fontWeight: FontWeight.w600, + fontSize: 14 * textScale, + ), ), if (poi.label.isNotEmpty) Text( poi.label, - style: TextStyle(color: metaColor, fontSize: 12), + style: TextStyle(color: metaColor, fontSize: 12 * textScale), ), ], ), ), + if (trailing != null) ...[const SizedBox(width: 4), trailing], ], ); } @@ -709,7 +873,7 @@ class _ChannelChatScreenState extends State { return colors[hash.abs() % colors.length]; } - Widget _buildReplyBanner() { + Widget _buildReplyBanner(double textScale) { final message = _replyingToMessage!; return Container( width: double.infinity, @@ -735,7 +899,7 @@ class _ChannelChatScreenState extends State { Text( context.l10n.chat_replyingTo(message.senderName), style: TextStyle( - fontSize: 12, + fontSize: 12 * textScale, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSecondaryContainer, ), @@ -745,7 +909,7 @@ class _ChannelChatScreenState extends State { maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( - fontSize: 11, + fontSize: 11 * textScale, color: Theme.of( context, ).colorScheme.onSecondaryContainer.withValues(alpha: 0.7), @@ -772,7 +936,15 @@ class _ChannelChatScreenState extends State { return Column( mainAxisSize: MainAxisSize.min, children: [ - if (_replyingToMessage != null) _buildReplyBanner(), + if (_replyingToMessage != null) + Builder( + builder: (context) { + final textScale = context.select( + (service) => service.scale, + ); + return _buildReplyBanner(textScale); + }, + ), Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( @@ -798,30 +970,47 @@ class _ChannelChatScreenState extends State { builder: (context, value, child) { final gifId = _parseGifId(value.text); if (gifId != null) { - return Row( - children: [ - Expanded( - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: GifMessage( - url: - 'https://media.giphy.com/media/$gifId/giphy.gif', - backgroundColor: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - fallbackTextColor: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), - maxSize: 160, + return Focus( + autofocus: true, + onKeyEvent: (node, event) { + if (event is KeyDownEvent && + (event.logicalKey == LogicalKeyboardKey.enter || + event.logicalKey == + LogicalKeyboardKey.numpadEnter)) { + _sendMessage(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: Row( + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: GifMessage( + url: + 'https://media.giphy.com/media/$gifId/giphy.gif', + backgroundColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + fallbackTextColor: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.6), + maxSize: 160, + ), ), ), - ), - const SizedBox(width: 8), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => _textController.clear(), - ), - ], + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.close), + onPressed: () { + _textController.clear(); + _textFieldFocusNode.requestFocus(); + }, + ), + ], + ), ); } @@ -884,6 +1073,7 @@ class _ChannelChatScreenState extends State { connector.sendChannelMessage(widget.channel, messageText); _textController.clear(); _cancelReply(); + _textFieldFocusNode.requestFocus(); } String _formatTime(DateTime time) { @@ -901,7 +1091,8 @@ class _ChannelChatScreenState extends State { Navigator.push( context, MaterialPageRoute( - builder: (context) => ChannelMessagePathScreen(message: message), + builder: (context) => + ChannelMessagePathScreen(message: message, channelMessage: true), ), ); } @@ -1006,6 +1197,157 @@ class _ChannelChatScreenState extends State { } } +class _SwipeReplyBubble extends StatefulWidget { + final double maxSwipeOffset; + final double replySwipeThreshold; + final VoidCallback onReplyTriggered; + final Widget Function({required bool isStart}) hintBuilder; + final Widget child; + + const _SwipeReplyBubble({ + required this.maxSwipeOffset, + required this.replySwipeThreshold, + required this.onReplyTriggered, + required this.hintBuilder, + required this.child, + }); + + @override + State<_SwipeReplyBubble> createState() => _SwipeReplyBubbleState(); +} + +class _SwipeReplyBubbleState extends State<_SwipeReplyBubble> { + Offset? _swipeStartPosition; + double _swipeOffset = 0; + double _maxSwipeDistance = 0; + int? _swipePointerId; + bool _swipeLockedToHorizontal = false; + + void _handleSwipeStart(Offset position) { + _swipeStartPosition = position; + _maxSwipeDistance = 0; + if (_swipeOffset != 0) { + setState(() => _swipeOffset = 0); + } + } + + void _handleSwipePointerDown(PointerDownEvent event) { + _swipePointerId = event.pointer; + _swipeLockedToHorizontal = false; + _handleSwipeStart(event.position); + } + + void _handleSwipePointerMove(PointerMoveEvent event) { + if (_swipePointerId != event.pointer || _swipeStartPosition == null) { + return; + } + + final dx = event.position.dx - _swipeStartPosition!.dx; + + const axisLockThreshold = 12.0; + if (!_swipeLockedToHorizontal) { + if (-dx < axisLockThreshold) { + return; + } + _swipeLockedToHorizontal = true; + } + + _handleSwipeUpdate(event.position); + } + + void _handleSwipeUpdate(Offset position) { + if (_swipeStartPosition == null) return; + + final dx = position.dx - _swipeStartPosition!.dx; + if (dx >= 0) return; + + if (-dx < 6) return; + + if (-dx > _maxSwipeDistance) { + _maxSwipeDistance = -dx; + } + + final double clamped = dx.clamp(-widget.maxSwipeOffset, 0.0).toDouble(); + final adjusted = _applySwipeResistance(clamped, widget.maxSwipeOffset); + if (adjusted != _swipeOffset) { + setState(() => _swipeOffset = adjusted); + } + } + + void _handleSwipePointerUp(Offset position) { + if (_swipeLockedToHorizontal && _swipeStartPosition != null) { + final dx = position.dx - _swipeStartPosition!.dx; + final peak = math.max( + _maxSwipeDistance, + (-dx).clamp(0.0, double.infinity), + ); + if (peak >= widget.replySwipeThreshold) { + widget.onReplyTriggered(); + HapticFeedback.selectionClick(); + } + } + _resetSwipe(); + } + + void _resetSwipe() { + if (_swipeOffset != 0) { + setState(() => _swipeOffset = 0); + } + _swipeStartPosition = null; + _maxSwipeDistance = 0; + _swipePointerId = null; + _swipeLockedToHorizontal = false; + } + + double _applySwipeResistance(double rawOffset, double maxOffset) { + final abs = rawOffset.abs(); + if (abs <= 0) return 0; + final norm = (abs / maxOffset).clamp(0.0, 1.0); + const deadZone = 0.18; + if (norm <= deadZone) { + return rawOffset.sign * maxOffset * (norm * 0.08); + } + final t = ((norm - deadZone) / (1 - deadZone)).clamp(0.0, 1.0); + final curved = t < 0.5 + ? 16 * math.pow(t, 5) + : 1 - math.pow(-2 * t + 2, 5) / 2; + const deadZoneEnd = 0.0144; + return rawOffset.sign * + maxOffset * + (deadZoneEnd + curved * (1 - deadZoneEnd)); + } + + @override + Widget build(BuildContext context) { + return Listener( + onPointerDown: _handleSwipePointerDown, + onPointerMove: _handleSwipePointerMove, + onPointerUp: (event) => _handleSwipePointerUp(event.position), + onPointerCancel: (_) => _resetSwipe(), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + child: Stack( + alignment: Alignment.center, + children: [ + Positioned.fill( + child: Opacity( + opacity: _swipeOffset.abs() / widget.maxSwipeOffset, + child: widget.hintBuilder(isStart: false), + ), + ), + AnimatedContainer( + duration: const Duration(milliseconds: 150), + transform: Matrix4.translationValues(_swipeOffset, 0, 0), + curve: Curves.easeOut, + child: widget.child, + ), + ], + ), + ), + ); + } +} + class _PoiInfo { final double lat; final double lon; diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index 1b0544c..44dfe79 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -9,26 +9,38 @@ import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; import '../services/map_tile_cache_service.dart'; +import '../services/app_settings_service.dart'; import '../connector/meshcore_protocol.dart'; import '../l10n/app_localizations.dart'; import '../l10n/l10n.dart'; import '../models/channel_message.dart'; +import '../models/app_settings.dart'; import '../models/contact.dart'; +import '../widgets/adaptive_app_bar_title.dart'; class ChannelMessagePathScreen extends StatelessWidget { final ChannelMessage message; - - const ChannelMessagePathScreen({super.key, required this.message}); + final bool channelMessage; + const ChannelMessagePathScreen({ + super.key, + required this.message, + this.channelMessage = false, + }); @override Widget build(BuildContext context) { return Consumer( builder: (context, connector, _) { final l10n = context.l10n; - final primaryPath = _selectPrimaryPath( + final primaryPathTmp = _selectPrimaryPath( message.pathBytes, message.pathVariants, ); + + final primaryPath = !channelMessage && !message.isOutgoing + ? Uint8List.fromList(primaryPathTmp.reversed.toList()) + : primaryPathTmp; + final hops = _buildPathHops(primaryPath, connector.contacts, l10n); final hasHopDetails = primaryPath.isNotEmpty; final observedLabel = _formatObservedHops( @@ -37,10 +49,9 @@ class ChannelMessagePathScreen extends StatelessWidget { l10n, ); final extraPaths = _otherPaths(primaryPath, message.pathVariants); - return Scaffold( appBar: AppBar( - title: Text(l10n.channelPath_title), + title: AdaptiveAppBarTitle(l10n.channelPath_title), actions: [ IconButton( icon: const Icon(Icons.radar_outlined), @@ -50,9 +61,9 @@ class ChannelMessagePathScreen extends StatelessWidget { MaterialPageRoute( builder: (context) => PathTraceMapScreen( title: context.l10n.contacts_repeaterPathTrace, - path: Uint8List.fromList(primaryPath), + path: primaryPath, flipPathRound: true, - reversePathRound: true, + reversePathRound: !message.isOutgoing && !channelMessage, ), ), ), @@ -62,7 +73,7 @@ class ChannelMessagePathScreen extends StatelessWidget { tooltip: l10n.channelPath_viewMap, onPressed: hasHopDetails ? () { - _openPathMap(context); + _openPathMap(context, channelMessage: channelMessage); } : null, ), @@ -157,7 +168,11 @@ class ChannelMessagePathScreen extends StatelessWidget { ), subtitle: Text(_formatPathPrefixes(variants[i])), trailing: const Icon(Icons.map_outlined, size: 20), - onTap: () => _openPathMap(context, initialPath: variants[i]), + onTap: () => _openPathMap( + context, + initialPath: variants[i], + channelMessage: channelMessage, + ), ), ), ], @@ -248,13 +263,18 @@ class ChannelMessagePathScreen extends StatelessWidget { ); } - void _openPathMap(BuildContext context, {Uint8List? initialPath}) { + void _openPathMap( + BuildContext context, { + Uint8List? initialPath, + bool channelMessage = false, + }) { Navigator.push( context, MaterialPageRoute( builder: (context) => ChannelMessagePathMapScreen( message: message, initialPath: initialPath, + channelMessage: channelMessage, ), ), ); @@ -264,11 +284,13 @@ class ChannelMessagePathScreen extends StatelessWidget { class ChannelMessagePathMapScreen extends StatefulWidget { final ChannelMessage message; final Uint8List? initialPath; + final bool channelMessage; const ChannelMessagePathMapScreen({ super.key, required this.message, this.initialPath, + this.channelMessage = false, }); @override @@ -278,8 +300,12 @@ class ChannelMessagePathMapScreen extends StatefulWidget { class _ChannelMessagePathMapScreenState extends State { + static const double _labelZoomThreshold = 8.5; + Uint8List? _selectedPath; double _pathDistance = 0.0; + bool _showNodeLabels = true; + bool _didReceivePositionUpdate = false; @override void initState() { @@ -314,6 +340,8 @@ class _ChannelMessagePathMapScreenState Widget build(BuildContext context) { return Consumer( builder: (context, connector, _) { + final settings = context.watch().settings; + final isImperial = settings.unitSystem == UnitSystem.imperial; final tileCache = context.read(); final primaryPath = _selectPrimaryPath( widget.message.pathBytes, @@ -323,11 +351,18 @@ class _ChannelMessagePathMapScreenState primaryPath, widget.message.pathVariants, ); - final selectedPath = _resolveSelectedPath( + final selectedPathTmp = _resolveSelectedPath( _selectedPath, observedPaths, primaryPath, ); + + final selectedPath = + ((!widget.message.isOutgoing && !widget.channelMessage) || + (widget.message.isOutgoing && widget.channelMessage)) + ? Uint8List.fromList(selectedPathTmp.reversed.toList()) + : selectedPathTmp; + final selectedIndex = _indexForPath(selectedPath, observedPaths); final hops = _buildPathHops( selectedPath, @@ -336,12 +371,22 @@ class _ChannelMessagePathMapScreenState ); final points = []; + + if ((widget.message.isOutgoing && !widget.channelMessage) || + (widget.message.isOutgoing && widget.channelMessage)) { + points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!)); + } + for (final hop in hops) { if (hop.hasLocation) { points.add(hop.position!); } } - points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!)); + + if ((!widget.message.isOutgoing && !widget.channelMessage) || + (!widget.message.isOutgoing && widget.channelMessage)) { + points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!)); + } final polylines = points.length > 1 ? [ @@ -357,6 +402,9 @@ class _ChannelMessagePathMapScreenState ? points.first : const LatLng(0, 0); final initialZoom = points.isNotEmpty ? 13.0 : 2.0; + if (!_didReceivePositionUpdate) { + _showNodeLabels = initialZoom >= _labelZoomThreshold; + } final bounds = points.length > 1 ? LatLngBounds.fromPoints(points) : null; @@ -366,7 +414,9 @@ class _ChannelMessagePathMapScreenState _pathDistance = _getPathDistance(points); return Scaffold( - appBar: AppBar(title: Text(context.l10n.channelPath_mapTitle)), + appBar: AppBar( + title: AdaptiveAppBarTitle(context.l10n.channelPath_mapTitle), + ), body: SafeArea( top: false, child: Stack( @@ -388,6 +438,17 @@ class _ChannelMessagePathMapScreenState interactionOptions: InteractionOptions( flags: ~InteractiveFlag.rotate, ), + onPositionChanged: (camera, hasGesture) { + final shouldShow = camera.zoom >= _labelZoomThreshold; + if (!_didReceivePositionUpdate || + shouldShow != _showNodeLabels) { + if (!mounted) return; + setState(() { + _didReceivePositionUpdate = true; + _showNodeLabels = shouldShow; + }); + } + }, ), children: [ TileLayer( @@ -399,7 +460,12 @@ class _ChannelMessagePathMapScreenState ), if (polylines.isNotEmpty) PolylineLayer(polylines: polylines), - MarkerLayer(markers: _buildHopMarkers(hops)), + MarkerLayer( + markers: _buildHopMarkers( + hops, + showLabels: _showNodeLabels, + ), + ), ], ), if (observedPaths.length > 1) @@ -422,7 +488,7 @@ class _ChannelMessagePathMapScreenState ), ), ), - _buildLegendCard(context, hops), + _buildLegendCard(context, hops, isImperial), ], ), ), @@ -494,45 +560,61 @@ class _ChannelMessagePathMapScreenState ); } - List _buildHopMarkers(List<_PathHop> hops) { - return [ - for (final hop in hops) - if (hop.hasLocation) - Marker( - point: hop.position!, - width: 35, - height: 35, - child: Container( - decoration: BoxDecoration( - color: Colors.green, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - alignment: Alignment.center, - child: Text( - hop.index.toString(), - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 12, + List _buildHopMarkers( + List<_PathHop> hops, { + required bool showLabels, + }) { + final markers = []; + for (final hop in hops) { + if (!hop.hasLocation) continue; + final point = hop.position!; + markers.add( + Marker( + point: point, + width: 35, + height: 35, + child: Container( + decoration: BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + offset: const Offset(0, 2), ), + ], + ), + alignment: Alignment.center, + child: Text( + hop.index.toString(), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 12, ), ), ), - if (context.read().selfLatitude != null && - context.read().selfLongitude != null) - Marker( - point: LatLng( - context.read().selfLatitude!, - context.read().selfLongitude!, + ), + ); + if (showLabels) { + markers.add( + _buildNodeLabelMarker( + point: point, + label: hop.contact?.name ?? _formatPrefix(hop.prefix), ), + ); + } + } + + final selfLat = context.read().selfLatitude; + final selfLon = context.read().selfLongitude; + if (selfLat != null && selfLon != null) { + final selfPoint = LatLng(selfLat, selfLon); + markers.add( + Marker( + point: selfPoint, width: 35, height: 35, child: Container( @@ -559,10 +641,60 @@ class _ChannelMessagePathMapScreenState ), ), ), - ]; + ); + if (showLabels) { + markers.add( + _buildNodeLabelMarker( + point: selfPoint, + label: context.l10n.pathTrace_you, + ), + ); + } + } + + return markers; } - Widget _buildLegendCard(BuildContext context, List<_PathHop> hops) { + Marker _buildNodeLabelMarker({required LatLng point, required String label}) { + return Marker( + point: point, + width: 120, + height: 24, + alignment: Alignment.topCenter, + child: IgnorePointer( + child: Transform.translate( + offset: const Offset(0, -20), + child: FittedBox( + fit: BoxFit.contain, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildLegendCard( + BuildContext context, + List<_PathHop> hops, + bool isImperial, + ) { final l10n = context.l10n; final maxHeight = MediaQuery.of(context).size.height * 0.35; final estimatedHeight = 72.0 + (hops.length * 56.0); @@ -581,7 +713,7 @@ class _ChannelMessagePathMapScreenState Padding( padding: const EdgeInsets.all(12), child: Text( - '${l10n.channelPath_repeaterHops} (${(_pathDistance / 1609.34).toStringAsFixed(2)} Miles / ${(_pathDistance / 1000).toStringAsFixed(2)} Km)', + '${l10n.channelPath_repeaterHops} ${formatDistance(_pathDistance, isImperial: isImperial)}', style: const TextStyle(fontWeight: FontWeight.w600), ), ), diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 6b8b92d..582fee7 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -3,18 +3,20 @@ import 'dart:math'; import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:meshcore_open/storage/channel_message_store.dart'; +import 'package:meshcore_open/widgets/app_bar.dart'; import 'package:provider/provider.dart'; import 'package:uuid/uuid.dart'; import '../connector/meshcore_connector.dart'; import '../l10n/l10n.dart'; +import '../services/app_settings_service.dart'; import '../models/channel.dart'; import '../models/community.dart'; import '../storage/community_store.dart'; import '../utils/dialog_utils.dart'; import '../utils/disconnect_navigation_mixin.dart'; import '../utils/route_transitions.dart'; -import '../widgets/battery_indicator.dart'; import '../widgets/list_filter_widget.dart'; import '../widgets/empty_state.dart'; import '../widgets/qr_code_display.dart'; @@ -104,6 +106,7 @@ class _ChannelsScreenState extends State @override Widget build(BuildContext context) { final connector = context.watch(); + final channelMessageStore = ChannelMessageStore(); // Auto-navigate back to scanner if disconnected if (!checkConnectionAndNavigate(connector)) { @@ -116,8 +119,7 @@ class _ChannelsScreenState extends State canPop: allowBack, child: Scaffold( appBar: AppBar( - leading: BatteryIndicator(connector: connector), - title: Text(context.l10n.channels_title), + title: AppBarTitle(context.l10n.channels_title), centerTitle: true, automaticallyImplyLeading: false, actions: [ @@ -304,6 +306,7 @@ class _ChannelsScreenState extends State return _buildChannelTile( context, connector, + channelMessageStore, channel, showDragHandle: true, dragIndex: index, @@ -323,6 +326,7 @@ class _ChannelsScreenState extends State return _buildChannelTile( context, connector, + channelMessageStore, channel, ); }, @@ -352,6 +356,7 @@ class _ChannelsScreenState extends State Widget _buildChannelTile( BuildContext context, MeshCoreConnector connector, + ChannelMessageStore channelMessageStore, Channel channel, { bool showDragHandle = false, int? dragIndex, @@ -468,7 +473,12 @@ class _ChannelsScreenState extends State ); } }, - onLongPress: () => _showChannelActions(context, connector, channel), + onLongPress: () => _showChannelActions( + context, + connector, + channelMessageStore, + channel, + ), ), ); } @@ -476,11 +486,16 @@ class _ChannelsScreenState extends State void _showChannelActions( BuildContext context, MeshCoreConnector connector, + ChannelMessageStore channelMessageStore, Channel channel, ) { + final parentContext = context; + final settingsService = context.read(); + final isMuted = settingsService.isChannelMuted(channel.name); + showModalBottomSheet( - context: context, - builder: (context) => SafeArea( + context: parentContext, + builder: (sheetContext) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -488,10 +503,30 @@ class _ChannelsScreenState extends State leading: const Icon(Icons.edit_outlined), title: Text(context.l10n.channels_editChannel), onTap: () async { - Navigator.pop(context); + Navigator.pop(sheetContext); await Future.delayed(const Duration(milliseconds: 100)); - if (context.mounted) { - _showEditChannelDialog(context, connector, channel); + if (parentContext.mounted) { + _showEditChannelDialog(parentContext, connector, channel); + } + }, + ), + ListTile( + leading: Icon( + isMuted + ? Icons.notifications_outlined + : Icons.notifications_off_outlined, + ), + title: Text( + isMuted + ? context.l10n.channels_unmuteChannel + : context.l10n.channels_muteChannel, + ), + onTap: () async { + Navigator.pop(sheetContext); + if (isMuted) { + await settingsService.unmuteChannel(channel.name); + } else { + await settingsService.muteChannel(channel.name); } }, ), @@ -502,10 +537,15 @@ class _ChannelsScreenState extends State style: const TextStyle(color: Colors.red), ), onTap: () async { - Navigator.pop(context); + Navigator.pop(sheetContext); await Future.delayed(const Duration(milliseconds: 100)); - if (context.mounted) { - _confirmDeleteChannel(context, connector, channel); + if (parentContext.mounted) { + _confirmDeleteChannel( + context, + connector, + channelMessageStore, + channel, + ); } }, ), @@ -1415,7 +1455,7 @@ class _ChannelsScreenState extends State child: Text(dialogContext.l10n.common_cancel), ), FilledButton( - onPressed: () { + onPressed: () async { final name = nameController.text.trim(); final pskHex = pskController.text.trim(); @@ -1432,13 +1472,25 @@ class _ChannelsScreenState extends State } Navigator.pop(dialogContext); - connector.setChannel(channel.index, name, psk); - connector.setChannelSmazEnabled(channel.index, smazEnabled); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.channels_channelUpdated(name)), - ), - ); + try { + await connector.setChannel(channel.index, name, psk); + await connector.setChannelSmazEnabled( + channel.index, + smazEnabled, + ); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.channels_channelUpdated(name)), + ), + ); + } catch (e, st) { + debugPrint(st.toString()); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to update channel: $e')), + ); + } }, child: Text(dialogContext.l10n.common_save), ), @@ -1451,6 +1503,7 @@ class _ChannelsScreenState extends State void _confirmDeleteChannel( BuildContext context, MeshCoreConnector connector, + ChannelMessageStore channelMessageStore, Channel channel, ) { showDialog( @@ -1466,16 +1519,36 @@ class _ChannelsScreenState extends State child: Text(dialogContext.l10n.common_cancel), ), TextButton( - onPressed: () { + onPressed: () async { Navigator.pop(dialogContext); - connector.deleteChannel(channel.index); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.channels_channelDeleted(channel.name), + try { + await connector.deleteChannel(channel.index); + + channelMessageStore.clearChannelMessages(channel.index); + + if (!context.mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.channels_channelDeleted(channel.name), + ), ), - ), - ); + ); + } catch (e, st) { + if (!context.mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.channels_channelDeleteFailed(channel.name), + ), + ), + ); + + // Preserve existing logging (if it was there) + debugPrint('Failed to delete channel: $e\n$st'); + } }, child: Text( dialogContext.l10n.common_delete, diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index f00f242..7c8fcfb 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -13,13 +13,19 @@ import 'package:latlong2/latlong.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; import '../helpers/reaction_helper.dart'; +import '../widgets/message_status_icon.dart'; import '../helpers/chat_scroll_controller.dart'; import '../helpers/link_handler.dart'; import '../helpers/utf8_length_limiter.dart'; import '../models/channel_message.dart'; import '../models/contact.dart'; import '../models/message.dart'; +import '../models/path_history.dart'; +import '../services/app_settings_service.dart'; +import '../services/chat_text_scale_service.dart'; import '../services/path_history_service.dart'; +import '../widgets/chat_zoom_wrapper.dart'; +import '../widgets/elements_ui.dart'; import 'channel_message_path_screen.dart'; import 'map_screen.dart'; import '../utils/emoji_utils.dart'; @@ -266,52 +272,62 @@ class _ChatScreenState extends State { _scrollController.scrollToBottomIfAtBottom(); }); - return ListView.builder( - reverse: true, // List grows from bottom up - controller: _scrollController, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16), - itemCount: itemCount, - itemBuilder: (context, index) { - // Loading indicator now appears at end (bottom) of reversed list - if (_isLoadingOlder && index == itemCount - 1) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: Center( - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), + return ChatZoomWrapper( + child: ListView.builder( + reverse: true, // List grows from bottom up + controller: _scrollController, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16), + itemCount: itemCount, + itemBuilder: (context, index) { + // Loading indicator now appears at end (bottom) of reversed list + if (_isLoadingOlder && index == itemCount - 1) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), ), - ), - ); - } - final messageIndex = index; - Contact contact = widget.contact; - final message = reversedMessages[messageIndex]; - String fourByteHex = ''; - if (widget.contact.type == advTypeRoom) { - contact = _resolveContactFrom4Bytes( - connector, - message.fourByteRoomContactKey.isEmpty - ? Uint8List.fromList([0, 0, 0, 0]) - : message.fourByteRoomContactKey, - ); - fourByteHex = message.fourByteRoomContactKey - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join() - .toUpperCase(); - } + ); + } + final messageIndex = index; + Contact contact = widget.contact; + final message = reversedMessages[messageIndex]; + String fourByteHex = ''; + if (widget.contact.type == advTypeRoom) { + contact = _resolveContactFrom4Bytes( + connector, + message.fourByteRoomContactKey.isEmpty + ? Uint8List.fromList([0, 0, 0, 0]) + : message.fourByteRoomContactKey, + ); + fourByteHex = message.fourByteRoomContactKey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toUpperCase(); + } - return _MessageBubble( - message: message, - senderName: widget.contact.type == advTypeRoom - ? "${contact.name} [$fourByteHex]" - : contact.name, - isRoomServer: widget.contact.type == advTypeRoom, - onTap: () => _openMessagePath(message, contact), - onLongPress: () => _showMessageActions(message, contact), - ); - }, + return Builder( + builder: (context) { + final textScale = context.select( + (service) => service.scale, + ); + return _MessageBubble( + message: message, + senderName: widget.contact.type == advTypeRoom + ? "${contact.name} [$fourByteHex]" + : contact.name, + isRoomServer: widget.contact.type == advTypeRoom, + textScale: textScale, + onTap: () => _openMessagePath(message, contact), + onLongPress: () => _showMessageActions(message, contact), + ); + }, + ); + }, + ), ); } @@ -338,28 +354,44 @@ class _ChatScreenState extends State { builder: (context, value, child) { final gifId = _parseGifId(value.text); if (gifId != null) { - return Row( - children: [ - Expanded( - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: GifMessage( - url: - 'https://media.giphy.com/media/$gifId/giphy.gif', - backgroundColor: - colorScheme.surfaceContainerHighest, - fallbackTextColor: colorScheme.onSurface - .withValues(alpha: 0.6), - maxSize: 160, + return Focus( + autofocus: true, + onKeyEvent: (node, event) { + if (event is KeyDownEvent && + (event.logicalKey == LogicalKeyboardKey.enter || + event.logicalKey == + LogicalKeyboardKey.numpadEnter)) { + _sendMessage(connector); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: Row( + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: GifMessage( + url: + 'https://media.giphy.com/media/$gifId/giphy.gif', + backgroundColor: + colorScheme.surfaceContainerHighest, + fallbackTextColor: colorScheme.onSurface + .withValues(alpha: 0.6), + maxSize: 160, + ), ), ), - ), - const SizedBox(width: 8), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => _textController.clear(), - ), - ], + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.close), + onPressed: () { + _textController.clear(); + _textFieldFocusNode.requestFocus(); + }, + ), + ], + ), ); } @@ -427,246 +459,322 @@ class _ChatScreenState extends State { connector.sendMessage(widget.contact, text); _textController.clear(); + _textFieldFocusNode.requestFocus(); } void _showPathHistory(BuildContext context) { final connector = Provider.of(context, listen: false); - + bool showAllPaths = false; showDialog( context: context, - builder: (context) => Consumer( - builder: (context, pathService, _) { - final paths = pathService.getRecentPaths(widget.contact.publicKeyHex); - return AlertDialog( - title: Row( - children: [ - const Icon(Icons.timeline), - const SizedBox(width: 8), - Text(context.l10n.chat_pathManagement), - ], - ), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + builder: (context) => StatefulBuilder( + builder: (context, setDialogState) => Consumer( + builder: (context, pathService, _) { + final paths = pathService.getRecentPaths( + widget.contact.publicKeyHex, + ); + + final repeatersList = List.of(connector.directRepeaters) + ..sort((a, b) => b.ranking.compareTo(a.ranking)); + + if (repeatersList.isEmpty) { + showAllPaths = true; + } + + final directRepeater = repeatersList.isEmpty + ? null + : repeatersList.first; + final secondDirectRepeater = repeatersList.length < 2 + ? null + : repeatersList.elementAt(1); + final thirdDirectRepeater = repeatersList.length < 3 + ? null + : repeatersList.elementAt(2); + + List>> + pathsWithRepeaters = paths.map((path) { + final isDirectRepeater = + directRepeater != null && + path.pathBytes.isNotEmpty && + directRepeater.pubkeyFirstByte == path.pathBytes.first; + final isSecondDirectRepeater = + secondDirectRepeater != null && + path.pathBytes.isNotEmpty && + secondDirectRepeater.pubkeyFirstByte == path.pathBytes.first; + final isThirdDirectRepeater = + thirdDirectRepeater != null && + path.pathBytes.isNotEmpty && + thirdDirectRepeater.pubkeyFirstByte == path.pathBytes.first; + + int ranking = -1; + Color color = Colors.grey; + if (isDirectRepeater) { + color = Colors.green; + ranking = 3; + } else if (isSecondDirectRepeater) { + color = Colors.yellow; + ranking = 2; + } else if (isThirdDirectRepeater) { + color = Colors.red; + ranking = 1; + } else if (path.wasFloodDiscovery) { + color = Colors.blue; + ranking = 0; + } + + return MapEntry(ranking, MapEntry(color, path)); + }).toList(); + + pathsWithRepeaters.sort((a, b) => b.key.compareTo(a.key)); + + return AlertDialog( + title: Row( children: [ - if (paths.isNotEmpty) ...[ + const Icon(Icons.timeline), + const SizedBox(width: 8), + Text(context.l10n.chat_pathManagement), + ], + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (pathsWithRepeaters.isNotEmpty) ...[ + if (repeatersList.isNotEmpty) + FeatureToggleRow( + title: context.l10n.chat_ShowAllPaths, + subtitle: "", + value: showAllPaths, + onChanged: (val) { + setDialogState(() { + showAllPaths = val; + }); + }, + ), + Text( + context.l10n.chat_recentAckPaths, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + if (pathsWithRepeaters.length >= 100) ...[ + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: Colors.amber[100], + borderRadius: BorderRadius.circular(8), + ), + child: Text( + context.l10n.chat_pathHistoryFull, + style: const TextStyle(fontSize: 12), + ), + ), + ], + const SizedBox(height: 8), + ...pathsWithRepeaters.map((entry) { + final path = entry.value.value; + final color = entry.value.key; + if (!showAllPaths && entry.key < 1) { + return const SizedBox.shrink(); + } else { + return Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: ListTile( + dense: true, + leading: CircleAvatar( + radius: 16, + backgroundColor: color, + child: Text( + '${path.hopCount}', + style: const TextStyle(fontSize: 12), + ), + ), + title: Text( + '${path.hopCount} ${path.hopCount == 1 ? context.l10n.chat_hopSingular : context.l10n.chat_hopPlural}', + style: const TextStyle(fontSize: 14), + ), + subtitle: Text( + '${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(path.timestamp)} • ${path.successCount} ${context.l10n.chat_successes}', + style: const TextStyle(fontSize: 11), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.close, size: 16), + tooltip: context.l10n.chat_removePath, + onPressed: () async { + await pathService.removePathRecord( + widget.contact.publicKeyHex, + path.pathBytes, + ); + }, + ), + path.wasFloodDiscovery + ? const Icon( + Icons.waves, + size: 16, + color: Colors.grey, + ) + : const Icon( + Icons.route, + size: 16, + color: Colors.grey, + ), + ], + ), + onLongPress: () => + _showFullPathDialog(context, path.pathBytes), + onTap: () async { + if (path.pathBytes.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context + .l10n + .chat_pathDetailsNotAvailable, + ), + duration: const Duration(seconds: 2), + ), + ); + return; + } + + final pathBytes = Uint8List.fromList( + path.pathBytes, + ); + final pathLength = path.pathBytes.length; + + // Set the path override to persist user's choice + await connector.setPathOverride( + widget.contact, + pathLen: pathLength, + pathBytes: pathBytes, + ); + + if (!context.mounted) return; + Navigator.pop(context); + await _notifyPathSet( + connector, + widget.contact, + pathBytes, + path.hopCount, + ); + }, + ), + ); + } + }), + const Divider(), + ] else ...[ + Text(context.l10n.chat_noPathHistoryYet), + const Divider(), + ], + const SizedBox(height: 8), Text( - context.l10n.chat_recentAckPaths, + context.l10n.chat_pathActions, style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 12, ), ), - if (paths.length >= 100) ...[ - const SizedBox(height: 8), - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - decoration: BoxDecoration( - color: Colors.amber[100], - borderRadius: BorderRadius.circular(8), - ), - child: Text( - context.l10n.chat_pathHistoryFull, - style: const TextStyle(fontSize: 12), - ), - ), - ], const SizedBox(height: 8), - ...paths.map((path) { - return Card( - margin: const EdgeInsets.symmetric(vertical: 4), - child: ListTile( - dense: true, - leading: CircleAvatar( - radius: 16, - backgroundColor: path.wasFloodDiscovery - ? Colors.blue - : Colors.green, - child: Text( - '${path.hopCount}', - style: const TextStyle(fontSize: 12), - ), + ListTile( + dense: true, + leading: const CircleAvatar( + radius: 16, + backgroundColor: Colors.purple, + child: Icon(Icons.edit_road, size: 16), + ), + title: Text( + context.l10n.chat_setCustomPath, + style: const TextStyle(fontSize: 14), + ), + subtitle: Text( + context.l10n.chat_setCustomPathSubtitle, + style: const TextStyle(fontSize: 11), + ), + onTap: () { + Navigator.pop(context); + _showCustomPathDialog(context); + }, + ), + ListTile( + dense: true, + leading: const CircleAvatar( + radius: 16, + backgroundColor: Colors.orange, + child: Icon(Icons.clear_all, size: 16), + ), + title: Text( + context.l10n.chat_clearPath, + style: const TextStyle(fontSize: 14), + ), + subtitle: Text( + context.l10n.chat_clearPathSubtitle, + style: const TextStyle(fontSize: 11), + ), + onTap: () async { + await connector.clearContactPath(widget.contact); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.chat_pathCleared), + duration: const Duration(seconds: 2), ), - title: Text( - '${path.hopCount} ${path.hopCount == 1 ? context.l10n.chat_hopSingular : context.l10n.chat_hopPlural}', - style: const TextStyle(fontSize: 14), + ); + Navigator.pop(context); + }, + ), + ListTile( + dense: true, + leading: const CircleAvatar( + radius: 16, + backgroundColor: Colors.blue, + child: Icon(Icons.waves, size: 16), + ), + title: Text( + context.l10n.chat_forceFloodMode, + style: const TextStyle(fontSize: 14), + ), + subtitle: Text( + context.l10n.chat_floodModeSubtitle, + style: const TextStyle(fontSize: 11), + ), + onTap: () async { + await connector.setPathOverride( + widget.contact, + pathLen: -1, + ); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.chat_floodModeEnabled), + duration: const Duration(seconds: 2), ), - subtitle: Text( - '${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(path.timestamp)} • ${path.successCount} ${context.l10n.chat_successes}', - style: const TextStyle(fontSize: 11), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.close, size: 16), - tooltip: context.l10n.chat_removePath, - onPressed: () async { - await pathService.removePathRecord( - widget.contact.publicKeyHex, - path.pathBytes, - ); - }, - ), - path.wasFloodDiscovery - ? const Icon( - Icons.waves, - size: 16, - color: Colors.grey, - ) - : const Icon( - Icons.route, - size: 16, - color: Colors.grey, - ), - ], - ), - onLongPress: () => - _showFullPathDialog(context, path.pathBytes), - onTap: () async { - if (path.pathBytes.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.chat_pathDetailsNotAvailable, - ), - duration: const Duration(seconds: 2), - ), - ); - return; - } - - final pathBytes = Uint8List.fromList( - path.pathBytes, - ); - final pathLength = path.pathBytes.length; - - // Set the path override to persist user's choice - await connector.setPathOverride( - widget.contact, - pathLen: pathLength, - pathBytes: pathBytes, - ); - - if (!context.mounted) return; - Navigator.pop(context); - await _notifyPathSet( - connector, - widget.contact, - pathBytes, - path.hopCount, - ); - }, - ), - ); - }), - const Divider(), - ] else ...[ - Text(context.l10n.chat_noPathHistoryYet), - const Divider(), + ); + Navigator.pop(context); + }, + ), ], - const SizedBox(height: 8), - Text( - context.l10n.chat_pathActions, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 12, - ), - ), - const SizedBox(height: 8), - ListTile( - dense: true, - leading: const CircleAvatar( - radius: 16, - backgroundColor: Colors.purple, - child: Icon(Icons.edit_road, size: 16), - ), - title: Text( - context.l10n.chat_setCustomPath, - style: const TextStyle(fontSize: 14), - ), - subtitle: Text( - context.l10n.chat_setCustomPathSubtitle, - style: const TextStyle(fontSize: 11), - ), - onTap: () { - Navigator.pop(context); - _showCustomPathDialog(context); - }, - ), - ListTile( - dense: true, - leading: const CircleAvatar( - radius: 16, - backgroundColor: Colors.orange, - child: Icon(Icons.clear_all, size: 16), - ), - title: Text( - context.l10n.chat_clearPath, - style: const TextStyle(fontSize: 14), - ), - subtitle: Text( - context.l10n.chat_clearPathSubtitle, - style: const TextStyle(fontSize: 11), - ), - onTap: () async { - await connector.clearContactPath(widget.contact); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.chat_pathCleared), - duration: const Duration(seconds: 2), - ), - ); - Navigator.pop(context); - }, - ), - ListTile( - dense: true, - leading: const CircleAvatar( - radius: 16, - backgroundColor: Colors.blue, - child: Icon(Icons.waves, size: 16), - ), - title: Text( - context.l10n.chat_forceFloodMode, - style: const TextStyle(fontSize: 14), - ), - subtitle: Text( - context.l10n.chat_floodModeSubtitle, - style: const TextStyle(fontSize: 11), - ), - onTap: () async { - await connector.setPathOverride( - widget.contact, - pathLen: -1, - ); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.chat_floodModeEnabled), - duration: const Duration(seconds: 2), - ), - ); - Navigator.pop(context); - }, - ), - ], + ), ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(context.l10n.common_close), - ), - ], - ); - }, + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(context.l10n.common_close), + ), + ], + ); + }, + ), ), ); } @@ -1084,17 +1192,21 @@ class _MessageBubble extends StatelessWidget { final bool isRoomServer; final VoidCallback? onTap; final VoidCallback? onLongPress; + final double textScale; const _MessageBubble({ required this.message, required this.senderName, required this.isRoomServer, + required this.textScale, this.onTap, this.onLongPress, }); @override Widget build(BuildContext context) { + final settingsService = context.watch(); + final enableTracing = settingsService.settings.enableMessageTracing; final isOutgoing = message.isOutgoing; final colorScheme = Theme.of(context).colorScheme; final gifId = _parseGifId(message.text); @@ -1109,6 +1221,7 @@ class _MessageBubble extends StatelessWidget { ? colorScheme.onErrorContainer : (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface); final metaColor = textColor.withValues(alpha: 0.7); + const bodyFontSize = 14.0; String messageText = message.text; if (isRoomServer && !isOutgoing) { messageText = message.text.substring(4.clamp(0, message.text.length)); @@ -1172,102 +1285,180 @@ class _MessageBubble extends StatelessWidget { if (gifId == null) const SizedBox(height: 4), ], if (poi != null) - _buildPoiMessage(context, poi, textColor, metaColor) + _buildPoiMessage( + context, + poi, + textColor, + metaColor, + textScale, + trailing: (!enableTracing && isOutgoing) + ? Padding( + padding: const EdgeInsets.only(bottom: 2), + child: MessageStatusIcon( + isAcked: + message.status == + MessageStatus.delivered && + message.pathBytes.isNotEmpty, + isFailed: + message.status == + MessageStatus.failed, + ), + ) + : null, + ) else if (gifId != null) - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: GifMessage( - url: - 'https://media.giphy.com/media/$gifId/giphy.gif', - backgroundColor: Colors.transparent, - fallbackTextColor: textColor.withValues( - alpha: 0.7, + Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: GifMessage( + url: + 'https://media.giphy.com/media/$gifId/giphy.gif', + backgroundColor: Colors.transparent, + fallbackTextColor: textColor.withValues( + alpha: 0.7, + ), + ), ), - ), + if (!enableTracing && isOutgoing) + Positioned( + top: 0, + right: 0, + child: Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: bubbleColor, + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(10), + topRight: Radius.circular(12), + ), + ), + child: MessageStatusIcon( + isAcked: + message.status == + MessageStatus.delivered && + message.pathBytes.isNotEmpty, + isFailed: + message.status == + MessageStatus.failed, + ), + ), + ), + ], ) else - Linkify( - text: messageText, - style: TextStyle(color: textColor), - linkStyle: const TextStyle( - color: Colors.green, - decoration: TextDecoration.underline, - ), - options: const LinkifyOptions( - humanize: false, - defaultToHttps: false, - ), - linkifiers: const [UrlLinkifier()], - onOpen: (link) => - LinkHandler.handleLinkTap(context, link.url), - ), - if (isOutgoing && message.retryCount > 0) ...[ - const SizedBox(height: 4), - Padding( - padding: gifId != null - ? const EdgeInsets.symmetric(horizontal: 8) - : EdgeInsets.zero, - child: Text( - context.l10n.chat_retryCount( - message.retryCount, - 4, - ), - style: TextStyle( - fontSize: 10, - color: metaColor, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - const SizedBox(height: 4), - Padding( - padding: gifId != null - ? const EdgeInsets.only( - left: 8, - right: 8, - bottom: 4, - ) - : EdgeInsets.zero, - child: Wrap( - spacing: 4, - crossAxisAlignment: WrapCrossAlignment.center, + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, children: [ - Text( - _formatTime(message.timestamp), - style: TextStyle( - fontSize: 10, - color: metaColor, + Flexible( + child: Linkify( + text: messageText, + style: TextStyle( + color: textColor, + fontSize: bodyFontSize * textScale, + ), + linkStyle: TextStyle( + color: Colors.green, + decoration: TextDecoration.underline, + fontSize: bodyFontSize * textScale, + ), + options: const LinkifyOptions( + humanize: false, + defaultToHttps: false, + ), + linkifiers: const [UrlLinkifier()], + onOpen: (link) => LinkHandler.handleLinkTap( + context, + link.url, + ), ), ), - if (isOutgoing) ...[ + if (!enableTracing && isOutgoing) ...[ const SizedBox(width: 4), - _buildStatusIcon(metaColor), - ], - if (message.tripTimeMs != null && - message.status == - MessageStatus.delivered) ...[ - const SizedBox(width: 4), - Icon( - Icons.speed, - size: 10, - color: isOutgoing - ? metaColor - : Colors.green[700], - ), - Text( - '${(message.tripTimeMs! / 1000).toStringAsFixed(1)}s', - style: TextStyle( - fontSize: 9, - color: isOutgoing - ? metaColor - : Colors.green[700], + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: MessageStatusIcon( + isAcked: + message.status == + MessageStatus.delivered && + message.pathBytes.isNotEmpty, + isFailed: + message.status == MessageStatus.failed, ), ), ], ], ), - ), + if (enableTracing) ...[ + if (isOutgoing && message.retryCount > 0) ...[ + const SizedBox(height: 4), + Padding( + padding: gifId != null + ? const EdgeInsets.symmetric(horizontal: 8) + : EdgeInsets.zero, + child: Text( + context.l10n.chat_retryCount( + message.retryCount, + 4, + ), + style: TextStyle( + fontSize: 10, + color: metaColor, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + const SizedBox(height: 4), + Padding( + padding: gifId != null + ? const EdgeInsets.only( + left: 8, + right: 8, + bottom: 4, + ) + : EdgeInsets.zero, + child: Wrap( + spacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + _formatTime(message.timestamp), + style: TextStyle( + fontSize: 10, + color: metaColor, + ), + ), + if (isOutgoing) ...[ + const SizedBox(width: 4), + _buildStatusIcon(metaColor), + ], + if (message.tripTimeMs != null && + message.status == + MessageStatus.delivered) ...[ + const SizedBox(width: 4), + Icon( + Icons.speed, + size: 10, + color: isOutgoing + ? metaColor + : Colors.green[700], + ), + Text( + '${(message.tripTimeMs! / 1000).toStringAsFixed(1)}s', + style: TextStyle( + fontSize: 9, + color: isOutgoing + ? metaColor + : Colors.green[700], + ), + ), + ], + ], + ), + ), + ], ], ), ), @@ -1311,7 +1502,9 @@ class _MessageBubble extends StatelessWidget { _PoiInfo poi, Color textColor, Color metaColor, - ) { + double textScale, { + Widget? trailing, + }) { return Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -1338,16 +1531,21 @@ class _MessageBubble extends StatelessWidget { children: [ Text( context.l10n.chat_poiShared, - style: TextStyle(color: textColor, fontWeight: FontWeight.w600), + style: TextStyle( + color: textColor, + fontWeight: FontWeight.w600, + fontSize: 14 * textScale, + ), ), if (poi.label.isNotEmpty) Text( poi.label, - style: TextStyle(color: metaColor, fontSize: 12), + style: TextStyle(color: metaColor, fontSize: 12 * textScale), ), ], ), ), + if (trailing != null) ...[const SizedBox(width: 4), trailing], ], ); } diff --git a/lib/screens/community_qr_scanner_screen.dart b/lib/screens/community_qr_scanner_screen.dart index a2914a1..9f8602d 100644 --- a/lib/screens/community_qr_scanner_screen.dart +++ b/lib/screens/community_qr_scanner_screen.dart @@ -6,6 +6,7 @@ import '../connector/meshcore_connector.dart'; import '../l10n/l10n.dart'; import '../models/community.dart'; import '../storage/community_store.dart'; +import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/qr_scanner_widget.dart'; /// Screen for scanning community QR codes to join communities. @@ -29,7 +30,7 @@ class _CommunityQrScannerScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text(context.l10n.community_scanQr), + title: AdaptiveAppBarTitle(context.l10n.community_scanQr), centerTitle: true, ), body: _isProcessing diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 6799d69..6c683cc 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:meshcore_open/screens/path_trace_map.dart'; +import 'package:meshcore_open/utils/app_logger.dart'; +import 'package:meshcore_open/widgets/app_bar.dart'; import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; @@ -16,7 +18,6 @@ import '../utils/dialog_utils.dart'; import '../utils/disconnect_navigation_mixin.dart'; import '../utils/emoji_utils.dart'; import '../utils/route_transitions.dart'; -import '../widgets/battery_indicator.dart'; import '../widgets/list_filter_widget.dart'; import '../widgets/empty_state.dart'; import '../widgets/quick_switch_bar.dart'; @@ -90,79 +91,90 @@ class _ContactsScreenState extends State _frameSubscription = connector.receivedFrames.listen((frame) { if (frame.isEmpty) return; final frameBuffer = BufferReader(frame); - final code = frameBuffer.readUInt8(); + try { + final code = frameBuffer.readUInt8(); - if (code == respCodeExportContact) { - final advertPacket = frameBuffer.readRemainingBytes(); - // Validate packet has expected minimum size (98+ bytes per protocol) - if (advertPacket.length < 98) { - if (mounted) { + if (code == respCodeExportContact) { + final advertPacket = frameBuffer.readRemainingBytes(); + // Validate packet has expected minimum size (98+ bytes per protocol) + if (advertPacket.length < 98) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.contacts_invalidAdvertFormat), + ), + ); + } + _pendingOperations.remove(ContactOperationType.export); + return; + } + final hexString = pubKeyToHex(advertPacket); + Clipboard.setData(ClipboardData(text: "meshcore://$hexString")); + } + + if (code == respCodeOk) { + // Show a snackbar indicating success + if (!mounted) return; + + if (_pendingOperations.contains(ContactOperationType.import)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.contacts_contactImported)), + ); + } + + if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(context.l10n.contacts_invalidAdvertFormat), + content: Text(context.l10n.contacts_zeroHopContactAdvertSent), ), ); } - _pendingOperations.remove(ContactOperationType.export); - return; - } - final hexString = pubKeyToHex(advertPacket); - Clipboard.setData(ClipboardData(text: "meshcore://$hexString")); - } - if (code == respCodeOk) { - // Show a snackbar indicating success - if (!mounted) return; + if (_pendingOperations.contains(ContactOperationType.export)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.contacts_contactAdvertCopied), + ), + ); + } - if (_pendingOperations.contains(ContactOperationType.import)) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.contacts_contactImported)), - ); + _pendingOperations.clear(); } - if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.contacts_zeroHopContactAdvertSent), - ), - ); + if (code == respCodeErr) { + // Show a snackbar indicating failure + if (!mounted) return; + + if (_pendingOperations.contains(ContactOperationType.import)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.contacts_contactImportFailed), + ), + ); + } + + if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.contacts_zeroHopContactAdvertFailed), + ), + ); + } + if (_pendingOperations.contains(ContactOperationType.export)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.contacts_contactAdvertCopyFailed), + ), + ); + } + + _pendingOperations.clear(); } - - if (_pendingOperations.contains(ContactOperationType.export)) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.contacts_contactAdvertCopied)), - ); - } - - _pendingOperations.clear(); - } - - if (code == respCodeErr) { - // Show a snackbar indicating failure - if (!mounted) return; - - if (_pendingOperations.contains(ContactOperationType.import)) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.contacts_contactImportFailed)), - ); - } - - if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.contacts_zeroHopContactAdvertFailed), - ), - ); - } - if (_pendingOperations.contains(ContactOperationType.export)) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.contacts_contactAdvertCopyFailed), - ), - ); - } - - _pendingOperations.clear(); + } catch (e) { + appLogger.error( + 'Error processing received frame: $e', + tag: 'ContactsScreen', + ); } }); } @@ -171,14 +183,17 @@ class _ContactsScreenState extends State final connector = Provider.of(context, listen: false); final exportContactFrame = buildExportContactFrame(pubKey); _pendingOperations.add(ContactOperationType.export); - await connector.sendFrame(exportContactFrame); + await connector.sendFrame(exportContactFrame, expectsGenericAck: true); } Future _contactZeroHop(Uint8List pubKey) async { final connector = Provider.of(context, listen: false); final exportContactZeroHopFrame = buildZeroHopContact(pubKey); _pendingOperations.add(ContactOperationType.zeroHopShare); - await connector.sendFrame(exportContactZeroHopFrame); + await connector.sendFrame( + exportContactZeroHopFrame, + expectsGenericAck: true, + ); } Future _contactImport() async { @@ -205,7 +220,7 @@ class _ContactsScreenState extends State try { final importContactFrame = buildImportContactFrame(hexString); _pendingOperations.add(ContactOperationType.import); - await connector.sendFrame(importContactFrame); + await connector.sendFrame(importContactFrame, expectsGenericAck: true); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -229,9 +244,7 @@ class _ContactsScreenState extends State canPop: allowBack, child: Scaffold( appBar: AppBar( - leading: BatteryIndicator(connector: connector), - title: Text(context.l10n.contacts_title), - centerTitle: true, + title: AppBarTitle(context.l10n.contacts_title), automaticallyImplyLeading: false, actions: [ PopupMenuButton( @@ -468,6 +481,7 @@ class _ContactsScreenState extends State contact: contact, lastSeen: _resolveLastSeen(contact), unreadCount: unreadCount, + isFavorite: contact.isFavorite, onTap: () => _openChat(context, contact), onLongPress: () => _showContactOptions(context, connector, contact), @@ -504,6 +518,8 @@ class _ContactsScreenState extends State }) .where((group) { if (_typeFilter == ContactTypeFilter.all) return true; + // Groups don't have a favorite flag, so hide them under favorites filter + if (_typeFilter == ContactTypeFilter.favorites) return false; for (final key in group.memberKeys) { final contact = contactsByKey[key]; if (contact != null && _matchesTypeFilter(contact)) return true; @@ -578,6 +594,8 @@ class _ContactsScreenState extends State switch (_typeFilter) { case ContactTypeFilter.all: return true; + case ContactTypeFilter.favorites: + return contact.isFavorite; case ContactTypeFilter.users: return contact.type == advTypeChat; case ContactTypeFilter.repeaters: @@ -968,6 +986,7 @@ class _ContactsScreenState extends State ) { final isRepeater = contact.type == advTypeRepeater; final isRoom = contact.type == advTypeRoom; + final isFavorite = contact.isFavorite; showModalBottomSheet( context: context, @@ -1074,6 +1093,21 @@ class _ContactsScreenState extends State }, ), ], + ListTile( + leading: Icon( + isFavorite ? Icons.star : Icons.star_border, + color: Colors.amber[700], + ), + title: Text( + isFavorite + ? context.l10n.listFilter_removeFromFavorites + : context.l10n.listFilter_addToFavorites, + ), + onTap: () async { + Navigator.pop(sheetContext); + await connector.setContactFavorite(contact, !isFavorite); + }, + ), ListTile( leading: const Icon(Icons.copy), title: Text(context.l10n.contacts_ShareContact), @@ -1142,6 +1176,7 @@ class _ContactTile extends StatelessWidget { final Contact contact; final DateTime lastSeen; final int unreadCount; + final bool isFavorite; final VoidCallback onTap; final VoidCallback onLongPress; @@ -1149,6 +1184,7 @@ class _ContactTile extends StatelessWidget { required this.contact, required this.lastSeen, required this.unreadCount, + required this.isFavorite, required this.onTap, required this.onLongPress, }); @@ -1160,12 +1196,17 @@ class _ContactTile extends StatelessWidget { backgroundColor: _getTypeColor(contact.type), child: _buildContactAvatar(contact), ), - title: Text(contact.name), + title: Text(contact.name, maxLines: 1, overflow: TextOverflow.ellipsis), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(contact.pathLabel), - Text(contact.shortPubKeyHex, style: TextStyle(fontSize: 12)), + Text(contact.pathLabel, maxLines: 1, overflow: TextOverflow.ellipsis), + Text( + contact.shortPubKeyHex, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12), + ), ], ), // Clamp text scaling in trailing section to prevent overflow while @@ -1176,26 +1217,36 @@ class _ContactTile extends StatelessWidget { MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3), ), ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (unreadCount > 0) ...[ - UnreadBadge(count: unreadCount), - const SizedBox(height: 4), - ], - Text( - _formatLastSeen(context, lastSeen), - style: TextStyle(fontSize: 12, color: Colors.grey[600]), - ), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (contact.hasLocation) - Icon(Icons.location_on, size: 14, color: Colors.grey[400]), + child: SizedBox( + width: 120, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (unreadCount > 0) ...[ + UnreadBadge(count: unreadCount), + const SizedBox(height: 4), ], - ), - ], + Text( + _formatLastSeen(context, lastSeen), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isFavorite) + Icon(Icons.star, size: 14, color: Colors.amber[700]), + if (isFavorite && contact.hasLocation) + const SizedBox(width: 2), + if (contact.hasLocation) + Icon(Icons.location_on, size: 14, color: Colors.grey[400]), + ], + ), + ], + ), ), ), onTap: onTap, diff --git a/lib/screens/line_of_sight_map_screen.dart b/lib/screens/line_of_sight_map_screen.dart new file mode 100644 index 0000000..ec8a391 --- /dev/null +++ b/lib/screens/line_of_sight_map_screen.dart @@ -0,0 +1,1307 @@ +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:provider/provider.dart'; + +import '../l10n/l10n.dart'; +import '../screens/channels_screen.dart'; +import '../screens/contacts_screen.dart'; +import '../models/app_settings.dart'; +import '../services/app_settings_service.dart'; +import '../services/line_of_sight_service.dart'; +import '../services/map_tile_cache_service.dart'; +import '../utils/route_transitions.dart'; +import '../connector/meshcore_connector.dart'; +import '../widgets/app_bar.dart'; +import '../widgets/quick_switch_bar.dart'; +import '../icons/los_icon.dart'; + +class LineOfSightEndpoint { + final String label; + final LatLng point; + final Color color; + final IconData icon; + final bool isCustom; + + const LineOfSightEndpoint({ + required this.label, + required this.point, + this.color = Colors.green, + this.icon = Icons.location_on, + this.isCustom = false, + }); +} + +class LineOfSightMapScreen extends StatefulWidget { + final String title; + final List candidates; + + const LineOfSightMapScreen({ + super.key, + required this.title, + required this.candidates, + }); + + @override + State createState() => _LineOfSightMapScreenState(); +} + +class _LineOfSightMapScreenState extends State { + static const String _errorSelectStartEnd = 'los_error_select_start_end'; + static const double _metersToFeet = 3.28084; + static const double _kmToMiles = 0.621371; + static const double _maxAntennaFeet = 400.0; + static const double _maxAntennaMeters = _maxAntennaFeet / _metersToFeet; + static const double _labelZoomThreshold = 8.5; + + final LineOfSightService _lineOfSightService = LineOfSightService(); + + bool _loading = false; + String? _error; + LineOfSightPathResult? _result; + LineOfSightEndpoint? _start; + LineOfSightEndpoint? _end; + final List _customEndpoints = []; + double _startAntennaHeight = 5.0; + double _endAntennaHeight = 5.0; + bool _showHud = true; + bool _menuExpanded = true; + bool _showDisplayNodes = true; + bool _showMarkerLabels = true; + bool _didReceivePositionUpdate = false; + int _losRequestNonce = 0; + bool _initialLosScheduled = false; + + @override + void initState() { + super.initState(); + if (widget.candidates.isNotEmpty) { + _start = widget.candidates.first; + if (widget.candidates.length > 1) { + _end = widget.candidates[1]; + } + } + _scheduleInitialRun(); + } + + void _scheduleInitialRun() { + if (_initialLosScheduled) return; + _initialLosScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _runLos(); + }); + } + + @override + void dispose() { + _lineOfSightService.dispose(); + super.dispose(); + } + + Future _runLos() async { + final start = _start; + final end = _end; + final startAntenna = _startAntennaHeight; + final endAntenna = _endAntennaHeight; + final requestId = ++_losRequestNonce; + if (start == null || end == null) { + setState(() { + _result = null; + _error = _errorSelectStartEnd; + }); + return; + } + + setState(() { + _loading = true; + _error = null; + }); + + try { + final connector = context.read(); + final frequencyMHz = _normalizeFrequencyMHz(connector.currentFreqHz); + final result = await _lineOfSightService.analyzePath( + [start.point, end.point], + startAntennaHeightMeters: startAntenna, + endAntennaHeightMeters: endAntenna, + frequencyMHz: frequencyMHz, + ); + if (!mounted) return; + if (!_isRunRequestCurrent( + requestId: requestId, + start: start, + end: end, + startAntenna: startAntenna, + endAntenna: endAntenna, + )) { + return; + } + setState(() { + _result = result; + }); + } catch (e) { + if (!mounted) return; + if (!_isRunRequestCurrent( + requestId: requestId, + start: start, + end: end, + startAntenna: startAntenna, + endAntenna: endAntenna, + )) { + return; + } + setState(() { + _result = null; + _error = context.l10n.losRunFailed(e.toString()); + }); + } finally { + if (mounted && requestId == _losRequestNonce) { + setState(() { + _loading = false; + }); + } + } + } + + bool _isRunRequestCurrent({ + required int requestId, + required LineOfSightEndpoint start, + required LineOfSightEndpoint end, + required double startAntenna, + required double endAntenna, + }) { + return requestId == _losRequestNonce && + identical(_start, start) && + identical(_end, end) && + _startAntennaHeight == startAntenna && + _endAntennaHeight == endAntenna; + } + + void _selectFromMap(LineOfSightEndpoint endpoint) { + setState(() { + _result = null; + _error = null; + if (_start == null || (_start != null && _end != null)) { + _start = endpoint; + if (_end == endpoint) _end = null; + } else { + _end = endpoint; + if (_start == endpoint) _start = null; + } + }); + + if (_start != null && _end != null) { + _runLos(); + } + } + + void _addCustomPoint(LatLng point) { + final endpoint = LineOfSightEndpoint( + label: context.l10n.losCustomPointLabel(_customEndpoints.length + 1), + point: point, + color: Colors.orange, + icon: Icons.push_pin, + isCustom: true, + ); + setState(() { + _customEndpoints.add(endpoint); + }); + _selectFromMap(endpoint); + } + + List _visibleEndpoints() { + return [if (_showDisplayNodes) ...widget.candidates, ..._customEndpoints]; + } + + bool _hasEndpoint( + List endpoints, + LineOfSightEndpoint? e, + ) { + if (e == null) return false; + return endpoints.any((item) => identical(item, e)); + } + + void _sanitizeSelection() { + final visible = _visibleEndpoints(); + if (!_hasEndpoint(visible, _start)) { + _start = null; + } + if (!_hasEndpoint(visible, _end)) { + _end = null; + } + } + + void _clearAllPoints() { + setState(() { + _customEndpoints.clear(); + _start = null; + _end = null; + _result = null; + _error = _errorSelectStartEnd; + }); + } + + void _deleteCustomPoint(LineOfSightEndpoint endpoint) { + setState(() { + _customEndpoints.removeWhere((e) => identical(e, endpoint)); + if (identical(_start, endpoint)) _start = null; + if (identical(_end, endpoint)) _end = null; + _result = null; + }); + } + + Future _renameCustomPoint(LineOfSightEndpoint endpoint) async { + final controller = TextEditingController(text: endpoint.label); + final newLabel = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(context.l10n.losRenameCustomPoint), + content: TextField( + controller: controller, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: context.l10n.losPointName, + ), + autofocus: true, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(context.l10n.common_cancel), + ), + TextButton( + onPressed: () { + final value = controller.text.trim(); + Navigator.pop(dialogContext, value); + }, + child: Text(context.l10n.common_save), + ), + ], + ), + ); + + if (newLabel == null || newLabel.isEmpty) return; + final index = _customEndpoints.indexWhere((e) => identical(e, endpoint)); + if (index < 0) return; + final renamed = LineOfSightEndpoint( + label: newLabel, + point: endpoint.point, + color: endpoint.color, + icon: endpoint.icon, + isCustom: endpoint.isCustom, + ); + setState(() { + _customEndpoints[index] = renamed; + if (identical(_start, endpoint)) _start = renamed; + if (identical(_end, endpoint)) _end = renamed; + }); + } + + @override + Widget build(BuildContext context) { + final settings = context.watch().settings; + final isImperial = settings.unitSystem == UnitSystem.imperial; + final tileCache = context.read(); + final endpoints = _visibleEndpoints(); + final mapPoints = [ + if (_start != null) _start!.point, + if (_end != null) _end!.point, + ]; + final initialCenter = mapPoints.isNotEmpty + ? mapPoints.first + : const LatLng(0, 0); + final bounds = mapPoints.length > 1 + ? LatLngBounds.fromPoints(mapPoints) + : null; + final initialZoom = mapPoints.length > 1 ? 13.0 : 2.0; + if (!_didReceivePositionUpdate) { + _showMarkerLabels = initialZoom >= _labelZoomThreshold; + } + + return Scaffold( + appBar: AppBar( + title: AppBarTitle(widget.title), + centerTitle: true, + actions: [ + IconButton( + icon: _loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.delete_outline), + onPressed: _loading ? null : _clearAllPoints, + tooltip: context.l10n.losClearAllPoints, + ), + ], + ), + body: Stack( + children: [ + FlutterMap( + options: MapOptions( + initialCenter: initialCenter, + initialZoom: initialZoom, + initialCameraFit: bounds == null + ? null + : CameraFit.bounds( + bounds: bounds, + padding: const EdgeInsets.all(64), + maxZoom: 16, + ), + interactionOptions: InteractionOptions( + flags: ~InteractiveFlag.rotate, + ), + onLongPress: (_, point) => _addCustomPoint(point), + onPositionChanged: (camera, hasGesture) { + final shouldShow = camera.zoom >= _labelZoomThreshold; + if (!_didReceivePositionUpdate || + shouldShow != _showMarkerLabels) { + setState(() { + _didReceivePositionUpdate = true; + _showMarkerLabels = shouldShow; + }); + } + }, + ), + children: [ + TileLayer( + urlTemplate: kMapTileUrlTemplate, + tileProvider: tileCache.tileProvider, + userAgentPackageName: MapTileCacheService.userAgentPackageName, + maxZoom: 19, + ), + if (_result != null && _result!.segments.isNotEmpty) + PolylineLayer(polylines: _buildSegmentPolylines(_result!)), + MarkerLayer(markers: _buildMarkers(endpoints)), + ], + ), + if (_showHud) + Positioned( + left: 12, + right: 12, + top: 12, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.52, + ), + child: _buildControlPanel(isImperial), + ), + ), + if (!_showHud && _result != null && _result!.segments.isNotEmpty) + Positioned( + left: 12, + bottom: 12, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: Text( + context.l10n.losElevationAttribution, + style: const TextStyle(fontSize: 10, color: Colors.white), + ), + ), + ), + ), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + setState(() { + _showHud = !_showHud; + }); + }, + tooltip: _showHud + ? context.l10n.losHidePanelTooltip + : context.l10n.losShowPanelTooltip, + child: Icon(_showHud ? Icons.visibility_off : Icons.tune), + ), + bottomNavigationBar: SafeArea( + top: false, + child: QuickSwitchBar( + selectedIndex: 2, + onDestinationSelected: (index) => _handleQuickSwitch(index, context), + ), + ), + ); + } + + Widget _buildControlPanel(bool isImperial) { + _sanitizeSelection(); + final segment = _primarySegmentResult(); + final connector = context.read(); + final reportedFrequencyMHz = _normalizeFrequencyMHz( + connector.currentFreqHz, + ); + final displayFrequencyMHz = segment?.frequencyMHz ?? reportedFrequencyMHz; + final kFactorUsed = segment?.usedKFactor; + final endpoints = _visibleEndpoints(); + final distanceUnit = isImperial ? 'mi' : 'km'; + final heightUnit = isImperial ? 'ft' : 'm'; + final antennaAMeters = _startAntennaHeight; + final antennaBMeters = _endAntennaHeight; + final antennaADisplay = _toDisplayHeight(antennaAMeters, isImperial); + final antennaBDisplay = _toDisplayHeight(antennaBMeters, isImperial); + final antennaSliderMax = isImperial ? _maxAntennaFeet : _maxAntennaMeters; + final antennaSliderDivisions = isImperial ? 400 : 122; + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (segment != null) + SizedBox( + height: 160, + width: double.infinity, + child: CustomPaint( + painter: _LosProfilePainter( + samples: segment.samples, + distanceUnit: distanceUnit, + heightUnit: heightUnit, + badgeTextStyle: + Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.white70, + fontSize: 10, + fontWeight: FontWeight.w600, + ) ?? + const TextStyle( + color: Colors.white70, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + terrainLabel: context.l10n.losLegendTerrain, + losBeamLabel: context.l10n.losLegendLosBeam, + radioHorizonLabel: context.l10n.losLegendRadioHorizon, + ), + ), + ) + else + SizedBox( + height: 44, + child: Center( + child: Text( + context.l10n.losRunToViewElevationProfile, + style: const TextStyle(fontSize: 11), + ), + ), + ), + if (segment != null) ...[ + const SizedBox(height: 8), + _LosLegend( + terrainLabel: context.l10n.losLegendTerrain, + losBeamLabel: context.l10n.losLegendLosBeam, + radioHorizonLabel: context.l10n.losLegendRadioHorizon, + ), + ], + const SizedBox(height: 8), + Text( + segment != null + ? _profileStats(segment, isImperial) + : _statusText(), + style: TextStyle( + fontSize: 12, + color: segment != null + ? (segment.isClear ? Colors.green : Colors.red) + : _statusColor(), + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + if (displayFrequencyMHz != null) + Padding( + padding: const EdgeInsets.only(top: 2, bottom: 4), + child: Row( + children: [ + Text( + context.l10n.losFrequencyLabel, + style: TextStyle( + fontSize: 11, + color: Colors.grey[700], + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 8), + Text( + '${displayFrequencyMHz.toStringAsFixed(3)} MHz', + style: TextStyle(fontSize: 11, color: Colors.grey[700]), + ), + if (kFactorUsed != null) ...[ + const SizedBox(width: 8), + Text( + 'k=${kFactorUsed.toStringAsFixed(3)}', + style: TextStyle( + fontSize: 11, + color: Colors.grey[700], + ), + ), + const SizedBox(width: 4), + IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + icon: const Icon(Icons.info_outline, size: 16), + color: Colors.grey[600], + tooltip: context.l10n.losFrequencyInfoTooltip, + onPressed: () { + _showFrequencyInfoDialog( + context, + displayFrequencyMHz, + kFactorUsed, + ); + }, + ), + ], + ], + ), + ), + Text( + context.l10n.losElevationAttribution, + style: TextStyle(fontSize: 10, color: Colors.grey[700]), + ), + const SizedBox(height: 6), + ExpansionTile( + initiallyExpanded: _menuExpanded, + onExpansionChanged: (value) { + setState(() { + _menuExpanded = value; + }); + }, + tilePadding: EdgeInsets.zero, + childrenPadding: EdgeInsets.zero, + title: Text( + context.l10n.losMenuTitle, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + subtitle: Text( + context.l10n.losMenuSubtitle, + style: const TextStyle(fontSize: 11), + ), + children: [ + SwitchListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: Text( + context.l10n.losShowDisplayNodes, + style: const TextStyle(fontSize: 12), + ), + value: _showDisplayNodes, + onChanged: (value) { + setState(() { + _showDisplayNodes = value; + _sanitizeSelection(); + _result = null; + }); + }, + ), + if (_customEndpoints.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + context.l10n.losCustomPoints, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + for (final point in _customEndpoints) + ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: Text( + point.label, + style: const TextStyle(fontSize: 12), + ), + subtitle: Text( + '${point.point.latitude.toStringAsFixed(5)}, ${point.point.longitude.toStringAsFixed(5)}', + style: const TextStyle(fontSize: 11), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit, size: 18), + onPressed: () => _renameCustomPoint(point), + tooltip: context.l10n.common_edit, + ), + IconButton( + icon: const Icon(Icons.delete_outline, size: 18), + onPressed: () => _deleteCustomPoint(point), + tooltip: context.l10n.common_delete, + ), + ], + ), + ), + ], + const SizedBox(height: 8), + _buildEndpointRow( + label: context.l10n.losPointA, + value: _start, + candidates: endpoints, + onChanged: (value) { + setState(() { + _start = value; + _result = null; + }); + if (_start != null && _end != null) { + _runLos(); + } + }, + ), + const SizedBox(height: 8), + _buildEndpointRow( + label: context.l10n.losPointB, + value: _end, + candidates: endpoints, + onChanged: (value) { + setState(() { + _end = value; + _result = null; + }); + if (_start != null && _end != null) { + _runLos(); + } + }, + ), + const SizedBox(height: 10), + Text( + context.l10n.losAntennaA( + antennaADisplay.toStringAsFixed(1), + heightUnit, + ), + style: const TextStyle(fontSize: 12), + ), + Slider( + value: antennaADisplay, + min: 0, + max: antennaSliderMax, + divisions: antennaSliderDivisions, + onChanged: (value) { + setState(() { + _startAntennaHeight = _toMetersHeight( + value, + isImperial, + ); + }); + }, + ), + Text( + context.l10n.losAntennaB( + antennaBDisplay.toStringAsFixed(1), + heightUnit, + ), + style: const TextStyle(fontSize: 12), + ), + Slider( + value: antennaBDisplay, + min: 0, + max: antennaSliderMax, + divisions: antennaSliderDivisions, + onChanged: (value) { + setState(() { + _endAntennaHeight = _toMetersHeight(value, isImperial); + }); + }, + ), + Align( + alignment: Alignment.centerRight, + child: ElevatedButton.icon( + onPressed: _loading ? null : _runLos, + icon: const LosIcon(), + label: Text(context.l10n.losRun), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildEndpointRow({ + required String label, + required LineOfSightEndpoint? value, + required List candidates, + required ValueChanged onChanged, + }) { + return Row( + children: [ + SizedBox( + width: 54, + child: Text(label, style: const TextStyle(fontSize: 12)), + ), + Expanded( + child: DropdownButton( + value: value, + isExpanded: true, + items: candidates + .map( + (e) => DropdownMenuItem( + value: e, + child: Text(e.label, overflow: TextOverflow.ellipsis), + ), + ) + .toList(), + onChanged: onChanged, + ), + ), + ], + ); + } + + LineOfSightResult? _primarySegmentResult() { + if (_result == null || _result!.segments.isEmpty) return null; + return _result!.segments.first.result; + } + + String _profileStats(LineOfSightResult result, bool isImperial) { + final distance = isImperial + ? (result.totalDistanceMeters / 1000.0) * _kmToMiles + : result.totalDistanceMeters / 1000.0; + final distanceUnit = isImperial ? 'mi' : 'km'; + final heightUnit = isImperial ? 'ft' : 'm'; + final minClearance = result.samples.isEmpty + ? 0.0 + : result.samples.map((s) => s.clearanceMeters).reduce(math.min); + final minClearanceDisplay = isImperial + ? minClearance * _metersToFeet + : minClearance; + final maxObstructionDisplay = isImperial + ? result.maxObstructionMeters * _metersToFeet + : result.maxObstructionMeters; + if (!result.hasData) { + return _localizedLosError(result.errorMessage); + } + if (result.isClear) { + return context.l10n.losProfileClear( + distance.toStringAsFixed(1), + distanceUnit, + minClearanceDisplay.toStringAsFixed(1), + heightUnit, + ); + } + return context.l10n.losProfileBlocked( + distance.toStringAsFixed(1), + distanceUnit, + maxObstructionDisplay.toStringAsFixed(1), + heightUnit, + ); + } + + List _buildSegmentPolylines(LineOfSightPathResult result) { + final polylines = []; + for (final segment in result.segments) { + final color = !segment.result.hasData + ? Colors.grey + : (segment.result.isClear ? Colors.green : Colors.red); + polylines.add( + Polyline( + points: [segment.start, segment.end], + strokeWidth: 4, + color: color, + ), + ); + } + return polylines; + } + + List _buildMarkers(List endpoints) { + return [ + for (final endpoint in endpoints) + Marker( + point: endpoint.point, + width: 36, + height: 36, + child: GestureDetector( + onTap: () => _selectFromMap(endpoint), + child: Container( + decoration: BoxDecoration( + color: endpoint.color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: const [ + BoxShadow(color: Colors.black26, blurRadius: 4), + ], + ), + child: Stack( + children: [ + Center( + child: Icon(endpoint.icon, color: Colors.white, size: 16), + ), + if (endpoint == _start || endpoint == _end) + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 14, + height: 14, + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(7), + border: Border.all(color: Colors.white, width: 1), + ), + alignment: Alignment.center, + child: Text( + endpoint == _start ? 'A' : 'B', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 9, + ), + ), + ), + ), + ], + ), + ), + ), + ), + for (final endpoint in endpoints) + if (_showMarkerLabels) + Marker( + point: endpoint.point, + width: 120, + height: 24, + alignment: Alignment.topCenter, + child: IgnorePointer( + child: Transform.translate( + offset: const Offset(0, -20), + child: FittedBox( + fit: BoxFit.contain, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: Text( + endpoint.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ), + ), + ]; + } + + String _statusText() { + if (_loading) return context.l10n.losStatusChecking; + if (_error == _errorSelectStartEnd) { + return context.l10n.losSelectStartEnd; + } + if (_error != null) return _error!; + if (_result == null) return context.l10n.losStatusNoData; + final total = _result!.segments.length; + return context.l10n.losStatusSummary( + _result!.clearSegments, + total, + _result!.blockedSegments, + _result!.unknownSegments, + ); + } + + Color _statusColor() { + if (_error != null) return Colors.red; + if (_loading) return Colors.orange; + if (_result == null) return Colors.grey; + if (_result!.blockedSegments > 0) return Colors.red; + if (_result!.clearSegments > 0) return Colors.green; + return Colors.grey; + } + + double _toDisplayHeight(double meters, bool isImperial) { + return isImperial ? meters * _metersToFeet : meters; + } + + double _toMetersHeight(double displayHeight, bool isImperial) { + return isImperial ? displayHeight / _metersToFeet : displayHeight; + } + + String _localizedLosError(String? message) { + if (message == LineOfSightService.errorElevationUnavailable) { + return context.l10n.losErrorElevationUnavailable; + } + if (message == LineOfSightService.errorInvalidInput) { + return context.l10n.losErrorInvalidInput; + } + return context.l10n.losNoElevationData; + } + + void _handleQuickSwitch(int index, BuildContext context) { + if (index == 2) { + Navigator.pop(context); + return; + } + switch (index) { + case 0: + Navigator.pushReplacement( + context, + buildQuickSwitchRoute(const ContactsScreen(hideBackButton: true)), + ); + break; + case 1: + Navigator.pushReplacement( + context, + buildQuickSwitchRoute(const ChannelsScreen(hideBackButton: true)), + ); + break; + } + } + + void _showFrequencyInfoDialog( + BuildContext context, + double frequencyMHz, + double kFactor, + ) { + final baselineFreq = LineOfSightService.baselineFrequencyMHz; + final baselineK = LineOfSightService.baselineKFactor; + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(context.l10n.losFrequencyDialogTitle), + content: Text( + context.l10n.losFrequencyDialogDescription( + baselineK, + baselineFreq, + frequencyMHz, + kFactor, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(context.l10n.common_ok), + ), + ], + ), + ); + } + + double? _normalizeFrequencyMHz(int? frequencyKHz) { + if (frequencyKHz == null || frequencyKHz <= 0) return null; + return frequencyKHz / 1000.0; + } +} + +class _LosProfilePainter extends CustomPainter { + final List samples; + final String distanceUnit; + final String heightUnit; + final TextStyle badgeTextStyle; + final String terrainLabel; + final String losBeamLabel; + final String radioHorizonLabel; + + const _LosProfilePainter({ + required this.samples, + required this.distanceUnit, + required this.heightUnit, + required this.badgeTextStyle, + required this.terrainLabel, + required this.losBeamLabel, + required this.radioHorizonLabel, + }); + + @override + void paint(Canvas canvas, Size size) { + final bg = Paint()..color = const Color(0xFF243A63); + canvas.drawRect(Offset.zero & size, bg); + _drawUnitBadge(canvas, size); + + if (samples.length < 2) return; + + final minY = samples + .map( + (s) => math.min( + math.min(s.terrainMeters, s.lineHeightMeters), + s.refractedHeightMeters, + ), + ) + .reduce(math.min); + final maxY = samples + .map( + (s) => math.max( + math.max(s.terrainMeters, s.lineHeightMeters), + s.refractedHeightMeters, + ), + ) + .reduce(math.max); + final ySpan = math.max(1.0, maxY - minY); + final maxDist = math.max(1.0, samples.last.distanceMeters); + const horizontalPadding = 12.0; + const verticalPadding = 12.0; + final chartWidth = math.max(1.0, size.width - horizontalPadding * 2); + final chartHeight = math.max(1.0, size.height - verticalPadding * 2); + + Offset mapPoint(double x, double y) { + final px = horizontalPadding + (x / maxDist) * chartWidth; + final py = + size.height - verticalPadding - ((y - minY) / ySpan) * chartHeight; + return Offset(px, py); + } + + final firstTerrainPoint = mapPoint( + samples.first.distanceMeters, + samples.first.terrainMeters, + ); + final lastTerrainPoint = mapPoint( + samples.last.distanceMeters, + samples.last.terrainMeters, + ); + + double distanceForCanvasX(double x) { + final normalized = ((x - horizontalPadding) / chartWidth).clamp(0.0, 1.0); + return normalized * maxDist; + } + + double elevationToPixel(double elevation) { + final normalized = ((elevation - minY) / ySpan).clamp(0.0, 1.0); + return size.height - verticalPadding - normalized * chartHeight; + } + + double extrapolateTerrain(double distance, bool isLeft) { + final samplesForSlope = isLeft + ? samples.sublist(0, math.min(2, samples.length)) + : samples.sublist(samples.length - math.min(2, samples.length)); + if (samplesForSlope.length < 2) { + return samplesForSlope.first.terrainMeters; + } + final a = samplesForSlope.first; + final b = samplesForSlope.last; + final dx = b.distanceMeters - a.distanceMeters; + if (dx.abs() < 1e-6) return a.terrainMeters; + final slope = (b.terrainMeters - a.terrainMeters) / dx; + return a.terrainMeters + slope * (distance - a.distanceMeters); + } + + final leftDistance = distanceForCanvasX(0.0); + final rightDistance = distanceForCanvasX(size.width); + final leftEdgeTerrain = extrapolateTerrain(leftDistance, true); + final rightEdgeTerrain = extrapolateTerrain(rightDistance, false); + final leftEdgePoint = Offset(0.0, elevationToPixel(leftEdgeTerrain)); + final rightEdgePoint = Offset( + size.width, + elevationToPixel(rightEdgeTerrain), + ); + + final terrainPath = ui.Path() + ..moveTo(0, size.height) + ..lineTo(leftEdgePoint.dx, leftEdgePoint.dy) + ..lineTo(firstTerrainPoint.dx, firstTerrainPoint.dy); + for (final sample in samples) { + final p = mapPoint(sample.distanceMeters, sample.terrainMeters); + terrainPath.lineTo(p.dx, p.dy); + } + terrainPath + ..lineTo(lastTerrainPoint.dx, lastTerrainPoint.dy) + ..lineTo(rightEdgePoint.dx, rightEdgePoint.dy) + ..lineTo(size.width, size.height) + ..close(); + + const terrainFillColor = Color(0xCC7C6F5D); + const terrainLineColor = Color(0xFF9FE870); + const losLineColor = Color(0xFFE0E7FF); + canvas.drawPath(terrainPath, Paint()..color = terrainFillColor); + + final terrainLine = ui.Path()..moveTo(leftEdgePoint.dx, leftEdgePoint.dy); + for (final sample in samples) { + final p = mapPoint(sample.distanceMeters, sample.terrainMeters); + terrainLine.lineTo(p.dx, p.dy); + } + terrainLine.lineTo(rightEdgePoint.dx, rightEdgePoint.dy); + canvas.drawPath( + terrainLine, + Paint() + ..color = terrainLineColor + ..style = PaintingStyle.stroke + ..strokeWidth = 2, + ); + + final losLine = ui.Path(); + for (int i = 0; i < samples.length; i++) { + final p = mapPoint( + samples[i].distanceMeters, + samples[i].lineHeightMeters, + ); + if (i == 0) { + losLine.moveTo(p.dx, p.dy); + } else { + losLine.lineTo(p.dx, p.dy); + } + } + canvas.drawPath( + losLine, + Paint() + ..color = losLineColor + ..style = PaintingStyle.stroke + ..strokeWidth = 2, + ); + + const refractedLineColor = Color(0xFFFFD57F); + final refractedLine = ui.Path(); + for (int i = 0; i < samples.length; i++) { + final p = mapPoint( + samples[i].distanceMeters, + samples[i].refractedHeightMeters, + ); + if (i == 0) { + refractedLine.moveTo(p.dx, p.dy); + } else { + refractedLine.lineTo(p.dx, p.dy); + } + } + canvas.drawPath( + refractedLine, + Paint() + ..color = refractedLineColor + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5, + ); + + final capPath = ui.Path(); + for (int i = 0; i < samples.length; i++) { + final p = mapPoint( + samples[i].distanceMeters, + samples[i].refractedHeightMeters, + ); + if (i == 0) { + capPath.moveTo(p.dx, p.dy); + } else { + capPath.lineTo(p.dx, p.dy); + } + } + for (int i = samples.length - 1; i >= 0; i--) { + final p = mapPoint( + samples[i].distanceMeters, + samples[i].lineHeightMeters, + ); + capPath.lineTo(p.dx, p.dy); + } + capPath.close(); + const horizonFillColor = Color(0x40FFD57F); + canvas.drawPath( + capPath, + Paint() + ..color = horizonFillColor + ..style = PaintingStyle.fill, + ); + } + + @override + bool shouldRepaint(covariant _LosProfilePainter oldDelegate) { + return oldDelegate.samples != samples || + oldDelegate.distanceUnit != distanceUnit || + oldDelegate.heightUnit != heightUnit || + oldDelegate.badgeTextStyle != badgeTextStyle || + oldDelegate.terrainLabel != terrainLabel || + oldDelegate.losBeamLabel != losBeamLabel || + oldDelegate.radioHorizonLabel != radioHorizonLabel; + } + + void _drawUnitBadge(Canvas canvas, Size size) { + final span = TextSpan( + text: '$heightUnit / $distanceUnit', + style: badgeTextStyle, + ); + final painter = TextPainter(text: span, textDirection: TextDirection.ltr) + ..layout(); + painter.paint(canvas, Offset(size.width - painter.width - 8, 8)); + } +} + +class _LosLegend extends StatelessWidget { + static const _terrainColor = Color(0xFF9FE870); + static const _losColor = Color(0xFFE0E7FF); + static const _radioColor = Color(0xFFFFD57F); + + final String terrainLabel; + final String losBeamLabel; + final String radioHorizonLabel; + + const _LosLegend({ + required this.terrainLabel, + required this.losBeamLabel, + required this.radioHorizonLabel, + }); + + @override + Widget build(BuildContext context) { + final textStyle = + Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.white70, + fontSize: 11, + fontWeight: FontWeight.w500, + ) ?? + const TextStyle( + color: Colors.white70, + fontSize: 11, + fontWeight: FontWeight.w500, + ); + + final entries = [ + _LegendEntry(terrainLabel, _terrainColor), + _LegendEntry(losBeamLabel, _losColor), + _LegendEntry(radioHorizonLabel, _radioColor), + ]; + + const swatchSize = 10.0; + + return Wrap( + spacing: 16, + runSpacing: 6, + children: entries + .map( + (entry) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: swatchSize, + height: swatchSize, + decoration: BoxDecoration( + color: entry.color, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 6), + Text(entry.label, style: textStyle), + ], + ), + ) + .toList(), + ); + } +} + +class _LegendEntry { + final String label; + final Color color; + + const _LegendEntry(this.label, this.color); +} diff --git a/lib/screens/map_cache_screen.dart b/lib/screens/map_cache_screen.dart index 3f61109..1391660 100644 --- a/lib/screens/map_cache_screen.dart +++ b/lib/screens/map_cache_screen.dart @@ -7,6 +7,7 @@ import '../l10n/app_localizations.dart'; import '../l10n/l10n.dart'; import '../services/app_settings_service.dart'; import '../services/map_tile_cache_service.dart'; +import '../widgets/adaptive_app_bar_title.dart'; class MapCacheScreen extends StatefulWidget { const MapCacheScreen({super.key}); @@ -224,7 +225,10 @@ class _MapCacheScreenState extends State { : (_completedTiles / _estimatedTiles).clamp(0.0, 1.0).toDouble(); return Scaffold( - appBar: AppBar(title: Text(l10n.mapCache_title), centerTitle: true), + appBar: AppBar( + title: AdaptiveAppBarTitle(l10n.mapCache_title), + centerTitle: true, + ), body: Column( children: [ Expanded( diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 552f579..b688a30 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -5,11 +5,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:meshcore_open/screens/path_trace_map.dart'; +import 'package:meshcore_open/widgets/app_bar.dart'; import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; import '../l10n/l10n.dart'; import '../connector/meshcore_protocol.dart'; +import '../models/app_settings.dart'; import '../models/channel.dart'; import '../models/contact.dart'; import '../services/app_settings_service.dart'; @@ -17,8 +19,8 @@ import '../services/map_marker_service.dart'; import '../services/map_tile_cache_service.dart'; import '../utils/contact_search.dart'; import '../utils/route_transitions.dart'; -import '../widgets/battery_indicator.dart'; import '../widgets/quick_switch_bar.dart'; +import '../icons/los_icon.dart'; import 'channels_screen.dart'; import 'chat_screen.dart'; import 'contacts_screen.dart'; @@ -26,6 +28,7 @@ import '../widgets/repeater_login_dialog.dart'; import '../widgets/room_login_dialog.dart'; import 'repeater_hub_screen.dart'; import 'settings_screen.dart'; +import 'line_of_sight_map_screen.dart'; class MapScreen extends StatefulWidget { final LatLng? highlightPosition; @@ -46,6 +49,8 @@ class MapScreen extends StatefulWidget { } class _MapScreenState extends State { + static const double _labelZoomThreshold = 8.5; + final MapController _mapController = MapController(); final MapMarkerService _markerService = MapMarkerService(); final Set _hiddenMarkerIds = {}; @@ -58,6 +63,7 @@ class _MapScreenState extends State { final List _points = []; final List _polylines = []; bool _legendExpanded = false; + bool _showNodeLabels = true; @override void initState() { @@ -105,7 +111,7 @@ class _MapScreenState extends State { double _zoomFromStdDev(double latStdDev, double lonStdDev) { final maxSpread = max(latStdDev, lonStdDev); if (maxSpread <= 0) return 13.0; - // Approzimate: each zoom level halves the visible area + // Approximate: each zoom level halves the visible area // ~0.01 degrees spread -> zoom 13, ~0.1 -> zoom 10, ~1.0 -> zoom 7 final zoom = 10.0 - log(maxSpread * 10 + 1) / ln10 * 3; return zoom.clamp(4.0, 15.0); @@ -247,6 +253,7 @@ class _MapScreenState extends State { // Re center map after removed markers have loaded if (!_hasInitializedMap && _removedMarkersLoaded) { _hasInitializedMap = true; + _showNodeLabels = initialZoom >= _labelZoomThreshold; if (hasMapContent) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { @@ -262,8 +269,7 @@ class _MapScreenState extends State { canPop: allowBack, child: Scaffold( appBar: AppBar( - leading: BatteryIndicator(connector: connector), - title: Text(context.l10n.map_title), + title: AppBarTitle(context.l10n.map_title), centerTitle: true, automaticallyImplyLeading: false, actions: [ @@ -273,6 +279,47 @@ class _MapScreenState extends State { onPressed: () => _startPath(), tooltip: context.l10n.contacts_pathTrace, ), + if (!_isBuildingPathTrace) + IconButton( + icon: const LosIcon(), + onPressed: () { + final candidates = []; + if (connector.selfLatitude != null && + connector.selfLongitude != null) { + candidates.add( + LineOfSightEndpoint( + label: context.l10n.pathTrace_you, + point: LatLng( + connector.selfLatitude!, + connector.selfLongitude!, + ), + color: Colors.teal, + icon: Icons.person_pin_circle, + ), + ); + } + for (final c in contactsWithLocation) { + candidates.add( + LineOfSightEndpoint( + label: c.name, + point: LatLng(c.latitude!, c.longitude!), + color: _getNodeColor(c.type), + icon: _getNodeIcon(c.type), + ), + ); + } + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => LineOfSightMapScreen( + title: context.l10n.map_losScreenTitle, + candidates: candidates, + ), + ), + ); + }, + tooltip: context.l10n.map_lineOfSight, + ), PopupMenuButton( itemBuilder: (context) => [ PopupMenuItem( @@ -351,6 +398,14 @@ class _MapScreenState extends State { position: latLng, ); }, + onPositionChanged: (camera, hasGesture) { + final shouldShow = camera.zoom >= _labelZoomThreshold; + if (shouldShow != _showNodeLabels && mounted) { + setState(() { + _showNodeLabels = shouldShow; + }); + } + }, ), children: [ TileLayer( @@ -375,7 +430,11 @@ class _MapScreenState extends State { size: 34, ), ), - ..._buildMarkers(contactsWithLocation, settings), + ..._buildMarkers( + contactsWithLocation, + settings, + showLabels: _showNodeLabels, + ), ...sharedMarkers.map(_buildSharedMarker), if (connector.selfLatitude != null && connector.selfLongitude != null) @@ -384,8 +443,8 @@ class _MapScreenState extends State { connector.selfLatitude!, connector.selfLongitude!, ), - width: 35, - height: 35, + width: 40, + height: 40, child: Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( @@ -404,23 +463,31 @@ class _MapScreenState extends State { ], ), alignment: Alignment.center, - child: Text( - context.l10n.pathTrace_you, - style: const TextStyle( - color: Colors.black, - fontWeight: FontWeight.bold, - fontSize: 12, - ), + child: const Icon( + Icons.person_pin_circle, + color: Colors.white, + size: 20, ), ), ), + if (_showNodeLabels && + connector.selfLatitude != null && + connector.selfLongitude != null) + _buildNodeLabelMarker( + point: LatLng( + connector.selfLatitude!, + connector.selfLongitude!, + ), + label: context.l10n.pathTrace_you, + ), ], ), ], ), if (!_isBuildingPathTrace) _buildLegend( - contactsWithLocation.length, + contactsWithLocation, + settings, sharedMarkers.length, ), if (_isBuildingPathTrace) _buildPathTraceOverlay(), @@ -445,20 +512,28 @@ class _MapScreenState extends State { ); } - List _buildMarkers(List contacts, settings) { + List _buildMarkers( + List contacts, + settings, { + required bool showLabels, + }) { final markers = []; for (final contact in contacts) { if (!contact.hasLocation) continue; // Apply node type filters - if (contact.type == advTypeRepeater && !settings.mapShowRepeaters) { + if (contact.type == advTypeRepeater && + (!settings.mapShowRepeaters && !_isBuildingPathTrace)) { + continue; + } + if (contact.type == advTypeChat && + !(settings.mapShowChatNodes && !_isBuildingPathTrace)) { continue; } - if (contact.type == advTypeChat && !settings.mapShowChatNodes) continue; if (contact.type != advTypeChat && contact.type != advTypeRepeater && - !settings.mapShowOtherNodes) { + (!settings.mapShowOtherNodes && !_isBuildingPathTrace)) { continue; } @@ -500,11 +575,54 @@ class _MapScreenState extends State { ); markers.add(marker); + if (showLabels) { + markers.add( + _buildNodeLabelMarker( + point: LatLng(contact.latitude!, contact.longitude!), + label: contact.name, + ), + ); + } } return markers; } + Marker _buildNodeLabelMarker({required LatLng point, required String label}) { + return Marker( + point: point, + width: 120, + height: 24, + alignment: Alignment.topCenter, + child: IgnorePointer( + child: Transform.translate( + offset: const Offset(0, -20), + child: FittedBox( + fit: BoxFit.contain, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ), + ); + } + Color _getNodeColor(int type) { switch (type) { case advTypeChat: @@ -535,7 +653,26 @@ class _MapScreenState extends State { } } - Widget _buildLegend(int nodeCount, int markerCount) { + Widget _buildLegend( + List contactsWithLocation, + settings, + int markerCount, + ) { + int nodeCount = 0; + for (final contact in contactsWithLocation) { + // Apply node type filters + if (contact.type == advTypeRepeater && !settings.mapShowRepeaters) { + continue; + } + if (contact.type == advTypeChat && !settings.mapShowChatNodes) continue; + if (contact.type != advTypeChat && + contact.type != advTypeRepeater && + !settings.mapShowOtherNodes) { + continue; + } + nodeCount++; + } + return Positioned( top: 16, right: 16, @@ -826,7 +963,7 @@ class _MapScreenState extends State { color: _getNodeColor(contact.type), ), const SizedBox(width: 8), - Expanded(child: Text(contact.name)), + Expanded(child: SelectableText(contact.name)), ], ), content: Column( @@ -997,7 +1134,7 @@ class _MapScreenState extends State { ), ), const SizedBox(height: 2), - Text(value, style: const TextStyle(fontSize: 14)), + SelectableText(value, style: const TextStyle(fontSize: 14)), ], ), ); @@ -1520,6 +1657,9 @@ class _MapScreenState extends State { Widget _buildPathTraceOverlay() { final l10n = context.l10n; + final isImperial = + context.read().settings.unitSystem == + UnitSystem.imperial; return Positioned( top: 16, left: 16, @@ -1540,7 +1680,7 @@ class _MapScreenState extends State { const SizedBox(height: 6), if (_pathTrace.isNotEmpty) Text( - "${l10n.path_currentPathLabel} ${formatDistance(getPathDistanceMeters(_points))}", + "${l10n.path_currentPathLabel} ${formatDistance(getPathDistanceMeters(_points), isImperial: isImperial)}", style: TextStyle(fontSize: 12, color: Colors.grey[700]), ), SelectableText( @@ -1550,8 +1690,10 @@ class _MapScreenState extends State { style: TextStyle(fontSize: 18), ), const SizedBox(height: 6), - Row( - mainAxisSize: MainAxisSize.min, + Wrap( + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 8, children: [ if (_pathTrace.isNotEmpty) ElevatedButton( diff --git a/lib/screens/neighbours_screen.dart b/lib/screens/neighbors_screen.dart similarity index 75% rename from lib/screens/neighbours_screen.dart rename to lib/screens/neighbors_screen.dart index b606188..3dee339 100644 --- a/lib/screens/neighbours_screen.dart +++ b/lib/screens/neighbors_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:meshcore_open/utils/app_logger.dart'; import 'package:provider/provider.dart'; import '../l10n/l10n.dart'; import '../models/contact.dart'; @@ -11,28 +12,28 @@ import '../services/repeater_command_service.dart'; import '../widgets/path_management_dialog.dart'; import '../widgets/snr_indicator.dart'; -class NeighboursScreen extends StatefulWidget { +class NeighborsScreen extends StatefulWidget { final Contact repeater; final String password; - const NeighboursScreen({ + const NeighborsScreen({ super.key, required this.repeater, required this.password, }); @override - State createState() => _NeighboursScreenState(); + State createState() => _NeighborsScreenState(); } -class _NeighboursScreenState extends State { - static const int _reqNeighboursKeyLen = 4; +class _NeighborsScreenState extends State { + static const int _reqNeighborsKeyLen = 4; static const int _statusPayloadOffset = 8; static const int _statusStatsSize = 52; static const int _statusResponseBytes = _statusPayloadOffset + _statusStatsSize; Uint8List _tagData = Uint8List(4); - int _neighbourCount = 0; + int _neighborCount = 0; bool _isLoading = false; bool _isLoaded = false; @@ -41,7 +42,7 @@ class _NeighboursScreenState extends State { StreamSubscription? _frameSubscription; RepeaterCommandService? _commandService; PathSelection? _pendingStatusSelection; - List>? _parsedNeighbours; + List>? _parsedNeighbors; @override void initState() { @@ -49,7 +50,7 @@ class _NeighboursScreenState extends State { final connector = Provider.of(context, listen: false); _commandService = RepeaterCommandService(connector); _setupMessageListener(); - _loadNeighbours(); + _loadNeighbors(); _hasData = false; } @@ -62,13 +63,12 @@ class _NeighboursScreenState extends State { if (frame[0] == respCodeSent) { _tagData = frame.sublist(2, 6); - //_timeEstment = frame.buffer.asByteData().getUint32(6, Endian.little); } // Check if it's a binary response if (frame[0] == pushCodeBinaryResponse && listEquals(frame.sublist(2, 6), _tagData)) { - _handleNeighboursResponse(connector, frame.sublist(6)); + _handleNeighborsResponse(connector, frame.sublist(6)); } }); } @@ -91,65 +91,77 @@ class _NeighboursScreenState extends State { return '${h}h ${m2}m'; } - static List> parseNeighboursData( + static List> parseNeighborsData( BufferReader buffer, int resultsCount, ) { - final Map> neighbours = {}; - for (var i = 0; i < resultsCount; i++) { - final neighbourData = neighbours.putIfAbsent( - i, - () => { - 'contact': null, - 'publicKey': {}, - 'lastHeard': {}, - 'snr': {}, - }, - ); - neighbourData['publicKey'] = buffer.readBytes(_reqNeighboursKeyLen); - neighbourData['lastHeard'] = buffer.readUInt32LE(); - neighbourData['snr'] = buffer.readInt8() / 4.0; - } + final Map> neighbors = {}; + try { + for (var i = 0; i < resultsCount; i++) { + final neighborData = neighbors.putIfAbsent( + i, + () => { + 'contact': null, + 'publicKey': {}, + 'lastHeard': {}, + 'snr': {}, + }, + ); + neighborData['publicKey'] = buffer.readBytes(_reqNeighborsKeyLen); + neighborData['lastHeard'] = buffer.readUInt32LE(); + neighborData['snr'] = buffer.readInt8() / 4.0; + } - return neighbours.values.toList(); + return neighbors.values.toList(); + } catch (e) { + appLogger.error( + 'Error parsing neighbors data: $e', + tag: 'NeighborsScreen', + ); + return []; + } } - void _handleNeighboursResponse(MeshCoreConnector connector, Uint8List frame) { + void _handleNeighborsResponse(MeshCoreConnector connector, Uint8List frame) { final buffer = BufferReader(frame); - final neighbourCount = buffer.readUInt16LE(); - final parsedNeighbours = parseNeighboursData(buffer, buffer.readUInt16LE()); - connector.contacts.where((c) => c.type == advTypeRepeater).forEach(( - repeater, - ) { - for (var neighbourData in parsedNeighbours) { - final publicKey = neighbourData['publicKey']; - if (listEquals( - repeater.publicKey.sublist(0, _reqNeighboursKeyLen), - publicKey, - )) { - neighbourData['contact'] = repeater; + try { + final neighborCount = buffer.readUInt16LE(); + final parsedNeighbors = parseNeighborsData(buffer, buffer.readUInt16LE()); + connector.contacts.where((c) => c.type == advTypeRepeater).forEach(( + repeater, + ) { + for (var neighborData in parsedNeighbors) { + final publicKey = neighborData['publicKey']; + if (listEquals( + repeater.publicKey.sublist(0, _reqNeighborsKeyLen), + publicKey, + )) { + neighborData['contact'] = repeater; + } } - } - }); + }); - setState(() { - _parsedNeighbours = parsedNeighbours; - _neighbourCount = neighbourCount; - }); + setState(() { + _parsedNeighbors = parsedNeighbors; + _neighborCount = neighborCount; + }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.neighbors_receivedData), - backgroundColor: Colors.green, - ), - ); - _statusTimeout?.cancel(); - if (!mounted) return; - setState(() { - _isLoading = false; - _isLoaded = true; - _hasData = true; - }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.neighbors_receivedData), + backgroundColor: Colors.green, + ), + ); + _statusTimeout?.cancel(); + if (!mounted) return; + setState(() { + _isLoading = false; + _isLoaded = true; + _hasData = true; + }); + } catch (e) { + appLogger.error('Error handling neighbors response: $e'); + } } Contact _resolveRepeater(MeshCoreConnector connector) { @@ -159,7 +171,7 @@ class _NeighboursScreenState extends State { ); } - Future _loadNeighbours() async { + Future _loadNeighbors() async { if (_commandService == null) return; setState(() { @@ -172,17 +184,17 @@ class _NeighboursScreenState extends State { final selection = await connector.preparePathForContactSend(repeater); _pendingStatusSelection = selection; - //[version][number of requested neighbours][offset_16bit][order by][len of public key] + //[version][number of requested neighbors][offset_16bit][order by][len of public key] final frame = buildSendBinaryReq( repeater.publicKey, payload: Uint8List.fromList([ - reqTypeGetNeighbours, + reqTypeGetNeighbors, 0x00, 0x0F, 0x00, 0x00, 0x00, - _reqNeighboursKeyLen, + _reqNeighborsKeyLen, ]), ); await connector.sendFrame(frame); @@ -258,7 +270,7 @@ class _NeighboursScreenState extends State { mainAxisSize: MainAxisSize.min, children: [ Text( - l10n.neighbors_repeatersNeighbours, + l10n.neighbors_repeatersNeighbors, style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), Text( @@ -345,7 +357,7 @@ class _NeighboursScreenState extends State { child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.refresh), - onPressed: _isLoading ? null : _loadNeighbours, + onPressed: _isLoading ? null : _loadNeighbors, tooltip: l10n.repeater_refresh, ), ], @@ -353,13 +365,13 @@ class _NeighboursScreenState extends State { body: SafeArea( top: false, child: RefreshIndicator( - onRefresh: _loadNeighbours, + onRefresh: _loadNeighbors, child: ListView( padding: const EdgeInsets.all(16), children: [ if (!_isLoaded && !_hasData && - (_parsedNeighbours == null || _parsedNeighbours!.isEmpty)) + (_parsedNeighbors == null || _parsedNeighbors!.isEmpty)) Center( child: Text( l10n.neighbors_noData, @@ -368,10 +380,9 @@ class _NeighboursScreenState extends State { ), if (_isLoaded || _hasData && - !(_parsedNeighbours == null || - _parsedNeighbours!.isEmpty)) - _buildNeighboursInfoCard( - "${l10n.repeater_neighbours} - $_neighbourCount", + !(_parsedNeighbors == null || _parsedNeighbors!.isEmpty)) + _buildNeighborsInfoCard( + "${l10n.repeater_neighbors} - $_neighborCount", ), ], ), @@ -380,7 +391,7 @@ class _NeighboursScreenState extends State { ); } - Widget _buildNeighboursInfoCard(String title) { + Widget _buildNeighborsInfoCard(String title) { final connector = Provider.of(context, listen: false); return Card( child: Padding( @@ -405,7 +416,7 @@ class _NeighboursScreenState extends State { ], ), const Divider(), - for (final entry in _parsedNeighbours!.asMap().entries) + for (final entry in _parsedNeighbors!.asMap().entries) _buildInfoRow( entry.value['contact'] != null ? entry.value['contact'].name @@ -430,6 +441,7 @@ class _NeighboursScreenState extends State { double snr, int spreadingFactor, ) { + final snrUi = snrUiFromSNR(snr, spreadingFactor); return Padding( padding: const EdgeInsets.symmetric(vertical: 3), child: Row( @@ -443,9 +455,15 @@ class _NeighboursScreenState extends State { style: const TextStyle(fontWeight: FontWeight.w500), ), subtitle: Text(value), - trailing: SNRIcon( - snr: snr, - snrLevels: getSNRfromSF(spreadingFactor), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(snrUi.icon, color: snrUi.color, size: 18.0), + Text( + snrUi.text, + style: TextStyle(fontSize: 10, color: snrUi.color), + ), + ], ), ), ), diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index 7677d0d..5f86cc1 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -8,8 +8,11 @@ import 'package:latlong2/latlong.dart'; import 'package:meshcore_open/connector/meshcore_connector.dart'; import 'package:meshcore_open/connector/meshcore_protocol.dart'; import 'package:meshcore_open/l10n/l10n.dart'; +import 'package:meshcore_open/models/app_settings.dart'; import 'package:meshcore_open/models/contact.dart'; +import 'package:meshcore_open/services/app_settings_service.dart'; import 'package:meshcore_open/services/map_tile_cache_service.dart'; +import 'package:meshcore_open/utils/app_logger.dart'; import 'package:meshcore_open/widgets/snr_indicator.dart'; import 'package:provider/provider.dart'; @@ -26,13 +29,16 @@ double getPathDistanceMeters(List points) { return distanceMeters; } -String formatDistance(double distanceMeters) { - return '(${(distanceMeters / 1609.34).toStringAsFixed(2)} Miles / ${(distanceMeters / 1000).toStringAsFixed(2)} Km)'; +String formatDistance(double distanceMeters, {required bool isImperial}) { + if (isImperial) { + return '(${(distanceMeters / 1609.34).toStringAsFixed(2)} mi)'; + } + return '(${(distanceMeters / 1000).toStringAsFixed(2)} km)'; } class PathTraceData { final Uint8List pathData; - final Uint8List snrData; + final List snrData; final Map pathContacts; PathTraceData({ @@ -45,6 +51,7 @@ class PathTraceData { class PathTraceMapScreen extends StatefulWidget { final String title; final Uint8List path; + final int? repeaterId; final bool flipPathRound; final bool reversePathRound; @@ -52,6 +59,7 @@ class PathTraceMapScreen extends StatefulWidget { super.key, required this.title, required this.path, + this.repeaterId, this.flipPathRound = false, this.reversePathRound = false, }); @@ -61,6 +69,8 @@ class PathTraceMapScreen extends StatefulWidget { } class _PathTraceMapScreenState extends State { + static const double _labelZoomThreshold = 8.5; + StreamSubscription? _frameSubscription; Timer? _timeoutTimer; @@ -75,6 +85,7 @@ class _PathTraceMapScreenState extends State { LatLngBounds? _bounds; ValueKey _mapKey = const ValueKey('initial'); double _pathDistanceMeters = 0.0; + bool _showNodeLabels = true; String _formatPathPrefixes(Uint8List pathBytes) { return pathBytes @@ -96,7 +107,7 @@ class _PathTraceMapScreenState extends State { super.dispose(); } - Uint8List addReturnpath(Uint8List pathBytes) { + Uint8List addReturnPath(Uint8List pathBytes) { Uint8List? traceBytes; final len = (pathBytes.length + pathBytes.length - 1); traceBytes = Uint8List(len); @@ -124,7 +135,7 @@ class _PathTraceMapScreenState extends State { : widget.path; if (widget.flipPathRound) { - path = addReturnpath(pathTmp); + path = addReturnPath(pathTmp); } else { path = pathTmp; } @@ -146,42 +157,57 @@ class _PathTraceMapScreenState extends State { _frameSubscription = connector.receivedFrames.listen((frame) { if (frame.isEmpty) return; final frameBuffer = BufferReader(frame); - final code = frameBuffer.readUInt8(); + try { + final code = frameBuffer.readUInt8(); - if (code == respCodeSent) { - frameBuffer.skipBytes(1); //reserved - tagData = frameBuffer.readBytes(4); - final timeoutSeconds = frameBuffer.readUInt32LE(); + if (code == respCodeSent) { + frameBuffer.skipBytes(1); //reserved + tagData = frameBuffer.readBytes(4); + final timeoutMilliseconds = frameBuffer.readUInt32LE(); - // Start timeout timer for trace response - _timeoutTimer?.cancel(); - _timeoutTimer = Timer(Duration(milliseconds: timeoutSeconds), () { + // Start timeout timer for trace response + _timeoutTimer?.cancel(); + _timeoutTimer = Timer( + Duration(milliseconds: timeoutMilliseconds), + () { + if (!mounted) return; + setState(() { + _isLoading = false; + _failed2Loaded = true; + }); + }, + ); + } + + if (code == respCodeErr) { + _timeoutTimer?.cancel(); if (!mounted) return; setState(() { _isLoading = false; _failed2Loaded = true; }); - }); - } + } - if (code == respCodeErr) { + // Check if it's a binary response + if (frame.length > 8 && + code == pushCodeTraceData && + listEquals(frame.sublist(4, 8), tagData)) { + _timeoutTimer?.cancel(); + if (!mounted) return; + frameBuffer.skipBytes(3); //reserved + path length + flag + if (listEquals(frameBuffer.readBytes(4), tagData)) { + _handleTraceResponse(frame); + } + } + } catch (e) { _timeoutTimer?.cancel(); if (!mounted) return; setState(() { _isLoading = false; _failed2Loaded = true; }); - } - // Check if it's a binary response - if (frame.length > 8 && - code == pushCodeTraceData && - listEquals(frame.sublist(4, 8), tagData)) { - _timeoutTimer?.cancel(); - if (!mounted) return; - frameBuffer.skipBytes(3); //reserved + path length + flag - if (listEquals(frameBuffer.readBytes(4), tagData)) { - _handleTraceResponse(frame); - } + // Handle any parsing errors gracefully + appLogger.error('Error parsing frame: $e', tag: 'PathTraceMapScreen'); } }); } @@ -190,69 +216,91 @@ class _PathTraceMapScreenState extends State { final connector = Provider.of(context, listen: false); final buffer = BufferReader(frame); - buffer.skipBytes(2); // Skip push code and reserved byte - int pathLength = buffer.readUInt8(); - buffer.skipBytes(5); // Skip Flag byte and tag data - buffer.skipBytes(4); // Skip auth code - Uint8List pathData = buffer.readBytes(pathLength); - Uint8List snrData = buffer.readRemainingBytes(); + try { + buffer.skipBytes(2); // Skip push code and reserved byte + int pathLength = buffer.readUInt8(); + buffer.skipBytes(5); // Skip Flag byte and tag data + buffer.skipBytes(4); // Skip auth code + Uint8List pathData = buffer.readBytes(pathLength); + List snrData = buffer + .readRemainingBytes() + .map((snr) => snr.toSigned(8).toDouble() / 4) + .toList(); - Map pathContacts = {}; + Map pathContacts = {}; - connector.contacts.where((c) => c.type != advTypeChat).forEach((repeater) { - for (var repeaterData in pathData) { - if (listEquals( - repeater.publicKey.sublist(0, 1), - Uint8List.fromList([repeaterData]), - )) { - pathContacts[repeaterData] = repeater; + connector.contacts.where((c) => c.type != advTypeChat).forEach(( + repeater, + ) { + for (var repeaterData in pathData) { + if (listEquals( + repeater.publicKey.sublist(0, 1), + Uint8List.fromList([repeaterData]), + )) { + pathContacts[repeaterData] = repeater; + } } - } - }); + }); - setState(() { - _isLoading = false; - _hasData = true; - _traceData = PathTraceData( - pathData: pathData, - snrData: snrData, - pathContacts: pathContacts, - ); - _points = []; - _points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!)); - for (final hop in _traceData!.pathData) { - final contact = _traceData!.pathContacts[hop]; - if (contact != null && - contact.hasLocation && - contact.latitude != null && - contact.longitude != null) { - _points.add(LatLng(contact.latitude!, contact.longitude!)); + setState(() { + _isLoading = false; + _hasData = true; + _traceData = PathTraceData( + pathData: pathData, + snrData: snrData, + pathContacts: pathContacts, + ); + _points = []; + _points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!)); + for (final hop in _traceData!.pathData) { + final contact = _traceData!.pathContacts[hop]; + if (contact != null && + contact.hasLocation && + contact.latitude != null && + contact.longitude != null) { + _points.add(LatLng(contact.latitude!, contact.longitude!)); + } } - } - _polylines = _points.length > 1 - ? [ - Polyline( - points: _points, - strokeWidth: 4, - color: Colors.blueAccent, - ), - ] - : []; + _polylines = _points.length > 1 + ? [ + Polyline( + points: _points, + strokeWidth: 4, + color: Colors.blueAccent, + ), + ] + : []; - _initialCenter = _points.isNotEmpty ? _points.first : const LatLng(0, 0); - _initialZoom = _points.isNotEmpty ? 13.0 : 2.0; - _bounds = _points.length > 1 ? LatLngBounds.fromPoints(_points) : null; - _mapKey = ValueKey( - '${context.l10n.pathTrace_you},${_formatPathPrefixes(_traceData!.pathData)}', + _initialCenter = _points.isNotEmpty + ? _points.first + : const LatLng(0, 0); + _initialZoom = _points.isNotEmpty ? 13.0 : 2.0; + _bounds = _points.length > 1 ? LatLngBounds.fromPoints(_points) : null; + _mapKey = ValueKey( + '${context.l10n.pathTrace_you},${_formatPathPrefixes(_traceData!.pathData)}', + ); + _pathDistanceMeters = getPathDistanceMeters(_points); + }); + } catch (e) { + appLogger.error( + 'Error handling trace response: $e', + tag: 'PathTraceMapScreen', ); - _pathDistanceMeters = getPathDistanceMeters(_points); - }); + if (mounted) { + setState(() { + _isLoading = false; + _failed2Loaded = true; + }); + } + } } @override Widget build(BuildContext context) { return Consumer( builder: (context, connector, _) { + final settings = context.watch().settings; + final isImperial = settings.unitSystem == UnitSystem.imperial; final tileCache = context.read(); return Scaffold( @@ -317,7 +365,8 @@ class _PathTraceMapScreenState extends State { ), ), ), - if (_hasData) _buildLegendCard(context, _traceData!), + if (_hasData) + _buildLegendCard(context, _traceData!, isImperial), ], ), ), @@ -326,55 +375,61 @@ class _PathTraceMapScreenState extends State { ); } - List _buildHopMarkers(List pathData) { - return [ - for (final hop in pathData) - if (_traceData!.pathContacts[hop] != null && - _traceData!.pathContacts[hop]!.hasLocation) - Marker( - point: LatLng( - _traceData!.pathContacts[hop]!.latitude!, - _traceData!.pathContacts[hop]!.longitude!, - ), - width: 35, - height: 35, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Colors.green, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - alignment: Alignment.center, - child: Text( - _traceData!.pathContacts[hop]!.publicKey - .sublist(0, 1) - .map( - (b) => b.toRadixString(16).padLeft(2, '0').toUpperCase(), - ) - .join(), - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 12, - ), - ), - ), - ), - if (context.read().selfLatitude != null && - context.read().selfLongitude != null) + List _buildHopMarkers( + List pathData, { + required bool showLabels, + }) { + final markers = []; + for (final hop in pathData) { + final contact = _traceData!.pathContacts[hop]; + if (contact == null || !contact.hasLocation) continue; + final point = LatLng(contact.latitude!, contact.longitude!); + markers.add( Marker( - point: LatLng( - context.read().selfLatitude!, - context.read().selfLongitude!, + point: point, + width: 35, + height: 35, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + alignment: Alignment.center, + child: Text( + contact.publicKey + .sublist(0, 1) + .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) + .join(), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), ), + ), + ); + if (showLabels) { + markers.add(_buildNodeLabelMarker(point: point, label: contact.name)); + } + } + + final selfLat = context.read().selfLatitude; + final selfLon = context.read().selfLongitude; + if (selfLat != null && selfLon != null) { + final selfPoint = LatLng(selfLat, selfLon); + markers.add( + Marker( + point: selfPoint, width: 35, height: 35, child: Container( @@ -402,7 +457,53 @@ class _PathTraceMapScreenState extends State { ), ), ), - ]; + ); + if (showLabels) { + markers.add( + _buildNodeLabelMarker( + point: selfPoint, + label: context.l10n.pathTrace_you, + ), + ); + } + } + + return markers; + } + + Marker _buildNodeLabelMarker({required LatLng point, required String label}) { + return Marker( + point: point, + width: 120, + height: 24, + alignment: Alignment.topCenter, + child: IgnorePointer( + child: Transform.translate( + offset: const Offset(0, -20), + child: FittedBox( + fit: BoxFit.contain, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ), + ); } String formatDirectionText(PathTraceData pathTraceData, int index) { @@ -482,6 +583,14 @@ class _PathTraceMapScreenState extends State { ), minZoom: 2.0, maxZoom: 18.0, + onPositionChanged: (camera, hasGesture) { + final shouldShow = camera.zoom >= _labelZoomThreshold; + if (shouldShow != _showNodeLabels && mounted) { + setState(() { + _showNodeLabels = shouldShow; + }); + } + }, ), children: [ TileLayer( @@ -492,12 +601,21 @@ class _PathTraceMapScreenState extends State { ), if (_polylines.isNotEmpty) PolylineLayer(polylines: _polylines), if (_traceData!.pathData.isNotEmpty) - MarkerLayer(markers: _buildHopMarkers(_traceData!.pathData)), + MarkerLayer( + markers: _buildHopMarkers( + _traceData!.pathData, + showLabels: _showNodeLabels, + ), + ), ], ); } - Widget _buildLegendCard(BuildContext context, PathTraceData pathTraceData) { + Widget _buildLegendCard( + BuildContext context, + PathTraceData pathTraceData, + bool isImperial, + ) { final l10n = context.l10n; final maxHeight = MediaQuery.of(context).size.height * 0.35; final estimatedHeight = 72.0 + (pathTraceData.pathData.length * 56.0); @@ -516,7 +634,7 @@ class _PathTraceMapScreenState extends State { Padding( padding: const EdgeInsets.all(12), child: Text( - '${l10n.channelPath_repeaterHops} ${formatDistance(_pathDistanceMeters)}', + '${l10n.channelPath_repeaterHops} ${formatDistance(_pathDistanceMeters, isImperial: isImperial)}', style: const TextStyle(fontWeight: FontWeight.w600), ), ), @@ -532,6 +650,12 @@ class _PathTraceMapScreenState extends State { itemCount: pathTraceData.pathData.length + 1, separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, index) { + final snrUi = snrUiFromSNR( + index < pathTraceData.snrData.length + ? pathTraceData.snrData[index] + : null, + context.read().currentSf, + ); return Column( children: [ ListTile( @@ -550,12 +674,22 @@ class _PathTraceMapScreenState extends State { ), style: const TextStyle(fontSize: 14), ), - trailing: SNRIcon( - snr: - pathTraceData.snrData[index].toSigned( - 8, - ) / - 4.0, + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + snrUi.icon, + color: snrUi.color, + size: 18.0, + ), + Text( + snrUi.text, + style: TextStyle( + fontSize: 10, + color: snrUi.color, + ), + ), + ], ), onTap: () { // Handle item tap diff --git a/lib/screens/repeater_cli_screen.dart b/lib/screens/repeater_cli_screen.dart index abfb06a..1c7ff43 100644 --- a/lib/screens/repeater_cli_screen.dart +++ b/lib/screens/repeater_cli_screen.dart @@ -168,6 +168,7 @@ class _RepeaterCliScreenState extends State { _commandController.clear(); _historyIndex = -1; + _commandFocusNode.requestFocus(); // Auto-scroll to bottom Future.delayed(const Duration(milliseconds: 100), () { diff --git a/lib/screens/repeater_hub_screen.dart b/lib/screens/repeater_hub_screen.dart index 903f89e..fd2da8e 100644 --- a/lib/screens/repeater_hub_screen.dart +++ b/lib/screens/repeater_hub_screen.dart @@ -1,12 +1,14 @@ import 'package:flutter/material.dart'; import 'package:meshcore_open/connector/meshcore_protocol.dart'; +import 'package:provider/provider.dart'; import '../l10n/l10n.dart'; import '../models/contact.dart'; +import '../services/app_settings_service.dart'; import 'repeater_status_screen.dart'; import 'repeater_cli_screen.dart'; import 'repeater_settings_screen.dart'; import 'telemetry_screen.dart'; -import 'neighbours_screen.dart'; +import 'neighbors_screen.dart'; class RepeaterHubScreen extends StatelessWidget { final Contact repeater; @@ -21,6 +23,10 @@ class RepeaterHubScreen extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = context.l10n; + final settingsService = context.watch(); + final chemistry = settingsService.batteryChemistryForRepeater( + repeater.publicKeyHex, + ); return Scaffold( appBar: AppBar( title: Column( @@ -107,6 +113,62 @@ class RepeaterHubScreen extends StatelessWidget { ), ), const SizedBox(height: 24), + Card( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.battery_full), + const SizedBox(width: 10), + Expanded( + child: Text( + l10n.appSettings_batteryChemistry, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: chemistry, + isExpanded: true, + decoration: const InputDecoration( + border: UnderlineInputBorder(), + isDense: true, + ), + onChanged: (value) { + if (value == null) return; + settingsService.setBatteryChemistryForRepeater( + repeater.publicKeyHex, + value, + ); + }, + items: [ + DropdownMenuItem( + value: 'nmc', + child: Text(l10n.appSettings_batteryNmc), + ), + DropdownMenuItem( + value: 'lifepo4', + child: Text(l10n.appSettings_batteryLifepo4), + ), + DropdownMenuItem( + value: 'lipo', + child: Text(l10n.appSettings_batteryLipo), + ), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 24), Text( l10n.repeater_managementTools, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), @@ -174,17 +236,15 @@ class RepeaterHubScreen extends StatelessWidget { _buildManagementCard( context, icon: Icons.group, - title: l10n.repeater_neighbours, - subtitle: l10n.repeater_neighboursSubtitle, + title: l10n.repeater_neighbors, + subtitle: l10n.repeater_neighborsSubtitle, color: Colors.orange, onTap: () { Navigator.push( context, MaterialPageRoute( - builder: (context) => NeighboursScreen( - repeater: repeater, - password: password, - ), + builder: (context) => + NeighborsScreen(repeater: repeater, password: password), ), ); }, diff --git a/lib/screens/repeater_status_screen.dart b/lib/screens/repeater_status_screen.dart index 472b013..95254f4 100644 --- a/lib/screens/repeater_status_screen.dart +++ b/lib/screens/repeater_status_screen.dart @@ -8,7 +8,9 @@ import '../models/contact.dart'; import '../models/path_selection.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; +import '../services/app_settings_service.dart'; import '../services/repeater_command_service.dart'; +import '../utils/battery_utils.dart'; import '../widgets/path_management_dialog.dart'; class RepeaterStatusScreen extends StatefulWidget { @@ -179,6 +181,12 @@ class _RepeaterStatusScreenState extends State { _dupDirect = directDups; _dupFlood = floodDups; }); + final connector = Provider.of(context, listen: false); + connector.updateRepeaterBatterySnapshot( + widget.repeater.publicKeyHex, + batteryMv, + source: 'status_binary', + ); _recordStatusResult(true); } @@ -201,6 +209,18 @@ class _RepeaterStatusScreenState extends State { _uptimeSecs = _asInt(data['uptime_secs']); _queueLen = _asInt(data['queue_len']); _debugFlags = _asInt(data['errors']); + final batteryMv = _batteryMv; + if (batteryMv != null) { + final connector = Provider.of( + context, + listen: false, + ); + connector.updateRepeaterBatterySnapshot( + widget.repeater.publicKeyHex, + batteryMv, + source: 'status_text', + ); + } } else if (data.containsKey('noise_floor')) { _noiseFloor = _asInt(data['noise_floor']); _lastRssi = _asInt(data['last_rssi']); @@ -590,18 +610,24 @@ class _RepeaterStatusScreenState extends State { } String _batteryText() { - if (_batteryMv == null) return '—'; - final percent = _batteryPercentFromMv(_batteryMv!); - final volts = (_batteryMv! / 1000.0).toStringAsFixed(2); + final connector = context.watch(); + final batteryMv = + connector.getRepeaterBatteryMillivolts(widget.repeater.publicKeyHex) ?? + _batteryMv; + if (batteryMv == null) return '—'; + final percent = estimateBatteryPercentFromMillivolts( + batteryMv, + _batteryChemistry(), + ); + final volts = (batteryMv / 1000.0).toStringAsFixed(2); return '$percent% / ${volts}V'; } - int _batteryPercentFromMv(int millivolts) { - const minMv = 3000; - const maxMv = 4200; - if (millivolts <= minMv) return 0; - if (millivolts >= maxMv) return 100; - return (((millivolts - minMv) * 100) / (maxMv - minMv)).round(); + String _batteryChemistry() { + final settingsService = context.read(); + return settingsService.batteryChemistryForRepeater( + widget.repeater.publicKeyHex, + ); } String _clockText() { diff --git a/lib/screens/scanner_screen.dart b/lib/screens/scanner_screen.dart index 932e29c..af9d75e 100644 --- a/lib/screens/scanner_screen.dart +++ b/lib/screens/scanner_screen.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; import '../l10n/l10n.dart'; +import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/device_tile.dart'; import 'contacts_screen.dart'; @@ -70,7 +71,7 @@ class _ScannerScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text(context.l10n.scanner_title), + title: AdaptiveAppBarTitle(context.l10n.scanner_title), centerTitle: true, automaticallyImplyLeading: false, ), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 94d541b..a198f99 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -8,6 +8,7 @@ import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; import '../l10n/l10n.dart'; import '../models/radio_settings.dart'; +import '../widgets/adaptive_app_bar_title.dart'; import 'app_settings_screen.dart'; import 'app_debug_log_screen.dart'; import 'ble_debug_log_screen.dart'; @@ -41,7 +42,10 @@ 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: AdaptiveAppBarTitle(l10n.settings_title), + centerTitle: true, + ), body: SafeArea( top: false, child: Consumer( diff --git a/lib/screens/telemetry_screen.dart b/lib/screens/telemetry_screen.dart index 8770938..3f95ccd 100644 --- a/lib/screens/telemetry_screen.dart +++ b/lib/screens/telemetry_screen.dart @@ -5,11 +5,14 @@ import 'package:provider/provider.dart'; import '../l10n/l10n.dart'; import '../models/contact.dart'; import '../models/path_selection.dart'; +import '../models/app_settings.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; +import '../services/app_settings_service.dart'; import '../services/repeater_command_service.dart'; import '../widgets/path_management_dialog.dart'; import '../helpers/cayenne_lpp.dart'; +import '../utils/battery_utils.dart'; class TelemetryScreen extends StatefulWidget { final Contact repeater; @@ -72,9 +75,19 @@ class _TelemetryScreenState extends State { } void _handleStatusResponse(Uint8List frame) { + final parsedTelemetry = CayenneLpp.parseByChannel(frame); + final batteryMv = _extractTelemetryBatteryMillivolts(parsedTelemetry); + if (batteryMv != null) { + final connector = Provider.of(context, listen: false); + connector.updateRepeaterBatterySnapshot( + widget.repeater.publicKeyHex, + batteryMv, + source: 'telemetry', + ); + } if (!mounted) return; setState(() { - _parsedTelemetry = CayenneLpp.parseByChannel(frame); + _parsedTelemetry = parsedTelemetry; }); ScaffoldMessenger.of(context).showSnackBar( @@ -181,6 +194,8 @@ class _TelemetryScreenState extends State { Widget build(BuildContext context) { final l10n = context.l10n; final connector = context.watch(); + final settings = context.watch().settings; + final isImperialUnits = settings.unitSystem == UnitSystem.imperial; final repeater = _resolveRepeater(connector); final isFloodMode = repeater.pathOverride == -1; @@ -307,6 +322,7 @@ class _TelemetryScreenState extends State { entry['values'], l10n.telemetry_channelTitle(entry['channel']), entry['channel'], + isImperialUnits, ), ], ), @@ -319,6 +335,7 @@ class _TelemetryScreenState extends State { Map channelData, String title, int channel, + bool isImperialUnits, ) { final l10n = context.l10n; return Card( @@ -358,12 +375,12 @@ class _TelemetryScreenState extends State { else if (entry.key == 'temperature' && channel == 1) _buildInfoRow( l10n.telemetry_mcuTemperatureLabel, - _temperatureText(entry.value), + _temperatureText(entry.value, isImperialUnits), ) else if (entry.key == 'temperature') _buildInfoRow( l10n.telemetry_temperatureLabel, - _temperatureText(entry.value), + _temperatureText(entry.value, isImperialUnits), ) else if (entry.key == 'current' && channel == 1) _buildInfoRow( @@ -405,29 +422,44 @@ class _TelemetryScreenState extends State { ); } - String _batteryText(double? batteryMv) { + int? _extractTelemetryBatteryMillivolts(List> entries) { + for (final entry in entries) { + if (entry['channel'] != 1) continue; + final values = entry['values']; + if (values is! Map) continue; + final voltage = values['voltage']; + if (voltage is num) return (voltage.toDouble() * 1000).round(); + } + return null; + } + + String _batteryText(double? telemetryVolts) { final l10n = context.l10n; + final connector = context.watch(); + final batteryMv = + connector.getRepeaterBatteryMillivolts(widget.repeater.publicKeyHex) ?? + (telemetryVolts == null ? null : (telemetryVolts * 1000).round()); if (batteryMv == null) return l10n.common_notAvailable; - final percent = _batteryPercentFromMv(batteryMv); - final volts = batteryMv.toStringAsFixed(2); + final chemistry = _batteryChemistry(); + final percent = estimateBatteryPercentFromMillivolts(batteryMv, chemistry); + final volts = (batteryMv / 1000).toStringAsFixed(2); return l10n.telemetry_batteryValue(percent, volts); } - int _batteryPercentFromMv(double millivolts) { - const minMv = 2.800; - const maxMv = 4.200; - if (millivolts <= minMv) return 0; - if (millivolts >= maxMv) return 100; - return (((millivolts - minMv) * 100) / (maxMv - minMv)).round(); + String _batteryChemistry() { + final settingsService = context.read(); + return settingsService.batteryChemistryForRepeater( + widget.repeater.publicKeyHex, + ); } - String _temperatureText(double? tempC) { + String _temperatureText(double? tempC, bool isImperialUnits) { final l10n = context.l10n; if (tempC == null) return l10n.common_notAvailable; final tempF = (tempC * 9 / 5) + 32; - return l10n.telemetry_temperatureValue( - tempC.toStringAsFixed(1), - tempF.toStringAsFixed(1), - ); + if (isImperialUnits) { + return '${tempF.toStringAsFixed(1)}°F'; + } + return '${tempC.toStringAsFixed(1)}°C'; } } diff --git a/lib/services/app_settings_service.dart b/lib/services/app_settings_service.dart index c1e8fc6..eacf26f 100644 --- a/lib/services/app_settings_service.dart +++ b/lib/services/app_settings_service.dart @@ -17,6 +17,12 @@ class AppSettingsService extends ChangeNotifier { return stored ?? 'nmc'; } + String batteryChemistryForRepeater(String repeaterPubKeyHex) { + final stored = _settings.batteryChemistryByRepeaterId[repeaterPubKeyHex]; + if (stored == 'liion') return 'nmc'; + return stored ?? 'nmc'; + } + Future loadSettings() async { final prefs = PrefsManager.instance; final jsonStr = prefs.getString(_settingsKey); @@ -74,6 +80,10 @@ class AppSettingsService extends ChangeNotifier { await updateSettings(_settings.copyWith(mapShowMarkers: value)); } + Future setEnableMessageTracing(bool value) async { + await updateSettings(_settings.copyWith(enableMessageTracing: value)); + } + Future setMapCacheBounds(Map? value) async { await updateSettings(_settings.copyWith(mapCacheBounds: value)); } @@ -132,4 +142,36 @@ class AppSettingsService extends ChangeNotifier { _settings.copyWith(batteryChemistryByDeviceId: updated), ); } + + Future setBatteryChemistryForRepeater( + String repeaterPubKeyHex, + String chemistry, + ) async { + final updated = Map.from( + _settings.batteryChemistryByRepeaterId, + ); + updated[repeaterPubKeyHex] = chemistry; + await updateSettings( + _settings.copyWith(batteryChemistryByRepeaterId: updated), + ); + } + + Future setUnitSystem(UnitSystem value) async { + await updateSettings(_settings.copyWith(unitSystem: value)); + } + + bool isChannelMuted(String channelName) { + return _settings.mutedChannels.contains(channelName); + } + + Future muteChannel(String channelName) async { + final updated = Set.from(_settings.mutedChannels)..add(channelName); + await updateSettings(_settings.copyWith(mutedChannels: updated)); + } + + Future unmuteChannel(String channelName) async { + final updated = Set.from(_settings.mutedChannels) + ..remove(channelName); + await updateSettings(_settings.copyWith(mutedChannels: updated)); + } } diff --git a/lib/services/ble_debug_log_service.dart b/lib/services/ble_debug_log_service.dart index 0a9aeae..bc46b59 100644 --- a/lib/services/ble_debug_log_service.dart +++ b/lib/services/ble_debug_log_service.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; import '../connector/meshcore_protocol.dart'; class BleDebugLogEntry { @@ -44,6 +45,7 @@ class BleDebugLogService extends ChangeNotifier { static const int maxEntries = 500; final List _entries = []; final List _rawLogRxEntries = []; + bool _notifyScheduled = false; List get entries => List.unmodifiable(_entries); List get rawLogRxEntries => @@ -78,13 +80,31 @@ class BleDebugLogService extends ChangeNotifier { } } - notifyListeners(); + _notifyListenersSafely(); } void clear() { _entries.clear(); _rawLogRxEntries.clear(); - notifyListeners(); + _notifyListenersSafely(); + } + + void _notifyListenersSafely() { + final phase = SchedulerBinding.instance.schedulerPhase; + final canNotifyNow = + phase == SchedulerPhase.idle || + phase == SchedulerPhase.postFrameCallbacks; + if (canNotifyNow) { + notifyListeners(); + return; + } + + if (_notifyScheduled) return; + _notifyScheduled = true; + SchedulerBinding.instance.addPostFrameCallback((_) { + _notifyScheduled = false; + notifyListeners(); + }); } String _describeFrame( diff --git a/lib/services/chat_text_scale_service.dart b/lib/services/chat_text_scale_service.dart new file mode 100644 index 0000000..21d6a5f --- /dev/null +++ b/lib/services/chat_text_scale_service.dart @@ -0,0 +1,72 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../storage/prefs_manager.dart'; + +/// Client-side accessibility/UI service that exposes a persistent shared text scale +/// factor. No MeshCoreConnector/RoomServer or protocol interaction occurs, and the +/// value is saved locally via SharedPreferences so it can be reused in Markdown +/// viewers, log panels, or other text-heavy widgets without redundant network +/// dependencies. +/// +/// Widgets should scope rebuilds using the snippet below so only the scaled text +/// is rebuilt instead of the entire chat list: +/// ```dart +/// context.select( +/// (service) => service.scale, +/// ) +/// ``` +class ChatTextScaleService extends ChangeNotifier { + static const _prefKey = 'chat_text_scale'; + static const double _minScale = 0.8; + static const double _maxScale = 1.8; + + double _scale = 1.0; + Timer? _saveTimer; + + double get scale => _scale; + + Future initialize() async { + final stored = PrefsManager.instance.getDouble(_prefKey); + if (stored != null) { + _scale = _clamp(stored); + } + } + + void setScale(double value, {bool persistImmediately = false}) { + final next = _clamp(value); + if (next == _scale) return; + _scale = next; + notifyListeners(); + if (persistImmediately) { + _commitScale(); + } else { + _scheduleSave(); + } + } + + void reset() { + setScale(1.0, persistImmediately: true); + } + + void persist() => _commitScale(); + + @override + void dispose() { + _saveTimer?.cancel(); + super.dispose(); + } + + void _scheduleSave() { + _saveTimer?.cancel(); + _saveTimer = Timer(const Duration(milliseconds: 250), _commitScale); + } + + void _commitScale() { + _saveTimer?.cancel(); + PrefsManager.instance.setDouble(_prefKey, _scale); + } + + double _clamp(double value) => value.clamp(_minScale, _maxScale).toDouble(); +} diff --git a/lib/services/line_of_sight_service.dart b/lib/services/line_of_sight_service.dart new file mode 100644 index 0000000..7f056c8 --- /dev/null +++ b/lib/services/line_of_sight_service.dart @@ -0,0 +1,446 @@ +import 'dart:convert'; +import 'dart:async'; +import 'dart:math'; + +import 'package:http/http.dart' as http; +import 'package:latlong2/latlong.dart'; + +typedef ElevationDataSource = + Future> Function(List points); + +class LineOfSightSample { + final double distanceMeters; + final double terrainMeters; + final double lineHeightMeters; + final double refractedHeightMeters; + final double clearanceMeters; + + const LineOfSightSample({ + required this.distanceMeters, + required this.terrainMeters, + required this.lineHeightMeters, + required this.refractedHeightMeters, + required this.clearanceMeters, + }); +} + +class LineOfSightResult { + final bool hasData; + final bool isClear; + final double totalDistanceMeters; + final double maxObstructionMeters; + final double? firstObstructionDistanceMeters; + final List samples; + final String? errorMessage; + final double usedKFactor; + final double? frequencyMHz; + + const LineOfSightResult({ + required this.hasData, + required this.isClear, + required this.totalDistanceMeters, + required this.maxObstructionMeters, + required this.firstObstructionDistanceMeters, + required this.samples, + required this.usedKFactor, + this.frequencyMHz, + this.errorMessage, + }); + + const LineOfSightResult.error({ + required this.totalDistanceMeters, + required this.errorMessage, + this.usedKFactor = 4.0 / 3.0, + this.frequencyMHz, + }) : hasData = false, + isClear = false, + maxObstructionMeters = 0, + firstObstructionDistanceMeters = null, + samples = const []; +} + +class LineOfSightPathSegment { + final int index; + final LatLng start; + final LatLng end; + final LineOfSightResult result; + + const LineOfSightPathSegment({ + required this.index, + required this.start, + required this.end, + required this.result, + }); +} + +class LineOfSightPathResult { + final List segments; + final int clearSegments; + final int blockedSegments; + final int unknownSegments; + + const LineOfSightPathResult({ + required this.segments, + required this.clearSegments, + required this.blockedSegments, + required this.unknownSegments, + }); +} + +class LineOfSightService { + static const String errorElevationUnavailable = + 'los_error_elevation_unavailable'; + static const String errorInvalidInput = 'los_error_invalid_input'; + + static const double _earthRadiusMeters = 6371000.0; + static const Distance _distance = Distance(); + static const Duration _cacheTtl = Duration(hours: 24); + static const int _maxFetchAttempts = 4; // initial try + 3 retries + static const Duration _initialBackoff = Duration(milliseconds: 300); + static const double _baselineFrequencyMHz = 915.0; + static const double _baselineKFactor = 4.0 / 3.0; + + static double get baselineFrequencyMHz => _baselineFrequencyMHz; + static double get baselineKFactor => _baselineKFactor; + + final http.Client _httpClient; + final bool _ownsHttpClient; + final ElevationDataSource? _elevationDataSource; + final Map _elevationCache = {}; + + LineOfSightService({ + http.Client? httpClient, + ElevationDataSource? elevationDataSource, + }) : _httpClient = httpClient ?? http.Client(), + _ownsHttpClient = httpClient == null, + _elevationDataSource = elevationDataSource; + + Future analyzePath( + List points, { + double startAntennaHeightMeters = 1.5, + double endAntennaHeightMeters = 1.5, + double? frequencyMHz, + double obstructionToleranceMeters = 0.0, + }) async { + if (points.length < 2) { + return const LineOfSightPathResult( + segments: [], + clearSegments: 0, + blockedSegments: 0, + unknownSegments: 0, + ); + } + + final segments = []; + var clearSegments = 0; + var blockedSegments = 0; + var unknownSegments = 0; + + final kFactor = _kFactorForFrequency(frequencyMHz); + for (int i = 0; i < points.length - 1; i++) { + final result = await analyzeLink( + points[i], + points[i + 1], + startAntennaHeightMeters: startAntennaHeightMeters, + endAntennaHeightMeters: endAntennaHeightMeters, + kFactor: kFactor, + frequencyMHz: frequencyMHz, + obstructionToleranceMeters: obstructionToleranceMeters, + ); + segments.add( + LineOfSightPathSegment( + index: i, + start: points[i], + end: points[i + 1], + result: result, + ), + ); + + if (!result.hasData) { + unknownSegments++; + } else if (result.isClear) { + clearSegments++; + } else { + blockedSegments++; + } + } + + return LineOfSightPathResult( + segments: segments, + clearSegments: clearSegments, + blockedSegments: blockedSegments, + unknownSegments: unknownSegments, + ); + } + + Future analyzeLink( + LatLng start, + LatLng end, { + double startAntennaHeightMeters = 1.5, + double endAntennaHeightMeters = 1.5, + required double kFactor, + double? frequencyMHz, + double obstructionToleranceMeters = 0.0, + }) async { + final totalDistanceMeters = _distance.as(LengthUnit.Meter, start, end); + if (totalDistanceMeters <= 1) { + return LineOfSightResult( + hasData: true, + isClear: true, + totalDistanceMeters: totalDistanceMeters, + maxObstructionMeters: 0, + firstObstructionDistanceMeters: null, + samples: const [], + usedKFactor: kFactor, + frequencyMHz: frequencyMHz, + ); + } + + final samplePoints = _buildSamplePoints(start, end, totalDistanceMeters); + final elevations = await _getElevations(samplePoints); + + if (elevations.any((e) => e == null)) { + return LineOfSightResult.error( + totalDistanceMeters: totalDistanceMeters, + errorMessage: errorElevationUnavailable, + usedKFactor: kFactor, + frequencyMHz: frequencyMHz, + ); + } + + return computeFromElevations( + points: samplePoints, + elevations: elevations.cast(), + startAntennaHeightMeters: startAntennaHeightMeters, + endAntennaHeightMeters: endAntennaHeightMeters, + kFactor: kFactor, + frequencyMHz: frequencyMHz, + obstructionToleranceMeters: obstructionToleranceMeters, + ); + } + + static LineOfSightResult computeFromElevations({ + required List points, + required List elevations, + double startAntennaHeightMeters = 1.5, + double endAntennaHeightMeters = 1.5, + required double kFactor, + double? frequencyMHz, + double obstructionToleranceMeters = 0.0, + }) { + if (points.length < 2 || elevations.length != points.length) { + return LineOfSightResult.error( + totalDistanceMeters: 0, + errorMessage: errorInvalidInput, + usedKFactor: kFactor, + frequencyMHz: frequencyMHz, + ); + } + + final totalDistanceMeters = _distance.as( + LengthUnit.Meter, + points.first, + points.last, + ); + final effectiveEarthRadius = _earthRadiusMeters * kFactor; + final startLineHeight = elevations.first + startAntennaHeightMeters; + final endLineHeight = elevations.last + endAntennaHeightMeters; + + var maxObstructionMeters = 0.0; + double? firstObstructionDistanceMeters; + final samples = []; + var isClear = true; + + for (int i = 0; i < points.length; i++) { + final fraction = points.length == 1 ? 0.0 : i / (points.length - 1); + final distanceFromStart = totalDistanceMeters * fraction; + final lineHeight = + startLineHeight + (endLineHeight - startLineHeight) * fraction; + + final earthBulge = + (distanceFromStart * (totalDistanceMeters - distanceFromStart)) / + (2 * effectiveEarthRadius); + final terrainHeight = elevations[i] + earthBulge; + final clearance = lineHeight - terrainHeight; + final unrefBulge = + (distanceFromStart * (totalDistanceMeters - distanceFromStart)) / + (2 * _earthRadiusMeters); + final refractedHeight = lineHeight + (unrefBulge - earthBulge); + + if (clearance < -obstructionToleranceMeters) { + isClear = false; + final obstruction = -clearance; + if (obstruction > maxObstructionMeters) { + maxObstructionMeters = obstruction; + } + firstObstructionDistanceMeters ??= distanceFromStart; + } + + samples.add( + LineOfSightSample( + distanceMeters: distanceFromStart, + terrainMeters: terrainHeight, + lineHeightMeters: lineHeight, + refractedHeightMeters: refractedHeight, + clearanceMeters: clearance, + ), + ); + } + + return LineOfSightResult( + hasData: true, + isClear: isClear, + totalDistanceMeters: totalDistanceMeters, + maxObstructionMeters: maxObstructionMeters, + firstObstructionDistanceMeters: firstObstructionDistanceMeters, + samples: samples, + usedKFactor: kFactor, + frequencyMHz: frequencyMHz, + ); + } + + static double _kFactorForFrequency(double? frequencyMHz) { + if (frequencyMHz == null) return _baselineKFactor; + final delta = + (frequencyMHz - _baselineFrequencyMHz) / _baselineFrequencyMHz; + final adjustment = delta * 0.15; + final scaled = _baselineKFactor * (1 + adjustment); + return scaled.clamp(1.1, 1.6).toDouble(); + } + + List _buildSamplePoints( + LatLng start, + LatLng end, + double distanceMeters, + ) { + final sampleCount = distanceMeters < 2000 + ? 21 + : distanceMeters < 10000 + ? 41 + : 81; + + final points = []; + for (int i = 0; i < sampleCount; i++) { + final t = i / (sampleCount - 1); + points.add( + LatLng( + start.latitude + (end.latitude - start.latitude) * t, + start.longitude + (end.longitude - start.longitude) * t, + ), + ); + } + return points; + } + + Future> _getElevations(List points) async { + final dataSource = _elevationDataSource; + if (dataSource != null) { + return dataSource(points); + } + + final uncached = {}; + final values = List.filled(points.length, null); + for (int i = 0; i < points.length; i++) { + final key = _cacheKey(points[i]); + final cached = _readCachedValue(key); + if (cached != null) { + values[i] = cached; + } else { + uncached[i] = points[i]; + } + } + + if (uncached.isEmpty) return values; + + final latCsv = uncached.values + .map((p) => p.latitude.toStringAsFixed(6)) + .join(','); + final lonCsv = uncached.values + .map((p) => p.longitude.toStringAsFixed(6)) + .join(','); + + final uri = Uri.parse( + 'https://api.open-meteo.com/v1/elevation?latitude=$latCsv&longitude=$lonCsv', + ); + + final response = await _getWithBackoff(uri); + if (response.statusCode != 200) { + return values; + } + + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + return values; + } + final elevations = decoded['elevation']; + if (elevations is! List) { + return values; + } + + final indices = uncached.keys.toList(); + for (int i = 0; i < min(indices.length, elevations.length); i++) { + final value = elevations[i]; + if (value is! num) continue; + final index = indices[i]; + final elevation = value.toDouble(); + values[index] = elevation; + _elevationCache[_cacheKey(points[index])] = _CachedElevation( + value: elevation, + expiresAt: DateTime.now().add(_cacheTtl), + ); + } + return values; + } + + Future _getWithBackoff(Uri uri) async { + var attempt = 0; + Duration backoff = _initialBackoff; + + while (true) { + attempt++; + try { + final response = await _httpClient.get(uri); + if (!_shouldRetryStatus(response.statusCode) || + attempt >= _maxFetchAttempts) { + return response; + } + } catch (_) { + if (attempt >= _maxFetchAttempts) rethrow; + } + + await Future.delayed(backoff); + backoff *= 2; + } + } + + bool _shouldRetryStatus(int statusCode) { + return statusCode == 429 || statusCode >= 500; + } + + double? _readCachedValue(String key) { + final cached = _elevationCache[key]; + if (cached == null) return null; + if (DateTime.now().isAfter(cached.expiresAt)) { + _elevationCache.remove(key); + return null; + } + return cached.value; + } + + String _cacheKey(LatLng point) { + return '${point.latitude.toStringAsFixed(5)},${point.longitude.toStringAsFixed(5)}'; + } + + void dispose() { + if (_ownsHttpClient) { + _httpClient.close(); + } + } +} + +class _CachedElevation { + final double value; + final DateTime expiresAt; + + const _CachedElevation({required this.value, required this.expiresAt}); +} diff --git a/lib/services/message_retry_service.dart b/lib/services/message_retry_service.dart index 9cbd68f..694a616 100644 --- a/lib/services/message_retry_service.dart +++ b/lib/services/message_retry_service.dart @@ -234,7 +234,11 @@ class MessageRetryService extends ChangeNotifier { } } - void updateMessageFromSent(Uint8List ackHash, int timeoutMs) { + bool updateMessageFromSent( + Uint8List ackHash, + int timeoutMs, { + bool allowQueueFallback = true, + }) { final ackHashHex = ackHash .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(); @@ -277,7 +281,7 @@ class MessageRetryService extends ChangeNotifier { } // FALLBACK: Old queue-based matching (for messages sent before hash computation was added) - if (messageId == null) { + if (messageId == null && allowQueueFallback) { _debugLogService?.warn( 'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue', tag: 'AckHash', @@ -320,7 +324,7 @@ class MessageRetryService extends ChangeNotifier { if (messageId == null || contact == null) { debugPrint('No pending message found for ACK hash: $ackHashHex'); - return; + return false; } // Store the mapping for future lookups (e.g., when ACK arrives) @@ -339,7 +343,7 @@ class MessageRetryService extends ChangeNotifier { 'Message $messageId no longer pending for ACK hash: $ackHashHex', ); _ackHashToMessageId.remove(ackHashHex); - return; + return false; } // Add this ACK hash to the list of expected ACKs for this message (for history) @@ -389,8 +393,11 @@ class MessageRetryService extends ChangeNotifier { _startTimeoutTimer(messageId, actualTimeout); debugPrint('Updated message $messageId with ACK hash: $ackHashHex'); + return true; } + bool get hasPendingMessages => _pendingMessages.isNotEmpty; + void _startTimeoutTimer(String messageId, int timeoutMs) { _timeoutTimers[messageId]?.cancel(); _timeoutTimers[messageId] = Timer(Duration(milliseconds: timeoutMs), () { diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index fc979c6..0b59bbc 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -58,11 +58,17 @@ class NotificationService { requestBadgePermission: true, requestSoundPermission: true, ); + const windowsSettings = WindowsInitializationSettings( + appName: 'MeshCore Open', + appUserModelId: 'org.meshcore.open.app', + guid: 'e7ea8f85-72f5-4f36-91f6-038f740ccf86', + ); const initSettings = InitializationSettings( android: androidSettings, iOS: iosSettings, macOS: macSettings, + windows: windowsSettings, ); try { @@ -76,6 +82,13 @@ class NotificationService { } } + Future _ensureInitialized() async { + if (!_isInitialized) { + await initialize(); + } + return _isInitialized; + } + Future requestPermissions() async { if (!_isInitialized) { await initialize(); @@ -114,9 +127,7 @@ class NotificationService { String? contactId, int? badgeCount, }) async { - if (!_isInitialized) { - await initialize(); - } + if (!await _ensureInitialized()) return; final androidDetails = AndroidNotificationDetails( 'messages', @@ -148,13 +159,17 @@ class NotificationService { macOS: macDetails, ); - await _notifications.show( - id: contactId?.hashCode ?? 0, - title: contactName, - body: message, - notificationDetails: notificationDetails, - payload: 'message:$contactId', - ); + try { + await _notifications.show( + id: contactId?.hashCode ?? 0, + title: contactName, + body: message, + notificationDetails: notificationDetails, + payload: 'message:$contactId', + ); + } catch (e) { + debugPrint('Failed to show message notification: $e'); + } } Future _showAdvertNotificationImpl({ @@ -162,9 +177,7 @@ class NotificationService { required String contactType, String? contactId, }) async { - if (!_isInitialized) { - await initialize(); - } + if (!await _ensureInitialized()) return; const androidDetails = AndroidNotificationDetails( 'adverts', @@ -193,13 +206,17 @@ class NotificationService { macOS: macDetails, ); - await _notifications.show( - id: contactId?.hashCode ?? DateTime.now().millisecondsSinceEpoch, - title: _l10n.notification_newTypeDiscovered(contactType), - body: contactName, - notificationDetails: notificationDetails, - payload: 'advert:$contactId', - ); + try { + await _notifications.show( + id: contactId?.hashCode ?? DateTime.now().millisecondsSinceEpoch, + title: _l10n.notification_newTypeDiscovered(contactType), + body: contactName, + notificationDetails: notificationDetails, + payload: 'advert:$contactId', + ); + } catch (e) { + debugPrint('Failed to show advert notification: $e'); + } } Future _showChannelMessageNotificationImpl({ @@ -208,9 +225,7 @@ class NotificationService { int? channelIndex, int? badgeCount, }) async { - if (!_isInitialized) { - await initialize(); - } + if (!await _ensureInitialized()) return; final androidDetails = AndroidNotificationDetails( 'channel_messages', @@ -247,13 +262,17 @@ class NotificationService { ? _l10n.notification_receivedNewMessage : preview; - await _notifications.show( - id: channelIndex?.hashCode ?? DateTime.now().millisecondsSinceEpoch, - title: channelName, - body: body, - notificationDetails: notificationDetails, - payload: 'channel:$channelIndex', - ); + try { + await _notifications.show( + id: channelIndex?.hashCode ?? DateTime.now().millisecondsSinceEpoch, + title: channelName, + body: body, + notificationDetails: notificationDetails, + payload: 'channel:$channelIndex', + ); + } catch (e) { + debugPrint('Failed to show channel notification: $e'); + } } /// Returns a privacy-safe identifier for debug logging. @@ -396,35 +415,39 @@ class NotificationService { Future _showNotificationImmediately( _PendingNotification notification, ) async { - switch (notification.type) { - case _NotificationType.message: - await _showMessageNotificationImpl( - contactName: notification.title, - message: notification.body, - contactId: notification.id, - badgeCount: notification.badgeCount, - ); - break; - case _NotificationType.advert: - await _showAdvertNotificationImpl( - contactName: notification.body, - contactType: notification.title, - contactId: notification.id, - ); - break; - case _NotificationType.channelMessage: - await _showChannelMessageNotificationImpl( - channelName: notification.title, - message: notification.body, - channelIndex: int.tryParse(notification.id ?? ''), - badgeCount: notification.badgeCount, - ); - break; + try { + switch (notification.type) { + case _NotificationType.message: + await _showMessageNotificationImpl( + contactName: notification.title, + message: notification.body, + contactId: notification.id, + badgeCount: notification.badgeCount, + ); + break; + case _NotificationType.advert: + await _showAdvertNotificationImpl( + contactName: notification.body, + contactType: notification.title, + contactId: notification.id, + ); + break; + case _NotificationType.channelMessage: + await _showChannelMessageNotificationImpl( + channelName: notification.title, + message: notification.body, + channelIndex: int.tryParse(notification.id ?? ''), + badgeCount: notification.badgeCount, + ); + break; + } + } catch (e) { + debugPrint('Failed to show immediate notification: $e'); } } Future _showBatchSummary(List<_PendingNotification> batch) async { - if (!_isInitialized) await initialize(); + if (!await _ensureInitialized()) return; // Group by type final messages = batch @@ -468,13 +491,17 @@ class NotificationService { const notificationDetails = NotificationDetails(android: androidDetails); - await _notifications.show( - id: 'batch_summary'.hashCode, - title: _l10n.notification_activityTitle, - body: parts.join(', '), - notificationDetails: notificationDetails, - payload: 'batch', - ); + try { + await _notifications.show( + id: 'batch_summary'.hashCode, + title: _l10n.notification_activityTitle, + body: parts.join(', '), + notificationDetails: notificationDetails, + payload: 'batch', + ); + } catch (e) { + debugPrint('Failed to show batch summary notification: $e'); + } } } diff --git a/lib/storage/contact_store.dart b/lib/storage/contact_store.dart index 08d158b..504ff16 100644 --- a/lib/storage/contact_store.dart +++ b/lib/storage/contact_store.dart @@ -33,6 +33,7 @@ class ContactStore { 'publicKey': base64Encode(contact.publicKey), 'name': contact.name, 'type': contact.type, + 'flags': contact.flags, 'pathLength': contact.pathLength, 'path': base64Encode(contact.path), 'pathOverride': contact.pathOverride, @@ -53,6 +54,7 @@ class ContactStore { publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)), name: json['name'] as String? ?? 'Unknown', type: json['type'] as int? ?? 0, + flags: json['flags'] as int? ?? 0, pathLength: json['pathLength'] as int? ?? -1, path: json['path'] != null ? Uint8List.fromList(base64Decode(json['path'] as String)) diff --git a/lib/utils/battery_utils.dart b/lib/utils/battery_utils.dart new file mode 100644 index 0000000..2bf4a5d --- /dev/null +++ b/lib/utils/battery_utils.dart @@ -0,0 +1,26 @@ +typedef BatteryVoltageRange = ({int minMv, int maxMv}); + +BatteryVoltageRange batteryVoltageRange(String chemistry) { + switch (chemistry) { + case 'lifepo4': + return (minMv: 2600, maxMv: 3650); + case 'lipo': + return (minMv: 3000, maxMv: 4200); + case 'nmc': + default: + return (minMv: 3000, maxMv: 4200); + } +} + +int estimateBatteryPercentFromMillivolts(int millivolts, String chemistry) { + final range = batteryVoltageRange(chemistry); + if (millivolts <= range.minMv) return 0; + if (millivolts >= range.maxMv) return 100; + return (((millivolts - range.minMv) * 100) / (range.maxMv - range.minMv)) + .round(); +} + +int estimateBatteryPercentFromVolts(double volts, String chemistry) { + final millivolts = (volts * 1000).round(); + return estimateBatteryPercentFromMillivolts(millivolts, chemistry); +} diff --git a/lib/widgets/adaptive_app_bar_title.dart b/lib/widgets/adaptive_app_bar_title.dart new file mode 100644 index 0000000..12363dd --- /dev/null +++ b/lib/widgets/adaptive_app_bar_title.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +class AdaptiveAppBarTitle extends StatelessWidget { + final String text; + + const AdaptiveAppBarTitle(this.text, {super.key}); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) => SizedBox( + width: constraints.maxWidth, + child: FittedBox(fit: BoxFit.scaleDown, child: Text(text, maxLines: 1)), + ), + ); + } +} diff --git a/lib/widgets/app_bar.dart b/lib/widgets/app_bar.dart new file mode 100644 index 0000000..e1cda77 --- /dev/null +++ b/lib/widgets/app_bar.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:meshcore_open/connector/meshcore_connector.dart'; +import 'package:meshcore_open/widgets/battery_indicator.dart'; +import 'package:provider/provider.dart'; + +import 'snr_indicator.dart'; + +class AppBarTitle extends StatelessWidget { + final String title; + final Widget? leading; + final Widget? trailing; + const AppBarTitle(this.title, {this.leading, this.trailing, super.key}); + + @override + Widget build(BuildContext context) { + final connector = context.watch(); + final selfName = connector.selfName; + + return LayoutBuilder( + builder: (context, constraints) { + final availableWidth = constraints.hasBoundedWidth + ? constraints.maxWidth + : MediaQuery.sizeOf(context).width; + final compact = availableWidth < 240; + final showSubtitle = + !compact && connector.isConnected && selfName != null; + final showBattery = availableWidth >= 60; + final showSnr = availableWidth >= 110; + final showIndicators = showBattery || showSnr; + + return Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + leading ?? const SizedBox.shrink(), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(title, maxLines: 1, overflow: TextOverflow.ellipsis), + if (showSubtitle) + Text( + '($selfName)', + style: TextStyle(fontSize: 14, color: Colors.grey[600]), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + if (showIndicators) const SizedBox(width: 6), + if (showIndicators) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (showBattery) BatteryIndicator(connector: connector), + if (showSnr) SNRIndicator(connector: connector), + ], + ), + trailing ?? const SizedBox.shrink(), + ], + ); + }, + ); + } +} diff --git a/lib/widgets/battery_indicator.dart b/lib/widgets/battery_indicator.dart index 7837415..ccea59d 100644 --- a/lib/widgets/battery_indicator.dart +++ b/lib/widgets/battery_indicator.dart @@ -68,20 +68,24 @@ class _BatteryIndicatorState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(batteryUi.icon, size: 18, color: batteryUi.color), - const SizedBox(width: 2), - Flexible( - child: Text( - displayText, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: batteryUi.color, + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(batteryUi.icon, size: 18, color: batteryUi.color), + const SizedBox(height: 2), + Flexible( + child: Text( + displayText, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: batteryUi.color, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), - overflow: TextOverflow.visible, - maxLines: 1, - softWrap: false, - ), + ], ), ], ), diff --git a/lib/widgets/chat_zoom_wrapper.dart b/lib/widgets/chat_zoom_wrapper.dart new file mode 100644 index 0000000..f0c6815 --- /dev/null +++ b/lib/widgets/chat_zoom_wrapper.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../services/chat_text_scale_service.dart'; + +/// Gesture wrapper that exposes two-finger pinch-to-zoom for chat scrollables. +/// Double-tap resets the scale. Only the wrapper itself listens to gestures; +/// child scrollables keep their normal touch handling. +class ChatZoomWrapper extends StatefulWidget { + const ChatZoomWrapper({super.key, required this.child, this.onDoubleTap}); + + final Widget child; + final VoidCallback? onDoubleTap; + + @override + State createState() => _ChatZoomWrapperState(); +} + +class _ChatZoomWrapperState extends State { + double? _startScale; + + @override + Widget build(BuildContext context) { + final service = context.read(); + + return GestureDetector( + behavior: HitTestBehavior.translucent, + onDoubleTap: () { + service.reset(); + service.persist(); + widget.onDoubleTap?.call(); + }, + onScaleStart: (details) { + if (details.pointerCount != 2) return; + _startScale = service.scale; + }, + onScaleUpdate: (details) { + if (details.pointerCount != 2) return; + final baseScale = _startScale ?? service.scale; + service.setScale(baseScale * details.scale); + }, + onScaleEnd: (_) { + _startScale = null; + service.persist(); + }, + child: widget.child, + ); + } +} diff --git a/lib/widgets/list_filter_widget.dart b/lib/widgets/list_filter_widget.dart index e9c0d9e..473a3df 100644 --- a/lib/widgets/list_filter_widget.dart +++ b/lib/widgets/list_filter_widget.dart @@ -3,7 +3,7 @@ import '../l10n/l10n.dart'; enum ContactSortOption { lastSeen, recentMessages, name } -enum ContactTypeFilter { all, users, repeaters, rooms } +enum ContactTypeFilter { all, favorites, users, repeaters, rooms } class SortFilterMenuOption { final int value; @@ -94,11 +94,12 @@ const int _actionSortRecentMessages = 1; const int _actionSortName = 2; const int _actionSortLastSeen = 3; const int _actionFilterAll = 4; -const int _actionFilterUsers = 5; -const int _actionFilterRepeaters = 6; -const int _actionFilterRooms = 7; -const int _actionToggleUnreadOnly = 8; -const int _actionNewGroup = 9; +const int _actionFilterFavorites = 5; +const int _actionFilterUsers = 6; +const int _actionFilterRepeaters = 7; +const int _actionFilterRooms = 8; +const int _actionToggleUnreadOnly = 9; +const int _actionNewGroup = 10; class ContactsFilterMenu extends StatelessWidget { final ContactSortOption sortOption; @@ -154,6 +155,11 @@ class ContactsFilterMenu extends StatelessWidget { label: l10n.listFilter_all, checked: typeFilter == ContactTypeFilter.all, ), + SortFilterMenuOption( + value: _actionFilterFavorites, + label: l10n.listFilter_favorites, + checked: typeFilter == ContactTypeFilter.favorites, + ), SortFilterMenuOption( value: _actionFilterUsers, label: l10n.listFilter_users, @@ -198,6 +204,9 @@ class ContactsFilterMenu extends StatelessWidget { case _actionFilterUsers: onTypeFilterChanged(ContactTypeFilter.users); break; + case _actionFilterFavorites: + onTypeFilterChanged(ContactTypeFilter.favorites); + break; case _actionFilterRepeaters: onTypeFilterChanged(ContactTypeFilter.repeaters); break; diff --git a/lib/widgets/message_status_icon.dart b/lib/widgets/message_status_icon.dart new file mode 100644 index 0000000..0689f0b --- /dev/null +++ b/lib/widgets/message_status_icon.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +class MessageStatusIcon extends StatelessWidget { + final bool isAcked; + final bool isFailed; + final double size; + + const MessageStatusIcon({ + super.key, + required this.isAcked, + this.isFailed = false, + this.size = 14, + }); + + @override + Widget build(BuildContext context) { + if (isFailed) { + return Icon(Icons.cancel, size: size, color: Colors.red); + } + + final Color color; + if (isAcked) { + color = Colors.green; + } else { + color = Colors.grey; + } + + return SvgPicture.asset( + 'assets/icons/done_all.svg', + width: size, + height: size, + colorFilter: ColorFilter.mode(color, BlendMode.srcIn), + ); + } +} diff --git a/lib/widgets/path_management_dialog.dart b/lib/widgets/path_management_dialog.dart index 483697f..c2b6d12 100644 --- a/lib/widgets/path_management_dialog.dart +++ b/lib/widgets/path_management_dialog.dart @@ -1,7 +1,9 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:meshcore_open/models/path_history.dart'; import 'package:meshcore_open/screens/path_trace_map.dart'; +import 'package:meshcore_open/widgets/elements_ui.dart'; import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; @@ -19,15 +21,22 @@ class PathManagementDialog { } } -class _PathManagementDialog extends StatelessWidget { +class _PathManagementDialog extends StatefulWidget { final Contact contact; const _PathManagementDialog({required this.contact}); + @override + State<_PathManagementDialog> createState() => _PathManagementDialogState(); +} + +class _PathManagementDialogState extends State<_PathManagementDialog> { + bool _showAllPaths = false; + Contact _resolveContact(MeshCoreConnector connector) { return connector.contacts.firstWhere( - (c) => c.publicKeyHex == contact.publicKeyHex, - orElse: () => contact, + (c) => c.publicKeyHex == widget.contact.publicKeyHex, + orElse: () => widget.contact, ); } @@ -134,6 +143,59 @@ class _PathManagementDialog extends StatelessWidget { final currentContact = _resolveContact(connector); final paths = pathService.getRecentPaths(currentContact.publicKeyHex); + final repeatersList = List.of(connector.directRepeaters) + ..sort((a, b) => b.ranking.compareTo(a.ranking)); + + if (repeatersList.isEmpty) { + _showAllPaths = true; + } + + final directRepeater = repeatersList.isEmpty + ? null + : repeatersList.first; + final secondDirectRepeater = repeatersList.length < 2 + ? null + : repeatersList.elementAt(1); + final thirdDirectRepeater = repeatersList.length < 3 + ? null + : repeatersList.elementAt(2); + + List>> pathsWithRepeaters = + paths.map((path) { + final isDirectRepeater = + directRepeater != null && + path.pathBytes.isNotEmpty && + directRepeater.pubkeyFirstByte == path.pathBytes.first; + final isSecondDirectRepeater = + secondDirectRepeater != null && + path.pathBytes.isNotEmpty && + secondDirectRepeater.pubkeyFirstByte == path.pathBytes.first; + final isThirdDirectRepeater = + thirdDirectRepeater != null && + path.pathBytes.isNotEmpty && + thirdDirectRepeater.pubkeyFirstByte == path.pathBytes.first; + + int ranking = -1; + Color color = Colors.grey; + if (isDirectRepeater) { + color = Colors.green; + ranking = 3; + } else if (isSecondDirectRepeater) { + color = Colors.yellow; + ranking = 2; + } else if (isThirdDirectRepeater) { + color = Colors.red; + ranking = 1; + } else if (path.wasFloodDiscovery) { + color = Colors.blue; + ranking = 0; + } + + return MapEntry(ranking, MapEntry(color, path)); + }).toList(); + + pathsWithRepeaters.sort((a, b) => b.key.compareTo(a.key)); + return AlertDialog( title: Text(l10n.chat_pathManagement), content: SingleChildScrollView( @@ -147,6 +209,17 @@ class _PathManagementDialog extends StatelessWidget { ), const SizedBox(height: 12), if (paths.isNotEmpty) ...[ + if (repeatersList.isNotEmpty) + FeatureToggleRow( + title: l10n.chat_ShowAllPaths, + subtitle: "", + value: _showAllPaths, + onChanged: (val) { + setState(() { + _showAllPaths = val; + }); + }, + ), Text( l10n.chat_recentAckPaths, style: const TextStyle( @@ -154,7 +227,7 @@ class _PathManagementDialog extends StatelessWidget { fontSize: 12, ), ), - if (paths.length >= 100) ...[ + if (pathsWithRepeaters.length >= 100) ...[ const SizedBox(height: 8), Container( width: double.infinity, @@ -173,92 +246,99 @@ class _PathManagementDialog extends StatelessWidget { ), ], const SizedBox(height: 8), - ...paths.map((path) { - return Card( - margin: const EdgeInsets.symmetric(vertical: 4), - child: ListTile( - dense: true, - leading: CircleAvatar( - radius: 16, - backgroundColor: path.wasFloodDiscovery - ? Colors.blue - : Colors.green, - child: Text( - '${path.hopCount}', - style: const TextStyle(fontSize: 12), - ), - ), - title: Text( - l10n.chat_hopsCount(path.hopCount), - style: const TextStyle(fontSize: 14), - ), - subtitle: Text( - '${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(context, path.timestamp)} • ${path.successCount} ${l10n.chat_successes}', - style: const TextStyle(fontSize: 11), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.close, size: 16), - tooltip: l10n.chat_removePath, - onPressed: () async { - await pathService.removePathRecord( - currentContact.publicKeyHex, - path.pathBytes, - ); - }, + ...pathsWithRepeaters.map((entry) { + final path = entry.value.value; + final color = entry.value.key; + + if (!_showAllPaths && entry.key < 1) { + return const SizedBox.shrink(); + } else { + return Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: ListTile( + dense: true, + leading: CircleAvatar( + radius: 16, + backgroundColor: color, + child: Text( + '${path.hopCount}', + style: const TextStyle(fontSize: 12), ), - path.wasFloodDiscovery - ? const Icon( - Icons.waves, - size: 16, - color: Colors.grey, - ) - : const Icon( - Icons.route, - size: 16, - color: Colors.grey, + ), + title: Text( + l10n.chat_hopsCount(path.hopCount), + style: const TextStyle(fontSize: 14), + ), + subtitle: Text( + '${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(context, path.timestamp)} • ${path.successCount} ${l10n.chat_successes}', + style: const TextStyle(fontSize: 11), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.close, size: 16), + tooltip: l10n.chat_removePath, + onPressed: () async { + await pathService.removePathRecord( + currentContact.publicKeyHex, + path.pathBytes, + ); + }, + ), + path.wasFloodDiscovery + ? const Icon( + Icons.waves, + size: 16, + color: Colors.grey, + ) + : const Icon( + Icons.route, + size: 16, + color: Colors.grey, + ), + ], + ), + onLongPress: () => + _showFullPathDialog(context, path.pathBytes), + onTap: () async { + if (path.pathBytes.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + l10n.chat_pathDetailsNotAvailable, ), - ], - ), - onLongPress: () => - _showFullPathDialog(context, path.pathBytes), - onTap: () async { - if (path.pathBytes.isEmpty) { + duration: const Duration(seconds: 2), + ), + ); + return; + } + + final pathBytes = Uint8List.fromList( + path.pathBytes, + ); + final pathLength = path.pathBytes.length; + + await connector.setPathOverride( + currentContact, + pathLen: pathLength, + pathBytes: pathBytes, + ); + + if (!context.mounted) return; + Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - l10n.chat_pathDetailsNotAvailable, + l10n.path_usingHopsPath(path.hopCount), ), duration: const Duration(seconds: 2), ), ); - return; - } - - final pathBytes = Uint8List.fromList(path.pathBytes); - final pathLength = path.pathBytes.length; - - await connector.setPathOverride( - currentContact, - pathLen: pathLength, - pathBytes: pathBytes, - ); - - if (!context.mounted) return; - Navigator.pop(context); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - l10n.path_usingHopsPath(path.hopCount), - ), - duration: const Duration(seconds: 2), - ), - ); - }, - ), - ); + }, + ), + ); + } }), const Divider(), ] else ...[ diff --git a/lib/widgets/snr_indicator.dart b/lib/widgets/snr_indicator.dart index da68a65..db4fb8e 100644 --- a/lib/widgets/snr_indicator.dart +++ b/lib/widgets/snr_indicator.dart @@ -1,4 +1,13 @@ import 'package:flutter/material.dart'; +import '../connector/meshcore_connector.dart'; +import '../l10n/l10n.dart'; + +class SNRUi { + final IconData icon; + final Color color; + final String text; + const SNRUi(this.icon, this.color, this.text); +} List getSNRfromSF(int spreadingFactor) { switch (spreadingFactor) { @@ -19,44 +28,178 @@ List getSNRfromSF(int spreadingFactor) { } } -class SNRIcon extends StatelessWidget { - final double snr; - final List snrLevels; +SNRUi snrUiFromSNR(double? snr, int? spreadingFactor) { + if (snr == null || + spreadingFactor == null || + spreadingFactor < 7 || + spreadingFactor > 12) { + return const SNRUi(Icons.signal_cellular_off, Colors.grey, '—'); + } - const SNRIcon({ - super.key, - required this.snr, - this.snrLevels = const [4.0, -2.0, -4.0, -6.0], - }); + final snrLevels = getSNRfromSF(spreadingFactor); + + IconData icon; + Color color; + String text = '${snr.toStringAsFixed(1)} dB'; + + if (snr >= snrLevels[0]) { + icon = Icons.signal_cellular_alt; + color = Colors.green; + } else if (snr >= snrLevels[1]) { + icon = Icons.signal_cellular_alt; + color = Colors.lightGreen; + } else if (snr >= snrLevels[2]) { + icon = Icons.signal_cellular_alt; + color = Colors.yellow; + } else if (snr >= snrLevels[3]) { + icon = Icons.signal_cellular_alt_2_bar; + color = Colors.orange; + } else { + icon = Icons.signal_cellular_alt_1_bar; + color = Colors.red; + } + + return SNRUi(icon, color, text); +} + +class SNRIndicator extends StatefulWidget { + final MeshCoreConnector connector; + + const SNRIndicator({super.key, required this.connector}); + @override + State createState() => _SNRIndicatorState(); +} + +class _SNRIndicatorState extends State { @override Widget build(BuildContext context) { - IconData icon; - Color color; + final directRepeaters = widget.connector.directRepeaters; + final directBestRepeaters = List.of(directRepeaters) + ..sort((a, b) => (b.ranking).compareTo(a.ranking)); + final directRepeater = directBestRepeaters.isEmpty + ? null + : directBestRepeaters.first; - if (snr >= snrLevels[0]) { - icon = Icons.signal_cellular_alt; - color = Colors.green; - } else if (snr >= snrLevels[1]) { - icon = Icons.signal_cellular_alt; - color = Colors.lightGreen; - } else if (snr >= snrLevels[2]) { - icon = Icons.signal_cellular_alt; - color = Colors.yellow; - } else if (snr >= snrLevels[3]) { - icon = Icons.signal_cellular_alt_2_bar; - color = Colors.orange; - } else { - icon = Icons.signal_cellular_alt_1_bar; - color = Colors.red; + final snrUi = snrUiFromSNR( + directBestRepeaters.isNotEmpty ? directRepeater!.snr : null, + widget.connector.currentSf, + ); + + return InkWell( + onTap: () { + if (directRepeater != null) { + _showFullPathDialog(context, directBestRepeaters); + } + }, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(snrUi.icon, size: 18, color: snrUi.color), + Text( + snrUi.text, + style: TextStyle(fontSize: 12, color: snrUi.color), + ), + ], + ), + if (directRepeater != null) + Text( + '${directRepeaters.length}: ${directRepeater.pubkeyFirstByte.toRadixString(16).padLeft(2, '0')}: ${_formatLastUpdated(directRepeater.lastUpdated)}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Colors.grey, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + } + + String _formatLastUpdated(DateTime lastSeen) { + final now = DateTime.now(); + final diff = now.difference(lastSeen); + if (diff.isNegative) { + return "0s"; } + if (diff.inMinutes < 1) { + return "${diff.inSeconds}s"; + } + if (diff.inMinutes < 60) { + return "${diff.inMinutes}m"; + } + if (diff.inHours < 24) { + final hours = diff.inHours; + return "${hours}h"; + } + final days = diff.inDays; + return "${days}d"; + } - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, color: color), - Text('$snr dB', style: TextStyle(fontSize: 10, color: color)), - ], + void _showFullPathDialog( + BuildContext context, + List directBestRepeaters, + ) { + final l10n = context.l10n; + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.snrIndicator_nearByRepeaters), + content: SizedBox( + child: Scrollbar( + child: ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: directBestRepeaters.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + final repeater = directBestRepeaters[index]; + final snrUi = snrUiFromSNR( + repeater.snr, + widget.connector.currentSf, + ); + + final name = widget.connector.contacts + .where((c) => c.publicKey.first == repeater.pubkeyFirstByte) + .map((c) => c.name) + .firstOrNull; + + return Column( + children: [ + ListTile( + leading: Icon(snrUi.icon, color: snrUi.color), + title: Text( + name ?? + repeater.pubkeyFirstByte + .toRadixString(16) + .padLeft(2, '0'), + ), + subtitle: Text( + 'SNR: ${repeater.snr.toStringAsFixed(1)} dB\n${l10n.snrIndicator_lastSeen}: ${_formatLastUpdated(repeater.lastUpdated)}', + ), + ), + ], + ); + }, + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.common_close), + ), + ], + ), ); } } diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 65fed26..8224cfb 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -5,10 +5,13 @@ PODS: - flutter_local_notifications (0.0.1): - FlutterMacOS - FlutterMacOS (1.0.0) - - mobile_scanner (6.0.2): + - mobile_scanner (7.0.0): + - Flutter - FlutterMacOS - package_info_plus (0.0.1): - FlutterMacOS + - share_plus (0.0.1): + - FlutterMacOS - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS @@ -24,8 +27,9 @@ DEPENDENCIES: - flutter_blue_plus_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin`) - flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - - mobile_scanner (from `Flutter/ephemeral/.symlinks/plugins/mobile_scanner/macos`) + - mobile_scanner (from `Flutter/ephemeral/.symlinks/plugins/mobile_scanner/darwin`) - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) + - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) @@ -39,9 +43,11 @@ EXTERNAL SOURCES: FlutterMacOS: :path: Flutter/ephemeral mobile_scanner: - :path: Flutter/ephemeral/.symlinks/plugins/mobile_scanner/macos + :path: Flutter/ephemeral/.symlinks/plugins/mobile_scanner/darwin package_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos + share_plus: + :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin sqflite_darwin: @@ -53,10 +59,11 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3 - flutter_local_notifications: 13862b132e32eb858dea558a86d45d08daeacfe7 + flutter_local_notifications: 4bf37a31afde695b56091b4ae3e4d9c7a7e6cda0 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 - mobile_scanner: 0e365ed56cad24f28c0fd858ca04edefb40dfac3 + mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 package_info_plus: f0052d280d17aa382b932f399edf32507174e870 + share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd diff --git a/pubspec.lock b/pubspec.lock deleted file mode 100644 index 09e9301..0000000 --- a/pubspec.lock +++ /dev/null @@ -1,1095 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - archive: - dependency: transitive - description: - name: archive - sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" - url: "https://pub.dev" - source: hosted - version: "4.0.7" - args: - dependency: transitive - description: - name: args - sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.dev" - source: hosted - version: "2.7.0" - async: - dependency: transitive - description: - name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" - url: "https://pub.dev" - source: hosted - version: "2.13.0" - bluez: - dependency: transitive - description: - name: bluez - sha256: "61a7204381925896a374301498f2f5399e59827c6498ae1e924aaa598751b545" - url: "https://pub.dev" - source: hosted - version: "0.8.3" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - cached_network_image: - dependency: "direct main" - description: - name: cached_network_image - sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" - url: "https://pub.dev" - source: hosted - version: "3.4.1" - cached_network_image_platform_interface: - dependency: transitive - description: - name: cached_network_image_platform_interface - sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" - url: "https://pub.dev" - source: hosted - version: "4.1.1" - cached_network_image_web: - dependency: transitive - description: - name: cached_network_image_web - sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" - url: "https://pub.dev" - source: hosted - version: "1.3.1" - characters: - dependency: "direct main" - description: - name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - checked_yaml: - dependency: transitive - description: - name: checked_yaml - sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" - url: "https://pub.dev" - source: hosted - version: "2.0.4" - cli_util: - dependency: transitive - description: - name: cli_util - sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c - url: "https://pub.dev" - source: hosted - version: "0.4.2" - clock: - dependency: transitive - description: - name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" - source: hosted - version: "1.1.2" - code_assets: - dependency: transitive - description: - name: code_assets - sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - collection: - dependency: transitive - description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" - source: hosted - version: "1.19.1" - convert: - dependency: transitive - description: - name: convert - sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.dev" - source: hosted - version: "3.1.2" - cross_file: - dependency: transitive - description: - name: cross_file - sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" - url: "https://pub.dev" - source: hosted - version: "0.3.5+2" - crypto: - dependency: "direct main" - description: - name: crypto - sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.dev" - source: hosted - version: "3.0.7" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 - url: "https://pub.dev" - source: hosted - version: "1.0.8" - dart_earcut: - dependency: transitive - description: - name: dart_earcut - sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b - url: "https://pub.dev" - source: hosted - version: "1.2.0" - dart_polylabel2: - dependency: transitive - description: - name: dart_polylabel2 - sha256: "7eeab15ce72894e4bdba6a8765712231fc81be0bd95247de4ad9966abc57adc6" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - dbus: - dependency: transitive - description: - name: dbus - sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 - url: "https://pub.dev" - source: hosted - version: "0.7.12" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.dev" - source: hosted - version: "1.3.3" - ffi: - dependency: transitive - description: - name: ffi - sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - file: - dependency: transitive - description: - name: file - sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.dev" - source: hosted - version: "7.0.1" - fixnum: - dependency: transitive - description: - name: fixnum - sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.dev" - source: hosted - version: "1.1.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_blue_plus: - dependency: "direct main" - description: - name: flutter_blue_plus - sha256: "88a65ead7dea67ddcc03e6ca846163c6b6cc09a2dcebdb8bb601fcd654ea9382" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - flutter_blue_plus_android: - dependency: transitive - description: - name: flutter_blue_plus_android - sha256: "5010b0960cce533a8fa71401573f044362c3e2e111dc6eb4898c92e85f85f50c" - url: "https://pub.dev" - source: hosted - version: "8.1.0" - flutter_blue_plus_darwin: - dependency: transitive - description: - name: flutter_blue_plus_darwin - sha256: "52b155d868e17c1c8ad85520a0912d447d92aedccb5a5e234d3edc98ebd1307a" - url: "https://pub.dev" - source: hosted - version: "8.1.1" - flutter_blue_plus_linux: - dependency: transitive - description: - name: flutter_blue_plus_linux - sha256: f5b02244d89465ba82c8c512686c66362fbb01f52fa03d645ed353ebf3883242 - url: "https://pub.dev" - source: hosted - version: "8.1.0" - flutter_blue_plus_platform_interface: - dependency: transitive - description: - name: flutter_blue_plus_platform_interface - sha256: "6e0fc04b77491dbfdbcd46c1a021b12f2f5fc5d6e01777f93a38a8431989b7f0" - url: "https://pub.dev" - source: hosted - version: "8.1.0" - flutter_blue_plus_web: - dependency: transitive - description: - name: flutter_blue_plus_web - sha256: "376aad9595ee389c7cd56e0c373e78abcaa790c821ece9cb81f0969ec94c5bca" - url: "https://pub.dev" - source: hosted - version: "8.1.0" - flutter_blue_plus_winrt: - dependency: transitive - description: - name: flutter_blue_plus_winrt - sha256: ed894f0ab341f4cece8fa33edc381d46424a7c5bfd0e841d933d0f8c34c86521 - url: "https://pub.dev" - source: hosted - version: "0.0.18" - flutter_cache_manager: - dependency: "direct main" - description: - name: flutter_cache_manager - sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" - url: "https://pub.dev" - source: hosted - version: "3.4.1" - flutter_foreground_task: - dependency: "direct main" - description: - name: flutter_foreground_task - sha256: "48ea45056155a99fb30b15f14f4039a044d925bc85f381ed0b2d3b00a60b99de" - url: "https://pub.dev" - source: hosted - version: "9.2.0" - flutter_launcher_icons: - dependency: "direct dev" - description: - name: flutter_launcher_icons - sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" - url: "https://pub.dev" - source: hosted - version: "0.14.4" - flutter_linkify: - dependency: "direct main" - description: - name: flutter_linkify - sha256: "74669e06a8f358fee4512b4320c0b80e51cffc496607931de68d28f099254073" - url: "https://pub.dev" - source: hosted - version: "6.0.0" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" - url: "https://pub.dev" - source: hosted - version: "6.0.0" - flutter_local_notifications: - dependency: "direct main" - description: - name: flutter_local_notifications - sha256: "2b50e938a275e1ad77352d6a25e25770f4130baa61eaf02de7a9a884680954ad" - url: "https://pub.dev" - source: hosted - version: "20.1.0" - flutter_local_notifications_linux: - dependency: transitive - description: - name: flutter_local_notifications_linux - sha256: dce0116868cedd2cdf768af0365fc37ff1cbef7c02c4f51d0587482e625868d0 - url: "https://pub.dev" - source: hosted - version: "7.0.0" - flutter_local_notifications_platform_interface: - dependency: transitive - description: - name: flutter_local_notifications_platform_interface - sha256: "23de31678a48c084169d7ae95866df9de5c9d2a44be3e5915a2ff067aeeba899" - url: "https://pub.dev" - source: hosted - version: "10.0.0" - flutter_local_notifications_windows: - dependency: transitive - description: - name: flutter_local_notifications_windows - sha256: e97a1a3016512437d9c0b12fae7d1491c3c7b9aa7f03a69b974308840656b02a - url: "https://pub.dev" - source: hosted - version: "2.0.1" - flutter_localizations: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_map: - dependency: "direct main" - description: - name: flutter_map - sha256: "391e7dc95cc3f5190748210a69d4cfeb5d8f84dcdfa9c3235d0a9d7742ccb3f8" - url: "https://pub.dev" - source: hosted - version: "8.2.2" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - glob: - dependency: transitive - description: - name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de - url: "https://pub.dev" - source: hosted - version: "2.1.3" - gpx: - dependency: "direct main" - description: - name: gpx - sha256: f5b12b86402c639079243600ee2b3afd85cd08d26117fc8885cf48efce471d8e - url: "https://pub.dev" - source: hosted - version: "2.3.0" - hooks: - dependency: transitive - description: - name: hooks - sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - http: - dependency: "direct main" - description: - name: http - sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" - url: "https://pub.dev" - source: hosted - version: "1.6.0" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.dev" - source: hosted - version: "4.1.2" - image: - dependency: transitive - description: - name: image - sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c" - url: "https://pub.dev" - source: hosted - version: "4.7.2" - intl: - dependency: "direct main" - description: - name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" - url: "https://pub.dev" - source: hosted - version: "0.20.2" - json_annotation: - dependency: transitive - description: - name: json_annotation - sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df" - url: "https://pub.dev" - source: hosted - version: "4.10.0" - latlong2: - dependency: "direct main" - description: - name: latlong2 - sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe" - url: "https://pub.dev" - source: hosted - version: "0.9.1" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" - url: "https://pub.dev" - source: hosted - version: "11.0.2" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" - url: "https://pub.dev" - source: hosted - version: "3.0.10" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - linkify: - dependency: transitive - description: - name: linkify - sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832" - url: "https://pub.dev" - source: hosted - version: "5.0.0" - lints: - dependency: transitive - description: - name: lints - sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" - url: "https://pub.dev" - source: hosted - version: "6.1.0" - lists: - dependency: transitive - description: - name: lists - sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - logger: - dependency: transitive - description: - name: logger - sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 - url: "https://pub.dev" - source: hosted - version: "2.6.2" - logging: - dependency: transitive - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" - source: hosted - version: "1.3.0" - matcher: - dependency: transitive - description: - name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 - url: "https://pub.dev" - source: hosted - version: "0.12.17" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec - url: "https://pub.dev" - source: hosted - version: "0.11.1" - meta: - dependency: transitive - description: - name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" - url: "https://pub.dev" - source: hosted - version: "1.17.0" - mgrs_dart: - dependency: transitive - description: - name: mgrs_dart - sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7 - url: "https://pub.dev" - source: hosted - version: "2.0.0" - mime: - dependency: transitive - description: - name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - mobile_scanner: - dependency: "direct main" - description: - name: mobile_scanner - sha256: c6184bf2913dd66be244108c9c27ca04b01caf726321c44b0e7a7a1e32d41044 - url: "https://pub.dev" - source: hosted - version: "7.1.4" - native_toolchain_c: - dependency: transitive - description: - name: native_toolchain_c - sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" - url: "https://pub.dev" - source: hosted - version: "0.17.4" - nested: - dependency: transitive - description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - objective_c: - dependency: transitive - description: - name: objective_c - sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" - url: "https://pub.dev" - source: hosted - version: "9.3.0" - octo_image: - dependency: transitive - description: - name: octo_image - sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" - url: "https://pub.dev" - source: hosted - version: "2.1.0" - package_info_plus: - dependency: "direct main" - description: - name: package_info_plus - sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d - url: "https://pub.dev" - source: hosted - version: "9.0.0" - package_info_plus_platform_interface: - dependency: transitive - description: - name: package_info_plus_platform_interface - sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" - url: "https://pub.dev" - source: hosted - version: "3.2.1" - path: - dependency: transitive - description: - name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" - source: hosted - version: "1.9.1" - path_provider: - dependency: "direct main" - description: - name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" - url: "https://pub.dev" - source: hosted - version: "2.1.5" - path_provider_android: - dependency: transitive - description: - name: path_provider_android - sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e - url: "https://pub.dev" - source: hosted - version: "2.2.22" - path_provider_foundation: - dependency: transitive - description: - name: path_provider_foundation - sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" - url: "https://pub.dev" - source: hosted - version: "2.6.0" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 - url: "https://pub.dev" - source: hosted - version: "2.2.1" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.dev" - source: hosted - version: "2.3.0" - petitparser: - dependency: transitive - description: - name: petitparser - sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" - url: "https://pub.dev" - source: hosted - version: "7.0.1" - platform: - dependency: transitive - description: - name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.dev" - source: hosted - version: "3.1.6" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" - source: hosted - version: "2.1.8" - pointycastle: - dependency: "direct main" - description: - name: pointycastle - sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" - url: "https://pub.dev" - source: hosted - version: "4.0.0" - posix: - dependency: transitive - description: - name: posix - sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" - url: "https://pub.dev" - source: hosted - version: "6.0.3" - proj4dart: - dependency: transitive - description: - name: proj4dart - sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e - url: "https://pub.dev" - source: hosted - version: "2.1.0" - provider: - dependency: "direct main" - description: - name: provider - sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" - url: "https://pub.dev" - source: hosted - version: "6.1.5+1" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - qr: - dependency: transitive - description: - name: qr - sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - qr_flutter: - dependency: "direct main" - description: - name: qr_flutter - sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" - url: "https://pub.dev" - source: hosted - version: "4.1.0" - quiver: - dependency: transitive - description: - name: quiver - sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 - url: "https://pub.dev" - source: hosted - version: "3.2.2" - rxdart: - dependency: transitive - description: - name: rxdart - sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" - url: "https://pub.dev" - source: hosted - version: "0.28.0" - share_plus: - dependency: "direct main" - description: - name: share_plus - sha256: "14c8860d4de93d3a7e53af51bff479598c4e999605290756bbbe45cf65b37840" - url: "https://pub.dev" - source: hosted - version: "12.0.1" - share_plus_platform_interface: - dependency: transitive - description: - name: share_plus_platform_interface - sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a" - url: "https://pub.dev" - source: hosted - version: "6.1.0" - shared_preferences: - dependency: "direct main" - description: - name: shared_preferences - sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" - url: "https://pub.dev" - source: hosted - version: "2.5.4" - shared_preferences_android: - dependency: transitive - description: - name: shared_preferences_android - sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f - url: "https://pub.dev" - source: hosted - version: "2.4.20" - shared_preferences_foundation: - dependency: transitive - description: - name: shared_preferences_foundation - sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" - url: "https://pub.dev" - source: hosted - version: "2.5.6" - shared_preferences_linux: - dependency: transitive - description: - name: shared_preferences_linux - sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - shared_preferences_platform_interface: - dependency: transitive - description: - name: shared_preferences_platform_interface - sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - shared_preferences_web: - dependency: transitive - description: - name: shared_preferences_web - sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 - url: "https://pub.dev" - source: hosted - version: "2.4.3" - shared_preferences_windows: - dependency: transitive - description: - name: shared_preferences_windows - sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - source_span: - dependency: transitive - description: - name: source_span - sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" - url: "https://pub.dev" - source: hosted - version: "1.10.2" - sqflite: - dependency: transitive - description: - name: sqflite - sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 - url: "https://pub.dev" - source: hosted - version: "2.4.2" - sqflite_android: - dependency: transitive - description: - name: sqflite_android - sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 - url: "https://pub.dev" - source: hosted - version: "2.4.2+2" - sqflite_common: - dependency: transitive - description: - name: sqflite_common - sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" - url: "https://pub.dev" - source: hosted - version: "2.5.6" - sqflite_darwin: - dependency: transitive - description: - name: sqflite_darwin - sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" - url: "https://pub.dev" - source: hosted - version: "2.4.2" - sqflite_platform_interface: - dependency: transitive - description: - name: sqflite_platform_interface - sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" - url: "https://pub.dev" - source: hosted - version: "2.4.0" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" - source: hosted - version: "1.12.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" - source: hosted - version: "1.4.1" - synchronized: - dependency: transitive - description: - name: synchronized - sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 - url: "https://pub.dev" - source: hosted - version: "3.4.0" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" - source: hosted - version: "1.2.2" - test_api: - dependency: transitive - description: - name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 - url: "https://pub.dev" - source: hosted - version: "0.7.7" - timezone: - dependency: transitive - description: - name: timezone - sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 - url: "https://pub.dev" - source: hosted - version: "0.10.1" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - unicode: - dependency: transitive - description: - name: unicode - sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1" - url: "https://pub.dev" - source: hosted - version: "0.3.1" - url_launcher: - dependency: "direct main" - description: - name: url_launcher - sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 - url: "https://pub.dev" - source: hosted - version: "6.3.2" - url_launcher_android: - dependency: transitive - description: - name: url_launcher_android - sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" - url: "https://pub.dev" - source: hosted - version: "6.3.28" - url_launcher_ios: - dependency: transitive - description: - name: url_launcher_ios - sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" - url: "https://pub.dev" - source: hosted - version: "6.4.1" - url_launcher_linux: - dependency: transitive - description: - name: url_launcher_linux - sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a - url: "https://pub.dev" - source: hosted - version: "3.2.2" - url_launcher_macos: - dependency: transitive - description: - name: url_launcher_macos - sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" - url: "https://pub.dev" - source: hosted - version: "3.2.5" - url_launcher_platform_interface: - dependency: transitive - description: - name: url_launcher_platform_interface - sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - url_launcher_web: - dependency: transitive - description: - name: url_launcher_web - sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f - url: "https://pub.dev" - source: hosted - version: "2.4.2" - url_launcher_windows: - dependency: transitive - description: - name: url_launcher_windows - sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" - url: "https://pub.dev" - source: hosted - version: "3.1.5" - uuid: - dependency: "direct main" - description: - name: uuid - sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 - url: "https://pub.dev" - source: hosted - version: "4.5.2" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b - url: "https://pub.dev" - source: hosted - version: "2.2.0" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" - url: "https://pub.dev" - source: hosted - version: "15.0.2" - wakelock_plus: - dependency: "direct main" - description: - name: wakelock_plus - sha256: "9296d40c9adbedaba95d1e704f4e0b434be446e2792948d0e4aa977048104228" - url: "https://pub.dev" - source: hosted - version: "1.4.0" - wakelock_plus_platform_interface: - dependency: transitive - description: - name: wakelock_plus_platform_interface - sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2" - url: "https://pub.dev" - source: hosted - version: "1.3.0" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - win32: - dependency: transitive - description: - name: win32 - sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e - url: "https://pub.dev" - source: hosted - version: "5.15.0" - wkt_parser: - dependency: transitive - description: - name: wkt_parser - sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - xml: - dependency: transitive - description: - name: xml - sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" - url: "https://pub.dev" - source: hosted - version: "6.6.1" - yaml: - dependency: transitive - description: - name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.dev" - source: hosted - version: "3.1.3" -sdks: - dart: ">=3.10.3 <4.0.0" - flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index f5ceaaf..fb68b00 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -50,7 +50,7 @@ dependencies: cached_network_image: ^3.4.1 flutter_cache_manager: ^3.4.1 flutter_foreground_task: ^9.2.0 - wakelock_plus: ^1.2.8 + wakelock_plus: ^1.4.0 characters: ^1.4.0 package_info_plus: ^9.0.0 mobile_scanner: ^7.1.4 # QR/barcode scanning @@ -60,6 +60,9 @@ dependencies: gpx: ^2.3.0 path_provider: ^2.1.5 share_plus: ^12.0.1 + material_symbols_icons: ^4.2906.0 + web: ^1.1.1 + flutter_svg: ^2.0.10+1 dev_dependencies: flutter_test: @@ -87,6 +90,7 @@ flutter: assets: - assets/images/ + - assets/icons/ flutter_launcher_icons: android: true diff --git a/test/services/line_of_sight_service_test.dart b/test/services/line_of_sight_service_test.dart new file mode 100644 index 0000000..267a70b --- /dev/null +++ b/test/services/line_of_sight_service_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:meshcore_open/services/line_of_sight_service.dart'; + +void main() { + List makePoints(int count) { + return List.generate(count, (i) => LatLng(0, i * 0.00001)); + } + + test('computeFromElevations reports clear LOS on flat terrain', () { + final points = makePoints(21); + final elevations = List.filled(points.length, 100); + + final result = LineOfSightService.computeFromElevations( + points: points, + elevations: elevations, + startAntennaHeightMeters: 2, + endAntennaHeightMeters: 2, + kFactor: 4.0 / 3.0, + ); + + expect(result.hasData, isTrue); + expect(result.isClear, isTrue); + expect(result.maxObstructionMeters, equals(0)); + expect(result.firstObstructionDistanceMeters, isNull); + }); + + test( + 'computeFromElevations reports blocked LOS with central obstruction', + () { + final points = makePoints(21); + final elevations = List.filled(points.length, 100); + elevations[10] = 300; + + final result = LineOfSightService.computeFromElevations( + points: points, + elevations: elevations, + startAntennaHeightMeters: 1.5, + endAntennaHeightMeters: 1.5, + kFactor: 4.0 / 3.0, + ); + + expect(result.hasData, isTrue); + expect(result.isClear, isFalse); + expect(result.maxObstructionMeters, greaterThan(0)); + expect(result.firstObstructionDistanceMeters, isNotNull); + }, + ); + + test('analyzePath summarizes clear and blocked segments', () async { + final service = LineOfSightService( + elevationDataSource: (points) async { + final elevations = List.filled(points.length, 100); + if (points.first.longitude > 0.00005) { + elevations[elevations.length ~/ 2] = 300; + } + return elevations; + }, + ); + + final path = [ + const LatLng(0, 0), + const LatLng(0, 0.0001), + const LatLng(0, 0.0002), + ]; + + final result = await service.analyzePath(path); + + expect(result.segments.length, 2); + expect(result.clearSegments, 1); + expect(result.blockedSegments, 1); + expect(result.unknownSegments, 0); + }); +} diff --git a/test/utils/battery_utils_test.dart b/test/utils/battery_utils_test.dart new file mode 100644 index 0000000..65dec1e --- /dev/null +++ b/test/utils/battery_utils_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/utils/battery_utils.dart'; + +void main() { + group('battery utils', () { + test('nmc range maps 3.0V to 0% and 4.2V to 100%', () { + expect(estimateBatteryPercentFromVolts(3.0, 'nmc'), 0); + expect(estimateBatteryPercentFromVolts(4.2, 'nmc'), 100); + }); + + test('lifepo4 range maps 2.6V to 0% and 3.65V to 100%', () { + expect(estimateBatteryPercentFromVolts(2.6, 'lifepo4'), 0); + expect(estimateBatteryPercentFromVolts(3.65, 'lifepo4'), 100); + }); + + test('unknown chemistry falls back to nmc mapping', () { + expect( + estimateBatteryPercentFromMillivolts(3600, 'unknown'), + estimateBatteryPercentFromMillivolts(3600, 'nmc'), + ); + }); + }); +}