diff --git a/.gitignore b/.gitignore index b9181133..157c7ece 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ migrate_working_dir/ .flutter-plugins-dependencies .pub-cache/ .pub/ +pubspec.lock /build/ /coverage/ @@ -65,6 +66,7 @@ secrets.dart **/ios/Flutter/Flutter.podspec # Android +.gradle/ **/android/.gradle/ **/android/captures/ **/android/local.properties diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 00000000..fcdb2e10 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +4.0.0 diff --git a/README.md b/README.md index bad9b6ca..10fb0a57 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 740451b9..e0a80290 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 { @@ -83,5 +83,5 @@ flutter { } dependencies { - coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") } diff --git a/assets/icons/done_all.svg b/assets/icons/done_all.svg new file mode 100644 index 00000000..bfeeec0d --- /dev/null +++ b/assets/icons/done_all.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..4d0355b2 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1770562336, + "narHash": "sha256-ub1gpAONMFsT/GU2hV6ZWJjur8rJ6kKxdm9IlCT0j84=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "d6c71932130818840fc8fe9509cf50be8c64634f", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..16711455 --- /dev/null +++ b/flake.nix @@ -0,0 +1,86 @@ +{ + description = "MeshCore Flutter Application"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ + # Flutter and Dart + flutter + dart + + # Java (required for Android development) + jdk17 + + # Android development tools + android-tools + gradle + + # For the shell hook to set up the environment for Flutter development + gtk3 + glib + sysprof + libclang + cmake + ninja + pkg-config + libdatrie + + # Additional tools for installing Android SDK if not present + curl + unzip + ]; + + shellHook = '' + echo "MeshCore Flutter Development Environment" + export PKG_CONFIG_PATH="${pkgs.gtk3}/lib/pkgconfig:${pkgs.glib}/lib/pkgconfig:${pkgs.sysprof}/lib/pkgconfig:$PKG_CONFIG_PATH" + export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath [pkgs.gtk3 pkgs.glib pkgs.sysprof pkgs.libdatrie]}:$LD_LIBRARY_PATH" + export CMAKE_INSTALL_PREFIX="$PWD/build/bundle" + + # Setup Android SDK in home directory (standard location) + export ANDROID_HOME="$HOME/Android/Sdk" + export ANDROID_SDK_ROOT="$ANDROID_HOME" + export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/tools/bin:$PATH" + + echo "Android SDK: $ANDROID_HOME" + echo "" + + # Check if Android SDK exists and offer to download if not + if [ ! -d "$ANDROID_HOME" ]; then + echo "WARNING: Android SDK not found at $ANDROID_HOME" + echo "" + echo "To download and set up the Android SDK, run this command:" + echo "" + cat << 'EOF' +mkdir -p ~/Android/Sdk && cd ~/Android/Sdk && \ +curl -o cmdline-tools.zip ${if pkgs.stdenv.isDarwin then "https://dl.google.com/android/repository/commandlinetools-mac-10406996_latest.zip" else "https://dl.google.com/android/repository/commandlinetools-linux-10406996_latest.zip"} && \ +unzip -q cmdline-tools.zip && \ +mkdir -p cmdline-tools/latest && \ +mv cmdline-tools/* cmdline-tools/latest/ 2>/dev/null || echo "Warning: failed to move Android cmdline-tools into 'latest' directory; please check your SDK layout." >&2 && \ +rm cmdline-tools.zip && \ +cd cmdline-tools/latest/bin && \ +yes | ./sdkmanager --sdk_root=~/Android/Sdk 'platform-tools' && \ +echo "Android SDK setup complete!" +EOF + echo "" + echo "Then run 'flutter doctor' again to verify." + echo "" + else + echo "Android SDK found at $ANDROID_HOME" + fi + + echo "To check that everything is set up correctly, run 'flutter doctor' and ensure there are no issues." + ''; + }; + } + ); +} diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 34ab744d..e1740461 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -5,7 +5,6 @@ import 'package:crypto/crypto.dart' as crypto; import 'package:pointycastle/export.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; -import 'package:wakelock_plus/wakelock_plus.dart'; import '../models/channel.dart'; import '../models/channel_message.dart'; @@ -30,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 { @@ -38,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, @@ -46,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; @@ -66,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" @@ -91,9 +143,12 @@ class MeshCoreConnector extends ChangeNotifier { int? _currentBwHz; int? _currentSf; int? _currentCr; + bool? _clientRepeat; + int? _firmwareVerCode; int? _batteryMillivolts; double? _selfLatitude; double? _selfLongitude; + final List _directRepeaters = List.empty(growable: true); bool _isLoadingContacts = false; bool _isLoadingChannels = false; bool _hasLoadedChannels = false; @@ -149,6 +204,7 @@ class MeshCoreConnector extends ChangeNotifier { final Map _contactSmazEnabled = {}; final Set _knownContactKeys = {}; final Map _contactUnreadCount = {}; + final Map _repeaterBatterySnapshots = {}; bool _unreadStateLoaded = false; int _cachedContactsUnreadTotal = 0; int _cachedChannelsUnreadTotal = 0; @@ -197,12 +253,15 @@ 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; int? get currentBwHz => _currentBwHz; int? get currentSf => _currentSf; int? get currentCr => _currentCr; + bool? get clientRepeat => _clientRepeat; + int? get firmwareVerCode => _firmwareVerCode; Map? get currentCustomVars => _currentCustomVars; int? get batteryMillivolts => _batteryMillivolts; int get maxContacts => _maxContacts; @@ -215,10 +274,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(); @@ -226,27 +307,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] ?? []; } @@ -638,6 +698,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, @@ -687,7 +748,8 @@ class MeshCoreConnector extends ChangeNotifier { _scanResults.clear(); for (var result in results) { if (result.device.platformName.startsWith("MeshCore-") || - result.advertisementData.advName.startsWith("MeshCore-")) { + result.advertisementData.advName.startsWith("MeshCore-") || + result.advertisementData.advName.startsWith("Whisper-")) { _scanResults.add(result); } } @@ -804,9 +866,6 @@ class MeshCoreConnector extends ChangeNotifier { _setState(MeshCoreConnectionState.connected); - // Enable wake lock to prevent BLE disconnection when screen turns off - await WakelockPlus.enable(); - await _requestDeviceInfo(); _startBatteryPolling(); final gotSelfInfo = await _waitForSelfInfo( @@ -915,9 +974,6 @@ class MeshCoreConnector extends ChangeNotifier { _setState(MeshCoreConnectionState.disconnecting); _stopBatteryPolling(); - // Disable wake lock when disconnecting - await WakelockPlus.disable(); - await _notifySubscription?.cancel(); _notifySubscription = null; @@ -951,7 +1007,10 @@ class MeshCoreConnector extends ChangeNotifier { _selfName = null; _selfLatitude = null; _selfLongitude = null; + _clientRepeat = null; + _firmwareVerCode = null; _batteryMillivolts = null; + _repeaterBatterySnapshots.clear(); _batteryRequested = false; _awaitingSelfInfo = false; _maxContacts = _defaultMaxContacts; @@ -963,6 +1022,9 @@ class MeshCoreConnector extends ChangeNotifier { _isSyncingChannels = false; _channelSyncInFlight = false; _hasLoadedChannels = false; + _pendingChannelSentQueue.clear(); + _pendingGenericAckQueue.clear(); + _reactionSendQueueSequence = 0; _setState(MeshCoreConnectionState.disconnected); if (!manual) { @@ -970,7 +1032,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"); } @@ -989,6 +1055,11 @@ class MeshCoreConnector extends ChangeNotifier { data.toList(), withoutResponse: canWriteWithoutResponse, ); + _trackPendingGenericAck( + data, + channelSendQueueId: channelSendQueueId, + expectsGenericAck: expectsGenericAck, + ); } Future requestBatteryStatus({bool force = false}) async { @@ -1144,11 +1215,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( @@ -1344,7 +1482,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; } @@ -1354,6 +1498,7 @@ class MeshCoreConnector extends ChangeNotifier { channel.index, ); _addChannelMessage(channel.index, message); + _pendingChannelSentQueue.add(message.messageId); notifyListeners(); final trimmed = text.trim(); @@ -1363,7 +1508,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 { @@ -1711,6 +1860,9 @@ class MeshCoreConnector extends ChangeNotifier { debugPrint('RX frame: code=$code len=${frame.length}'); switch (code) { + case respCodeOk: + _handleOk(); + break; case respCodeDeviceInfo: _handleDeviceInfo(frame); break; @@ -1726,6 +1878,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); @@ -1770,6 +1927,7 @@ class MeshCoreConnector extends ChangeNotifier { case pushCodeStatusResponse: break; case pushCodeLogRxData: + _handleRxData(frame); _handleLogRxData(frame); break; case respCodeChannelInfo: @@ -1783,11 +1941,35 @@ class MeshCoreConnector extends ChangeNotifier { break; case respCodeCustomVars: _handleCustomVars(frame); + break; + // RESP_CODE_ERR is a defined firmware response (code 1), not an unknown frame. + case respCodeErr: + _handleErrorFrame(frame); + break; default: debugPrint('Unknown frame code: $code'); } } + void _handleErrorFrame(Uint8List frame) { + final errCode = frame.length > 1 ? frame[1] : -1; + _appDebugLogService?.warn( + '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) { // Frame format: [0]=code, [1-32]=pub_key if (frame.length >= 33 && _pathHistoryService != null) { @@ -1856,6 +2038,13 @@ class MeshCoreConnector extends ChangeNotifier { void _handleDeviceInfo(Uint8List frame) { if (frame.length < 4) return; + _firmwareVerCode = frame[1]; + + // Parse client_repeat from firmware v9+ (byte 80) + if (frame.length >= 81) { + _clientRepeat = frame[80] != 0; + } + // Firmware reports MAX_CONTACTS / 2 for v3+ device info. final reportedContacts = frame[2]; final reportedChannels = frame[3]; @@ -1876,8 +2065,8 @@ class MeshCoreConnector extends ChangeNotifier { unawaited(getChannels(maxChannels: nextMaxChannels)); } } - notifyListeners(); } + notifyListeners(); } void _handleNoMoreMessages() { @@ -2038,6 +2227,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); } @@ -2364,6 +2627,8 @@ class MeshCoreConnector extends ChangeNotifier { } final label = channelName ?? _channelDisplayName(channelIndex); + if (_appSettingsService!.isChannelMuted(label)) return; + _notificationService.showChannelMessageNotification( channelName: label, message: message.text, @@ -2487,8 +2752,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 @@ -2505,6 +2784,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 @@ -3085,18 +3422,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); } @@ -3246,8 +3587,6 @@ class MeshCoreConnector extends ChangeNotifier { } void _handleDisconnection() { - // Disable wake lock when connection is lost - WakelockPlus.disable(); _stopBatteryPolling(); for (final entry in _pendingRepeaterAcks.values) { @@ -3271,11 +3610,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 = {}; @@ -3301,7 +3666,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) { @@ -3325,6 +3694,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; @@ -3382,3 +3936,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 25359a8d..d5ce9ee1 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; @@ -550,18 +586,29 @@ Uint8List buildSetChannelFrame(int channelIndex, String name, Uint8List psk) { } // Build CMD_SET_RADIO_PARAMS frame -// Format: [cmd][freq x4][bw x4][sf][cr] +// Format: [cmd][freq x4][bw x4][sf][cr] (pre-v9) +// [cmd][freq x4][bw x4][sf][cr][repeat] (firmware v9+) // freq: frequency in Hz (300000-2500000) // bw: bandwidth in Hz (7000-500000) // sf: spreading factor (5-12) // cr: coding rate (5-8) -Uint8List buildSetRadioParamsFrame(int freqHz, int bwHz, int sf, int cr) { +// clientRepeat: enable off-grid packet repeat (firmware v9+, omit for older) +Uint8List buildSetRadioParamsFrame( + int freqHz, + int bwHz, + int sf, + int cr, { + bool? clientRepeat, +}) { final writer = BufferWriter(); writer.writeByte(cmdSetRadioParams); writer.writeUInt32LE(freqHz); writer.writeUInt32LE(bwHz); writer.writeByte(sf); writer.writeByte(cr); + if (clientRepeat != null) { + writer.writeByte(clientRepeat ? 1 : 0); + } return writer.toBytes(); } @@ -777,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 bf9b8e77..07909e63 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 00000000..58d75b00 --- /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 01afb0cb..975e0671 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": "Контакти", @@ -131,9 +139,6 @@ "settings_infoContactsCount": "Брой контакти", "settings_infoChannelCount": "Брой канали", "settings_presets": "Предварителни настройки", - "settings_preset915Mhz": "915 MHz", - "settings_preset868Mhz": "868 MHz", - "settings_preset433Mhz": "433 MHz", "settings_frequency": "Честота (MHz)", "settings_frequencyHelper": "300.0 - 2500.0", "settings_frequencyInvalid": "Невалидна честота (300-2500 MHz)", @@ -143,8 +148,6 @@ "settings_txPower": "TX Мощност (dBm)", "settings_txPowerHelper": "0 - 22", "settings_txPowerInvalid": "Невалидна мощност на TX (0-22 dBm)", - "settings_longRange": "Дълъг обхват", - "settings_fastSpeed": "Бърза скорост", "settings_error": "Грешка: {message}", "@settings_error": { "placeholders": { @@ -339,6 +342,8 @@ "channels_publicChannel": "Публичен канал", "channels_privateChannel": "Частен канал", "channels_editChannel": "Редактирай канал", + "channels_muteChannel": "Заглуши канала", + "channels_unmuteChannel": "Включи известията на канала", "channels_deleteChannel": "Изтрий канала", "channels_deleteChannelConfirm": "Изтрий \"{name}\"? Това не може да бъде отменено.", "@channels_deleteChannelConfirm": { @@ -1356,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": "Присъедини се към Частен Канал", @@ -1557,6 +1562,8 @@ "contacts_clipboardEmpty": "Клипборда е празна.", "contacts_invalidAdvertFormat": "Невалидни данни за контакт", "appSettings_languageRu": "Руски", + "appSettings_enableMessageTracing": "Разрешаване на проследяване на съобщения", + "appSettings_enableMessageTracingSubtitle": "Показване на подробни метаданни за маршрутизация и синхронизация за съобщения", "contacts_contactImported": "Контактът е импортиран.", "contacts_zeroHopAdvert": "Реклама без скок", "contacts_contactImportFailed": "Контактът не е успешно импортиран.", @@ -1575,7 +1582,6 @@ "notification_newNodesCount": "{count} {count, plural, =1{нов възел} other{нови възли}}", "notification_newTypeDiscovered": "Открит нов {contactType}", "notification_receivedNewMessage": "Получено ново съобщение", - "contacts_contactAdvertCopyFailed": "Копирането на обявата в клипборда не успя.", "settings_gpxExportContactsSubtitle": "Експортира спътници с местоположение в GPX файл.", "settings_gpxExportRepeatersSubtitle": "Изпраща повторители / roomserver с местоположение в GPX файл.", "settings_gpxExportAll": "Експортирай всички контакти в GPX", @@ -1591,6 +1597,207 @@ "settings_gpxExportAllContacts": "Местоположения на всички контакти", "settings_gpxExportShareText": "Картинни данни изнесени от meshcore-open", "settings_gpxExportShareSubject": "meshcore-open износ на данни за карта в формат GPX", - "pathTrace_someHopsNoLocation": "Един или повече от хмелите липсва местоположение!" - + "pathTrace_someHopsNoLocation": "Един или повече от хмелите липсва местоположение!", + "map_pathTraceCancelled": "Отменен е следването на пътя.", + "pathTrace_clearTooltip": "Изчисти пътя", + "map_removeLast": "Премахни Последно", + "map_runTrace": "Изпълни Път на Следване", + "map_tapToAdd": "Натиснете върху възлите, за да ги добавите към пътя.", + "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_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": "Любими", + "@contacts_searchFavorites": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchUsers": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchRepeaters": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchRoomServers": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "contacts_searchFavorites": "Търсене на {number}{str} любими...", + "contacts_searchRoomServers": "Търсене на {number}{str} сървъри в стаята...", + "contacts_unread": "Непрочетено", + "contacts_searchRepeaters": "Търсене на {number}{str} повтарящи се...", + "contacts_searchContactsNoNumber": "Търси контакти...", + "contacts_searchUsers": "Търсене на {number}{str} потребители..." } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 3dcd0ca1..74b6c05a 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1,4 +1,12 @@ { + "channels_channelDeleteFailed": "Kanal {name} konnte nicht gelöscht werden", + "@channels_channelDeleteFailed": { + "placeholders": { + "name": { + "type": "String" + } + } + }, "@@locale": "de", "appTitle": "MeshCore Open", "nav_contacts": "Kontakte", @@ -96,14 +104,14 @@ "settings_privacyModeEnabled": "Datenschutzmodus aktiviert", "settings_privacyModeDisabled": "Datenschutzmodus deaktiviert", "settings_actions": "Aktionen", - "settings_sendAdvertisement": "Sende eine Ankündigung", - "settings_sendAdvertisementSubtitle": "Sende Ankündigung", + "settings_sendAdvertisement": "Sende Ankündigung", + "settings_sendAdvertisementSubtitle": "Sende eine Ankündigung", "settings_advertisementSent": "Ankündigung gesendet", "settings_syncTime": "Zeitsynchronisierung", "settings_syncTimeSubtitle": "Stelle die Gerätezeit auf die Uhrzeit des Telefons ein", "settings_timeSynchronized": "Zeit synchronisiert", "settings_refreshContacts": "Kontakte aktualisieren", - "settings_refreshContactsSubtitle": "Kontakte-Liste vom Gerät neu laden", + "settings_refreshContactsSubtitle": "Kontakt-Liste vom Gerät neu laden", "settings_rebootDevice": "Gerät neu starten", "settings_rebootDeviceSubtitle": "MeshCore-Gerät neu starten", "settings_rebootDeviceConfirm": "Sind Sie sicher, dass Sie das Gerät neu starten möchten? Sie werden getrennt.", @@ -131,9 +139,6 @@ "settings_infoContactsCount": "Anzahl Kontakte", "settings_infoChannelCount": "Anzahl Kanäle", "settings_presets": "Voreinstellungen", - "settings_preset915Mhz": "915 MHz", - "settings_preset868Mhz": "868 MHz", - "settings_preset433Mhz": "433 MHz", "settings_frequency": "Frequenz (MHz)", "settings_frequencyHelper": "300,00 - 2.500,00", "settings_frequencyInvalid": "Ungültige Frequenz (300-2500 MHz)", @@ -143,8 +148,6 @@ "settings_txPower": "TX-Leistung (dBm)", "settings_txPowerHelper": "0 - 22", "settings_txPowerInvalid": "Ungültige TX-Leistung (0-22 dBm)", - "settings_longRange": "Grosse Reichweite", - "settings_fastSpeed": "Schnelle Geschwindigkeit", "settings_error": "Fehler: {message}", "@settings_error": { "placeholders": { @@ -339,6 +342,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": { @@ -540,7 +545,7 @@ "chat_routingMode": "Routenmodus", "chat_autoUseSavedPath": "Automatisch (gespeicherten Pfad verwenden)", "chat_forceFloodMode": "Flut-Modus erzwingen", - "chat_recentAckPaths": "Aktuelle ACK-Pfade (tasten, um zu verwenden):", + "chat_recentAckPaths": "Aktuelle ACK-Pfade (antippen, um zu verwenden):", "chat_pathHistoryFull": "Die Pfadhistorie ist voll. Entferne Einträge, um neue hinzuzufügen.", "chat_hopSingular": "Sprung", "chat_hopPlural": "Sprünge", @@ -554,7 +559,7 @@ }, "chat_successes": "Erfolgreich", "chat_removePath": "Pfad entfernen", - "chat_noPathHistoryYet": "Keine eine Pfadhistorie vorhanden.\nSende eine Nachricht, um Pfade zu entdecken.", + "chat_noPathHistoryYet": "Keine Pfadhistorie vorhanden.\nSende eine Nachricht, um Pfade zu entdecken.", "chat_pathActions": "Pfadaktionen:", "chat_setCustomPath": "Lege benutzerdefinierten Pfad fest", "chat_setCustomPathSubtitle": "Manuellen Routenpfad festlegen", @@ -717,7 +722,7 @@ "mapCache_cacheArea": "Zwischenspeicherbereich", "mapCache_useCurrentView": "Aktuelle Ansicht verwenden", "mapCache_zoomRange": "Zoom Bereich", - "mapCache_estimatedTiles": "Geschätzte Fliesen: {count}", + "mapCache_estimatedTiles": "Geschätzte Kacheln: {count}", "@mapCache_estimatedTiles": { "placeholders": { "count": { @@ -854,7 +859,7 @@ }, "path_enterCustomPath": "Gebe Pfad ein", "path_currentPathLabel": "Aktueller Pfad", - "path_hexPrefixInstructions": "Gebe für jeden Hopfen 2-stellige Hex-Präfixe ein, getrennt durch Kommas.", + "path_hexPrefixInstructions": "Gebe für jeden Zwischen-Hop das 2-stellige Hex-Präfix ein, getrennt durch Kommas.", "path_hexPrefixExample": "Beispiel: A1,F2,3C (jeder Knoten verwendet den ersten Byte seines öffentlichen Schlüssels)", "path_labelHexPrefixes": "Pfad (Hex-Präfixe)", "path_helperMaxHops": "Max 64 Sprünge. Jede Präfixe ist 2 Hexadezimalzeichen (1 Byte)", @@ -887,7 +892,7 @@ "repeater_forceFloodMode": "Flut-Modus erzwingen", "repeater_pathManagement": "Pfadverwaltung", "repeater_refresh": "Aktualisieren", - "repeater_statusRequestTimeout": "Statusanfrage zeitweise fehlgeschlagen.", + "repeater_statusRequestTimeout": "Statusanfrage durch Timeout fehlgeschlagen.", "repeater_errorLoadingStatus": "Fehler beim Laden des Status: {error}", "@repeater_errorLoadingStatus": { "placeholders": { @@ -957,7 +962,7 @@ } } }, - "repeater_duplicatesFloodDirect": "Überflut: {flood}, Direkt: {direct}", + "repeater_duplicatesFloodDirect": "Flut: {flood}, Direkt: {direct}", "@repeater_duplicatesFloodDirect": { "placeholders": { "flood": { @@ -983,7 +988,7 @@ "repeater_adminPassword": "Admin-Passwort", "repeater_adminPasswordHelper": "Vollzugriffspasswort", "repeater_guestPassword": "Gast-Passwort", - "repeater_guestPasswordHelper": "Schreibgeschützter Zugriffspasswort", + "repeater_guestPasswordHelper": "Schreibgeschütztes Zugriffspasswort", "repeater_radioSettings": "Funk Einstellungen", "repeater_frequencyMhz": "Frequenz (MHz)", "repeater_frequencyHelper": "300-2500 MHz", @@ -1086,11 +1091,11 @@ } }, "repeater_cliTitle": "Repeater CLI", - "repeater_debugNextCommand": "Fehlersuche Nächster Befehl", + "repeater_debugNextCommand": "Fehlersuche des nächsten Befehls", "repeater_commandHelp": "Hilfe", "repeater_clearHistory": "Löschen der Historie", "repeater_noCommandsSent": "Noch keine Befehle gesendet.", - "repeater_typeCommandOrUseQuick": "Geben Sie einen Befehl unten ein oder verwenden Sie Schnellbefehle", + "repeater_typeCommandOrUseQuick": "Geben Sie unten einen Befehl ein oder verwenden Sie die Schnellbefehle", "repeater_enterCommandHint": "Geben Sie den Befehl ein...", "repeater_previousCommand": "Vorhergehende Aktion", "repeater_nextCommand": "Nächste Aktion", @@ -1132,7 +1137,7 @@ "repeater_cliHelpSetLat": "Legt die Breitengrad der Ankündigung fest. (dezimale Grad)", "repeater_cliHelpSetLon": "Legt die Längengrade der Ankündigung fest. (dezimale Grad)", "repeater_cliHelpSetRadio": "Legt komplett neue Radio-Parameter fest und speichert diese als Präferenzen. Benötigt einen \"Reboot\"-Befehl, um sie anzuwenden.", - "repeater_cliHelpSetRxDelay": "Sets (experimentell) als Basis (muss > 1 sein für den Effekt) zur Anwendung einer leichten Verzögerung bei empfangenen Paketen, basierend auf Signalstärke/Punktzahl. Auf 0 setzen, um die Funktion zu deaktivieren.", + "repeater_cliHelpSetRxDelay": "Fügt eine leichte Verzögerung bei empfangenen Paketen hinzu, basierend auf Signalstärke/Punktzahl. Auf 0 setzen, um die Funktion zu deaktivieren.", "repeater_cliHelpSetTxDelay": "Legt einen Faktor fest, der mit der Zeit bei voller Zuluft für ein Flood-Mode-Paket und mit einem zufälligen Slot-System multipliziert wird, um dessen Weiterleitung zu verzögern (um Kollisionen zu vermeiden).", "repeater_cliHelpSetDirectTxDelay": "Ähnlich wie txdelay, aber zum Anwenden einer zufälligen Verzögerung bei der Weiterleitung von Direktmodus-Paketen.", "repeater_cliHelpSetBridgeEnabled": "Brücke aktivieren/deaktivieren.", @@ -1143,14 +1148,14 @@ "repeater_cliHelpSetAdcMultiplier": "Legt einen benutzerdefinierten Faktor zur Anpassung der gemeldeten Batteriewirkspannung fest (nur auf ausgewählten Boards unterstützt).", "repeater_cliHelpTempRadio": "Legt vorübergehende Funkparameter für die angegebene Anzahl von Minuten fest und kehrt anschließend zu den ursprünglichen Funkparametern zurück (wird nicht in den Einstellungen gespeichert).", "repeater_cliHelpSetPerm": "Ändert die ACL. Entfernt das passende Eintragen (durch Pubkey-Präfix), wenn \"permissions\" auf 0 steht. Fügt ein neues Eintragen hinzu, wenn die Pubkey-Hex-Länge vollständig ist und nicht bereits in der ACL vorhanden ist. Aktualisiert das Eintragen anhand des übereinstimmenden Pubkey-Präfix. Berechtigungsbits variieren je nach Firmware-Rolle, aber die unteren 2 Bits sind: 0 (Gast), 1 (Nur Lesen), 2 (Lesen/Schreiben), 3 (Admin)", - "repeater_cliHelpGetBridgeType": "Ruft Brückentyp none, rs232, espnow ab.", + "repeater_cliHelpGetBridgeType": "Ruft Brückentyp: none, rs232, espnow ab.", "repeater_cliHelpLogStart": "Beginnt die Paketprotokollierung in das Dateisystem.", "repeater_cliHelpLogStop": "Stoppt das Paketprotokollieren in das Dateisystem.", "repeater_cliHelpLogErase": "Löscht die Paketprotokolle aus dem Dateisystem.", "repeater_cliHelpNeighbors": "Zeigt eine Liste anderer Repeater-Knoten an, die über Zero-Hop-Ankündigung gehört wurden. Jede Zeile ist id-prefix-hex:timestamp:snr-times-4", "repeater_cliHelpNeighborRemove": "Entfernt das erste übereinstimmende Element (über Pubkey-Präfix (hex)) aus der Liste der Nachbarn.", "repeater_cliHelpRegion": "Listet alle definierten Regionen auf.", - "repeater_cliHelpRegionLoad": "Hinweis: Dies ist ein spezieller Mehrbefehl-Aufruf. Jeder nachfolgende Befehl ist ein Regionsname (eingedruckt mit Leerzeichen zur Angabe der übergeordneten Hierarchie, mit mindestens einem Leerzeichen). Beendet durch das Senden einer Leerzeile/des Befehls.", + "repeater_cliHelpRegionLoad": "Hinweis: Dies ist ein spezieller Mehrbefehl-Aufruf. Jeder nachfolgende Befehl ist ein Regionsname (eingerückt mit Leerzeichen zur Angabe der übergeordneten Hierarchie, mit mindestens einem Leerzeichen). Beendet durch das Senden einer Leerzeile.", "repeater_cliHelpRegionGet": "Sucht die Region mit dem gegebenen Namenspräfix (oder \"\\\" für den globalen Scope) und antwortet mit \"-> region-name (parent-name) 'F'\".", "repeater_cliHelpRegionPut": "Fügt eine Region-Definition mit dem angegebenen Namen hinzu oder aktualisiert diese.", "repeater_cliHelpRegionRemove": "Löscht eine Regiondefinition mit dem angegebenen Namen. (muss genau übereinstimmen und keine Kindregionen haben)", @@ -1243,7 +1248,7 @@ "channelPath_otherObservedPaths": "Sonstige beobachtete Pfade", "channelPath_repeaterHops": "Repeater-Sprünge", "channelPath_noHopDetails": "Die Detailangaben für dieses Paket sind nicht verfügbar.", - "channelPath_messageDetails": "Nachrichtsdetails", + "channelPath_messageDetails": "Nachrichtendetails", "channelPath_senderLabel": "Sender", "channelPath_timeLabel": "Zeit", "channelPath_repeatsLabel": "Wiederholungen", @@ -1344,10 +1349,13 @@ "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", - "listFilter_unreadOnly": "Nur nicht gelesen", + "listFilter_unreadOnly": "Nicht gelesen", "listFilter_newGroup": "Neue Gruppe", "@neighbors_errorLoading": { "placeholders": { @@ -1356,13 +1364,13 @@ } } }, - "repeater_neighbours": "Nachbarn", - "repeater_neighboursSubtitle": "Anzahl der Hop-Nachbarn anzeigen.", - "neighbors_receivedData": "Empfangene Nachbarendaten", - "neighbors_requestTimedOut": "Nachbarn melden zeitweise Ausfall.", + "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_noData": "Keine Nachbardaten verfügbar.", + "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.", "channels_createPrivateChannel": "Erstelle einen privaten Kanal", @@ -1389,8 +1397,8 @@ } } }, - "neighbors_heardAgo": "Hörte: {time} vor her.", - "neighbors_unknownContact": "Unbekannte {pubkey}", + "neighbors_heardAgo": "Gehört vor: {time}", + "neighbors_unknownContact": "Unbekannt {pubkey}", "settings_locationGPSEnable": "GPS aktivieren", "settings_locationGPSEnableSubtitle": "Aktiviert GPS zur automatischen Aktualisierung des Standorts.", "settings_locationIntervalSec": "Intervall für GPS (Sekunden)", @@ -1493,9 +1501,9 @@ "community_deleted": "Community \"{name}\" verlassen", "community_addHashtagChannel": "Füge einen Community-Hashtag hinzu", "community_addHashtagChannelDesc": "Füge einen Hashtag-Kanal für diese Community hinzu", - "community_selectCommunity": "Wählen Sie Community", + "community_selectCommunity": "Wählen Sie eine Community", "community_regularHashtag": "Regulärer Hashtag", - "community_regularHashtagDesc": "Öffentliches Hashtag (jeder kann teilnehmen)", + "community_regularHashtagDesc": "Öffentlicher Hashtag (jeder kann teilnehmen)", "community_communityHashtagDesc": "Nur für Mitglieder der Community", "community_forCommunity": "Für {name}", "community_communityHashtag": "Community Hashtag", @@ -1557,61 +1565,267 @@ "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-Anzeige", - "contacts_floodAdvert": "Überflutungsanzeige", + "contacts_zeroHopAdvert": "Zero-Hop-Ankündigung", + "contacts_floodAdvert": "Flut-Ankündigung", "contacts_addContactFromClipboard": "Kontakt aus Zwischenablage hinzufügen", "contacts_ShareContactZeroHop": "Kontakt über Anzeige teilen", - "contacts_copyAdvertToClipboard": "Werbung in die Zwischenablage kopieren", + "contacts_copyAdvertToClipboard": "Ankündigung in die Zwischenablage kopieren", "contacts_ShareContact": "Kontakt in die Zwischenablage kopieren", "contacts_zeroHopContactAdvertFailed": "Kontakt konnte nicht gesendet werden.", "contacts_zeroHopContactAdvertSent": "Kontakt über Anzeige gesendet", "contacts_contactAdvertCopied": "Anzeige in die Zwischenablage kopiert.", - "contacts_contactAdvertCopyFailed": "Kopieren des Werbeinhalts in die Zwischenablage fehlgeschlagen.", - + "contacts_contactAdvertCopyFailed": "Kopieren der Ankündigung in die Zwischenablage fehlgeschlagen.", "notification_activityTitle": "MeshCore Aktivität", "notification_messagesCount": "{count} {count, plural, =1{Nachricht} other{Nachrichten}}", "@notification_messagesCount": { "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "notification_channelMessagesCount": "{count} {count, plural, =1{Kanalnachricht} other{Kanalnachrichten}}", "@notification_channelMessagesCount": { "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "notification_newNodesCount": "{count} {count, plural, =1{neuer Knoten} other{neue Knoten}}", "@notification_newNodesCount": { "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "notification_newTypeDiscovered": "Neuer {contactType} entdeckt", "@notification_newTypeDiscovered": { "placeholders": { - "contactType": {"type": "String"} + "contactType": { + "type": "String" + } } }, "notification_receivedNewMessage": "Neue Nachricht empfangen", - "contacts_contactAdvertCopyFailed": "Kopieren des Werbeinhalts in die Zwischenablage fehlgeschlagen.", - "settings_gpxExportAll": "Alle Kontakte nach GPX exportieren", - "settings_gpxExportAllSubtitle": "Exportiert alle Kontakte mit einem Standort in eine GPX-Datei.", - "settings_gpxExportRepeaters": "Repeater und Raumserver nach GPX exportieren", - "settings_gpxExportContacts": "Begleiter nach GPX exportieren", + "settings_gpxExportAll": "Alle Knoten als GPX exportieren", + "settings_gpxExportAllSubtitle": "Exportiert alle Knoten mit einem Standort in eine GPX-Datei.", + "settings_gpxExportRepeaters": "Repeater und Raumserver als GPX exportieren", + "settings_gpxExportContacts": "Kontakte als GPX exportieren", "settings_gpxExportRepeatersSubtitle": "Exportiert Repeater und Raumserver mit einem Standort in eine GPX-Datei.", - "settings_gpxExportContactsSubtitle": "Exportiert Begleiter mit einem Ort in eine GPX-Datei.", + "settings_gpxExportContactsSubtitle": "Exportiert Kontakte mit einem Ort in eine GPX-Datei.", "settings_gpxExportRepeatersRoom": "Repeater- und Raumserver-Standorte", - "settings_gpxExportChat": "Begleiterstandorte", + "settings_gpxExportChat": "Kontaktstandorte", "settings_gpxExportNoContacts": "Keine Kontakte zum Exportieren.", "settings_gpxExportError": "Beim Export ist ein Fehler aufgetreten.", "settings_gpxExportNotAvailable": "Nicht auf Ihrem Gerät/Betriebssystem unterstützt", - "settings_gpxExportSuccess": "Erfolgreich GPX-Datei exportiert.", + "settings_gpxExportSuccess": "GPX-Datei erfolgreich exportiert.", "settings_gpxExportAllContacts": "Alle Kontaktstandorte", - "settings_gpxExportShareSubject": "meshcore-open GPX-Kartendaten exportieren", - "settings_gpxExportShareText": "Kartendaten aus meshcore-open exportiert", - "pathTrace_someHopsNoLocation": "Eine oder mehrere der Hopfen fehlen einen Standort!" - + "settings_gpxExportShareSubject": "GPX-Kartendaten aus meshcore-open exportieren", + "settings_gpxExportShareText": "GPX-Kartendaten aus meshcore-open exportiert", + "pathTrace_someHopsNoLocation": "Bei einer oder mehreren Knoten fehlt der Standort!", + "map_removeLast": "Letztes Entfernen", + "map_tapToAdd": "Tippen Sie auf Knoten, um sie zum Pfad hinzuzufügen.", + "map_runTrace": "Pfadverlauf ausführen", + "pathTrace_clearTooltip": "Pfad löschen", + "map_pathTraceCancelled": "Pfadverfolgung abgebrochen.", + "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_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" + } + } + }, + "@contacts_searchFavorites": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchUsers": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchRepeaters": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchRoomServers": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "contacts_unread": "Ungelesen", + "contacts_searchContactsNoNumber": "Kontakte suchen...", + "contacts_searchRepeaters": "Suche {number}{str} Repeater...", + "contacts_searchFavorites": "Suche {number}{str} Favoriten...", + "contacts_searchUsers": "Suche {number}{str} Benutzer...", + "contacts_searchRoomServers": "Suche {number}{str} Raumserver..." } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e602719a..aabb76a6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,12 +1,9 @@ { "@@locale": "en", - "appTitle": "MeshCore Open", - "nav_contacts": "Contacts", "nav_channels": "Channels", "nav_map": "Map", - "common_cancel": "Cancel", "common_ok": "OK", "common_connect": "Connect", @@ -35,16 +32,19 @@ "common_voltageValue": "{volts} V", "@common_voltageValue": { "placeholders": { - "volts": {"type": "String"} + "volts": { + "type": "String" + } } }, "common_percentValue": "{percent}%", "@common_percentValue": { "placeholders": { - "percent": {"type": "int"} + "percent": { + "type": "int" + } } }, - "scanner_title": "MeshCore Open", "scanner_scanning": "Scanning for devices...", "scanner_connecting": "Connecting...", @@ -53,7 +53,9 @@ "scanner_connectedTo": "Connected to {deviceName}", "@scanner_connectedTo": { "placeholders": { - "deviceName": {"type": "String"} + "deviceName": { + "type": "String" + } } }, "scanner_searchingDevices": "Searching for MeshCore devices...", @@ -61,15 +63,18 @@ "scanner_connectionFailed": "Connection failed: {error}", "@scanner_connectionFailed": { "placeholders": { - "error": {"type": "String"} + "error": { + "type": "String" + } } }, "scanner_stop": "Stop", "scanner_scan": "Scan", - + "scanner_bluetoothOff": "Bluetooth is off", + "scanner_bluetoothOffMessage": "Please turn on Bluetooth to scan for devices", + "scanner_enableBluetooth": "Enable Bluetooth", "device_quickSwitch": "Quick switch", "device_meshcore": "MeshCore", - "settings_title": "Settings", "settings_deviceInfo": "Device Info", "settings_appSettings": "App Settings", @@ -119,11 +124,14 @@ "settings_aboutVersion": "MeshCore Open v{version}", "@settings_aboutVersion": { "placeholders": { - "version": {"type": "String"} + "version": { + "type": "String" + } } }, "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", @@ -132,9 +140,6 @@ "settings_infoContactsCount": "Contacts Count", "settings_infoChannelCount": "Channel Count", "settings_presets": "Presets", - "settings_preset915Mhz": "915 MHz", - "settings_preset868Mhz": "868 MHz", - "settings_preset433Mhz": "433 MHz", "settings_frequency": "Frequency (MHz)", "settings_frequencyHelper": "300.0 - 2500.0", "settings_frequencyInvalid": "Invalid frequency (300-2500 MHz)", @@ -144,15 +149,17 @@ "settings_txPower": "TX Power (dBm)", "settings_txPowerHelper": "0 - 22", "settings_txPowerInvalid": "Invalid TX power (0-22 dBm)", - "settings_longRange": "Long Range", - "settings_fastSpeed": "Fast Speed", + "settings_clientRepeat": "Off-Grid Repeat", + "settings_clientRepeatSubtitle": "Allow this device to repeat mesh packets for others", + "settings_clientRepeatFreqWarning": "Off-grid repeat requires 433, 869, or 918 MHz frequency", "settings_error": "Error: {message}", "@settings_error": { "placeholders": { - "message": {"type": "String"} + "message": { + "type": "String" + } } }, - "appSettings_title": "App Settings", "appSettings_appearance": "Appearance", "appSettings_theme": "Theme", @@ -176,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", @@ -202,7 +211,9 @@ "appSettings_batteryChemistryPerDevice": "Set per device ({deviceName})", "@appSettings_batteryChemistryPerDevice": { "placeholders": { - "deviceName": {"type": "String"} + "deviceName": { + "type": "String" + } } }, "appSettings_batteryChemistryConnectFirst": "Connect to a device to choose", @@ -221,7 +232,9 @@ "appSettings_timeFilterShowLast": "Show nodes from last {hours} hours", "@appSettings_timeFilterShowLast": { "placeholders": { - "hours": {"type": "int"} + "hours": { + "type": "int" + } } }, "appSettings_mapTimeFilter": "Map Time Filter", @@ -232,12 +245,19 @@ "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": { "placeholders": { - "minZoom": {"type": "int"}, - "maxZoom": {"type": "int"} + "minZoom": { + "type": "int" + }, + "maxZoom": { + "type": "int" + } } }, "appSettings_debugCard": "Debug", @@ -245,18 +265,75 @@ "appSettings_appDebugLoggingSubtitle": "Log app debug messages for troubleshooting", "appSettings_appDebugLoggingEnabled": "App debug logging enabled", "appSettings_appDebugLoggingDisabled": "App debug logging disabled", - "contacts_title": "Contacts", "contacts_noContacts": "No contacts yet", "contacts_contactsWillAppear": "Contacts will appear when devices advertise", - "contacts_searchContacts": "Search contacts...", + "contacts_unread": "Unread", + "contacts_searchContactsNoNumber": "Search Contacts...", + "contacts_searchContacts": "Search {number}{str} Contacts...", + "@contacts_searchContacts": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "contacts_searchFavorites": "Search {number}{str} Favorites...", + "@contacts_searchFavorites": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "contacts_searchUsers": "Search {number}{str} Users...", + "@contacts_searchUsers": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "contacts_searchRepeaters": "Search {number}{str} Repeaters...", + "@contacts_searchRepeaters": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "contacts_searchRoomServers": "Search {number}{str} Room servers...", + "@contacts_searchRoomServers": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, "contacts_noUnreadContacts": "No unread contacts", "contacts_noContactsFound": "No contacts or groups found", "contacts_deleteContact": "Delete Contact", "contacts_removeConfirm": "Remove {contactName} from contacts?", "@contacts_removeConfirm": { "placeholders": { - "contactName": {"type": "String"} + "contactName": { + "type": "String" + } } }, "contacts_manageRepeater": "Manage Repeater", @@ -268,7 +345,9 @@ "contacts_deleteGroupConfirm": "Remove \"{groupName}\"?", "@contacts_deleteGroupConfirm": { "placeholders": { - "groupName": {"type": "String"} + "groupName": { + "type": "String" + } } }, "contacts_newGroup": "New Group", @@ -277,7 +356,9 @@ "contacts_groupAlreadyExists": "Group \"{name}\" already exists", "@contacts_groupAlreadyExists": { "placeholders": { - "name": {"type": "String"} + "name": { + "type": "String" + } } }, "contacts_filterContacts": "Filter contacts...", @@ -287,24 +368,29 @@ "contacts_lastSeenMinsAgo": "Last seen {minutes} mins ago", "@contacts_lastSeenMinsAgo": { "placeholders": { - "minutes": {"type": "int"} + "minutes": { + "type": "int" + } } }, "contacts_lastSeenHourAgo": "Last seen 1 hour ago", "contacts_lastSeenHoursAgo": "Last seen {hours} hours ago", "@contacts_lastSeenHoursAgo": { "placeholders": { - "hours": {"type": "int"} + "hours": { + "type": "int" + } } }, "contacts_lastSeenDayAgo": "Last seen 1 day ago", "contacts_lastSeenDaysAgo": "Last seen {days} days ago", "@contacts_lastSeenDaysAgo": { "placeholders": { - "days": {"type": "int"} + "days": { + "type": "int" + } } }, - "channels_title": "Channels", "channels_noChannelsConfigured": "No channels configured", "channels_addPublicChannel": "Add Public Channel", @@ -313,7 +399,9 @@ "channels_channelIndex": "Channel {index}", "@channels_channelIndex": { "placeholders": { - "index": {"type": "int"} + "index": { + "type": "int" + } } }, "channels_hashtagChannel": "Hashtag channel", @@ -322,17 +410,31 @@ "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": { "placeholders": { - "name": {"type": "String"} + "name": { + "type": "String" + } + } + }, + "channels_channelDeleteFailed": "Failed to delete channel \"{name}\"", + "@channels_channelDeleteFailed": { + "placeholders": { + "name": { + "type": "String" + } } }, "channels_channelDeleted": "Channel \"{name}\" deleted", "@channels_channelDeleted": { "placeholders": { - "name": {"type": "String"} + "name": { + "type": "String" + } } }, "channels_addChannel": "Add Channel", @@ -347,20 +449,26 @@ "channels_channelAdded": "Channel \"{name}\" added", "@channels_channelAdded": { "placeholders": { - "name": {"type": "String"} + "name": { + "type": "String" + } } }, "channels_editChannelTitle": "Edit Channel {index}", "@channels_editChannelTitle": { "placeholders": { - "index": {"type": "int"} + "index": { + "type": "int" + } } }, "channels_smazCompression": "SMAZ compression", "channels_channelUpdated": "Channel \"{name}\" updated", "@channels_channelUpdated": { "placeholders": { - "name": {"type": "String"} + "name": { + "type": "String" + } } }, "channels_publicChannelAdded": "Public channel added", @@ -381,34 +489,41 @@ "channels_scanQrCodeComingSoon": "Coming soon", "channels_enterHashtag": "Enter hashtag", "channels_hashtagHint": "e.g. #team", - "chat_noMessages": "No messages yet", "chat_sendMessageToStart": "Send a message to get started", "chat_originalMessageNotFound": "Original message not found", "chat_replyingTo": "Replying to {name}", "@chat_replyingTo": { "placeholders": { - "name": {"type": "String"} + "name": { + "type": "String" + } } }, "chat_replyTo": "Reply to {name}", "@chat_replyTo": { "placeholders": { - "name": {"type": "String"} + "name": { + "type": "String" + } } }, "chat_location": "Location", "chat_sendMessageTo": "Send a message to {contactName}", "@chat_sendMessageTo": { "placeholders": { - "contactName": {"type": "String"} + "contactName": { + "type": "String" + } } }, "chat_typeMessage": "Type a message...", "chat_messageTooLong": "Message too long (max {maxBytes} bytes).", "@chat_messageTooLong": { "placeholders": { - "maxBytes": {"type": "int"} + "maxBytes": { + "type": "int" + } } }, "chat_messageCopied": "Message copied", @@ -417,8 +532,12 @@ "chat_retryCount": "Retry {current}/{max}", "@chat_retryCount": { "placeholders": { - "current": {"type": "int"}, - "max": {"type": "int"} + "current": { + "type": "int" + }, + "max": { + "type": "int" + } } }, "chat_sendGif": "Send GIF", @@ -450,39 +569,53 @@ "debugFrame_length": "Frame Length: {count} bytes", "@debugFrame_length": { "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "debugFrame_command": "Command: 0x{value}", "@debugFrame_command": { "placeholders": { - "value": {"type": "String"} + "value": { + "type": "String" + } } }, "debugFrame_textMessageHeader": "Text Message Frame:", "debugFrame_destinationPubKey": "- Destination PubKey: {pubKey}", "@debugFrame_destinationPubKey": { "placeholders": { - "pubKey": {"type": "String"} + "pubKey": { + "type": "String" + } } }, "debugFrame_timestamp": "- Timestamp: {timestamp}", "@debugFrame_timestamp": { "placeholders": { - "timestamp": {"type": "int"} + "timestamp": { + "type": "int" + } } }, "debugFrame_flags": "- Flags: 0x{value}", "@debugFrame_flags": { "placeholders": { - "value": {"type": "String"} + "value": { + "type": "String" + } } }, "debugFrame_textType": "- Text Type: {type} ({label})", "@debugFrame_textType": { "placeholders": { - "type": {"type": "int"}, - "label": {"type": "String"} + "type": { + "type": "int" + }, + "label": { + "type": "String" + } } }, "debugFrame_textTypeCli": "CLI", @@ -490,11 +623,14 @@ "debugFrame_text": "- Text: \"{text}\"", "@debugFrame_text": { "placeholders": { - "text": {"type": "String"} + "text": { + "type": "String" + } } }, "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", @@ -505,7 +641,9 @@ "chat_hopsCount": "{count} {count, plural, =1{hop} other{hops}}", "@chat_hopsCount": { "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "chat_successes": "successes", @@ -524,8 +662,12 @@ "chat_pathSetHops": "Path set: {hopCount} {hopCount, plural, =1{hop} other{hops}} - {status}", "@chat_pathSetHops": { "placeholders": { - "hopCount": {"type": "int"}, - "status": {"type": "String"} + "hopCount": { + "type": "int" + }, + "status": { + "type": "String" + } } }, "chat_pathSavedLocally": "Saved locally. Connect to sync.", @@ -540,7 +682,9 @@ "chat_hopsForced": "{count} hops (forced)", "@chat_hopsForced": { "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "chat_floodAuto": "Flood (auto)", @@ -549,7 +693,9 @@ "chat_unread": "Unread: {count}", "@chat_unread": { "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "chat_openLink": "Open Link?", @@ -558,24 +704,31 @@ "chat_couldNotOpenLink": "Could not open link: {url}", "@chat_couldNotOpenLink": { "placeholders": { - "url": {"type": "String"} + "url": { + "type": "String" + } } }, "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}", "@map_nodesCount": { "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "map_pinsCount": "Pins: {count}", "@map_pinsCount": { "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "map_chat": "Chat", @@ -601,7 +754,9 @@ "map_publicLocationShareConfirm": "You are about to share a location in {channelLabel}. This channel is public and anyone with the PSK can see it.", "@map_publicLocationShareConfirm": { "placeholders": { - "channelLabel": {"type": "String"} + "channelLabel": { + "type": "String" + } } }, "map_connectToShareMarkers": "Connect to a device to share markers", @@ -619,6 +774,10 @@ "map_sharedPin": "Shared pin", "map_joinRoom": "Join Room", "map_manageRepeater": "Manage Repeater", + "map_tapToAdd": "Tap on nodes to add them to the path.", + "map_runTrace": "Run Path Trace", + "map_removeLast": "Remove Last", + "map_pathTraceCancelled": "Path trace cancelled.", "mapCache_title": "Offline Map Cache", "mapCache_selectAreaFirst": "Select an area to cache first", "mapCache_noTilesToDownload": "No tiles to download for this area", @@ -626,21 +785,29 @@ "mapCache_downloadTilesPrompt": "Download {count} tiles for offline use?", "@mapCache_downloadTilesPrompt": { "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "mapCache_downloadAction": "Download", "mapCache_cachedTiles": "Cached {count} tiles", "@mapCache_cachedTiles": { "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "mapCache_cachedTilesWithFailed": "Cached {downloaded} tiles ({failed} failed)", "@mapCache_cachedTilesWithFailed": { "placeholders": { - "downloaded": {"type": "int"}, - "failed": {"type": "int"} + "downloaded": { + "type": "int" + }, + "failed": { + "type": "int" + } } }, "mapCache_clearOfflineCacheTitle": "Clear offline cache", @@ -653,14 +820,20 @@ "mapCache_estimatedTiles": "Estimated tiles: {count}", "@mapCache_estimatedTiles": { "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "mapCache_downloadedTiles": "Downloaded {completed} / {total}", "@mapCache_downloadedTiles": { "placeholders": { - "completed": {"type": "int"}, - "total": {"type": "int"} + "completed": { + "type": "int" + }, + "total": { + "type": "int" + } } }, "mapCache_downloadTilesButton": "Download Tiles", @@ -668,36 +841,51 @@ "mapCache_failedDownloads": "Failed downloads: {count}", "@mapCache_failedDownloads": { "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "mapCache_boundsLabel": "N {north}, S {south}, E {east}, W {west}", "@mapCache_boundsLabel": { "placeholders": { - "north": {"type": "String"}, - "south": {"type": "String"}, - "east": {"type": "String"}, - "west": {"type": "String"} + "north": { + "type": "String" + }, + "south": { + "type": "String" + }, + "east": { + "type": "String" + }, + "west": { + "type": "String" + } } }, - "time_justNow": "Just now", "time_minutesAgo": "{minutes}m ago", "@time_minutesAgo": { "placeholders": { - "minutes": {"type": "int"} + "minutes": { + "type": "int" + } } }, "time_hoursAgo": "{hours}h ago", "@time_hoursAgo": { "placeholders": { - "hours": {"type": "int"} + "hours": { + "type": "int" + } } }, "time_daysAgo": "{days}d ago", "@time_daysAgo": { "placeholders": { - "days": {"type": "int"} + "days": { + "type": "int" + } } }, "time_hour": "hour", @@ -710,10 +898,8 @@ "time_months": "months", "time_minutes": "minutes", "time_allTime": "All Time", - "dialog_disconnect": "Disconnect", "dialog_disconnectConfirm": "Are you sure you want to disconnect from this device?", - "login_repeaterLogin": "Repeater Login", "login_roomLogin": "Room Server Login", "login_password": "Password", @@ -731,32 +917,39 @@ "login_attempt": "Attempt {current}/{max}", "@login_attempt": { "placeholders": { - "current": {"type": "int"}, - "max": {"type": "int"} + "current": { + "type": "int" + }, + "max": { + "type": "int" + } } }, "login_failed": "Login failed: {error}", "@login_failed": { "placeholders": { - "error": {"type": "String"} + "error": { + "type": "String" + } } }, "login_failedMessage": "Login failed. Either the password is incorrect or the repeater is unreachable.", - - "common_reload": "Reload", "common_clear": "Clear", - "path_currentPath": "Current path: {path}", "@path_currentPath": { "placeholders": { - "path": {"type": "String"} + "path": { + "type": "String" + } } }, "path_usingHopsPath": "Using {count} {count, plural, =1{hop} other{hops}} path", "@path_usingHopsPath": { "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "path_enterCustomPath": "Enter Custom Path", @@ -771,12 +964,13 @@ "path_invalidHexPrefixes": "Invalid hex prefixes: {prefixes}", "@path_invalidHexPrefixes": { "placeholders": { - "prefixes": {"type": "String"} + "prefixes": { + "type": "String" + } } }, "path_tooLong": "Path too long. Maximum 64 hops allowed.", "path_setPath": "Set Path", - "repeater_management": "Repeater Management", "room_management": "Room Server Management", "repeater_managementTools": "Management Tools", @@ -786,11 +980,10 @@ "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", "repeater_routingMode": "Routing mode", "repeater_autoUseSavedPath": "Auto (use saved path)", @@ -801,7 +994,9 @@ "repeater_errorLoadingStatus": "Error loading status: {error}", "@repeater_errorLoadingStatus": { "placeholders": { - "error": {"type": "String"} + "error": { + "type": "String" + } } }, "repeater_systemInformation": "System Information", @@ -823,42 +1018,67 @@ "repeater_daysHoursMinsSecs": "{days} days {hours}h {minutes}m {seconds}s", "@repeater_daysHoursMinsSecs": { "placeholders": { - "days": {"type": "int"}, - "hours": {"type": "int"}, - "minutes": {"type": "int"}, - "seconds": {"type": "int"} + "days": { + "type": "int" + }, + "hours": { + "type": "int" + }, + "minutes": { + "type": "int" + }, + "seconds": { + "type": "int" + } } }, "repeater_packetTxTotal": "Total: {total}, Flood: {flood}, Direct: {direct}", "@repeater_packetTxTotal": { "placeholders": { - "total": {"type": "int"}, - "flood": {"type": "String"}, - "direct": {"type": "String"} + "total": { + "type": "int" + }, + "flood": { + "type": "String" + }, + "direct": { + "type": "String" + } } }, "repeater_packetRxTotal": "Total: {total}, Flood: {flood}, Direct: {direct}", "@repeater_packetRxTotal": { "placeholders": { - "total": {"type": "int"}, - "flood": {"type": "String"}, - "direct": {"type": "String"} + "total": { + "type": "int" + }, + "flood": { + "type": "String" + }, + "direct": { + "type": "String" + } } }, "repeater_duplicatesFloodDirect": "Flood: {flood}, Direct: {direct}", "@repeater_duplicatesFloodDirect": { "placeholders": { - "flood": {"type": "String"}, - "direct": {"type": "String"} + "flood": { + "type": "String" + }, + "direct": { + "type": "String" + } } }, "repeater_duplicatesTotal": "Total: {total}", "@repeater_duplicatesTotal": { "placeholders": { - "total": {"type": "int"} + "total": { + "type": "int" + } } }, - "repeater_settingsTitle": "Repeater Settings", "repeater_basicSettings": "Basic Settings", "repeater_repeaterName": "Repeater Name", @@ -892,14 +1112,18 @@ "repeater_localAdvertIntervalMinutes": "{minutes} minutes", "@repeater_localAdvertIntervalMinutes": { "placeholders": { - "minutes": {"type": "int"} + "minutes": { + "type": "int" + } } }, "repeater_floodAdvertInterval": "Flood Advertisement Interval", "repeater_floodAdvertIntervalHours": "{hours} hours", "@repeater_floodAdvertIntervalHours": { "placeholders": { - "hours": {"type": "int"} + "hours": { + "type": "int" + } } }, "repeater_encryptedAdvertInterval": "Encrypted Advertisement Interval", @@ -917,13 +1141,17 @@ "repeater_commandSent": "Command sent: {command}", "@repeater_commandSent": { "placeholders": { - "command": {"type": "String"} + "command": { + "type": "String" + } } }, "repeater_errorSendingCommand": "Error sending command: {error}", "@repeater_errorSendingCommand": { "placeholders": { - "error": {"type": "String"} + "error": { + "type": "String" + } } }, "repeater_confirm": "Confirm", @@ -931,7 +1159,9 @@ "repeater_errorSavingSettings": "Error saving settings: {error}", "@repeater_errorSavingSettings": { "placeholders": { - "error": {"type": "String"} + "error": { + "type": "String" + } } }, "repeater_refreshBasicSettings": "Refresh Basic Settings", @@ -945,16 +1175,19 @@ "repeater_refreshed": "{label} refreshed", "@repeater_refreshed": { "placeholders": { - "label": {"type": "String"} + "label": { + "type": "String" + } } }, "repeater_errorRefreshing": "Error refreshing {label}", "@repeater_errorRefreshing": { "placeholders": { - "label": {"type": "String"} + "label": { + "type": "String" + } } }, - "repeater_cliTitle": "Repeater CLI", "repeater_debugNextCommand": "Debug Next Command", "repeater_commandHelp": "Command Help", @@ -969,7 +1202,9 @@ "repeater_cliCommandError": "Error: {error}", "@repeater_cliCommandError": { "placeholders": { - "error": {"type": "String"} + "error": { + "type": "String" + } } }, "repeater_cliQuickGetName": "Get Name", @@ -1049,14 +1284,18 @@ "telemetry_errorLoading": "Error loading telemetry: {error}", "@telemetry_errorLoading": { "placeholders": { - "error": {"type": "String"} + "error": { + "type": "String" + } } }, "telemetry_noData": "No telemetry data available.", "telemetry_channelTitle": "Channel {channel}", "@telemetry_channelTitle": { "placeholders": { - "channel": {"type": "int"} + "channel": { + "type": "int" + } } }, "telemetry_batteryLabel": "Battery", @@ -1067,50 +1306,67 @@ "telemetry_batteryValue": "{percent}% / {volts}V", "@telemetry_batteryValue": { "placeholders": { - "percent": {"type": "int"}, - "volts": {"type": "String"} + "percent": { + "type": "int" + }, + "volts": { + "type": "String" + } } }, "telemetry_voltageValue": "{volts}V", "@telemetry_voltageValue": { "placeholders": { - "volts": {"type": "String"} + "volts": { + "type": "String" + } } }, "telemetry_currentValue": "{amps}A", "@telemetry_currentValue": { "placeholders": { - "amps": {"type": "String"} + "amps": { + "type": "String" + } } }, "telemetry_temperatureValue": "{celsius}°C / {fahrenheit}°F", "@telemetry_temperatureValue": { "placeholders": { - "celsius": {"type": "String"}, - "fahrenheit": {"type": "String"} + "celsius": { + "type": "String" + }, + "fahrenheit": { + "type": "String" + } } }, - - "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": { - "error": {"type": "String"} + "error": { + "type": "String" + } } }, - "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": { - "pubkey": {"type": "String"} + "pubkey": { + "type": "String" + } } }, "neighbors_heardAgo": "Heard: {time} ago", "@neighbors_heardAgo": { "placeholders": { - "time": {"type": "String"} + "time": { + "type": "String" + } } }, "channelPath_title": "Packet Path", @@ -1122,28 +1378,40 @@ "channelPath_senderLabel": "Sender", "channelPath_timeLabel": "Time", "channelPath_repeatsLabel": "Repeats", - "channelPath_pathLabel": "Path", + "channelPath_pathLabel": "Path {index}", "channelPath_observedLabel": "Observed", "channelPath_observedPathTitle": "Observed path {index} • {hops}", "@channelPath_observedPathTitle": { "placeholders": { - "index": {"type": "int"}, - "hops": {"type": "String"} + "index": { + "type": "int" + }, + "hops": { + "type": "String" + } } }, "channelPath_noLocationData": "No location data", "channelPath_timeWithDate": "{day}/{month} {time}", "@channelPath_timeWithDate": { "placeholders": { - "day": {"type": "int"}, - "month": {"type": "int"}, - "time": {"type": "String"} + "day": { + "type": "int" + }, + "month": { + "type": "int" + }, + "time": { + "type": "String" + } } }, "channelPath_timeOnly": "{time}", "@channelPath_timeOnly": { "placeholders": { - "time": {"type": "String"} + "time": { + "type": "String" + } } }, "channelPath_unknownPath": "Unknown", @@ -1152,14 +1420,20 @@ "channelPath_observedZeroOf": "0 of {total} hops", "@channelPath_observedZeroOf": { "placeholders": { - "total": {"type": "int"} + "total": { + "type": "int" + } } }, "channelPath_observedSomeOf": "{observed} of {total} hops", "@channelPath_observedSomeOf": { "placeholders": { - "observed": {"type": "int"}, - "total": {"type": "int"} + "observed": { + "type": "int" + }, + "total": { + "type": "int" + } } }, "channelPath_mapTitle": "Path Map", @@ -1167,13 +1441,16 @@ "channelPath_primaryPath": "Path {index} (Primary)", "@channelPath_primaryPath": { "placeholders": { - "index": {"type": "int"} + "index": { + "type": "int" + } } }, - "channelPath_pathLabel": "Path {index}", "@channelPath_pathLabel": { "placeholders": { - "index": {"type": "int"} + "index": { + "type": "int" + } } }, "channelPath_pathLabelTitle": "Path", @@ -1181,13 +1458,16 @@ "channelPath_selectedPathLabel": "{label} • {prefixes}", "@channelPath_selectedPathLabel": { "placeholders": { - "label": {"type": "String"}, - "prefixes": {"type": "String"} + "label": { + "type": "String" + }, + "prefixes": { + "type": "String" + } } }, "channelPath_noHopDetailsAvailable": "No hop details available for this packet.", "channelPath_unknownRepeater": "Unknown Repeater", - "community_title": "Community", "community_create": "Create Community", "community_createDesc": "Create a new community and share via QR code.", @@ -1196,7 +1476,9 @@ "community_joinConfirmation": "Do you want to join the community \"{name}\"?", "@community_joinConfirmation": { "placeholders": { - "name": {"type": "String"} + "name": { + "type": "String" + } } }, "community_scanQr": "Scan Community QR", @@ -1209,20 +1491,26 @@ "community_created": "Community \"{name}\" created", "@community_created": { "placeholders": { - "name": {"type": "String"} + "name": { + "type": "String" + } } }, "community_joined": "Joined community \"{name}\"", "@community_joined": { "placeholders": { - "name": {"type": "String"} + "name": { + "type": "String" + } } }, "community_qrTitle": "Share Community", "community_qrInstructions": "Scan this QR code to join \"{name}\"", "@community_qrInstructions": { "placeholders": { - "name": {"type": "String"} + "name": { + "type": "String" + } } }, "community_hashtagPrivacyHint": "Community hashtag channels are only joinable by members of the community", @@ -1231,7 +1519,9 @@ "community_alreadyMemberMessage": "You are already a member of \"{name}\".", "@community_alreadyMemberMessage": { "placeholders": { - "name": {"type": "String"} + "name": { + "type": "String" + } } }, "community_addPublicChannel": "Add Community Public Channel", @@ -1243,46 +1533,60 @@ "community_deleteConfirm": "Leave \"{name}\"?", "@community_deleteConfirm": { "placeholders": { - "name": {"type": "String"} + "name": { + "type": "String" + } } }, "community_deleteChannelsWarning": "This will also delete {count} channel(s) and their messages.", "@community_deleteChannelsWarning": { "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "community_deleted": "Left community \"{name}\"", "@community_deleted": { "placeholders": { - "name": {"type": "String"} + "name": { + "type": "String" + } } }, "community_regenerateSecret": "Regenerate Secret", "community_regenerateSecretConfirm": "Regenerate the secret key for \"{name}\"? All members will need to scan the new QR code to continue communicating.", "@community_regenerateSecretConfirm": { "placeholders": { - "name": {"type": "String"} + "name": { + "type": "String" + } } }, "community_regenerate": "Regenerate", "community_secretRegenerated": "Secret regenerated for \"{name}\"", "@community_secretRegenerated": { "placeholders": { - "name": {"type": "String"} + "name": { + "type": "String" + } } }, "community_updateSecret": "Update Secret", "community_secretUpdated": "Secret updated for \"{name}\"", "@community_secretUpdated": { "placeholders": { - "name": {"type": "String"} + "name": { + "type": "String" + } } }, "community_scanToUpdateSecret": "Scan the new QR code to update the secret for \"{name}\"", "@community_scanToUpdateSecret": { "placeholders": { - "name": {"type": "String"} + "name": { + "type": "String" + } } }, "community_addHashtagChannel": "Add Community Hashtag", @@ -1295,10 +1599,11 @@ "community_forCommunity": "For {name}", "@community_forCommunity": { "placeholders": { - "name": {"type": "String"} + "name": { + "type": "String" + } } }, - "listFilter_tooltip": "Filter and sort", "listFilter_sortBy": "Sort by", "listFilter_latestMessages": "Latest messages", @@ -1307,17 +1612,153 @@ "listFilter_usersFirst": "Users first", "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", "listFilter_unreadOnly": "Unread only", "listFilter_newGroup": "New group", - "pathTrace_you": "You", "pathTrace_failed": "Path trace failed.", "pathTrace_notAvailable": "Path trace not available.", "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", @@ -1328,52 +1769,59 @@ "contacts_pathTraceTo": "Trace route to {name}", "@contacts_pathTraceTo": { "placeholders": { - "name": {"type": "String"} + "name": { + "type": "String" + } } }, - "contacts_clipboardEmpty": "Clipboard is empty.", "contacts_invalidAdvertFormat": "Invalid contact data", "contacts_contactImported": "Contact has been imported.", "contacts_contactImportFailed": "Failed to import contact.", - "contacts_zeroHopAdvert":"Zero Hop Advert", - "contacts_floodAdvert":"Flood Advert", - "contacts_copyAdvertToClipboard":"Copy Advert to Clipboard", - "contacts_addContactFromClipboard":"Add Contact from Clipboard", + "contacts_zeroHopAdvert": "Zero Hop Advert", + "contacts_floodAdvert": "Flood Advert", + "contacts_copyAdvertToClipboard": "Copy Advert to Clipboard", + "contacts_addContactFromClipboard": "Add Contact from Clipboard", "contacts_ShareContact": "Copy contact to Clipboard", "contacts_ShareContactZeroHop": "Share contact by advert", "contacts_zeroHopContactAdvertSent": "Sent contact by advert.", "contacts_zeroHopContactAdvertFailed": "Failed to send contact.", "contacts_contactAdvertCopied": "Advert copied to Clipboard.", "contacts_contactAdvertCopyFailed": "Copying advert to Clipboard failed.", - "notification_activityTitle": "MeshCore Activity", "notification_messagesCount": "{count} {count, plural, =1{message} other{messages}}", "@notification_messagesCount": { "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "notification_channelMessagesCount": "{count} {count, plural, =1{channel message} other{channel messages}}", "@notification_channelMessagesCount": { "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "notification_newNodesCount": "{count} {count, plural, =1{new node} other{new nodes}}", "@notification_newNodesCount": { "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "notification_newTypeDiscovered": "New {contactType} discovered", "@notification_newTypeDiscovered": { "placeholders": { - "contactType": {"type": "String"} + "contactType": { + "type": "String" + } } }, "notification_receivedNewMessage": "Received new message", - "settings_gpxExportRepeaters": "Export repeaters / room server to GPX", "settings_gpxExportRepeatersSubtitle": "Exports repeaters / roomserver with a location to GPX file.", "settings_gpxExportContacts": "Export companions to GPX", @@ -1388,5 +1836,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 3d5ab639..74339ff7 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", @@ -131,9 +139,6 @@ "settings_infoContactsCount": "Número de contactos", "settings_infoChannelCount": "Número de canales", "settings_presets": "Preajustes", - "settings_preset915Mhz": "915 MHz", - "settings_preset868Mhz": "868 MHz", - "settings_preset433Mhz": "433 MHz", "settings_frequency": "Frecuencia (MHz)", "settings_frequencyHelper": "300,0 - 2500,0", "settings_frequencyInvalid": "Frecuencia inválida (300-2500 MHz)", @@ -143,8 +148,6 @@ "settings_txPower": "TX Potencia (dBm)", "settings_txPowerHelper": "0 - 22", "settings_txPowerInvalid": "Potencia de TX inválida (0-22 dBm)", - "settings_longRange": "Largo Alcance", - "settings_fastSpeed": "Velocidad Rápida", "settings_error": "Error: {message}", "@settings_error": { "placeholders": { @@ -339,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": { @@ -1356,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", @@ -1556,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.", @@ -1569,34 +1576,40 @@ "contacts_zeroHopContactAdvertSent": "Envió contacto por anuncio.", "contacts_contactAdvertCopied": "Anuncio copiado al Portapapeles.", "contacts_contactAdvertCopyFailed": "Copiar anuncio al Portapapeles ha fallado.", - "notification_activityTitle": "Actividad de MeshCore", "notification_messagesCount": "{count} {count, plural, =1{mensaje} other{mensajes}}", "@notification_messagesCount": { "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "notification_channelMessagesCount": "{count} {count, plural, =1{mensaje de canal} other{mensajes de canal}}", "@notification_channelMessagesCount": { "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "notification_newNodesCount": "{count} {count, plural, =1{nuevo nodo} other{nuevos nodos}}", "@notification_newNodesCount": { "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "notification_newTypeDiscovered": "Nuevo {contactType} descubierto", "@notification_newTypeDiscovered": { "placeholders": { - "contactType": {"type": "String"} + "contactType": { + "type": "String" + } } }, "notification_receivedNewMessage": "Nuevo mensaje recibido", - "contacts_contactAdvertCopyFailed": "Copiar anuncio al Portapapeles ha fallado.", "settings_gpxExportContactsSubtitle": "Exporta compañeros con una ubicación a archivo GPX.", "settings_gpxExportRepeaters": "Exportar repetidores / servidor de sala a GPX", "settings_gpxExportSuccess": "Archivo GPX exportado con éxito.", @@ -1612,6 +1625,207 @@ "settings_gpxExportAllContacts": "Todas las ubicaciones de contactos", "settings_gpxExportShareText": "Datos del mapa exportados desde meshcore-open", "settings_gpxExportShareSubject": "meshcore-open exportación de datos de mapa GPX", - "pathTrace_someHopsNoLocation": "Uno o más de los lúpulos carecen de una ubicación" - + "pathTrace_someHopsNoLocation": "Uno o más de los lúpulos carecen de una ubicación", + "pathTrace_clearTooltip": "Borrar ruta", + "map_runTrace": "Ejecutar Rastreo de Ruta", + "map_tapToAdd": "Pulse en los nodos para agregarlos al camino.", + "map_removeLast": "Eliminar último", + "map_pathTraceCancelled": "Rastreo de ruta cancelado.", + "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_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", + "@contacts_searchFavorites": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchUsers": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchRepeaters": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchRoomServers": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "contacts_searchContactsNoNumber": "Buscar contactos...", + "contacts_unread": "No leído", + "contacts_searchFavorites": "Buscar {number}{str} Favoritos...", + "contacts_searchUsers": "Buscar {number}{str} Usuarios...", + "contacts_searchRepeaters": "Buscar {number}{str} Repetidores...", + "contacts_searchRoomServers": "Buscar {number}{str} servidores de sala..." } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 044b8060..0697aee7 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", @@ -131,9 +139,6 @@ "settings_infoContactsCount": "Nombre de contacts", "settings_infoChannelCount": "Nombre de canaux", "settings_presets": "Préréglages", - "settings_preset915Mhz": "915 MHz", - "settings_preset868Mhz": "868 MHz", - "settings_preset433Mhz": "433 MHz", "settings_frequency": "Fréquence (MHz)", "settings_frequencyHelper": "300,0 - 2 500,0", "settings_frequencyInvalid": "Fréquence invalide (300-2500 MHz)", @@ -143,8 +148,6 @@ "settings_txPower": "TX Puissance (dBm)", "settings_txPowerHelper": "0 - 22", "settings_txPowerInvalid": "Puissance TX invalide (0-22 dBm)", - "settings_longRange": "Portée Longue", - "settings_fastSpeed": "Vitesse Rapide", "settings_error": "Erreur : {message}", "@settings_error": { "placeholders": { @@ -210,8 +213,8 @@ "appSettings_batteryLifepo4": "LiFePO4 (2,6-3,65V)", "appSettings_batteryLipo": "LiPo (3,0-4,2V)", "appSettings_mapDisplay": "Affichage de la carte", - "appSettings_showRepeaters": "Afficher les répétiteurs", - "appSettings_showRepeatersSubtitle": "Afficher les nœuds répétiteurs sur la carte", + "appSettings_showRepeaters": "Afficher les répéteurs", + "appSettings_showRepeatersSubtitle": "Afficher les nœuds répéteurs sur la carte", "appSettings_showChatNodes": "Afficher les nœuds de discussion", "appSettings_showChatNodesSubtitle": "Afficher les nœuds de chat sur la carte", "appSettings_showOtherNodes": "Afficher d'autres nœuds", @@ -266,8 +269,8 @@ } } }, - "contacts_manageRepeater": "Gérer le répétiteur", - "contacts_roomLogin": "Connexion Salle", + "contacts_manageRepeater": "Gérer le répéteur", + "contacts_roomLogin": "Connexion Room Server", "contacts_openChat": "Ouverture du Chat", "contacts_editGroup": "Modifier le groupe", "contacts_deleteGroup": "Supprimer le groupe", @@ -339,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": { @@ -542,9 +547,9 @@ "chat_forceFloodMode": "Mode tout le réseau forcé", "chat_recentAckPaths": "Chemins ACK récents (touchez pour utiliser) :", "chat_pathHistoryFull": "L'historique du chemin est plein. Supprimez les entrées pour en ajouter de nouvelles.", - "chat_hopSingular": "Sautez", - "chat_hopPlural": "sautez", - "chat_hopsCount": "{count} {count, plural, =1{hop} other{hops}}", + "chat_hopSingular": "saut", + "chat_hopPlural": "sauts", + "chat_hopsCount": "{count} {count, plural, =1{saut} other{sauts}}", "@chat_hopsCount": { "placeholders": { "count": { @@ -636,7 +641,7 @@ } }, "map_chat": "Chat", - "map_repeater": "Répétiteur", + "map_repeater": "Répéteur", "map_room": "Salle", "map_sensor": "Capteur", "map_pinDm": "Clé (DM)", @@ -677,7 +682,7 @@ "map_lastSeenTime": "Dernière fois vu", "map_sharedPin": "Clé partagée", "map_joinRoom": "Rejoindre la salle", - "map_manageRepeater": "Gérer le répétiteur", + "map_manageRepeater": "Gérer le répéteur", "mapCache_title": "Cache de Carte Hors Ligne", "mapCache_selectAreaFirst": "Sélectionner une zone pour la mise en cache en premier", "mapCache_noTilesToDownload": "Aucun tuilage à télécharger pour cette zone.", @@ -800,13 +805,13 @@ "time_allTime": "Tout le temps", "dialog_disconnect": "Déconnecter", "dialog_disconnectConfirm": "Êtes-vous sûr de vouloir vous déconnecter de cet appareil ?", - "login_repeaterLogin": "Connexion au répétiteur", - "login_roomLogin": "Connexion Salle", + "login_repeaterLogin": "Connexion au répéteur", + "login_roomLogin": "Connexion Room Server", "login_password": "Mot de passe", "login_enterPassword": "Entrez votre mot de passe", "login_savePassword": "Sauvegarder le mot de passe", "login_savePasswordSubtitle": "Le mot de passe sera stocké en toute sécurité sur cet appareil.", - "login_repeaterDescription": "Entrez le mot de passe du répétiteur pour accéder aux paramètres et à l'état.", + "login_repeaterDescription": "Entrez le mot de passe du répéteur pour accéder aux paramètres et à l'état.", "login_roomDescription": "Entrez le mot de passe de la pièce pour accéder aux paramètres et à l'état.", "login_routing": "Redirection", "login_routingMode": "Mode de routage", @@ -871,17 +876,17 @@ }, "path_tooLong": "Le chemin est trop long. Maximum 64 sauts autorisés.", "path_setPath": "Définir le chemin", - "repeater_management": "Gestion des répétiteurs", + "repeater_management": "Gestion des répéteurs", "repeater_managementTools": "Outils de Gestion", "repeater_status": "État", - "repeater_statusSubtitle": "Afficher l'état, les statistiques et les voisins du répétiteur", + "repeater_statusSubtitle": "Afficher l'état, les statistiques et les voisins du répéteur", "repeater_telemetry": "Télémetrie", "repeater_telemetrySubtitle": "Afficher la télémétrie des capteurs et les statistiques du système", "repeater_cli": "CLI", - "repeater_cliSubtitle": "Envoyer des commandes au répétiteur", + "repeater_cliSubtitle": "Envoyer des commandes au répéteur", "repeater_settings": "Paramètres", - "repeater_settingsSubtitle": "Configurer les paramètres du répétiteur", - "repeater_statusTitle": "État du répétiteur", + "repeater_settingsSubtitle": "Configurer les paramètres du répéteur", + "repeater_statusTitle": "État du répéteur", "repeater_routingMode": "Mode de routage", "repeater_autoUseSavedPath": "Auto (utiliser le chemin sauvegardé)", "repeater_forceFloodMode": "Mode tout le réseau forcé", @@ -976,10 +981,10 @@ } } }, - "repeater_settingsTitle": "Paramètres du répétiteur", + "repeater_settingsTitle": "Paramètres du répéteur", "repeater_basicSettings": "Paramètres de base", - "repeater_repeaterName": "Nom du répétiteur", - "repeater_repeaterNameHelper": "Afficher le nom de ce répétiteur", + "repeater_repeaterName": "Nom du répéteur", + "repeater_repeaterNameHelper": "Afficher le nom de ce répéteur", "repeater_adminPassword": "Mot de passe Administrateur", "repeater_adminPasswordHelper": "Mot de passe d'accès complet", "repeater_guestPassword": "Mot de passe invité", @@ -999,7 +1004,7 @@ "repeater_longitudeHelper": "Degrés décimaux (par exemple, -122,4194)", "repeater_features": "Fonctionnalités", "repeater_packetForwarding": "Transfert de paquets", - "repeater_packetForwardingSubtitle": "Activer le répétiteur pour transmettre des paquets", + "repeater_packetForwardingSubtitle": "Activer le répéteur pour transmettre des paquets", "repeater_guestAccess": "Accès Invité", "repeater_guestAccessSubtitle": "Autoriser l'accès invité en lecture seule", "repeater_privacyMode": "Mode de confidentialité", @@ -1026,14 +1031,14 @@ "repeater_encryptedAdvertInterval": "Intervalle d'annonces cryptées", "repeater_dangerZone": "Zone dangereuse", "repeater_rebootRepeater": "Redémarrer Répéteur", - "repeater_rebootRepeaterSubtitle": "Réinitialiser l'appareil répétiteur", - "repeater_rebootRepeaterConfirm": "Êtes-vous sûr de vouloir redémarrer ce répétiteur ?", + "repeater_rebootRepeaterSubtitle": "Réinitialiser l'appareil répéteur", + "repeater_rebootRepeaterConfirm": "Êtes-vous sûr de vouloir redémarrer ce répéteur ?", "repeater_regenerateIdentityKey": "Ré générer la clé d'identité", "repeater_regenerateIdentityKeySubtitle": "Générer une nouvelle paire de clés publique/privée", - "repeater_regenerateIdentityKeyConfirm": "Cela générera une nouvelle identité pour le répétiteur. Continuer ?", + "repeater_regenerateIdentityKeyConfirm": "Cela générera une nouvelle identité pour le répéteur. Continuer ?", "repeater_eraseFileSystem": "Supprimer le système de fichiers", - "repeater_eraseFileSystemSubtitle": "Formater le système de fichiers du répétiteur", - "repeater_eraseFileSystemConfirm": "AVERTISSEMENT : Cela effacera toutes les données du répétiteur. Cela ne peut pas être annulé !", + "repeater_eraseFileSystemSubtitle": "Formater le système de fichiers du répéteur", + "repeater_eraseFileSystemConfirm": "AVERTISSEMENT : Cela effacera toutes les données du répéteur. Cela ne peut pas être annulé !", "repeater_eraseSerialOnly": "Erase n'est disponible que via la console série.", "repeater_commandSent": "Commande envoyée : {command}", "@repeater_commandSent": { @@ -1085,7 +1090,7 @@ } } }, - "repeater_cliTitle": "Répétiteur CLI", + "repeater_cliTitle": "Répéteur CLI", "repeater_debugNextCommand": "Déboguer Prochaine Commande", "repeater_commandHelp": "Aide", "repeater_clearHistory": "Effacer l'historique", @@ -1119,13 +1124,13 @@ "repeater_cliHelpClearStats": "Réinitialise divers compteurs de statistiques à zéro.", "repeater_cliHelpSetAf": "Définit le facteur de temps d'air.", "repeater_cliHelpSetTx": "Définit la puissance de transmission LoRa en dBm (réinitialisation requise pour appliquer).", - "repeater_cliHelpSetRepeat": "Active ou désactive le rôle du répétiteur pour ce nœud.", + "repeater_cliHelpSetRepeat": "Active ou désactive le rôle du répéteur pour ce nœud.", "repeater_cliHelpSetAllowReadOnly": "(Room server) Si \"activé\", alors un mot de passe vide permettra la connexion, mais ne permettra pas de publier dans la pièce. (lecture seule uniquement)", "repeater_cliHelpSetFloodMax": "Définit le nombre maximal de sauts pour les paquets de balayage entrants (si >= max, le paquet n'est pas acheminé).", "repeater_cliHelpSetIntThresh": "Définit le seuil d'interférence (en dB). La valeur par défaut est de 14. Définir sur 0 désactive la détection des interférences de canal.", "repeater_cliHelpSetAgcResetInterval": "Définit l'intervalle pour réinitialiser le contrôleur de gain automatique. Mettez à 0 pour désactiver.", "repeater_cliHelpSetMultiAcks": "Active ou désactive la fonctionnalité « double ACKs ».", - "repeater_cliHelpSetAdvertInterval": "Définit l'intervalle du minuteur pour envoyer un paquet d'annonce local (sans relais). Définir sur 0 pour désactiver.", + "repeater_cliHelpSetAdvertInterval": "Définit l'intervalle entre chaque émission d'une annonce locale (sans relais). Définir sur 0 pour désactiver.", "repeater_cliHelpSetFloodAdvertInterval": "Définit l'intervalle du minuteur en heures pour envoyer un paquet d'annonce massive. Définir sur 0 pour désactiver.", "repeater_cliHelpSetGuestPassword": "Définit/met à jour le mot de passe de l'invité. (pour les répéteurs, les connexions d'invités peuvent envoyer la requête \"Get Stats\")", "repeater_cliHelpSetName": "Définit le nom de l'annonce.", @@ -1147,7 +1152,7 @@ "repeater_cliHelpLogStart": "Démarre l'enregistrement des paquets dans le système de fichiers.", "repeater_cliHelpLogStop": "Arrêter de journaliser les paquets vers le système de fichiers.", "repeater_cliHelpLogErase": "Supprime les journaux de paquets du système de fichiers.", - "repeater_cliHelpNeighbors": "Affiche une liste d'autres nœuds répétiteurs entendus via des annonces sans relais. Chaque ligne est id-préfixe-hexadécimal:timestamp:snr-fois-4", + "repeater_cliHelpNeighbors": "Affiche une liste d'autres nœuds répéteurs entendus via des annonces sans relais. Chaque ligne est id-préfixe-hexadécimal:timestamp:snr-fois-4", "repeater_cliHelpNeighborRemove": "Supprime la première entrée correspondante (par préfixe de clé publique (hexadécimal)) de la liste des voisins.", "repeater_cliHelpRegion": "(série uniquement) Liste toutes les régions définies et les autorisations actuelles d'annonces sur tout le réseau (flood).", "repeater_cliHelpRegionLoad": "REMARQUE : il s'agit d'une invocation multi-commande spéciale. Chaque commande subséquente est un nom de région (indenté avec des espaces pour indiquer la hiérarchie parent, avec un minimum d'un espace). Terminé par l'envoi d'une ligne vide/commande.", @@ -1171,8 +1176,8 @@ "repeater_settingsCategory": "Paramètres", "repeater_bridge": "Pont", "repeater_logging": "Journalisation", - "repeater_neighborsRepeaterOnly": "Voisins (Uniquement répétiteur)", - "repeater_regionManagementRepeaterOnly": "Gestion des régions (uniquement pour le répétiteur)", + "repeater_neighborsRepeaterOnly": "Voisins (Uniquement répéteur)", + "repeater_regionManagementRepeaterOnly": "Gestion des régions (uniquement pour le répéteur)", "repeater_regionNote": "Les commandes de région ont été introduites pour gérer les définitions et les autorisations des régions.", "repeater_gpsManagement": "Gestion GPS", "repeater_gpsNote": "La commande GPS a été introduite pour gérer les sujets liés à la localisation.", @@ -1241,7 +1246,7 @@ "channelPath_title": "Chemin de paquet", "channelPath_viewMap": "Afficher la carte", "channelPath_otherObservedPaths": "Autres chemins observés", - "channelPath_repeaterHops": "Sauts du répétiteur", + "channelPath_repeaterHops": "Sauts du répéteur", "channelPath_noHopDetails": "Les détails de l'envoi ne sont pas fournis pour ce paquet.", "channelPath_messageDetails": "Détails du message", "channelPath_senderLabel": "Expéditeur", @@ -1306,7 +1311,7 @@ } }, "channelPath_mapTitle": "Carte du chemin", - "channelPath_noRepeaterLocations": "Aucune position de répétiteur disponible pour ce chemin.", + "channelPath_noRepeaterLocations": "Aucune position de répéteur disponible pour ce chemin.", "channelPath_primaryPath": "Chemin {index} (Principal)", "@channelPath_primaryPath": { "placeholders": { @@ -1356,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é", @@ -1396,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": { @@ -1556,11 +1561,13 @@ "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 de crue", + "contacts_floodAdvert": "Annonce à tout le réseau", "contacts_contactImportFailed": "Échec de l'importation du contact.", - "contacts_zeroHopAdvert": "Annonce Zero Hop", + "contacts_zeroHopAdvert": "Annonce Zero saut", "contacts_copyAdvertToClipboard": "Copier l'annonce dans le presse-papiers", "contacts_addContactFromClipboard": "Ajouter un contact depuis le presse-papiers", "contacts_ShareContact": "Copier le contact dans le presse-papiers", @@ -1575,7 +1582,6 @@ "notification_newNodesCount": "{count} {count, plural, =1{nouveau nœud} other{nouveaux nœuds}}", "notification_newTypeDiscovered": "Nouveau {contactType} découvert", "notification_receivedNewMessage": "Nouveau message reçu", - "contacts_zeroHopContactAdvertFailed": "Échec de l'envoi du contact.", "settings_gpxExportRepeaters": "Exporter les répéteurs / serveur de salle au format GPX", "settings_gpxExportRepeatersSubtitle": "Exporte les répéteurs / roomserver avec une localisation vers un fichier GPX.", "settings_gpxExportNoContacts": "Aucun contact à exporter.", @@ -1591,6 +1597,207 @@ "settings_gpxExportAllContacts": "Tous les emplacements des contacts", "settings_gpxExportShareText": "Données de carte exportées à partir de meshcore-open", "settings_gpxExportShareSubject": "meshcore-open exporter les données de carte GPX", - "pathTrace_someHopsNoLocation": "Une ou plusieurs des houblons manquent d'une localisation !" - + "pathTrace_someHopsNoLocation": "Un ou plusieurs des sauts manquent d'une localisation !", + "map_tapToAdd": "Appuyez sur les nœuds pour les ajouter au chemin.", + "pathTrace_clearTooltip": "Effacer le chemin", + "map_pathTraceCancelled": "Traçage de chemin annulé", + "map_removeLast": "Supprimer le dernier", + "map_runTrace": "Exécuter la traçage de chemin", + "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_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", + "@contacts_searchFavorites": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchUsers": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchRepeaters": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchRoomServers": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "contacts_unread": "Non lu", + "contacts_searchFavorites": "Rechercher {number}{str} Favoris...", + "contacts_searchUsers": "Rechercher {number}{str} utilisateurs...", + "contacts_searchRoomServers": "Rechercher {number}{str} serveurs de salle...", + "contacts_searchRepeaters": "Rechercher {number}{str} Répéteurs...", + "contacts_searchContactsNoNumber": "Rechercher des contacts..." } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index dd9c3730..4798d263 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", @@ -131,9 +139,6 @@ "settings_infoContactsCount": "Numero contatti", "settings_infoChannelCount": "Numero Canale", "settings_presets": "Preset", - "settings_preset915Mhz": "915 MHz", - "settings_preset868Mhz": "868 MHz", - "settings_preset433Mhz": "433 MHz", "settings_frequency": "Frequenza (MHz)", "settings_frequencyHelper": "300,0 - 2500,0", "settings_frequencyInvalid": "Frequenza non valida (300-2500 MHz)", @@ -143,8 +148,6 @@ "settings_txPower": "TX Potenza (dBm)", "settings_txPowerHelper": "0 - 22", "settings_txPowerInvalid": "Potere TX non valido (0-22 dBm)", - "settings_longRange": "Lungo Raggio", - "settings_fastSpeed": "Velocità Rapida", "settings_error": "Errore: {message}", "@settings_error": { "placeholders": { @@ -339,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": { @@ -1356,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.", @@ -1556,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", @@ -1575,7 +1582,6 @@ "notification_newNodesCount": "{count} {count, plural, =1{nuovo nodo} other{nuovi nodi}}", "notification_newTypeDiscovered": "Nuovo {contactType} scoperto", "notification_receivedNewMessage": "Nuovo messaggio ricevuto", - "contacts_contactAdvertCopied": "Annuncio copiato negli Appunti.", "settings_gpxExportRepeaters": "Esporta ripetitori / server di stanza in GPX", "settings_gpxExportContacts": "Esporta compagni in GPX", "settings_gpxExportSuccess": "Esportazione del file GPX completata con successo.", @@ -1591,6 +1597,207 @@ "settings_gpxExportAllContacts": "Tutte le posizioni dei contatti", "settings_gpxExportShareText": "Dati mappa esportati da meshcore-open", "settings_gpxExportShareSubject": "meshcore-open esportazione dati mappa GPX", - "pathTrace_someHopsNoLocation": "Uno o più dei luppoli mancano di una posizione!" - + "pathTrace_someHopsNoLocation": "Uno o più dei luppoli mancano di una posizione!", + "map_removeLast": "Rimuovi ultimo", + "map_pathTraceCancelled": "Tracciamento del percorso annullato.", + "pathTrace_clearTooltip": "Pulisci percorso", + "map_runTrace": "Esegui Path Trace", + "map_tapToAdd": "Tocca i nodi per aggiungerli al percorso.", + "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_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", + "@contacts_searchFavorites": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchUsers": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchRepeaters": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchRoomServers": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "contacts_searchUsers": "Cerca {number}{str} Utenti...", + "contacts_searchContactsNoNumber": "Cerca Contatti...", + "contacts_searchFavorites": "Cerca {number}{str} Preferiti...", + "contacts_unread": "Non letti", + "contacts_searchRepeaters": "Cerca {number}{str} Ripetitori...", + "contacts_searchRoomServers": "Cerca {number}{str} server Room..." } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index cd742e50..f65dddcc 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -376,6 +376,24 @@ abstract class AppLocalizations { /// **'Scan'** String get scanner_scan; + /// No description provided for @scanner_bluetoothOff. + /// + /// In en, this message translates to: + /// **'Bluetooth is off'** + String get scanner_bluetoothOff; + + /// No description provided for @scanner_bluetoothOffMessage. + /// + /// In en, this message translates to: + /// **'Please turn on Bluetooth to scan for devices'** + String get scanner_bluetoothOffMessage; + + /// No description provided for @scanner_enableBluetooth. + /// + /// In en, this message translates to: + /// **'Enable Bluetooth'** + String get scanner_enableBluetooth; + /// No description provided for @device_quickSwitch. /// /// In en, this message translates to: @@ -682,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: @@ -730,24 +754,6 @@ abstract class AppLocalizations { /// **'Presets'** String get settings_presets; - /// No description provided for @settings_preset915Mhz. - /// - /// In en, this message translates to: - /// **'915 MHz'** - String get settings_preset915Mhz; - - /// No description provided for @settings_preset868Mhz. - /// - /// In en, this message translates to: - /// **'868 MHz'** - String get settings_preset868Mhz; - - /// No description provided for @settings_preset433Mhz. - /// - /// In en, this message translates to: - /// **'433 MHz'** - String get settings_preset433Mhz; - /// No description provided for @settings_frequency. /// /// In en, this message translates to: @@ -802,17 +808,23 @@ abstract class AppLocalizations { /// **'Invalid TX power (0-22 dBm)'** String get settings_txPowerInvalid; - /// No description provided for @settings_longRange. + /// No description provided for @settings_clientRepeat. /// /// In en, this message translates to: - /// **'Long Range'** - String get settings_longRange; + /// **'Off-Grid Repeat'** + String get settings_clientRepeat; - /// No description provided for @settings_fastSpeed. + /// No description provided for @settings_clientRepeatSubtitle. /// /// In en, this message translates to: - /// **'Fast Speed'** - String get settings_fastSpeed; + /// **'Allow this device to repeat mesh packets for others'** + String get settings_clientRepeatSubtitle; + + /// No description provided for @settings_clientRepeatFreqWarning. + /// + /// In en, this message translates to: + /// **'Off-grid repeat requires 433, 869, or 918 MHz frequency'** + String get settings_clientRepeatFreqWarning; /// No description provided for @settings_error. /// @@ -958,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: @@ -1234,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: @@ -1294,11 +1336,47 @@ abstract class AppLocalizations { /// **'Contacts will appear when devices advertise'** String get contacts_contactsWillAppear; + /// No description provided for @contacts_unread. + /// + /// In en, this message translates to: + /// **'Unread'** + String get contacts_unread; + + /// No description provided for @contacts_searchContactsNoNumber. + /// + /// In en, this message translates to: + /// **'Search Contacts...'** + String get contacts_searchContactsNoNumber; + /// No description provided for @contacts_searchContacts. /// /// In en, this message translates to: - /// **'Search contacts...'** - String get contacts_searchContacts; + /// **'Search {number}{str} Contacts...'** + String contacts_searchContacts(int number, String str); + + /// No description provided for @contacts_searchFavorites. + /// + /// In en, this message translates to: + /// **'Search {number}{str} Favorites...'** + String contacts_searchFavorites(int number, String str); + + /// No description provided for @contacts_searchUsers. + /// + /// In en, this message translates to: + /// **'Search {number}{str} Users...'** + String contacts_searchUsers(int number, String str); + + /// No description provided for @contacts_searchRepeaters. + /// + /// In en, this message translates to: + /// **'Search {number}{str} Repeaters...'** + String contacts_searchRepeaters(int number, String str); + + /// No description provided for @contacts_searchRoomServers. + /// + /// In en, this message translates to: + /// **'Search {number}{str} Room servers...'** + String contacts_searchRoomServers(int number, String str); /// No description provided for @contacts_noUnreadContacts. /// @@ -1516,6 +1594,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: @@ -1528,6 +1618,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: @@ -2026,6 +2122,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: @@ -2278,6 +2380,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: @@ -2518,6 +2632,30 @@ abstract class AppLocalizations { /// **'Manage Repeater'** String get map_manageRepeater; + /// No description provided for @map_tapToAdd. + /// + /// In en, this message translates to: + /// **'Tap on nodes to add them to the path.'** + String get map_tapToAdd; + + /// No description provided for @map_runTrace. + /// + /// In en, this message translates to: + /// **'Run Path Trace'** + String get map_runTrace; + + /// No description provided for @map_removeLast. + /// + /// In en, this message translates to: + /// **'Remove Last'** + String get map_removeLast; + + /// No description provided for @map_pathTraceCancelled. + /// + /// In en, this message translates to: + /// **'Path trace cancelled.'** + String get map_pathTraceCancelled; + /// No description provided for @mapCache_title. /// /// In en, this message translates to: @@ -2997,17 +3135,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. /// @@ -4151,13 +4289,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. @@ -4166,16 +4304,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. @@ -4676,6 +4814,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: @@ -4736,6 +4892,231 @@ abstract class AppLocalizations { /// **'One or more of the hops is missing a location!'** String get pathTrace_someHopsNoLocation; + /// No description provided for @pathTrace_clearTooltip. + /// + /// In en, this message translates to: + /// **'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: @@ -4993,6 +5374,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 0ffd2452..123d01c2 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -143,6 +143,16 @@ class AppLocalizationsBg extends AppLocalizations { @override String get scanner_scan => 'Сканирай'; + @override + String get scanner_bluetoothOff => 'Bluetooth е изключен.'; + + @override + String get scanner_bluetoothOffMessage => + 'Моля, активирайте Bluetooth, за да сканирате за устройства.'; + + @override + String get scanner_enableBluetooth => 'Активирайте Bluetooth'; + @override String get device_quickSwitch => 'Бързо превключване'; @@ -316,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 => 'Име'; @@ -340,15 +354,6 @@ class AppLocalizationsBg extends AppLocalizations { @override String get settings_presets => 'Предварителни настройки'; - @override - String get settings_preset915Mhz => '915 MHz'; - - @override - String get settings_preset868Mhz => '868 MHz'; - - @override - String get settings_preset433Mhz => '433 MHz'; - @override String get settings_frequency => 'Честота (MHz)'; @@ -377,10 +382,15 @@ class AppLocalizationsBg extends AppLocalizations { String get settings_txPowerInvalid => 'Невалидна мощност на TX (0-22 dBm)'; @override - String get settings_longRange => 'Дълъг обхват'; + String get settings_clientRepeat => 'Без електричество – повторение'; @override - String get settings_fastSpeed => 'Бърза скорост'; + String get settings_clientRepeatSubtitle => + 'Позволете на това устройство да предава пакети към мрежата за други устройства.'; + + @override + String get settings_clientRepeatFreqWarning => + 'За повторение извън мрежата са необходими честоти от 433, 869 или 918 MHz.'; @override String settings_error(String message) { @@ -456,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 => 'Уведомления'; @@ -616,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 => 'Няма избрана област'; @@ -654,7 +681,35 @@ class AppLocalizationsBg extends AppLocalizations { 'Контактите ще се появят, когато устройствата рекламират.'; @override - String get contacts_searchContacts => 'Търсене на контакти...'; + String get contacts_unread => 'Непрочетено'; + + @override + String get contacts_searchContactsNoNumber => 'Търси контакти...'; + + @override + String contacts_searchContacts(int number, String str) { + return 'Търсене на контакти...'; + } + + @override + String contacts_searchFavorites(int number, String str) { + return 'Търсене на $number$str любими...'; + } + + @override + String contacts_searchUsers(int number, String str) { + return 'Търсене на $number$str потребители...'; + } + + @override + String contacts_searchRepeaters(int number, String str) { + return 'Търсене на $number$str повтарящи се...'; + } + + @override + String contacts_searchRoomServers(int number, String str) { + return 'Търсене на $number$str сървъри в стаята...'; + } @override String get contacts_noUnreadContacts => 'Няма непрочетени контакти'; @@ -779,6 +834,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 => 'Изтрий канала'; @@ -787,6 +848,11 @@ class AppLocalizationsBg extends AppLocalizations { return 'Изтрий \"$name\"? Това не може да бъде отменено.'; } + @override + String channels_channelDeleteFailed(String name) { + return 'Неуспешно изтриване на канала \"$name\"'; + } + @override String channels_channelDeleted(String name) { return 'Каналът \"$name\" е изтрит'; @@ -1074,6 +1140,9 @@ class AppLocalizationsBg extends AppLocalizations { @override String get chat_pathManagement => 'Управление на пътища'; + @override + String get chat_ShowAllPaths => 'Покажи всички пътища'; + @override String get chat_routingMode => 'Режим на маршрутизиране'; @@ -1234,6 +1303,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 => 'Няма възли с данни за местоположение.'; @@ -1363,6 +1438,19 @@ class AppLocalizationsBg extends AppLocalizations { @override String get map_manageRepeater => 'Управление на Повтарящ се Елемент'; + @override + String get map_tapToAdd => + 'Натиснете върху възлите, за да ги добавите към пътя.'; + + @override + String get map_runTrace => 'Изпълни Път на Следване'; + + @override + String get map_removeLast => 'Премахни Последно'; + + @override + String get map_pathTraceCancelled => 'Отменен е следването на пътя.'; + @override String get mapCache_title => 'Кеш на офлайн карти'; @@ -1658,10 +1746,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 @@ -2361,7 +2449,7 @@ class AppLocalizationsBg extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => 'Повторители Съседи'; + String get neighbors_repeatersNeighbors => 'Повторители Съседи'; @override String get neighbors_noData => 'Няма налични данни за съседи.'; @@ -2671,6 +2759,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 => 'Потребители'; @@ -2702,6 +2799,147 @@ class AppLocalizationsBg extends AppLocalizations { String get pathTrace_someHopsNoLocation => 'Един или повече от хмелите липсва местоположение!'; + @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 => 'Пътен проследяване'; @@ -2871,4 +3109,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 0877e474..18a9aec3 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -143,6 +143,16 @@ class AppLocalizationsDe extends AppLocalizations { @override String get scanner_scan => 'Scannen'; + @override + String get scanner_bluetoothOff => 'Bluetooth ist deaktiviert.'; + + @override + String get scanner_bluetoothOffMessage => + 'Bitte aktivieren Sie Bluetooth, um nach Geräten zu suchen.'; + + @override + String get scanner_enableBluetooth => 'Bluetooth aktivieren'; + @override String get device_quickSwitch => 'Schnelles Umschalten'; @@ -244,10 +254,10 @@ class AppLocalizationsDe extends AppLocalizations { String get settings_actions => 'Aktionen'; @override - String get settings_sendAdvertisement => 'Sende eine Ankündigung'; + String get settings_sendAdvertisement => 'Sende Ankündigung'; @override - String get settings_sendAdvertisementSubtitle => 'Sende Ankündigung'; + String get settings_sendAdvertisementSubtitle => 'Sende eine Ankündigung'; @override String get settings_advertisementSent => 'Ankündigung gesendet'; @@ -267,7 +277,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settings_refreshContactsSubtitle => - 'Kontakte-Liste vom Gerät neu laden'; + 'Kontakt-Liste vom Gerät neu laden'; @override String get settings_rebootDevice => 'Gerät neu starten'; @@ -310,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'; @@ -334,15 +348,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settings_presets => 'Voreinstellungen'; - @override - String get settings_preset915Mhz => '915 MHz'; - - @override - String get settings_preset868Mhz => '868 MHz'; - - @override - String get settings_preset433Mhz => '433 MHz'; - @override String get settings_frequency => 'Frequenz (MHz)'; @@ -371,10 +376,15 @@ class AppLocalizationsDe extends AppLocalizations { String get settings_txPowerInvalid => 'Ungültige TX-Leistung (0-22 dBm)'; @override - String get settings_longRange => 'Grosse Reichweite'; + String get settings_clientRepeat => 'Wiederholung, ohne Stromanschluss'; @override - String get settings_fastSpeed => 'Schnelle Geschwindigkeit'; + String get settings_clientRepeatSubtitle => + 'Ermöglichen Sie diesem Gerät, Mesh-Pakete für andere zu wiederholen.'; + + @override + String get settings_clientRepeatFreqWarning => + 'Die Kommunikation ohne Stromversorgung erfordert Frequenzen von 433, 869 oder 918 MHz.'; @override String settings_error(String message) { @@ -450,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'; @@ -613,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'; @@ -650,7 +677,35 @@ class AppLocalizationsDe extends AppLocalizations { 'Kontakte werden angezeigt, wenn Geräte eine Ankündigung machen.'; @override - String get contacts_searchContacts => 'Suche Kontakte...'; + String get contacts_unread => 'Ungelesen'; + + @override + String get contacts_searchContactsNoNumber => 'Kontakte suchen...'; + + @override + String contacts_searchContacts(int number, String str) { + return 'Suche Kontakte...'; + } + + @override + String contacts_searchFavorites(int number, String str) { + return 'Suche $number$str Favoriten...'; + } + + @override + String contacts_searchUsers(int number, String str) { + return 'Suche $number$str Benutzer...'; + } + + @override + String contacts_searchRepeaters(int number, String str) { + return 'Suche $number$str Repeater...'; + } + + @override + String contacts_searchRoomServers(int number, String str) { + return 'Suche $number$str Raumserver...'; + } @override String get contacts_noUnreadContacts => 'Keine ungesehene Kontakte'; @@ -776,6 +831,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'; @@ -784,6 +845,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'; @@ -1074,6 +1140,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'; @@ -1086,7 +1155,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get chat_recentAckPaths => - 'Aktuelle ACK-Pfade (tasten, um zu verwenden):'; + 'Aktuelle ACK-Pfade (antippen, um zu verwenden):'; @override String get chat_pathHistoryFull => @@ -1117,7 +1186,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get chat_noPathHistoryYet => - 'Keine eine Pfadhistorie vorhanden.\nSende eine Nachricht, um Pfade zu entdecken.'; + 'Keine Pfadhistorie vorhanden.\nSende eine Nachricht, um Pfade zu entdecken.'; @override String get chat_pathActions => 'Pfadaktionen:'; @@ -1233,6 +1302,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'; @@ -1362,6 +1437,19 @@ class AppLocalizationsDe extends AppLocalizations { @override String get map_manageRepeater => 'Repeater verwalten'; + @override + String get map_tapToAdd => + 'Tippen Sie auf Knoten, um sie zum Pfad hinzuzufügen.'; + + @override + String get map_runTrace => 'Pfadverlauf ausführen'; + + @override + String get map_removeLast => 'Letztes Entfernen'; + + @override + String get map_pathTraceCancelled => 'Pfadverfolgung abgebrochen.'; + @override String get mapCache_title => 'Offline-Karten-Cache'; @@ -1418,7 +1506,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String mapCache_estimatedTiles(int count) { - return 'Geschätzte Fliesen: $count'; + return 'Geschätzte Kacheln: $count'; } @override @@ -1592,7 +1680,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get path_hexPrefixInstructions => - 'Gebe für jeden Hopfen 2-stellige Hex-Präfixe ein, getrennt durch Kommas.'; + 'Gebe für jeden Zwischen-Hop das 2-stellige Hex-Präfix ein, getrennt durch Kommas.'; @override String get path_hexPrefixExample => @@ -1657,10 +1745,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'; @@ -1689,7 +1777,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get repeater_statusRequestTimeout => - 'Statusanfrage zeitweise fehlgeschlagen.'; + 'Statusanfrage durch Timeout fehlgeschlagen.'; @override String repeater_errorLoadingStatus(String error) { @@ -1766,7 +1854,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String repeater_duplicatesFloodDirect(String flood, String direct) { - return 'Überflut: $flood, Direkt: $direct'; + return 'Flut: $flood, Direkt: $direct'; } @override @@ -1797,7 +1885,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get repeater_guestPasswordHelper => - 'Schreibgeschützter Zugriffspasswort'; + 'Schreibgeschütztes Zugriffspasswort'; @override String get repeater_radioSettings => 'Funk Einstellungen'; @@ -1992,7 +2080,7 @@ class AppLocalizationsDe extends AppLocalizations { String get repeater_cliTitle => 'Repeater CLI'; @override - String get repeater_debugNextCommand => 'Fehlersuche Nächster Befehl'; + String get repeater_debugNextCommand => 'Fehlersuche des nächsten Befehls'; @override String get repeater_commandHelp => 'Hilfe'; @@ -2005,7 +2093,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get repeater_typeCommandOrUseQuick => - 'Geben Sie einen Befehl unten ein oder verwenden Sie Schnellbefehle'; + 'Geben Sie unten einen Befehl ein oder verwenden Sie die Schnellbefehle'; @override String get repeater_enterCommandHint => 'Geben Sie den Befehl ein...'; @@ -2131,7 +2219,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get repeater_cliHelpSetRxDelay => - 'Sets (experimentell) als Basis (muss > 1 sein für den Effekt) zur Anwendung einer leichten Verzögerung bei empfangenen Paketen, basierend auf Signalstärke/Punktzahl. Auf 0 setzen, um die Funktion zu deaktivieren.'; + 'Fügt eine leichte Verzögerung bei empfangenen Paketen hinzu, basierend auf Signalstärke/Punktzahl. Auf 0 setzen, um die Funktion zu deaktivieren.'; @override String get repeater_cliHelpSetTxDelay => @@ -2175,7 +2263,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get repeater_cliHelpGetBridgeType => - 'Ruft Brückentyp none, rs232, espnow ab.'; + 'Ruft Brückentyp: none, rs232, espnow ab.'; @override String get repeater_cliHelpLogStart => @@ -2202,7 +2290,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get repeater_cliHelpRegionLoad => - 'Hinweis: Dies ist ein spezieller Mehrbefehl-Aufruf. Jeder nachfolgende Befehl ist ein Regionsname (eingedruckt mit Leerzeichen zur Angabe der übergeordneten Hierarchie, mit mindestens einem Leerzeichen). Beendet durch das Senden einer Leerzeile/des Befehls.'; + 'Hinweis: Dies ist ein spezieller Mehrbefehl-Aufruf. Jeder nachfolgende Befehl ist ein Regionsname (eingerückt mit Leerzeichen zur Angabe der übergeordneten Hierarchie, mit mindestens einem Leerzeichen). Beendet durch das Senden einer Leerzeile.'; @override String get repeater_cliHelpRegionGet => @@ -2351,10 +2439,11 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String get neighbors_receivedData => 'Empfangene Nachbarendaten'; + String get neighbors_receivedData => 'Empfangene Nachbarsdaten'; @override - String get neighbors_requestTimedOut => 'Nachbarn melden zeitweise Ausfall.'; + String get neighbors_requestTimedOut => + 'Anfrage durch Timeout fehlgeschlagen.'; @override String neighbors_errorLoading(String error) { @@ -2362,19 +2451,19 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => 'Nachbarn'; + String get neighbors_repeatersNeighbors => 'Nachbarn'; @override - String get neighbors_noData => 'Keine Nachbardaten verfügbar.'; + String get neighbors_noData => 'Keine Nachbarsdaten verfügbar.'; @override String neighbors_unknownContact(String pubkey) { - return 'Unbekannte $pubkey'; + return 'Unbekannt $pubkey'; } @override String neighbors_heardAgo(String time) { - return 'Hörte: $time vor her.'; + return 'Gehört vor: $time'; } @override @@ -2394,7 +2483,7 @@ class AppLocalizationsDe extends AppLocalizations { 'Die Detailangaben für dieses Paket sind nicht verfügbar.'; @override - String get channelPath_messageDetails => 'Nachrichtsdetails'; + String get channelPath_messageDetails => 'Nachrichtendetails'; @override String get channelPath_senderLabel => 'Sender'; @@ -2630,14 +2719,14 @@ class AppLocalizationsDe extends AppLocalizations { 'Füge einen Hashtag-Kanal für diese Community hinzu'; @override - String get community_selectCommunity => 'Wählen Sie Community'; + String get community_selectCommunity => 'Wählen Sie eine Community'; @override String get community_regularHashtag => 'Regulärer Hashtag'; @override String get community_regularHashtagDesc => - 'Öffentliches Hashtag (jeder kann teilnehmen)'; + 'Öffentlicher Hashtag (jeder kann teilnehmen)'; @override String get community_communityHashtag => 'Community Hashtag'; @@ -2675,6 +2764,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'; @@ -2685,7 +2783,7 @@ class AppLocalizationsDe extends AppLocalizations { String get listFilter_roomServers => 'Raumserver'; @override - String get listFilter_unreadOnly => 'Nur nicht gelesen'; + String get listFilter_unreadOnly => 'Nicht gelesen'; @override String get listFilter_newGroup => 'Neue Gruppe'; @@ -2704,7 +2802,149 @@ class AppLocalizationsDe extends AppLocalizations { @override String get pathTrace_someHopsNoLocation => - 'Eine oder mehrere der Hopfen fehlen einen Standort!'; + 'Bei einer oder mehreren Knoten fehlt der Standort!'; + + @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'; @@ -2746,14 +2986,14 @@ class AppLocalizationsDe extends AppLocalizations { 'Kontakt konnte nicht importiert werden'; @override - String get contacts_zeroHopAdvert => 'Zero-Hop-Anzeige'; + String get contacts_zeroHopAdvert => 'Zero-Hop-Ankündigung'; @override - String get contacts_floodAdvert => 'Überflutungsanzeige'; + String get contacts_floodAdvert => 'Flut-Ankündigung'; @override String get contacts_copyAdvertToClipboard => - 'Werbung in die Zwischenablage kopieren'; + 'Ankündigung in die Zwischenablage kopieren'; @override String get contacts_addContactFromClipboard => @@ -2779,7 +3019,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get contacts_contactAdvertCopyFailed => - 'Kopieren des Werbeinhalts in die Zwischenablage fehlgeschlagen.'; + 'Kopieren der Ankündigung in die Zwischenablage fehlgeschlagen.'; @override String get notification_activityTitle => 'MeshCore Aktivität'; @@ -2827,28 +3067,28 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settings_gpxExportRepeaters => - 'Repeater und Raumserver nach GPX exportieren'; + 'Repeater und Raumserver als GPX exportieren'; @override String get settings_gpxExportRepeatersSubtitle => 'Exportiert Repeater und Raumserver mit einem Standort in eine GPX-Datei.'; @override - String get settings_gpxExportContacts => 'Begleiter nach GPX exportieren'; + String get settings_gpxExportContacts => 'Kontakte als GPX exportieren'; @override String get settings_gpxExportContactsSubtitle => - 'Exportiert Begleiter mit einem Ort in eine GPX-Datei.'; + 'Exportiert Kontakte mit einem Ort in eine GPX-Datei.'; @override - String get settings_gpxExportAll => 'Alle Kontakte nach GPX exportieren'; + String get settings_gpxExportAll => 'Alle Knoten als GPX exportieren'; @override String get settings_gpxExportAllSubtitle => - 'Exportiert alle Kontakte mit einem Standort in eine GPX-Datei.'; + 'Exportiert alle Knoten mit einem Standort in eine GPX-Datei.'; @override - String get settings_gpxExportSuccess => 'Erfolgreich GPX-Datei exportiert.'; + String get settings_gpxExportSuccess => 'GPX-Datei erfolgreich exportiert.'; @override String get settings_gpxExportNoContacts => 'Keine Kontakte zum Exportieren.'; @@ -2866,16 +3106,22 @@ class AppLocalizationsDe extends AppLocalizations { 'Repeater- und Raumserver-Standorte'; @override - String get settings_gpxExportChat => 'Begleiterstandorte'; + String get settings_gpxExportChat => 'Kontaktstandorte'; @override String get settings_gpxExportAllContacts => 'Alle Kontaktstandorte'; @override String get settings_gpxExportShareText => - 'Kartendaten aus meshcore-open exportiert'; + 'GPX-Kartendaten aus meshcore-open exportiert'; @override String get settings_gpxExportShareSubject => - 'meshcore-open GPX-Kartendaten exportieren'; + '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 d780de42..582565b2 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -142,6 +142,16 @@ class AppLocalizationsEn extends AppLocalizations { @override String get scanner_scan => 'Scan'; + @override + String get scanner_bluetoothOff => 'Bluetooth is off'; + + @override + String get scanner_bluetoothOffMessage => + 'Please turn on Bluetooth to scan for devices'; + + @override + String get scanner_enableBluetooth => 'Enable Bluetooth'; + @override String get device_quickSwitch => 'Quick switch'; @@ -308,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'; @@ -332,15 +346,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settings_presets => 'Presets'; - @override - String get settings_preset915Mhz => '915 MHz'; - - @override - String get settings_preset868Mhz => '868 MHz'; - - @override - String get settings_preset433Mhz => '433 MHz'; - @override String get settings_frequency => 'Frequency (MHz)'; @@ -369,10 +374,15 @@ class AppLocalizationsEn extends AppLocalizations { String get settings_txPowerInvalid => 'Invalid TX power (0-22 dBm)'; @override - String get settings_longRange => 'Long Range'; + String get settings_clientRepeat => 'Off-Grid Repeat'; @override - String get settings_fastSpeed => 'Fast Speed'; + String get settings_clientRepeatSubtitle => + 'Allow this device to repeat mesh packets for others'; + + @override + String get settings_clientRepeatFreqWarning => + 'Off-grid repeat requires 433, 869, or 918 MHz frequency'; @override String settings_error(String message) { @@ -448,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'; @@ -608,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'; @@ -644,7 +670,35 @@ class AppLocalizationsEn extends AppLocalizations { 'Contacts will appear when devices advertise'; @override - String get contacts_searchContacts => 'Search contacts...'; + String get contacts_unread => 'Unread'; + + @override + String get contacts_searchContactsNoNumber => 'Search Contacts...'; + + @override + String contacts_searchContacts(int number, String str) { + return 'Search $number$str Contacts...'; + } + + @override + String contacts_searchFavorites(int number, String str) { + return 'Search $number$str Favorites...'; + } + + @override + String contacts_searchUsers(int number, String str) { + return 'Search $number$str Users...'; + } + + @override + String contacts_searchRepeaters(int number, String str) { + return 'Search $number$str Repeaters...'; + } + + @override + String contacts_searchRoomServers(int number, String str) { + return 'Search $number$str Room servers...'; + } @override String get contacts_noUnreadContacts => 'No unread contacts'; @@ -768,6 +822,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'; @@ -776,6 +836,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'; @@ -1059,6 +1124,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'; @@ -1213,6 +1281,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'; @@ -1342,6 +1416,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get map_manageRepeater => 'Manage Repeater'; + @override + String get map_tapToAdd => 'Tap on nodes to add them to the path.'; + + @override + String get map_runTrace => 'Run Path Trace'; + + @override + String get map_removeLast => 'Remove Last'; + + @override + String get map_pathTraceCancelled => 'Path trace cancelled.'; + @override String get mapCache_title => 'Offline Map Cache'; @@ -1632,10 +1718,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'; @@ -2311,10 +2397,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) { @@ -2322,10 +2408,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) { @@ -2631,6 +2717,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'; @@ -2662,6 +2757,146 @@ class AppLocalizationsEn extends AppLocalizations { String get pathTrace_someHopsNoLocation => 'One or more of the hops is missing a location!'; + @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'; @@ -2827,4 +3062,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 4b9e62e5..66fe8237 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -143,6 +143,16 @@ class AppLocalizationsEs extends AppLocalizations { @override String get scanner_scan => 'Escanea'; + @override + String get scanner_bluetoothOff => 'Bluetooth está desactivado.'; + + @override + String get scanner_bluetoothOffMessage => + 'Por favor, active el Bluetooth para escanear dispositivos.'; + + @override + String get scanner_enableBluetooth => 'Habilitar Bluetooth'; + @override String get device_quickSwitch => 'Cambiar rápidamente'; @@ -313,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'; @@ -337,15 +351,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settings_presets => 'Preajustes'; - @override - String get settings_preset915Mhz => '915 MHz'; - - @override - String get settings_preset868Mhz => '868 MHz'; - - @override - String get settings_preset433Mhz => '433 MHz'; - @override String get settings_frequency => 'Frecuencia (MHz)'; @@ -374,10 +379,15 @@ class AppLocalizationsEs extends AppLocalizations { String get settings_txPowerInvalid => 'Potencia de TX inválida (0-22 dBm)'; @override - String get settings_longRange => 'Largo Alcance'; + String get settings_clientRepeat => 'Repetir sin conexión'; @override - String get settings_fastSpeed => 'Velocidad Rápida'; + String get settings_clientRepeatSubtitle => + 'Permita que este dispositivo repita los paquetes de red para otros usuarios.'; + + @override + String get settings_clientRepeatFreqWarning => + 'Para la comunicación fuera de la red, se requiere una frecuencia de 433, 869 o 918 MHz.'; @override String settings_error(String message) { @@ -453,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'; @@ -614,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'; @@ -651,7 +678,35 @@ class AppLocalizationsEs extends AppLocalizations { 'Los contactos aparecerán cuando los dispositivos anuncien.'; @override - String get contacts_searchContacts => 'Buscar contactos...'; + String get contacts_unread => 'No leído'; + + @override + String get contacts_searchContactsNoNumber => 'Buscar contactos...'; + + @override + String contacts_searchContacts(int number, String str) { + return 'Buscar contactos...'; + } + + @override + String contacts_searchFavorites(int number, String str) { + return 'Buscar $number$str Favoritos...'; + } + + @override + String contacts_searchUsers(int number, String str) { + return 'Buscar $number$str Usuarios...'; + } + + @override + String contacts_searchRepeaters(int number, String str) { + return 'Buscar $number$str Repetidores...'; + } + + @override + String contacts_searchRoomServers(int number, String str) { + return 'Buscar $number$str servidores de sala...'; + } @override String get contacts_noUnreadContacts => 'No contactos sin leer'; @@ -777,6 +832,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'; @@ -785,6 +846,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'; @@ -1073,6 +1139,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'; @@ -1231,6 +1300,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'; @@ -1360,6 +1435,18 @@ class AppLocalizationsEs extends AppLocalizations { @override String get map_manageRepeater => 'Gestionar Repetidor'; + @override + String get map_tapToAdd => 'Pulse en los nodos para agregarlos al camino.'; + + @override + String get map_runTrace => 'Ejecutar Rastreo de Ruta'; + + @override + String get map_removeLast => 'Eliminar último'; + + @override + String get map_pathTraceCancelled => 'Rastreo de ruta cancelado.'; + @override String get mapCache_title => 'Caché de Mapa Offline'; @@ -1656,10 +1743,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'; @@ -2358,7 +2445,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.'; @@ -2670,6 +2757,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'; @@ -2701,6 +2797,149 @@ class AppLocalizationsEs extends AppLocalizations { String get pathTrace_someHopsNoLocation => 'Uno o más de los lúpulos carecen de una ubicación'; + @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'; @@ -2871,4 +3110,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 a276cac5..903ef299 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -143,6 +143,16 @@ class AppLocalizationsFr extends AppLocalizations { @override String get scanner_scan => 'Scanner'; + @override + String get scanner_bluetoothOff => 'Le Bluetooth est désactivé.'; + + @override + String get scanner_bluetoothOffMessage => + 'Veuillez activer le Bluetooth pour rechercher des appareils.'; + + @override + String get scanner_enableBluetooth => 'Activer le Bluetooth'; + @override String get device_quickSwitch => 'Basculement rapide'; @@ -314,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'; @@ -338,15 +352,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get settings_presets => 'Préréglages'; - @override - String get settings_preset915Mhz => '915 MHz'; - - @override - String get settings_preset868Mhz => '868 MHz'; - - @override - String get settings_preset433Mhz => '433 MHz'; - @override String get settings_frequency => 'Fréquence (MHz)'; @@ -375,10 +380,15 @@ class AppLocalizationsFr extends AppLocalizations { String get settings_txPowerInvalid => 'Puissance TX invalide (0-22 dBm)'; @override - String get settings_longRange => 'Portée Longue'; + String get settings_clientRepeat => 'Répétition hors réseau'; @override - String get settings_fastSpeed => 'Vitesse Rapide'; + String get settings_clientRepeatSubtitle => + 'Permettez à cet appareil de répéter les paquets de données pour les autres.'; + + @override + String get settings_clientRepeatFreqWarning => + 'Pour les transmissions hors réseau, il est nécessaire d\'utiliser les fréquences de 433, 869 ou 918 MHz.'; @override String settings_error(String message) { @@ -454,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'; @@ -560,11 +578,11 @@ class AppLocalizationsFr extends AppLocalizations { String get appSettings_mapDisplay => 'Affichage de la carte'; @override - String get appSettings_showRepeaters => 'Afficher les répétiteurs'; + String get appSettings_showRepeaters => 'Afficher les répéteurs'; @override String get appSettings_showRepeatersSubtitle => - 'Afficher les nœuds répétiteurs sur la carte'; + 'Afficher les nœuds répéteurs sur la carte'; @override String get appSettings_showChatNodes => 'Afficher les nœuds de discussion'; @@ -616,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'; @@ -654,7 +681,35 @@ class AppLocalizationsFr extends AppLocalizations { 'Les contacts apparaîtront lorsque les appareils font leur annonce.'; @override - String get contacts_searchContacts => 'Rechercher des contacts...'; + String get contacts_unread => 'Non lu'; + + @override + String get contacts_searchContactsNoNumber => 'Rechercher des contacts...'; + + @override + String contacts_searchContacts(int number, String str) { + return 'Rechercher des contacts...'; + } + + @override + String contacts_searchFavorites(int number, String str) { + return 'Rechercher $number$str Favoris...'; + } + + @override + String contacts_searchUsers(int number, String str) { + return 'Rechercher $number$str utilisateurs...'; + } + + @override + String contacts_searchRepeaters(int number, String str) { + return 'Rechercher $number$str Répéteurs...'; + } + + @override + String contacts_searchRoomServers(int number, String str) { + return 'Rechercher $number$str serveurs de salle...'; + } @override String get contacts_noUnreadContacts => 'Aucun contact non lu'; @@ -671,13 +726,13 @@ class AppLocalizationsFr extends AppLocalizations { } @override - String get contacts_manageRepeater => 'Gérer le répétiteur'; + String get contacts_manageRepeater => 'Gérer le répéteur'; @override 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'; @@ -779,6 +834,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'; @@ -787,6 +848,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é'; @@ -1076,6 +1142,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'; @@ -1094,18 +1163,18 @@ class AppLocalizationsFr extends AppLocalizations { 'L\'historique du chemin est plein. Supprimez les entrées pour en ajouter de nouvelles.'; @override - String get chat_hopSingular => 'Sautez'; + String get chat_hopSingular => 'saut'; @override - String get chat_hopPlural => 'sautez'; + String get chat_hopPlural => 'sauts'; @override String chat_hopsCount(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'hops', - one: 'hop', + other: 'sauts', + one: 'saut', ); return '$count $_temp0'; } @@ -1237,6 +1306,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'; @@ -1259,7 +1334,7 @@ class AppLocalizationsFr extends AppLocalizations { String get map_chat => 'Chat'; @override - String get map_repeater => 'Répétiteur'; + String get map_repeater => 'Répéteur'; @override String get map_room => 'Salle'; @@ -1365,7 +1440,20 @@ class AppLocalizationsFr extends AppLocalizations { String get map_joinRoom => 'Rejoindre la salle'; @override - String get map_manageRepeater => 'Gérer le répétiteur'; + String get map_manageRepeater => 'Gérer le répéteur'; + + @override + String get map_tapToAdd => + 'Appuyez sur les nœuds pour les ajouter au chemin.'; + + @override + String get map_runTrace => 'Exécuter la traçage de chemin'; + + @override + String get map_removeLast => 'Supprimer le dernier'; + + @override + String get map_pathTraceCancelled => 'Traçage de chemin annulé'; @override String get mapCache_title => 'Cache de Carte Hors Ligne'; @@ -1509,10 +1597,10 @@ class AppLocalizationsFr extends AppLocalizations { 'Êtes-vous sûr de vouloir vous déconnecter de cet appareil ?'; @override - String get login_repeaterLogin => 'Connexion au répétiteur'; + 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'; @@ -1529,7 +1617,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get login_repeaterDescription => - 'Entrez le mot de passe du répétiteur pour accéder aux paramètres et à l\'état.'; + 'Entrez le mot de passe du répéteur pour accéder aux paramètres et à l\'état.'; @override String get login_roomDescription => @@ -1634,10 +1722,10 @@ class AppLocalizationsFr extends AppLocalizations { String get path_setPath => 'Définir le chemin'; @override - String get repeater_management => 'Gestion des répétiteurs'; + 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'; @@ -1647,7 +1735,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get repeater_statusSubtitle => - 'Afficher l\'état, les statistiques et les voisins du répétiteur'; + 'Afficher l\'état, les statistiques et les voisins du répéteur'; @override String get repeater_telemetry => 'Télémetrie'; @@ -1660,24 +1748,23 @@ class AppLocalizationsFr extends AppLocalizations { String get repeater_cli => 'CLI'; @override - String get repeater_cliSubtitle => 'Envoyer des commandes au répétiteur'; + 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'; @override String get repeater_settingsSubtitle => - 'Configurer les paramètres du répétiteur'; + 'Configurer les paramètres du répéteur'; @override - String get repeater_statusTitle => 'État du répétiteur'; + String get repeater_statusTitle => 'État du répéteur'; @override String get repeater_routingMode => 'Mode de routage'; @@ -1783,16 +1870,16 @@ class AppLocalizationsFr extends AppLocalizations { } @override - String get repeater_settingsTitle => 'Paramètres du répétiteur'; + String get repeater_settingsTitle => 'Paramètres du répéteur'; @override String get repeater_basicSettings => 'Paramètres de base'; @override - String get repeater_repeaterName => 'Nom du répétiteur'; + String get repeater_repeaterName => 'Nom du répéteur'; @override - String get repeater_repeaterNameHelper => 'Afficher le nom de ce répétiteur'; + String get repeater_repeaterNameHelper => 'Afficher le nom de ce répéteur'; @override String get repeater_adminPassword => 'Mot de passe Administrateur'; @@ -1856,7 +1943,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get repeater_packetForwardingSubtitle => - 'Activer le répétiteur pour transmettre des paquets'; + 'Activer le répéteur pour transmettre des paquets'; @override String get repeater_guestAccess => 'Accès Invité'; @@ -1905,11 +1992,11 @@ class AppLocalizationsFr extends AppLocalizations { @override String get repeater_rebootRepeaterSubtitle => - 'Réinitialiser l\'appareil répétiteur'; + 'Réinitialiser l\'appareil répéteur'; @override String get repeater_rebootRepeaterConfirm => - 'Êtes-vous sûr de vouloir redémarrer ce répétiteur ?'; + 'Êtes-vous sûr de vouloir redémarrer ce répéteur ?'; @override String get repeater_regenerateIdentityKey => 'Ré générer la clé d\'identité'; @@ -1920,18 +2007,18 @@ class AppLocalizationsFr extends AppLocalizations { @override String get repeater_regenerateIdentityKeyConfirm => - 'Cela générera une nouvelle identité pour le répétiteur. Continuer ?'; + 'Cela générera une nouvelle identité pour le répéteur. Continuer ?'; @override String get repeater_eraseFileSystem => 'Supprimer le système de fichiers'; @override String get repeater_eraseFileSystemSubtitle => - 'Formater le système de fichiers du répétiteur'; + 'Formater le système de fichiers du répéteur'; @override String get repeater_eraseFileSystemConfirm => - 'AVERTISSEMENT : Cela effacera toutes les données du répétiteur. Cela ne peut pas être annulé !'; + 'AVERTISSEMENT : Cela effacera toutes les données du répéteur. Cela ne peut pas être annulé !'; @override String get repeater_eraseSerialOnly => @@ -1999,7 +2086,7 @@ class AppLocalizationsFr extends AppLocalizations { } @override - String get repeater_cliTitle => 'Répétiteur CLI'; + String get repeater_cliTitle => 'Répéteur CLI'; @override String get repeater_debugNextCommand => 'Déboguer Prochaine Commande'; @@ -2091,7 +2178,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get repeater_cliHelpSetRepeat => - 'Active ou désactive le rôle du répétiteur pour ce nœud.'; + 'Active ou désactive le rôle du répéteur pour ce nœud.'; @override String get repeater_cliHelpSetAllowReadOnly => @@ -2115,7 +2202,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get repeater_cliHelpSetAdvertInterval => - 'Définit l\'intervalle du minuteur pour envoyer un paquet d\'annonce local (sans relais). Définir sur 0 pour désactiver.'; + 'Définit l\'intervalle entre chaque émission d\'une annonce locale (sans relais). Définir sur 0 pour désactiver.'; @override String get repeater_cliHelpSetFloodAdvertInterval => @@ -2201,7 +2288,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get repeater_cliHelpNeighbors => - 'Affiche une liste d\'autres nœuds répétiteurs entendus via des annonces sans relais. Chaque ligne est id-préfixe-hexadécimal:timestamp:snr-fois-4'; + 'Affiche une liste d\'autres nœuds répéteurs entendus via des annonces sans relais. Chaque ligne est id-préfixe-hexadécimal:timestamp:snr-fois-4'; @override String get repeater_cliHelpNeighborRemove => @@ -2289,12 +2376,11 @@ class AppLocalizationsFr extends AppLocalizations { String get repeater_logging => 'Journalisation'; @override - String get repeater_neighborsRepeaterOnly => - 'Voisins (Uniquement répétiteur)'; + String get repeater_neighborsRepeaterOnly => 'Voisins (Uniquement répéteur)'; @override String get repeater_regionManagementRepeaterOnly => - 'Gestion des régions (uniquement pour le répétiteur)'; + 'Gestion des régions (uniquement pour le répéteur)'; @override String get repeater_regionNote => @@ -2373,7 +2459,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 => @@ -2399,7 +2485,7 @@ class AppLocalizationsFr extends AppLocalizations { String get channelPath_otherObservedPaths => 'Autres chemins observés'; @override - String get channelPath_repeaterHops => 'Sauts du répétiteur'; + String get channelPath_repeaterHops => 'Sauts du répéteur'; @override String get channelPath_noHopDetails => @@ -2467,7 +2553,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get channelPath_noRepeaterLocations => - 'Aucune position de répétiteur disponible pour ce chemin.'; + 'Aucune position de répéteur disponible pour ce chemin.'; @override String channelPath_primaryPath(int index) { @@ -2687,6 +2773,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'; @@ -2716,7 +2811,149 @@ class AppLocalizationsFr extends AppLocalizations { @override String get pathTrace_someHopsNoLocation => - 'Une ou plusieurs des houblons manquent d\'une localisation !'; + 'Un ou plusieurs des sauts manquent d\'une localisation !'; + + @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'; @@ -2759,10 +2996,10 @@ class AppLocalizationsFr extends AppLocalizations { 'Échec de l\'importation du contact.'; @override - String get contacts_zeroHopAdvert => 'Annonce Zero Hop'; + String get contacts_zeroHopAdvert => 'Annonce Zero saut'; @override - String get contacts_floodAdvert => 'Annonce de crue'; + String get contacts_floodAdvert => 'Annonce à tout le réseau'; @override String get contacts_copyAdvertToClipboard => @@ -2895,4 +3132,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 72c37b76..67c6c925 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -143,6 +143,16 @@ class AppLocalizationsIt extends AppLocalizations { @override String get scanner_scan => 'Scansiona'; + @override + String get scanner_bluetoothOff => 'Il Bluetooth è disattivato.'; + + @override + String get scanner_bluetoothOffMessage => + 'Si prega di attivare il Bluetooth per effettuare la scansione dei dispositivi.'; + + @override + String get scanner_enableBluetooth => 'Abilita il Bluetooth'; + @override String get device_quickSwitch => 'Passa velocemente'; @@ -312,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'; @@ -336,15 +350,6 @@ class AppLocalizationsIt extends AppLocalizations { @override String get settings_presets => 'Preset'; - @override - String get settings_preset915Mhz => '915 MHz'; - - @override - String get settings_preset868Mhz => '868 MHz'; - - @override - String get settings_preset433Mhz => '433 MHz'; - @override String get settings_frequency => 'Frequenza (MHz)'; @@ -373,10 +378,15 @@ class AppLocalizationsIt extends AppLocalizations { String get settings_txPowerInvalid => 'Potere TX non valido (0-22 dBm)'; @override - String get settings_longRange => 'Lungo Raggio'; + String get settings_clientRepeat => 'Ripetizione \"fuori dalla rete\"'; @override - String get settings_fastSpeed => 'Velocità Rapida'; + String get settings_clientRepeatSubtitle => + 'Permetti a questo dispositivo di ripetere i pacchetti di rete per gli altri.'; + + @override + String get settings_clientRepeatFreqWarning => + 'Per la comunicazione fuori rete, è necessario utilizzare frequenze di 433, 869 o 918 MHz.'; @override String settings_error(String message) { @@ -452,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'; @@ -613,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'; @@ -650,7 +677,35 @@ class AppLocalizationsIt extends AppLocalizations { 'I contatti appariranno quando i dispositivi pubblicizzano.'; @override - String get contacts_searchContacts => 'Cerca contatti...'; + String get contacts_unread => 'Non letti'; + + @override + String get contacts_searchContactsNoNumber => 'Cerca Contatti...'; + + @override + String contacts_searchContacts(int number, String str) { + return 'Cerca contatti...'; + } + + @override + String contacts_searchFavorites(int number, String str) { + return 'Cerca $number$str Preferiti...'; + } + + @override + String contacts_searchUsers(int number, String str) { + return 'Cerca $number$str Utenti...'; + } + + @override + String contacts_searchRepeaters(int number, String str) { + return 'Cerca $number$str Ripetitori...'; + } + + @override + String contacts_searchRoomServers(int number, String str) { + return 'Cerca $number$str server Room...'; + } @override String get contacts_noUnreadContacts => 'Nessun contatto non letto'; @@ -775,6 +830,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'; @@ -783,6 +844,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'; @@ -1071,6 +1137,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'; @@ -1230,6 +1299,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'; @@ -1359,6 +1434,18 @@ class AppLocalizationsIt extends AppLocalizations { @override String get map_manageRepeater => 'Gestisci Ripetitore'; + @override + String get map_tapToAdd => 'Tocca i nodi per aggiungerli al percorso.'; + + @override + String get map_runTrace => 'Esegui Path Trace'; + + @override + String get map_removeLast => 'Rimuovi ultimo'; + + @override + String get map_pathTraceCancelled => 'Tracciamento del percorso annullato.'; + @override String get mapCache_title => 'Cache Mappa Offline'; @@ -1654,10 +1741,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 @@ -2358,7 +2445,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.'; @@ -2670,6 +2757,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'; @@ -2702,6 +2798,148 @@ class AppLocalizationsIt extends AppLocalizations { String get pathTrace_someHopsNoLocation => 'Uno o più dei luppoli mancano di una posizione!'; + @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'; @@ -2875,4 +3113,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 82b650e0..313d37f7 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -142,6 +142,16 @@ class AppLocalizationsNl extends AppLocalizations { @override String get scanner_scan => 'Scan'; + @override + String get scanner_bluetoothOff => 'Bluetooth is uitgeschakeld'; + + @override + String get scanner_bluetoothOffMessage => + 'Zorg ervoor dat Bluetooth is ingeschakeld om naar apparaten te zoeken.'; + + @override + String get scanner_enableBluetooth => 'Activeer Bluetooth'; + @override String get device_quickSwitch => 'Snelle overschakeling'; @@ -310,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'; @@ -334,15 +348,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get settings_presets => 'Presets'; - @override - String get settings_preset915Mhz => '915 MHz'; - - @override - String get settings_preset868Mhz => '868 MHz'; - - @override - String get settings_preset433Mhz => '433 MHz'; - @override String get settings_frequency => 'Frequentie (MHz)'; @@ -371,10 +376,15 @@ class AppLocalizationsNl extends AppLocalizations { String get settings_txPowerInvalid => 'Ongeldige TX-vermogen (0-22 dBm)'; @override - String get settings_longRange => 'Lange Afstand'; + String get settings_clientRepeat => 'Herhalen: Afgekoppeld'; @override - String get settings_fastSpeed => 'Hoge Snelheid'; + String get settings_clientRepeatSubtitle => + 'Laat dit apparaat de mesh-pakketten opnieuw verzenden voor andere apparaten.'; + + @override + String get settings_clientRepeatFreqWarning => + 'Om een signaal buiten het netwerk te versturen, zijn frequenties van 433, 869 of 918 MHz vereist.'; @override String settings_error(String message) { @@ -450,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'; @@ -611,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'; @@ -648,7 +674,35 @@ class AppLocalizationsNl extends AppLocalizations { 'Contacten verschijnen wanneer apparaten zich aanbieden.'; @override - String get contacts_searchContacts => 'Zoek contacten...'; + String get contacts_unread => 'Ongelezen'; + + @override + String get contacts_searchContactsNoNumber => 'Zoek contacten...'; + + @override + String contacts_searchContacts(int number, String str) { + return 'Zoek contacten...'; + } + + @override + String contacts_searchFavorites(int number, String str) { + return 'Zoek $number$str favorieten...'; + } + + @override + String contacts_searchUsers(int number, String str) { + return 'Zoek $number$str gebruikers...'; + } + + @override + String contacts_searchRepeaters(int number, String str) { + return 'Zoek $number$str Repeaters...'; + } + + @override + String contacts_searchRoomServers(int number, String str) { + return 'Zoek $number$str Room servers...'; + } @override String get contacts_noUnreadContacts => 'Geen ongelezen contacten'; @@ -773,6 +827,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'; @@ -781,6 +841,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'; @@ -1068,6 +1133,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'; @@ -1226,6 +1294,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'; @@ -1355,6 +1429,19 @@ class AppLocalizationsNl extends AppLocalizations { @override String get map_manageRepeater => 'Beheer Repeater'; + @override + String get map_tapToAdd => + 'Tik op knooppunten om ze toe te voegen aan het pad'; + + @override + String get map_runTrace => 'Padeshulp traceren'; + + @override + String get map_removeLast => 'Verwijder Laatste'; + + @override + String get map_pathTraceCancelled => 'Pad traceren geannuleerd'; + @override String get mapCache_title => 'Offline Kaarten Cache'; @@ -1649,10 +1736,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'; @@ -2348,7 +2435,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.'; @@ -2661,6 +2748,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'; @@ -2692,6 +2788,148 @@ class AppLocalizationsNl extends AppLocalizations { String get pathTrace_someHopsNoLocation => 'Een of meer van de hops ontbreken een locatie!'; + @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'; @@ -2862,4 +3100,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 eaf9e7a1..77137f2e 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -143,6 +143,16 @@ class AppLocalizationsPl extends AppLocalizations { @override String get scanner_scan => 'Przeskanuj'; + @override + String get scanner_bluetoothOff => 'Bluetooth jest wyłączony'; + + @override + String get scanner_bluetoothOffMessage => + 'Prosimy włączyć Bluetooth, aby przeskanować urządzenia.'; + + @override + String get scanner_enableBluetooth => 'Włącz Bluetooth'; + @override String get device_quickSwitch => 'Szybka zmiana'; @@ -313,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ę'; @@ -337,15 +351,6 @@ class AppLocalizationsPl extends AppLocalizations { @override String get settings_presets => 'Preset'; - @override - String get settings_preset915Mhz => '915 MHz'; - - @override - String get settings_preset868Mhz => '868 MHz'; - - @override - String get settings_preset433Mhz => '433 MHz'; - @override String get settings_frequency => 'Częstotliwość (MHz)'; @@ -375,10 +380,15 @@ class AppLocalizationsPl extends AppLocalizations { String get settings_txPowerInvalid => 'Nieprawidłowa moc TX (0-22 dBm)'; @override - String get settings_longRange => 'Długi zasięg'; + String get settings_clientRepeat => 'Powtórzenie: Niezależne od sieci'; @override - String get settings_fastSpeed => 'Szybka prędkość'; + String get settings_clientRepeatSubtitle => + 'Pozwól temu urządzeniu powtarzać pakiety danych dla innych urządzeń.'; + + @override + String get settings_clientRepeatFreqWarning => + 'Powtórka poza siecią wymaga częstotliwości 433, 869 lub 918 MHz.'; @override String settings_error(String message) { @@ -454,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'; @@ -615,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.'; @@ -652,7 +678,35 @@ class AppLocalizationsPl extends AppLocalizations { 'Kontakty będą wyświetlane, gdy urządzenia reklamują się.'; @override - String get contacts_searchContacts => 'Wyszukaj kontakty...'; + String get contacts_unread => 'Nieprzeczytane'; + + @override + String get contacts_searchContactsNoNumber => 'Wyszukaj kontakty...'; + + @override + String contacts_searchContacts(int number, String str) { + return 'Wyszukaj kontakty...'; + } + + @override + String contacts_searchFavorites(int number, String str) { + return 'Wyszukaj $number$str ulubione...'; + } + + @override + String contacts_searchUsers(int number, String str) { + return 'Wyszukaj $number$str Użytkowników...'; + } + + @override + String contacts_searchRepeaters(int number, String str) { + return 'Wyszukaj $number$str powtórników...'; + } + + @override + String contacts_searchRoomServers(int number, String str) { + return 'Wyszukaj $number$str serwerów Room...'; + } @override String get contacts_noUnreadContacts => 'Brak nieprzeczytanych kontaktów'; @@ -778,6 +832,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ł'; @@ -786,6 +846,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'; @@ -1073,6 +1138,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'; @@ -1232,6 +1300,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'; @@ -1361,6 +1435,18 @@ class AppLocalizationsPl extends AppLocalizations { @override String get map_manageRepeater => 'Zarządzaj Powtórzami'; + @override + String get map_tapToAdd => 'Kliknij na węzły, aby dodać je do ścieżki.'; + + @override + String get map_runTrace => 'Uruchom ślad ścieżki'; + + @override + String get map_removeLast => 'Usuń ostatni'; + + @override + String get map_pathTraceCancelled => 'Śledzenie ścieżki anulowano.'; + @override String get mapCache_title => 'Bufor Map Offline'; @@ -1658,10 +1744,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 @@ -2357,7 +2443,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.'; @@ -2669,6 +2755,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'; @@ -2700,6 +2795,147 @@ class AppLocalizationsPl extends AppLocalizations { String get pathTrace_someHopsNoLocation => 'Jeden lub więcej z chmieli nie ma określonej lokalizacji!'; + @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'; @@ -2877,4 +3113,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 8eddae30..0606f1c6 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -143,6 +143,16 @@ class AppLocalizationsPt extends AppLocalizations { @override String get scanner_scan => 'Digitalizar'; + @override + String get scanner_bluetoothOff => 'Bluetooth está desativado'; + + @override + String get scanner_bluetoothOffMessage => + 'Por favor, ative o Bluetooth para escanear por dispositivos.'; + + @override + String get scanner_enableBluetooth => 'Ative o Bluetooth'; + @override String get device_quickSwitch => 'Mudar rapidamente'; @@ -314,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'; @@ -338,15 +352,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get settings_presets => 'Presets'; - @override - String get settings_preset915Mhz => '915 MHz'; - - @override - String get settings_preset868Mhz => '868 MHz'; - - @override - String get settings_preset433Mhz => '433 MHz'; - @override String get settings_frequency => 'Frequência (MHz)'; @@ -375,10 +380,15 @@ class AppLocalizationsPt extends AppLocalizations { String get settings_txPowerInvalid => 'Potência de TX inválida (0-22 dBm)'; @override - String get settings_longRange => 'Alcance Longo'; + String get settings_clientRepeat => 'Repetição sem rede'; @override - String get settings_fastSpeed => 'Velocidade Rápida'; + String get settings_clientRepeatSubtitle => + 'Permita que este dispositivo repita pacotes de rede para outros dispositivos.'; + + @override + String get settings_clientRepeatFreqWarning => + 'A repetição fora da rede requer frequências de 433, 869 ou 918 MHz.'; @override String settings_error(String message) { @@ -454,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'; @@ -614,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'; @@ -652,7 +679,35 @@ class AppLocalizationsPt extends AppLocalizations { 'Os contatos serão exibidos quando os dispositivos anunciarem.'; @override - String get contacts_searchContacts => 'Pesquisar contatos...'; + String get contacts_unread => 'Não lido'; + + @override + String get contacts_searchContactsNoNumber => 'Pesquisar Contatos...'; + + @override + String contacts_searchContacts(int number, String str) { + return 'Pesquisar contatos...'; + } + + @override + String contacts_searchFavorites(int number, String str) { + return 'Pesquisar $number$str Favoritos...'; + } + + @override + String contacts_searchUsers(int number, String str) { + return 'Pesquisar $number$str Usuários...'; + } + + @override + String contacts_searchRepeaters(int number, String str) { + return 'Pesquisar $number$str Repetidores...'; + } + + @override + String contacts_searchRoomServers(int number, String str) { + return 'Pesquisar $number$str servidores de sala...'; + } @override String get contacts_noUnreadContacts => 'Sem contatos não lidos.'; @@ -778,6 +833,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'; @@ -786,6 +847,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'; @@ -1073,6 +1139,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'; @@ -1231,6 +1300,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.'; @@ -1361,6 +1436,18 @@ class AppLocalizationsPt extends AppLocalizations { @override String get map_manageRepeater => 'Gerenciar Repetidor'; + @override + String get map_tapToAdd => 'Toque nos nós para adicioná-los ao caminho.'; + + @override + String get map_runTrace => 'Executar Traçado de Caminho'; + + @override + String get map_removeLast => 'Remover Último'; + + @override + String get map_pathTraceCancelled => 'Rastreamento de caminho cancelado.'; + @override String get mapCache_title => 'Cache de Mapa Offline'; @@ -1656,11 +1743,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'; @@ -2359,7 +2445,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.'; @@ -2672,6 +2758,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'; @@ -2703,6 +2798,147 @@ class AppLocalizationsPt extends AppLocalizations { String get pathTrace_someHopsNoLocation => 'Um ou mais dos lúpulos estão sem localização!'; + @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'; @@ -2872,4 +3108,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 cd96a6dd..1c05f651 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -142,6 +142,16 @@ class AppLocalizationsRu extends AppLocalizations { @override String get scanner_scan => 'Сканирование'; + @override + String get scanner_bluetoothOff => 'Bluetooth выключен'; + + @override + String get scanner_bluetoothOffMessage => + 'Пожалуйста, включите Bluetooth, чтобы найти устройства.'; + + @override + String get scanner_enableBluetooth => 'Включите Bluetooth'; + @override String get device_quickSwitch => 'Быстрое переключение'; @@ -311,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 => 'Имя'; @@ -335,15 +349,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get settings_presets => 'Пресеты'; - @override - String get settings_preset915Mhz => '915 МГц'; - - @override - String get settings_preset868Mhz => '868 МГц'; - - @override - String get settings_preset433Mhz => '433 МГц'; - @override String get settings_frequency => 'Частота (МГц)'; @@ -373,10 +378,15 @@ class AppLocalizationsRu extends AppLocalizations { 'Недопустимая мощность передачи (0–22 дБм)'; @override - String get settings_longRange => 'Дальний радиус'; + String get settings_clientRepeat => 'Повторение \"вне сети\"'; @override - String get settings_fastSpeed => 'Высокая скорость'; + String get settings_clientRepeatSubtitle => + 'Позвольте этому устройству повторять пакеты данных для других устройств.'; + + @override + String get settings_clientRepeatFreqWarning => + 'Для работы в режиме \"без подключения к сети\" требуется частота 433, 869 или 918 МГц.'; @override String settings_error(String message) { @@ -452,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 => 'Уведомления'; @@ -614,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 => 'Область не выбрана'; @@ -651,7 +678,35 @@ class AppLocalizationsRu extends AppLocalizations { 'Контакты появятся, когда устройства начнут рассылать оповещения'; @override - String get contacts_searchContacts => 'Поиск контактов...'; + String get contacts_unread => 'Непрочитанное'; + + @override + String get contacts_searchContactsNoNumber => 'Поиск контактов...'; + + @override + String contacts_searchContacts(int number, String str) { + return 'Поиск контактов...'; + } + + @override + String contacts_searchFavorites(int number, String str) { + return 'Поиск $number$str избранного...'; + } + + @override + String contacts_searchUsers(int number, String str) { + return 'Поиск $number$str пользователей...'; + } + + @override + String contacts_searchRepeaters(int number, String str) { + return 'Поиск $number$str ретрансляторов...'; + } + + @override + String contacts_searchRoomServers(int number, String str) { + return 'Поиск $number$str серверов комнат...'; + } @override String get contacts_noUnreadContacts => 'Нет непрочитанных контактов'; @@ -776,6 +831,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 => 'Удалить канал'; @@ -784,6 +845,11 @@ class AppLocalizationsRu extends AppLocalizations { return 'Удалить \"$name\"? Это действие нельзя отменить.'; } + @override + String channels_channelDeleteFailed(String name) { + return 'Не удалось удалить канал $name.'; + } + @override String channels_channelDeleted(String name) { return 'Канал \"$name\" удалён'; @@ -1071,6 +1137,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get chat_pathManagement => 'Управление маршрутами'; + @override + String get chat_ShowAllPaths => 'Показать все пути'; + @override String get chat_routingMode => 'Режим маршрутизации'; @@ -1233,6 +1302,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 => 'Нет нод с данными о местоположении'; @@ -1362,6 +1437,18 @@ class AppLocalizationsRu extends AppLocalizations { @override String get map_manageRepeater => 'Управление репитером'; + @override + String get map_tapToAdd => 'Нажимайте на узлы, чтобы добавить их в путь.'; + + @override + String get map_runTrace => 'Запустить трассировку пути'; + + @override + String get map_removeLast => 'Удалить последний'; + + @override + String get map_pathTraceCancelled => 'Отмена трассировки пути'; + @override String get mapCache_title => 'Кэш офлайн-карты'; @@ -1658,10 +1745,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 => 'Настройки'; @@ -2361,7 +2448,7 @@ class AppLocalizationsRu extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => 'Соседи репитеров'; + String get neighbors_repeatersNeighbors => 'Соседи репитеров'; @override String get neighbors_noData => 'Данные о соседях недоступны.'; @@ -2674,6 +2761,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 => 'Пользователи'; @@ -2705,6 +2801,147 @@ class AppLocalizationsRu extends AppLocalizations { String get pathTrace_someHopsNoLocation => 'Одному или нескольким хмелям не указано местоположение!'; + @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 => 'Трассировка пути'; @@ -2883,4 +3120,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 12ff841e..f71695b7 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -143,6 +143,16 @@ class AppLocalizationsSk extends AppLocalizations { @override String get scanner_scan => 'Skončiť'; + @override + String get scanner_bluetoothOff => 'Bluetooth je vypnutý'; + + @override + String get scanner_bluetoothOffMessage => + 'Prosím, zapnite Bluetooth, aby ste mohli skenovať pre zariadenia.'; + + @override + String get scanner_enableBluetooth => 'Povolte Bluetooth'; + @override String get device_quickSwitch => 'Rýchle prepínač'; @@ -310,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'; @@ -334,15 +348,6 @@ class AppLocalizationsSk extends AppLocalizations { @override String get settings_presets => 'Prednastavenia'; - @override - String get settings_preset915Mhz => '915 MHz'; - - @override - String get settings_preset868Mhz => '868 MHz'; - - @override - String get settings_preset433Mhz => '433 MHz'; - @override String get settings_frequency => 'Frekvencia (MHz)'; @@ -371,10 +376,15 @@ class AppLocalizationsSk extends AppLocalizations { String get settings_txPowerInvalid => 'Neplatná hodnota výkonu TX (0-22 dBm)'; @override - String get settings_longRange => 'Dlhý dosah'; + String get settings_clientRepeat => 'Opätovné použitie bez elektrickej siete'; @override - String get settings_fastSpeed => 'Rýchla rýchlosť'; + String get settings_clientRepeatSubtitle => + 'Umožnite, aby toto zariadenie opakovávalo siete pre ostatných.'; + + @override + String get settings_clientRepeatFreqWarning => + 'Použitie off-grid systému vyžaduje frekvencie 433, 869 alebo 918 MHz.'; @override String settings_error(String message) { @@ -450,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'; @@ -608,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ť'; @@ -645,7 +671,35 @@ class AppLocalizationsSk extends AppLocalizations { 'Kontakty sa zobrazia, keď zariadenia spúšťajú reklamu.'; @override - String get contacts_searchContacts => 'Vyhľadávajte kontakty...'; + String get contacts_unread => 'Neprečítané'; + + @override + String get contacts_searchContactsNoNumber => 'Hľadať kontakty...'; + + @override + String contacts_searchContacts(int number, String str) { + return 'Vyhľadávajte kontakty...'; + } + + @override + String contacts_searchFavorites(int number, String str) { + return 'Hľadať $number$str obľúbené...'; + } + + @override + String contacts_searchUsers(int number, String str) { + return 'Hľadať $number$str používateľov...'; + } + + @override + String contacts_searchRepeaters(int number, String str) { + return 'Hľadať $number$str opakovače...'; + } + + @override + String contacts_searchRoomServers(int number, String str) { + return 'Hľadaj $number$str serverov miestností...'; + } @override String get contacts_noUnreadContacts => 'Žiadne neprečítané kontakty'; @@ -773,6 +827,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'; @@ -781,6 +841,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ý'; @@ -1068,6 +1133,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'; @@ -1227,6 +1295,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'; @@ -1356,6 +1430,18 @@ class AppLocalizationsSk extends AppLocalizations { @override String get map_manageRepeater => 'Spravovať Opakovanie'; + @override + String get map_tapToAdd => 'Kliknite na uzly, aby ste ich pridali k ceste.'; + + @override + String get map_runTrace => 'Spustiť trasovaním cesty'; + + @override + String get map_removeLast => 'Odstrániť posledný'; + + @override + String get map_pathTraceCancelled => 'Zrušenie stopáže cesty bolo zrušené.'; + @override String get mapCache_title => 'Offline Mapa Pamäť'; @@ -1651,10 +1737,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'; @@ -2345,7 +2431,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 => @@ -2657,6 +2743,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'; @@ -2688,6 +2783,147 @@ class AppLocalizationsSk extends AppLocalizations { String get pathTrace_someHopsNoLocation => 'Jedna alebo viac chmeľov chýba lokalita!'; + @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'; @@ -2859,4 +3095,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 8dd78ff3..62de2713 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -143,6 +143,16 @@ class AppLocalizationsSl extends AppLocalizations { @override String get scanner_scan => 'Skeniraj'; + @override + String get scanner_bluetoothOff => 'Bluetooth je izklopljen'; + + @override + String get scanner_bluetoothOffMessage => + 'Prosimo, vklopite Bluetooth, da lahko poiščete naprave.'; + + @override + String get scanner_enableBluetooth => 'Omogočite Bluetooth'; + @override String get device_quickSwitch => 'Hitro preklop'; @@ -309,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'; @@ -333,15 +347,6 @@ class AppLocalizationsSl extends AppLocalizations { @override String get settings_presets => 'Prednastavitve'; - @override - String get settings_preset915Mhz => '915 MHz'; - - @override - String get settings_preset868Mhz => '868 MHz'; - - @override - String get settings_preset433Mhz => '433 MHz'; - @override String get settings_frequency => 'Frekvenca (MHz)'; @@ -370,10 +375,15 @@ class AppLocalizationsSl extends AppLocalizations { String get settings_txPowerInvalid => 'Neveljavna TX moč (0-22 dBm)'; @override - String get settings_longRange => 'DDolg doseg'; + String get settings_clientRepeat => 'Neovadno ponavljanje'; @override - String get settings_fastSpeed => 'Visoka hitrost'; + String get settings_clientRepeatSubtitle => + 'Omogočite temu naprave, da ponavlja paketne sporočila za druge.'; + + @override + String get settings_clientRepeatFreqWarning => + 'Za ponovni prenos na brezžični način so potrebne frekvence 433, 869 ali 918 MHz.'; @override String settings_error(String message) { @@ -449,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'; @@ -609,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'; @@ -646,7 +672,35 @@ class AppLocalizationsSl extends AppLocalizations { 'Stiki se bodo prikazali, ko se naprave oglasijo.'; @override - String get contacts_searchContacts => 'Iskanje stikov...'; + String get contacts_unread => 'Neprebrano'; + + @override + String get contacts_searchContactsNoNumber => 'Iskanje stikov...'; + + @override + String contacts_searchContacts(int number, String str) { + return 'Iskanje stikov...'; + } + + @override + String contacts_searchFavorites(int number, String str) { + return 'Iskanje $number$str priljubljenih...'; + } + + @override + String contacts_searchUsers(int number, String str) { + return 'Išči $number$str uporabnikov...'; + } + + @override + String contacts_searchRepeaters(int number, String str) { + return 'Išči $number$str ponavljalnike...'; + } + + @override + String contacts_searchRoomServers(int number, String str) { + return 'Išči $number$str strežnikov sob...'; + } @override String get contacts_noUnreadContacts => 'Ne prebrani stiki.'; @@ -771,6 +825,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'; @@ -779,6 +839,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.'; @@ -1066,6 +1131,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'; @@ -1222,6 +1290,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.'; @@ -1352,6 +1426,18 @@ class AppLocalizationsSl extends AppLocalizations { @override String get map_manageRepeater => 'Upravljajte Ponovitve'; + @override + String get map_tapToAdd => 'Pritisnite na vozlišča, da jih dodate poti.'; + + @override + String get map_runTrace => 'Zaženi sledenje poti'; + + @override + String get map_removeLast => 'Odstrani Zadnji'; + + @override + String get map_pathTraceCancelled => 'Spremljanje poti je prekinjeno.'; + @override String get mapCache_title => 'Omrezni predpomnilnik zemljeških zemljejevskih slik'; @@ -1650,10 +1736,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'; @@ -2349,7 +2435,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.'; @@ -2660,6 +2746,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'; @@ -2691,6 +2786,147 @@ class AppLocalizationsSl extends AppLocalizations { String get pathTrace_someHopsNoLocation => 'Ena ali več hmelju manjka lokacija!'; + @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'; @@ -2864,4 +3100,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 55fbe06c..49ee78e0 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -142,6 +142,16 @@ class AppLocalizationsSv extends AppLocalizations { @override String get scanner_scan => 'Skanna'; + @override + String get scanner_bluetoothOff => 'Bluetooth är avstängt'; + + @override + String get scanner_bluetoothOffMessage => + 'Vänligen aktivera Bluetooth för att söka efter enheter.'; + + @override + String get scanner_enableBluetooth => 'Aktivera Bluetooth'; + @override String get device_quickSwitch => 'Snabb växling'; @@ -307,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'; @@ -331,15 +345,6 @@ class AppLocalizationsSv extends AppLocalizations { @override String get settings_presets => 'Fördefinierade inställningar'; - @override - String get settings_preset915Mhz => '915 MHz'; - - @override - String get settings_preset868Mhz => '868 MHz'; - - @override - String get settings_preset433Mhz => '433 MHz'; - @override String get settings_frequency => 'Frekvens (MHz)'; @@ -368,10 +373,15 @@ class AppLocalizationsSv extends AppLocalizations { String get settings_txPowerInvalid => 'Ogiltig TX-effekt (0-22 dBm)'; @override - String get settings_longRange => 'Lång räckvidd'; + String get settings_clientRepeat => 'Upprepa utan elnät'; @override - String get settings_fastSpeed => 'Snabb hastighet'; + String get settings_clientRepeatSubtitle => + 'Låt enheten repetera nätpaket för andra användare.'; + + @override + String get settings_clientRepeatFreqWarning => + 'För att kunna kommunicera utanför elnätet krävs frekvenserna 433, 869 eller 918 MHz.'; @override String settings_error(String message) { @@ -447,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'; @@ -604,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'; @@ -641,7 +667,35 @@ class AppLocalizationsSv extends AppLocalizations { 'Kontakter kommer att visas när enheter annonserar.'; @override - String get contacts_searchContacts => 'Sök kontakter...'; + String get contacts_unread => 'Oläst'; + + @override + String get contacts_searchContactsNoNumber => 'Sök kontakter...'; + + @override + String contacts_searchContacts(int number, String str) { + return 'Sök kontakter...'; + } + + @override + String contacts_searchFavorites(int number, String str) { + return 'Sök $number$str Favoriter...'; + } + + @override + String contacts_searchUsers(int number, String str) { + return 'Sök $number$str användare...'; + } + + @override + String contacts_searchRepeaters(int number, String str) { + return 'Sök $number$str upprepningsenheter...'; + } + + @override + String contacts_searchRoomServers(int number, String str) { + return 'Sök $number$str Room-servrar...'; + } @override String get contacts_noUnreadContacts => 'Inga oinlästa kontakter'; @@ -767,6 +821,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'; @@ -775,6 +835,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'; @@ -1063,6 +1128,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'; @@ -1219,6 +1287,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'; @@ -1348,6 +1422,18 @@ class AppLocalizationsSv extends AppLocalizations { @override String get map_manageRepeater => 'Hantera Upprepare'; + @override + String get map_tapToAdd => 'Tryck på noder för att lägga till dem i banan.'; + + @override + String get map_runTrace => 'Kör spårsökning'; + + @override + String get map_removeLast => 'Ta bort sista'; + + @override + String get map_pathTraceCancelled => 'Sökvägsspårning avbruten.'; + @override String get mapCache_title => 'Offline Kartcache'; @@ -1640,10 +1726,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'; @@ -2334,7 +2420,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.'; @@ -2645,6 +2731,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'; @@ -2676,6 +2771,145 @@ class AppLocalizationsSv extends AppLocalizations { String get pathTrace_someHopsNoLocation => 'En eller flera av humlen saknar en plats!'; + @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'; @@ -2844,4 +3078,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 706f50de..6d026a7a 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -143,6 +143,16 @@ class AppLocalizationsUk extends AppLocalizations { @override String get scanner_scan => 'Сканувати'; + @override + String get scanner_bluetoothOff => 'Bluetooth вимкнено'; + + @override + String get scanner_bluetoothOffMessage => + 'Будь ласка, увімкніть Bluetooth, щоб сканувати пристрої.'; + + @override + String get scanner_enableBluetooth => 'Увімкніть Bluetooth'; + @override String get device_quickSwitch => 'Швидке перемикання'; @@ -312,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 => 'Ім\'я'; @@ -336,15 +350,6 @@ class AppLocalizationsUk extends AppLocalizations { @override String get settings_presets => 'Попередні налаштування'; - @override - String get settings_preset915Mhz => '915 МГц'; - - @override - String get settings_preset868Mhz => '868 МГц'; - - @override - String get settings_preset433Mhz => '433 МГц'; - @override String get settings_frequency => 'Частота (МГц)'; @@ -373,10 +378,15 @@ class AppLocalizationsUk extends AppLocalizations { String get settings_txPowerInvalid => 'Некоректна потужність TX (0-22 дБм)'; @override - String get settings_longRange => 'Дальній діапазон'; + String get settings_clientRepeat => 'Автономна система'; @override - String get settings_fastSpeed => 'Висока швидкість'; + String get settings_clientRepeatSubtitle => + 'Дозвольте цьому пристрою повторювати пакети даних для інших пристроїв.'; + + @override + String get settings_clientRepeatFreqWarning => + 'Повтор без підключення до мережі вимагає частоти 433, 869 або 918 МГц.'; @override String settings_error(String message) { @@ -452,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 => 'Сповіщення'; @@ -612,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 => 'Область не вибрано'; @@ -649,7 +676,35 @@ class AppLocalizationsUk extends AppLocalizations { 'Контакти з\'являться, коли пристрої надішлють оголошення.'; @override - String get contacts_searchContacts => 'Пошук контактів...'; + String get contacts_unread => 'Непрочитане'; + + @override + String get contacts_searchContactsNoNumber => 'Пошук контактів...'; + + @override + String contacts_searchContacts(int number, String str) { + return 'Пошук контактів...'; + } + + @override + String contacts_searchFavorites(int number, String str) { + return 'Пошук $number$str улюблених...'; + } + + @override + String contacts_searchUsers(int number, String str) { + return 'Пошук $number$str користувачів...'; + } + + @override + String contacts_searchRepeaters(int number, String str) { + return 'Пошук $number$str ретрансляторів...'; + } + + @override + String contacts_searchRoomServers(int number, String str) { + return 'Пошук $number$str серверів кімнат...'; + } @override String get contacts_noUnreadContacts => 'Немає непрочитаних контактів'; @@ -774,6 +829,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 => 'Видалити канал'; @@ -782,6 +843,11 @@ class AppLocalizationsUk extends AppLocalizations { return 'Видалити $name? Це не можна скасувати.'; } + @override + String channels_channelDeleteFailed(String name) { + return 'Не вдалося видалити канал \"$name\"'; + } + @override String channels_channelDeleted(String name) { return 'Канал «$name» видалено'; @@ -1069,6 +1135,9 @@ class AppLocalizationsUk extends AppLocalizations { @override String get chat_pathManagement => 'Керування шляхами'; + @override + String get chat_ShowAllPaths => 'Показати всі шляхи'; + @override String get chat_routingMode => 'Режим маршрутизації'; @@ -1231,6 +1300,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 => 'Немає вузлів з даними про розташування'; @@ -1361,6 +1436,18 @@ class AppLocalizationsUk extends AppLocalizations { @override String get map_manageRepeater => 'Керувати ретранслятором'; + @override + String get map_tapToAdd => 'Натисніть на вузли, щоб додати їх до шляху'; + + @override + String get map_runTrace => 'Виконати трасування шляху'; + + @override + String get map_removeLast => 'Видалити останній'; + + @override + String get map_pathTraceCancelled => 'Відмінується трасування шляху'; + @override String get mapCache_title => 'Офлайн-кеш карти'; @@ -1657,10 +1744,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 @@ -2362,7 +2449,7 @@ class AppLocalizationsUk extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => 'Ретранслятори-сусіди'; + String get neighbors_repeatersNeighbors => 'Ретранслятори-сусіди'; @override String get neighbors_noData => 'Дані про сусідів недоступні.'; @@ -2681,6 +2768,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 => 'Користувачі'; @@ -2712,6 +2808,148 @@ class AppLocalizationsUk extends AppLocalizations { String get pathTrace_someHopsNoLocation => 'Одне або більше хмелів відсутнє місце розташування!'; + @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 => 'Трасування шляхів'; @@ -2889,4 +3127,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 2d5b1c74..bf773d12 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 @@ -142,11 +142,20 @@ class AppLocalizationsZh extends AppLocalizations { @override String get scanner_scan => '扫描'; + @override + String get scanner_bluetoothOff => '蓝牙已关闭'; + + @override + String get scanner_bluetoothOffMessage => '请开启蓝牙以搜索设备'; + + @override + String get scanner_enableBluetooth => '启用蓝牙'; + @override String get device_quickSwitch => '快速切换'; @override - String get device_meshcore => '网格核心'; + String get device_meshcore => 'MeshCore'; @override String get settings_title => '设置'; @@ -173,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 坐标'; @@ -194,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 秒。'; @@ -224,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 => '隐私模式已启用'; @@ -233,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 => '同步时间'; @@ -251,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 => '调试'; @@ -278,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 => '关于'; @@ -299,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 => '状态'; @@ -317,20 +330,11 @@ class AppLocalizationsZh extends AppLocalizations { String get settings_infoContactsCount => '联系人数量'; @override - String get settings_infoChannelCount => '通道数量'; + String get settings_infoChannelCount => '频道数量'; @override String get settings_presets => '预设'; - @override - String get settings_preset915Mhz => '915 兆赫'; - - @override - String get settings_preset868Mhz => '868 兆赫'; - - @override - String get settings_preset433Mhz => '433 兆赫'; - @override String get settings_frequency => '频率 (MHz)'; @@ -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'; @@ -359,14 +363,18 @@ class AppLocalizationsZh extends AppLocalizations { String get settings_txPowerInvalid => '无效的发射功率(0-22 dBm)'; @override - String get settings_longRange => '远距离'; + String get settings_clientRepeat => '离网重复'; @override - String get settings_fastSpeed => '高速'; + String get settings_clientRepeatSubtitle => '允许此设备重复发送网状数据包给其他设备'; + + @override + String get settings_clientRepeatFreqWarning => + '离网重复通信需要使用 433、869 或 918 兆赫兹的频率。'; @override String settings_error(String message) { - return '[保存:$message]\n错误:$message'; + return '错误:$message'; } @override @@ -379,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 => '英语'; @@ -409,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 => '葡萄牙语'; @@ -430,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 => '通知'; @@ -445,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 => '权限被拒绝'; @@ -460,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 => '自动路径轮换已启用'; @@ -506,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) { @@ -514,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 => '显示聊天节点'; @@ -554,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 => '所有时间'; @@ -581,57 +594,94 @@ 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 => '搜索联系人...'; + String get contacts_unread => '未读'; @override - String get contacts_noUnreadContacts => '没有未读通讯'; + String get contacts_searchContactsNoNumber => '搜索联系人...'; @override - String get contacts_noContactsFound => '未找到任何联系人或群组'; + String contacts_searchContacts(int number, String str) { + return '搜索联系人...'; + } + + @override + String contacts_searchFavorites(int number, String str) { + return '搜索 $number$str 收藏...'; + } + + @override + String contacts_searchUsers(int number, String str) { + return '搜索 $number$str 位用户...'; + } + + @override + String contacts_searchRepeaters(int number, String str) { + return '搜索 $number$str 重复器...'; + } + + @override + String contacts_searchRoomServers(int number, String str) { + return '搜索 $number$str 房间服务器...'; + } + + @override + String get contacts_noUnreadContacts => '没有未读内容'; + + @override + 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 => '管理房间服务器'; @@ -640,64 +690,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 @@ -721,34 +771,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 @@ -764,23 +825,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 @@ -800,13 +861,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 => '最新消息'; @@ -815,13 +876,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 => '手动输入密钥。'; @@ -830,19 +891,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 => '输入标签'; @@ -851,30 +912,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 @@ -882,7 +943,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String chat_messageTooLong(int maxBytes) { - return '消息内容过长(最大 $maxBytes 字节)。'; + return '消息过长(最多 $maxBytes 字节)'; } @override @@ -892,60 +953,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 调试日志'; @@ -954,7 +1015,7 @@ class AppLocalizationsZh extends AppLocalizations { String get debugLog_copyLog => '复制日志'; @override - String get debugLog_clearLog => '清晰的日志'; + String get debugLog_clearLog => '清除日志'; @override String get debugLog_copied => '调试日志已复制'; @@ -963,19 +1024,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) { @@ -988,7 +1049,7 @@ class AppLocalizationsZh extends AppLocalizations { } @override - String get debugFrame_textMessageHeader => '短信模板:'; + String get debugFrame_textMessageHeader => '文本消息:'; @override String debugFrame_destinationPubKey(String pubKey) { @@ -997,7 +1058,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String debugFrame_timestamp(int timestamp) { - return '- Timestamp: $timestamp'; + return '- 时间戳:$timestamp'; } @override @@ -1007,14 +1068,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) { @@ -1027,46 +1088,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 => '路径操作:'; @@ -1078,45 +1136,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 => '类型'; @@ -1131,71 +1183,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 => '房间'; @@ -1204,28 +1262,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 => '在此分享标记'; @@ -1237,10 +1295,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 => '发送到频道'; @@ -1249,11 +1307,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 @@ -1269,19 +1327,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 => '标记'; @@ -1290,32 +1348,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 => '点击节点以添加到路径'; + + @override + String get map_runTrace => '运行路径追踪'; + + @override + String get map_removeLast => '移除最后一个'; + + @override + 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 @@ -1323,12 +1393,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 @@ -1341,7 +1411,7 @@ class AppLocalizationsZh extends AppLocalizations { String get mapCache_offlineCacheCleared => '离线缓存已清除'; @override - String get mapCache_noAreaSelected => '未选择任何区域'; + String get mapCache_noAreaSelected => '未选择区域'; @override String get mapCache_cacheArea => '缓存区域'; @@ -1350,27 +1420,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 @@ -1380,7 +1450,7 @@ class AppLocalizationsZh extends AppLocalizations { String east, String west, ) { - return 'N $north, S $south, E $east, W $west'; + return '北 $north, 南 $south, 东 $east, 西 $west'; } @override @@ -1388,12 +1458,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 @@ -1408,22 +1478,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 => '分钟'; @@ -1435,13 +1505,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 => '密码'; @@ -1453,13 +1523,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 => '路由'; @@ -1468,10 +1538,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 => '管理路径'; @@ -1481,37 +1551,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 @@ -1521,42 +1585,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 => '管理工具'; @@ -1565,56 +1629,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 @@ -1624,34 +1688,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 => '数据包统计'; @@ -1660,7 +1724,7 @@ class AppLocalizationsZh extends AppLocalizations { String get repeater_sent => '发送'; @override - String get repeater_received => '已收到'; + String get repeater_received => '接收'; @override String get repeater_duplicates => '重复'; @@ -1677,35 +1741,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 => '管理员密码'; @@ -1720,13 +1784,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 功率'; @@ -1738,7 +1802,7 @@ class AppLocalizationsZh extends AppLocalizations { String get repeater_bandwidth => '带宽'; @override - String get repeater_spreadingFactor => '传播系数'; + String get repeater_spreadingFactor => '扩频因子'; @override String get repeater_codingRate => '编码速率'; @@ -1750,68 +1814,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 => '重新生成身份密钥'; @@ -1820,77 +1884,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 => '调试下一条命令'; @@ -1899,39 +1963,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'; @@ -1943,199 +2007,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 => '通用'; @@ -2144,39 +2188,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) { @@ -2190,13 +2234,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) { @@ -2219,30 +2263,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 @@ -2255,16 +2299,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 => '时间'; @@ -2282,11 +2326,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) { @@ -2302,37 +2346,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) { @@ -2340,19 +2384,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 => '加入'; @@ -2362,23 +2406,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 => '社区名称'; @@ -2388,12 +2432,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 @@ -2401,34 +2445,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 => '管理社区'; @@ -2438,7 +2482,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String community_deleteConfirm(String name) { - return '是否要删除\"$name\"?'; + return '是否退出 \"$name\"?'; } @override @@ -2448,52 +2492,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 => '社区标签'; @@ -2503,50 +2547,59 @@ 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_usersFirst => 'Users first'; + String get listFilter_usersFirst => '用户优先'; @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 => '路径追踪失败。'; @@ -2555,78 +2608,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 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 => '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 => '乒'; + String get contacts_ping => 'Ping'; @override - String get contacts_repeaterPathTrace => '追踪路径至中继器'; + String get contacts_repeaterPathTrace => 'Trace 转发节点'; @override - String get contacts_repeaterPing => '中继器'; + String get contacts_repeaterPing => 'Ping 转发节点'; @override - String get contacts_roomPathTrace => '追踪到房间服务器'; + String get contacts_roomPathTrace => 'Trace 房间服务器'; @override - String get contacts_roomPing => '会议室服务器'; + String get contacts_roomPing => 'Ping 房间服务器'; @override - String get contacts_chatTraceRoute => '路径跟踪路线'; + String get contacts_chatTraceRoute => '路由追踪'; @override String contacts_pathTraceTo(String name) { - return '追踪路径至 $name'; + return '追踪至 $name 的路径'; } @override - String get contacts_clipboardEmpty => '剪贴板为空。'; + String get contacts_clipboardEmpty => '剪贴板为空'; @override - String get contacts_invalidAdvertFormat => '无效的联系信息'; + String get contacts_invalidAdvertFormat => '无效的联系人信息格式'; @override - String get contacts_contactImported => '已建立联系。'; + String get contacts_contactImported => '联系人已导入'; @override - String get contacts_contactImportFailed => '未能导入联系人。'; + String get contacts_contactImportFailed => '导入联系人失败。'; @override - String get contacts_zeroHopAdvert => '零跳广告'; + String get contacts_zeroHopAdvert => '发送零跳广播'; @override - String get contacts_floodAdvert => '防洪广告'; + String get contacts_floodAdvert => '发送泛洪广播'; @override - String get contacts_copyAdvertToClipboard => '复制广告到剪贴板'; + 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 活动'; @@ -2655,47 +2844,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 91163ac0..69ebecce 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", @@ -131,9 +139,6 @@ "settings_infoContactsCount": "Aantal Contacten", "settings_infoChannelCount": "Aantal Kanalen", "settings_presets": "Presets", - "settings_preset915Mhz": "915 MHz", - "settings_preset868Mhz": "868 MHz", - "settings_preset433Mhz": "433 MHz", "settings_frequency": "Frequentie (MHz)", "settings_frequencyHelper": "300,0 - 2500,0", "settings_frequencyInvalid": "Ongeldige frequentie (300-2500 MHz)", @@ -143,8 +148,6 @@ "settings_txPower": "TX Vermogen (dBm)", "settings_txPowerHelper": "0 - 22", "settings_txPowerInvalid": "Ongeldige TX-vermogen (0-22 dBm)", - "settings_longRange": "Lange Afstand", - "settings_fastSpeed": "Hoge Snelheid", "settings_error": "Fout: {message}", "@settings_error": { "placeholders": { @@ -339,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": { @@ -1356,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", @@ -1560,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.", @@ -1575,7 +1582,6 @@ "notification_newNodesCount": "{count} {count, plural, =1{nieuw knooppunt} other{nieuwe knooppunten}}", "notification_newTypeDiscovered": "Nieuw {contactType} ontdekt", "notification_receivedNewMessage": "Nieuw bericht ontvangen", - "contacts_zeroHopContactAdvertFailed": "Mislukt om contact te verzenden", "settings_gpxExportRepeatersSubtitle": "Exporteert repeaters / roomserver met een locatie naar GPX-bestand.", "settings_gpxExportRepeaters": "Exporteer repeaters / roomserver naar GPX", "settings_gpxExportSuccess": "Succesvol GPX-bestand geëxporteerd.", @@ -1591,6 +1597,207 @@ "settings_gpxExportAllContacts": "Alle contactlocaties", "settings_gpxExportShareText": "Kaartgegevens geëxporteerd uit meshcore-open", "settings_gpxExportShareSubject": "meshcore-open GPX kaartgegevens exporteren", - "pathTrace_someHopsNoLocation": "Een of meer van de hops ontbreken een locatie!" - + "pathTrace_someHopsNoLocation": "Een of meer van de hops ontbreken een locatie!", + "map_removeLast": "Verwijder Laatste", + "pathTrace_clearTooltip": "Weg wissen", + "map_pathTraceCancelled": "Pad traceren geannuleerd", + "map_tapToAdd": "Tik op knooppunten om ze toe te voegen aan het pad", + "map_runTrace": "Padeshulp traceren", + "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_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", + "@contacts_searchFavorites": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchUsers": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchRepeaters": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchRoomServers": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "contacts_unread": "Ongelezen", + "contacts_searchRepeaters": "Zoek {number}{str} Repeaters...", + "contacts_searchContactsNoNumber": "Zoek contacten...", + "contacts_searchUsers": "Zoek {number}{str} gebruikers...", + "contacts_searchFavorites": "Zoek {number}{str} favorieten...", + "contacts_searchRoomServers": "Zoek {number}{str} Room servers..." } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 3c2a96fc..75e1d348 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", @@ -131,9 +139,6 @@ "settings_infoContactsCount": "Liczba kontaktów", "settings_infoChannelCount": "Liczba kanałów", "settings_presets": "Preset", - "settings_preset915Mhz": "915 MHz", - "settings_preset868Mhz": "868 MHz", - "settings_preset433Mhz": "433 MHz", "settings_frequency": "Częstotliwość (MHz)", "settings_frequencyHelper": "300,0 - 2500,0", "settings_frequencyInvalid": "Nieprawidłowa częstotliwość (300-2500 MHz)", @@ -143,8 +148,6 @@ "settings_txPower": "TX Moc (dBm)", "settings_txPowerHelper": "0 - 22", "settings_txPowerInvalid": "Nieprawidłowa moc TX (0-22 dBm)", - "settings_longRange": "Długi zasięg", - "settings_fastSpeed": "Szybka prędkość", "settings_error": "Błąd: {message}", "@settings_error": { "placeholders": { @@ -339,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": { @@ -1356,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ł", @@ -1555,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", @@ -1575,7 +1582,6 @@ "notification_newNodesCount": "{count} {count, plural, =1{nowy węzeł} few{nowe węzły} many{nowych węzłów} other{nowych węzłów}}", "notification_newTypeDiscovered": "Nowy {contactType} wykryty", "notification_receivedNewMessage": "Otrzymano nową wiadomość", - "contacts_zeroHopContactAdvertFailed": "Nie udało się wysłać kontaktu.", "settings_gpxExportContacts": "Eksportuj towarzyszy do GPX", "settings_gpxExportRepeaters": "Eksportuj powtórki / serwer pokojowy do GPX", "settings_gpxExportRepeatersSubtitle": "Eksportuje powtarzacze / roomserver z lokalizacją do pliku GPX.", @@ -1591,6 +1597,207 @@ "settings_gpxExportChat": "Lokalizacje towarzyszy", "settings_gpxExportShareText": "Dane mapy wyeksportowane z meshcore-open", "settings_gpxExportShareSubject": "Eksport danych mapy GPX meshcore-open", - "pathTrace_someHopsNoLocation": "Jeden lub więcej z chmieli nie ma określonej lokalizacji!" - + "pathTrace_someHopsNoLocation": "Jeden lub więcej z chmieli nie ma określonej lokalizacji!", + "map_pathTraceCancelled": "Śledzenie ścieżki anulowano.", + "map_runTrace": "Uruchom ślad ścieżki", + "pathTrace_clearTooltip": "Wyczyść ścieżkę", + "map_removeLast": "Usuń ostatni", + "map_tapToAdd": "Kliknij na węzły, aby dodać je do ścieżki.", + "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_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", + "@contacts_searchFavorites": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchUsers": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchRepeaters": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchRoomServers": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "contacts_unread": "Nieprzeczytane", + "contacts_searchContactsNoNumber": "Wyszukaj kontakty...", + "contacts_searchFavorites": "Wyszukaj {number}{str} ulubione...", + "contacts_searchRoomServers": "Wyszukaj {number}{str} serwerów Room...", + "contacts_searchUsers": "Wyszukaj {number}{str} Użytkowników...", + "contacts_searchRepeaters": "Wyszukaj {number}{str} powtórników..." } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index dc38c115..f6ada195 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", @@ -131,9 +139,6 @@ "settings_infoContactsCount": "Número de Contatos", "settings_infoChannelCount": "Número do Canal", "settings_presets": "Presets", - "settings_preset915Mhz": "915 MHz", - "settings_preset868Mhz": "868 MHz", - "settings_preset433Mhz": "433 MHz", "settings_frequency": "Frequência (MHz)", "settings_frequencyHelper": "300,0 - 2500,0", "settings_frequencyInvalid": "Frequência inválida (300-2500 MHz)", @@ -143,8 +148,6 @@ "settings_txPower": "TX Potência (dBm)", "settings_txPowerHelper": "0 - 22", "settings_txPowerInvalid": "Potência de TX inválida (0-22 dBm)", - "settings_longRange": "Alcance Longo", - "settings_fastSpeed": "Velocidade Rápida", "settings_error": "Erro: {message}", "@settings_error": { "placeholders": { @@ -339,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": { @@ -1356,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.", @@ -1561,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.", @@ -1575,7 +1582,6 @@ "notification_newNodesCount": "{count} {count, plural, =1{novo nó} other{novos nós}}", "notification_newTypeDiscovered": "Novo {contactType} descoberto", "notification_receivedNewMessage": "Nova mensagem recebida", - "contacts_zeroHopContactAdvertFailed": "Falha ao enviar contato.", "settings_gpxExportRepeaters": "Exportar repetidores / servidor de sala para GPX", "settings_gpxExportRepeatersSubtitle": "Exporta repetidores / roomserver com localização para arquivo GPX.", "settings_gpxExportSuccess": "Arquivo GPX exportado com sucesso.", @@ -1591,6 +1597,207 @@ "settings_gpxExportAllContacts": "Todos os locais de contatos", "settings_gpxExportShareText": "Dados do mapa exportados do meshcore-open", "settings_gpxExportShareSubject": "meshcore-open exportação de dados de mapa GPX", - "pathTrace_someHopsNoLocation": "Um ou mais dos lúpulos estão sem localização!" - + "pathTrace_someHopsNoLocation": "Um ou mais dos lúpulos estão sem localização!", + "map_runTrace": "Executar Traçado de Caminho", + "map_pathTraceCancelled": "Rastreamento de caminho cancelado.", + "pathTrace_clearTooltip": "Limpar caminho", + "map_removeLast": "Remover Último", + "map_tapToAdd": "Toque nos nós para adicioná-los ao caminho.", + "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_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", + "@contacts_searchFavorites": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchUsers": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchRepeaters": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchRoomServers": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "contacts_searchRepeaters": "Pesquisar {number}{str} Repetidores...", + "contacts_searchFavorites": "Pesquisar {number}{str} Favoritos...", + "contacts_searchUsers": "Pesquisar {number}{str} Usuários...", + "contacts_searchContactsNoNumber": "Pesquisar Contatos...", + "contacts_unread": "Não lido", + "contacts_searchRoomServers": "Pesquisar {number}{str} servidores de sala..." } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index ddbbe79d..9aef298e 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": "Контакты", @@ -101,9 +109,6 @@ "settings_infoContactsCount": "Количество контактов", "settings_infoChannelCount": "Количество каналов", "settings_presets": "Пресеты", - "settings_preset915Mhz": "915 МГц", - "settings_preset868Mhz": "868 МГц", - "settings_preset433Mhz": "433 МГц", "settings_frequency": "Частота (МГц)", "settings_frequencyHelper": "300.0 – 2500.0", "settings_frequencyInvalid": "Недопустимая частота (300–2500 МГц)", @@ -113,8 +118,6 @@ "settings_txPower": "Мощность передачи (дБм)", "settings_txPowerHelper": "0 – 22", "settings_txPowerInvalid": "Недопустимая мощность передачи (0–22 дБм)", - "settings_longRange": "Дальний радиус", - "settings_fastSpeed": "Высокая скорость", "settings_error": "Ошибка: {message}", "appSettings_title": "Настройки приложения", "appSettings_appearance": "Внешний вид", @@ -231,6 +234,8 @@ "channels_publicChannel": "Публичный канал", "channels_privateChannel": "Приватный канал", "channels_editChannel": "Изменить канал", + "channels_muteChannel": "Отключить уведомления канала", + "channels_unmuteChannel": "Включить уведомления канала", "channels_deleteChannel": "Удалить канал", "channels_deleteChannelConfirm": "Удалить \"{name}\"? Это действие нельзя отменить.", "channels_channelDeleted": "Канал \"{name}\" удалён", @@ -472,8 +477,8 @@ "repeater_telemetrySubtitle": "Просмотр телеметрии датчиков и системной статистики", "repeater_cli": "CLI", "repeater_cliSubtitle": "Отправка команд репитеру", - "repeater_neighbours": "Соседи", - "repeater_neighboursSubtitle": "Просмотр соседей на нулевом хопе.", + "repeater_neighbors": "Соседи", + "repeater_neighborsSubtitle": "Просмотр соседей на нулевом хопе.", "repeater_settings": "Настройки", "repeater_settingsSubtitle": "Настройка параметров репитера", "repeater_statusTitle": "Статус репитера", @@ -666,7 +671,7 @@ "neighbors_receivedData": "Полученные данные о соседях", "neighbors_requestTimedOut": "Время ожидания данных о соседях истекло.", "neighbors_errorLoading": "Ошибка загрузки соседей: {error}", - "neighbors_repeatersNeighbours": "Соседи репитеров", + "neighbors_repeatersNeighbors": "Соседи репитеров", "neighbors_noData": "Данные о соседях недоступны.", "neighbors_unknownContact": "Неизвестный {pubkey}", "neighbors_heardA ago": "Слышали: {time} назад", @@ -799,6 +804,8 @@ "contacts_invalidAdvertFormat": "Недействительные контактные данные", "contacts_zeroHopAdvert": "Реклама Zero Hop", "appSettings_languageUk": "Українська", + "appSettings_enableMessageTracing": "Включить трассировку сообщений", + "appSettings_enableMessageTracingSubtitle": "Показывать подробные метаданные о маршрутизации и времени для сообщений", "contacts_floodAdvert": "Рекламный поток", "contacts_clipboardEmpty": "Буфер обмена пуст.", "contacts_copyAdvertToClipboard": "Копировать рекламу в буфер обмена", @@ -815,7 +822,6 @@ "notification_newNodesCount": "{count} {count, plural, =1{новый узел} few{новых узла} many{новых узлов} other{новых узлов}}", "notification_newTypeDiscovered": "Обнаружен новый {contactType}", "notification_receivedNewMessage": "Получено новое сообщение", - "contacts_zeroHopContactAdvertSent": "Отправлено сообщение по объявлению.", "settings_gpxExportRepeaters": "Экспортировать рипитеры / сервер комнаты в GPX", "settings_gpxExportRepeatersSubtitle": "Экспортирует ретрансляторы / сервер комнат с местоположением в файл GPX.", "settings_gpxExportContacts": "Экспортировать спутников в GPX", @@ -831,6 +837,207 @@ "settings_gpxExportNoContacts": "Нет контактов для экспорта.", "settings_gpxExportShareText": "Данные карты экспортированы из meshcore-open", "settings_gpxExportShareSubject": "meshcore-open экспорт данных карты GPX", - "pathTrace_someHopsNoLocation": "Одному или нескольким хмелям не указано местоположение!" - + "pathTrace_someHopsNoLocation": "Одному или нескольким хмелям не указано местоположение!", + "map_tapToAdd": "Нажимайте на узлы, чтобы добавить их в путь.", + "map_removeLast": "Удалить последний", + "map_pathTraceCancelled": "Отмена трассировки пути", + "pathTrace_clearTooltip": "Очистить путь", + "map_runTrace": "Запустить трассировку пути", + "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_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": "Удалить из избранного", + "@contacts_searchFavorites": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchUsers": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchRepeaters": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchRoomServers": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "contacts_searchRepeaters": "Поиск {number}{str} ретрансляторов...", + "contacts_searchContactsNoNumber": "Поиск контактов...", + "contacts_unread": "Непрочитанное", + "contacts_searchRoomServers": "Поиск {number}{str} серверов комнат...", + "contacts_searchFavorites": "Поиск {number}{str} избранного...", + "contacts_searchUsers": "Поиск {number}{str} пользователей..." } diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index c09502a3..672b7d74 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", @@ -131,9 +139,6 @@ "settings_infoContactsCount": "Počet kontaktov", "settings_infoChannelCount": "Počet kanálov", "settings_presets": "Prednastavenia", - "settings_preset915Mhz": "915 MHz", - "settings_preset868Mhz": "868 MHz", - "settings_preset433Mhz": "433 MHz", "settings_frequency": "Frekvencia (MHz)", "settings_frequencyHelper": "300,0 – 2500,0", "settings_frequencyInvalid": "Neplatná frekvencia (300-2500 MHz)", @@ -143,8 +148,6 @@ "settings_txPower": "TX Výkon (dBm)", "settings_txPowerHelper": "0 - 22", "settings_txPowerInvalid": "Neplatná hodnota výkonu TX (0-22 dBm)", - "settings_longRange": "Dlhý dosah", - "settings_fastSpeed": "Rýchla rýchlosť", "settings_error": "Chyba: {message}", "@settings_error": { "placeholders": { @@ -339,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": { @@ -1356,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", @@ -1561,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.", @@ -1575,7 +1582,6 @@ "notification_newNodesCount": "{count} {count, plural, =1{nový uzol} few{nové uzly} other{nových uzlov}}", "notification_newTypeDiscovered": "Nový {contactType} objavený", "notification_receivedNewMessage": "Prijatá nová správa", - "contacts_ShareContact": "Kopírovať kontakt do schránky", "settings_gpxExportRepeatersSubtitle": "Exportuje repeater / roomserver s lokalitou do súboru GPX.", "settings_gpxExportContacts": "Export sprievodcov do GPX", "settings_gpxExportSuccess": "Úspešne exportovaný súbor GPX.", @@ -1591,6 +1597,207 @@ "settings_gpxExportChat": "Lokácie sprievodcov", "settings_gpxExportShareText": "Mapové údaje exportované z meshcore-open", "settings_gpxExportShareSubject": "meshcore-open export dát GPX mapových údajov", - "pathTrace_someHopsNoLocation": "Jedna alebo viac chmeľov chýba lokalita!" - + "pathTrace_someHopsNoLocation": "Jedna alebo viac chmeľov chýba lokalita!", + "pathTrace_clearTooltip": "Zmazať cestu", + "map_tapToAdd": "Kliknite na uzly, aby ste ich pridali k ceste.", + "map_removeLast": "Odstrániť posledný", + "map_runTrace": "Spustiť trasovaním cesty", + "map_pathTraceCancelled": "Zrušenie stopáže cesty bolo zrušené.", + "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_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é", + "@contacts_searchFavorites": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchUsers": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchRepeaters": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchRoomServers": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "contacts_searchRoomServers": "Hľadaj {number}{str} serverov miestností...", + "contacts_searchFavorites": "Hľadať {number}{str} obľúbené...", + "contacts_searchRepeaters": "Hľadať {number}{str} opakovače...", + "contacts_searchUsers": "Hľadať {number}{str} používateľov...", + "contacts_searchContactsNoNumber": "Hľadať kontakty...", + "contacts_unread": "Neprečítané" } diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index 97a396a1..09359a13 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", @@ -131,9 +139,6 @@ "settings_infoContactsCount": "Število stikov", "settings_infoChannelCount": "Število kanalov", "settings_presets": "Prednastavitve", - "settings_preset915Mhz": "915 MHz", - "settings_preset868Mhz": "868 MHz", - "settings_preset433Mhz": "433 MHz", "settings_frequency": "Frekvenca (MHz)", "settings_frequencyHelper": "300,00 - 2500,00", "settings_frequencyInvalid": "Neveljavna frekvenca (300-2500 MHz)", @@ -143,8 +148,6 @@ "settings_txPower": "TX Moč (dBm)", "settings_txPowerHelper": "0 - 22", "settings_txPowerInvalid": "Neveljavna TX moč (0-22 dBm)", - "settings_longRange": "DDolg doseg", - "settings_fastSpeed": "Visoka hitrost", "settings_error": "Napaka: {message}", "@settings_error": { "placeholders": { @@ -339,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": { @@ -1356,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.", @@ -1555,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", @@ -1575,7 +1582,6 @@ "notification_newNodesCount": "{count} {count, plural, =1{novo vozlišče} =2{novi vozlišči} few{nova vozlišča} other{novih vozlišč}}", "notification_newTypeDiscovered": "Odkrito novo {contactType}", "notification_receivedNewMessage": "Prejeto novo sporočilo", - "contacts_ShareContact": "Kopiraj stik v Odložišče", "settings_gpxExportAll": "Izvozi vse kontakte v GPX", "settings_gpxExportContacts": "Izvoz spremljevalcev v GPX", "settings_gpxExportRepeatersSubtitle": "Izvozi ponovljene oddajnike / strežnik sobe z lokacijo v datoteko GPX.", @@ -1591,6 +1597,207 @@ "settings_gpxExportNoContacts": "Ni stikov za izvoz.", "settings_gpxExportNotAvailable": "Ni podprto na vašem napravi/operacijskem sistemu", "settings_gpxExportShareSubject": "meshcore-open izvoz podatkov GPX karte", - "pathTrace_someHopsNoLocation": "Ena ali več hmelju manjka lokacija!" - + "pathTrace_someHopsNoLocation": "Ena ali več hmelju manjka lokacija!", + "map_tapToAdd": "Pritisnite na vozlišča, da jih dodate poti.", + "map_removeLast": "Odstrani Zadnji", + "map_runTrace": "Zaženi sledenje poti", + "pathTrace_clearTooltip": "Počisti pot", + "map_pathTraceCancelled": "Spremljanje poti je prekinjeno.", + "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_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", + "@contacts_searchFavorites": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchUsers": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchRepeaters": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchRoomServers": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "contacts_unread": "Neprebrano", + "contacts_searchFavorites": "Iskanje {number}{str} priljubljenih...", + "contacts_searchRoomServers": "Išči {number}{str} strežnikov sob...", + "contacts_searchContactsNoNumber": "Iskanje stikov...", + "contacts_searchRepeaters": "Išči {number}{str} ponavljalnike...", + "contacts_searchUsers": "Išči {number}{str} uporabnikov..." } diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index 6df28bdf..a923cc9c 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", @@ -131,9 +139,6 @@ "settings_infoContactsCount": "Kontakterantal", "settings_infoChannelCount": "Kanalantal", "settings_presets": "Fördefinierade inställningar", - "settings_preset915Mhz": "915 MHz", - "settings_preset868Mhz": "868 MHz", - "settings_preset433Mhz": "433 MHz", "settings_frequency": "Frekvens (MHz)", "settings_frequencyHelper": "300,0 - 2500,0", "settings_frequencyInvalid": "Ogiltig frekvens (300-2500 MHz)", @@ -143,8 +148,6 @@ "settings_txPower": "TX-effekt (dBm)", "settings_txPowerHelper": "0 - 22", "settings_txPowerInvalid": "Ogiltig TX-effekt (0-22 dBm)", - "settings_longRange": "Lång räckvidd", - "settings_fastSpeed": "Snabb hastighet", "settings_error": "Fel: {message}", "@settings_error": { "placeholders": { @@ -339,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": { @@ -1356,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", @@ -1561,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.", @@ -1575,7 +1582,6 @@ "notification_newNodesCount": "{count} {count, plural, =1{ny nod} other{nya noder}}", "notification_newTypeDiscovered": "Ny {contactType} upptäckt", "notification_receivedNewMessage": "Nytt meddelande mottaget", - "contacts_ShareContactZeroHop": "Dela kontakt via annons", "settings_gpxExportAll": "Exportera alla kontakter till GPX", "settings_gpxExportRepeatersSubtitle": "Exporterar repeater / roomserver med plats till GPX-fil.", "settings_gpxExportSuccess": "Har exporterat GPX-fil med framgång", @@ -1591,6 +1597,207 @@ "settings_gpxExportAllContacts": "Alla kontakters platser", "settings_gpxExportShareSubject": "meshcore-open export av GPX-kartdata", "settings_gpxExportShareText": "Kartdata exporterad från meshcore-open", - "pathTrace_someHopsNoLocation": "En eller flera av humlen saknar en plats!" - + "pathTrace_someHopsNoLocation": "En eller flera av humlen saknar en plats!", + "pathTrace_clearTooltip": "Rensa väg", + "map_pathTraceCancelled": "Sökvägsspårning avbruten.", + "map_runTrace": "Kör spårsökning", + "map_tapToAdd": "Tryck på noder för att lägga till dem i banan.", + "map_removeLast": "Ta bort sista", + "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_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", + "@contacts_searchFavorites": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchUsers": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchRepeaters": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchRoomServers": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "contacts_unread": "Oläst", + "contacts_searchContactsNoNumber": "Sök kontakter...", + "contacts_searchRepeaters": "Sök {number}{str} upprepningsenheter...", + "contacts_searchFavorites": "Sök {number}{str} Favoriter...", + "contacts_searchUsers": "Sök {number}{str} användare...", + "contacts_searchRoomServers": "Sök {number}{str} Room-servrar..." } diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 0e63785e..93214b12 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": "Контакти", @@ -131,9 +139,6 @@ "settings_infoContactsCount": "Кількість контактів", "settings_infoChannelCount": "Кількість каналів", "settings_presets": "Попередні налаштування", - "settings_preset915Mhz": "915 МГц", - "settings_preset868Mhz": "868 МГц", - "settings_preset433Mhz": "433 МГц", "settings_frequency": "Частота (МГц)", "settings_frequencyHelper": "300.0 - 2500.0", "settings_frequencyInvalid": "Некоректна частота (300-2500 МГц)", @@ -143,8 +148,6 @@ "settings_txPower": "Потужність TX (дБм)", "settings_txPowerHelper": "0 - 22", "settings_txPowerInvalid": "Некоректна потужність TX (0-22 дБм)", - "settings_longRange": "Дальній діапазон", - "settings_fastSpeed": "Висока швидкість", "settings_error": "Помилка: {message}", "@settings_error": { "placeholders": { @@ -340,6 +343,8 @@ "channels_publicChannel": "Публічний канал", "channels_privateChannel": "Приватний канал", "channels_editChannel": "Редагувати канал", + "channels_muteChannel": "Вимкнути сповіщення каналу", + "channels_unmuteChannel": "Увімкнути сповіщення каналу", "channels_deleteChannel": "Видалити канал", "channels_deleteChannelConfirm": "Видалити {name}? Це не можна скасувати.", "@channels_deleteChannelConfirm": { @@ -1358,12 +1363,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": "Приєднатися до приватного каналу", @@ -1563,6 +1568,8 @@ "contacts_copyAdvertToClipboard": "Копіювати оголошення в буфер обміну", "contacts_clipboardEmpty": "Буфер обміну порожній", "appSettings_languageRu": "Російська", + "appSettings_enableMessageTracing": "Увімкнути відстеження повідомлень", + "appSettings_enableMessageTracingSubtitle": "Показувати детальні метадані про маршрутизацію та час для повідомлень", "contacts_ShareContact": "Копіювати контакт у буфер обміну", "contacts_zeroHopContactAdvertFailed": "Не вдалося надіслати контакт.", "contacts_contactAdvertCopied": "Оголошення скопійовано до буфера обміну.", @@ -1576,7 +1583,6 @@ "notification_newNodesCount": "{count} {count, plural, =1{новий вузол} few{нових вузли} many{нових вузлів} other{нових вузлів}}", "notification_newTypeDiscovered": "Виявлено новий {contactType}", "notification_receivedNewMessage": "Отримано нове повідомлення", - "contacts_ShareContactZeroHop": "Поділитися контактом за оголошенням", "settings_gpxExportRepeaters": "Експортувати ретранслятори / сервер кімнати до GPX", "settings_gpxExportRepeatersSubtitle": "Експортує ретранслятори / сервер кімнати з місцезнаходженням у файл GPX.", "settings_gpxExportSuccess": "Успішно експортовано файл GPX.", @@ -1592,6 +1598,207 @@ "settings_gpxExportShareText": "Дані карти експортовані з meshcore-open", "settings_gpxExportAllContacts": "Усі місця контактів", "settings_gpxExportShareSubject": "експорт даних карти meshcore-open у форматі GPX", - "pathTrace_someHopsNoLocation": "Одне або більше хмелів відсутнє місце розташування!" - + "pathTrace_someHopsNoLocation": "Одне або більше хмелів відсутнє місце розташування!", + "map_tapToAdd": "Натисніть на вузли, щоб додати їх до шляху", + "map_runTrace": "Виконати трасування шляху", + "pathTrace_clearTooltip": "Очистити шлях", + "map_removeLast": "Видалити останній", + "map_pathTraceCancelled": "Відмінується трасування шляху", + "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_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": "Улюблені", + "@contacts_searchFavorites": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchUsers": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchRepeaters": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchRoomServers": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "contacts_searchRoomServers": "Пошук {number}{str} серверів кімнат...", + "contacts_searchUsers": "Пошук {number}{str} користувачів...", + "contacts_searchFavorites": "Пошук {number}{str} улюблених...", + "contacts_searchContactsNoNumber": "Пошук контактів...", + "contacts_searchRepeaters": "Пошук {number}{str} ретрансляторів...", + "contacts_unread": "Непрочитане" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 8c65510a..63c02a57 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1,11 +1,19 @@ { + "channels_channelDeleteFailed": "无法删除频道 \"{name}\"", + "@channels_channelDeleteFailed": { + "placeholders": { + "name": { + "type": "String" + } + } + }, "@@locale": "zh", "appTitle": "MeshCore Open", - "nav_contacts": "联系方式", + "nav_contacts": "联系人", "nav_channels": "频道", "nav_map": "地图", "common_cancel": "取消", - "common_ok": "好的", + "common_ok": "确定", "common_connect": "连接", "common_unknownDevice": "未知设备", "common_save": "保存", @@ -15,9 +23,9 @@ "common_add": "添加", "common_settings": "设置", "common_disconnect": "断开", - "common_connected": "连接", - "common_disconnected": "断开", - "common_create": "创造", + "common_connected": "已连接", + "common_disconnected": "已断开", + "common_create": "创建", "common_continue": "继续", "common_share": "分享", "common_copy": "复制", @@ -26,7 +34,7 @@ "common_remove": "移除", "common_enable": "启用", "common_disable": "禁用", - "common_reboot": "重新启动", + "common_reboot": "重启", "common_loading": "正在加载...", "common_notAvailable": "—", "common_voltageValue": "{volts} V", @@ -45,7 +53,7 @@ } } }, - "scanner_title": "MeshCore 开放", + "scanner_title": "连接设备", "scanner_scanning": "正在搜索设备...", "scanner_connecting": "正在连接...", "scanner_disconnecting": "断开连接...", @@ -59,8 +67,8 @@ } }, "scanner_searchingDevices": "正在搜索 MeshCore 设备...", - "scanner_tapToScan": "点击“扫描”功能,以查找 MeshCore 设备。", - "scanner_connectionFailed": "Connection failed: {error}", + "scanner_tapToScan": "点击“扫描”按钮以查找 MeshCore 设备。", + "scanner_connectionFailed": "连接失败:{error}", "@scanner_connectionFailed": { "placeholders": { "error": { @@ -71,7 +79,7 @@ "scanner_stop": "停止", "scanner_scan": "扫描", "device_quickSwitch": "快速切换", - "device_meshcore": "网格核心", + "device_meshcore": "MeshCore", "settings_title": "设置", "settings_deviceInfo": "设备信息", "settings_appSettings": "应用设置", @@ -80,43 +88,43 @@ "settings_nodeName": "节点名称", "settings_nodeNameNotSet": "未设置", "settings_nodeNameHint": "请输入节点名称", - "settings_nodeNameUpdated": "姓名已更新", - "settings_radioSettings": "收音机设置", + "settings_nodeNameUpdated": "节点名称已更新", + "settings_radioSettings": "无线电设置", "settings_radioSettingsSubtitle": "频率、功率、扩频因子", - "settings_radioSettingsUpdated": "收音机设置已更新", - "settings_location": "地点", + "settings_radioSettingsUpdated": "无线电设置已更新", + "settings_location": "位置", "settings_locationSubtitle": "GPS 坐标", "settings_locationUpdated": "位置和 GPS 设置已更新", - "settings_locationBothRequired": "请输入经度和纬度。", - "settings_locationInvalid": "无效的经度和纬度。", - "settings_locationGPSEnable": "开启 GPS 功能", - "settings_locationGPSEnableSubtitle": "使 GPS 能够自动更新位置。", - "settings_locationIntervalSec": "GPS 间隔时间(秒)", + "settings_locationBothRequired": "请输入经度和纬度", + "settings_locationInvalid": "无效的经度和纬度", + "settings_locationGPSEnable": "启用 GPS", + "settings_locationGPSEnableSubtitle": "启用 GPS 以自动更新位置。", + "settings_locationIntervalSec": "GPS 间隔(秒)", "settings_locationIntervalInvalid": "间隔时间必须至少为 60 秒,但不超过 86400 秒。", "settings_latitude": "纬度", "settings_longitude": "经度", "settings_privacyMode": "隐私模式", "settings_privacyModeSubtitle": "在广告中隐藏姓名/位置", - "settings_privacyModeToggle": "切换隐私模式,以隐藏您的姓名和位置,从而在广告中保护您的个人信息。", + "settings_privacyModeToggle": "切换隐私模式以在广告中隐藏姓名和位置,保护个人信息。", "settings_privacyModeEnabled": "隐私模式已启用", "settings_privacyModeDisabled": "隐私模式已关闭", - "settings_actions": "行动", - "settings_sendAdvertisement": "发布广告", - "settings_sendAdvertisementSubtitle": "现已开始进行广播节目", - "settings_advertisementSent": "已发送广告", + "settings_actions": "操作", + "settings_sendAdvertisement": "发送广播", + "settings_sendAdvertisementSubtitle": "立即发送广播", + "settings_advertisementSent": "已发送广播", "settings_syncTime": "同步时间", "settings_syncTimeSubtitle": "将设备时钟设置为与手机时间一致", - "settings_timeSynchronized": "时间同步", + "settings_timeSynchronized": "时间已同步", "settings_refreshContacts": "刷新联系人", - "settings_refreshContactsSubtitle": "从设备中重新加载联系人列表", + "settings_refreshContactsSubtitle": "从设备重新加载联系人列表", "settings_rebootDevice": "重启设备", - "settings_rebootDeviceSubtitle": "重新启动 MeshCore 设备", - "settings_rebootDeviceConfirm": "您确定要重启设备吗?这将导致您与设备断开连接。", + "settings_rebootDeviceSubtitle": "重启 MeshCore 设备", + "settings_rebootDeviceConfirm": "确定要重启设备吗?这将断开与设备的连接。", "settings_debug": "调试", "settings_bleDebugLog": "BLE 调试日志", "settings_bleDebugLogSubtitle": "BLE 命令、响应和原始数据", - "settings_appDebugLog": "应用程序调试日志", - "settings_appDebugLogSubtitle": "应用程序调试消息", + "settings_appDebugLog": "应用调试日志", + "settings_appDebugLogSubtitle": "应用调试消息", "settings_about": "关于", "settings_aboutVersion": "MeshCore Open v{version}", "@settings_aboutVersion": { @@ -128,29 +136,29 @@ }, "settings_aboutLegalese": "2026 MeshCore 开源项目", "settings_aboutDescription": "一个开源的 Flutter 客户端,用于 MeshCore LoRa 无线网络设备。", - "settings_infoName": "姓名", - "settings_infoId": "ID", + "settings_infoName": "名称", + "settings_infoId": "MAC ID", "settings_infoStatus": "状态", "settings_infoBattery": "电池", "settings_infoPublicKey": "公钥", "settings_infoContactsCount": "联系人数量", - "settings_infoChannelCount": "通道数量", + "settings_infoChannelCount": "频道数量", "settings_presets": "预设", - "settings_preset915Mhz": "915 兆赫", - "settings_preset868Mhz": "868 兆赫", - "settings_preset433Mhz": "433 兆赫", + "settings_preset915Mhz": "915 MHz", + "settings_preset868Mhz": "868 MHz", + "settings_preset433Mhz": "433 MHz", "settings_frequency": "频率 (MHz)", "settings_frequencyHelper": "300.0 - 2500.0", - "settings_frequencyInvalid": "无效频率(300-2500 MHz)", + "settings_frequencyInvalid": "无效频率范围(300-2500 MHz)", "settings_bandwidth": "带宽", - "settings_spreadingFactor": "传播系数", + "settings_spreadingFactor": "扩频因子", "settings_codingRate": "编码速率", - "settings_txPower": "TX 功率(dBm)", + "settings_txPower": "TX 功率 (dBm)", "settings_txPowerHelper": "0 - 22", "settings_txPowerInvalid": "无效的发射功率(0-22 dBm)", "settings_longRange": "远距离", "settings_fastSpeed": "高速", - "settings_error": "[保存:{message}]\n错误:{message}", + "settings_error": "错误:{message}", "@settings_error": { "placeholders": { "message": { @@ -161,49 +169,51 @@ "appSettings_title": "应用设置", "appSettings_appearance": "外观", "appSettings_theme": "主题", - "appSettings_themeSystem": "系统默认设置", - "appSettings_themeLight": "光", - "appSettings_themeDark": "黑暗", + "appSettings_themeSystem": "跟随系统", + "appSettings_themeLight": "浅色", + "appSettings_themeDark": "深色", "appSettings_language": "语言", - "appSettings_languageSystem": "系统默认设置", + "appSettings_languageSystem": "跟随系统", "appSettings_languageEn": "英语", "appSettings_languageFr": "法语", "appSettings_languageEs": "西班牙语", "appSettings_languageDe": "德语", "appSettings_languagePl": "波兰语", - "appSettings_languageSl": "斯洛文语", + "appSettings_languageSl": "斯洛文尼亚语", "appSettings_languagePt": "葡萄牙语", "appSettings_languageIt": "意大利语", "appSettings_languageZh": "中文", "appSettings_languageSv": "瑞典语", "appSettings_languageNl": "荷兰语", "appSettings_languageSk": "斯洛伐克语", - "appSettings_languageBg": "保加利亚", + "appSettings_languageBg": "保加利亚语", "appSettings_languageRu": "俄语", - "appSettings_languageUk": "乌克兰", + "appSettings_languageUk": "乌克兰语", + "appSettings_enableMessageTracing": "启用消息追踪", + "appSettings_enableMessageTracingSubtitle": "显示消息的详细路由和时间元数据", "appSettings_notifications": "通知", "appSettings_enableNotifications": "启用通知", - "appSettings_enableNotificationsSubtitle": "接收消息和广告的通知", + "appSettings_enableNotificationsSubtitle": "接收消息和广播的通知", "appSettings_notificationPermissionDenied": "权限被拒绝", "appSettings_notificationsEnabled": "通知已启用", "appSettings_notificationsDisabled": "通知已关闭", "appSettings_messageNotifications": "消息通知", - "appSettings_messageNotificationsSubtitle": "在收到新消息时显示通知", + "appSettings_messageNotificationsSubtitle": "收到新消息时显示通知", "appSettings_channelMessageNotifications": "频道消息通知", - "appSettings_channelMessageNotificationsSubtitle": "在收到频道消息时,显示通知。", - "appSettings_advertisementNotifications": "广告通知", - "appSettings_advertisementNotificationsSubtitle": "在发现新的节点时,显示通知。", - "appSettings_messaging": "信息传递", - "appSettings_clearPathOnMaxRetry": "关于“最大重试”的清晰说明", - "appSettings_clearPathOnMaxRetrySubtitle": "在尝试发送失败后 5 次,重置联系路径。", - "appSettings_pathsWillBeCleared": "如果尝试 5 次后仍然失败,则将重新规划路径。", - "appSettings_pathsWillNotBeCleared": "路径不会自动清除。", + "appSettings_channelMessageNotificationsSubtitle": "收到频道消息时显示通知", + "appSettings_advertisementNotifications": "广播通知", + "appSettings_advertisementNotificationsSubtitle": "发现新节点时显示通知", + "appSettings_messaging": "消息", + "appSettings_clearPathOnMaxRetry": "达到最大重试次数时清除路径", + "appSettings_clearPathOnMaxRetrySubtitle": "在5次发送失败后重置联系路径。", + "appSettings_pathsWillBeCleared": "5次失败后将重新路由", + "appSettings_pathsWillNotBeCleared": "路径不会自动清除", "appSettings_autoRouteRotation": "自动路径轮换", - "appSettings_autoRouteRotationSubtitle": "在最佳路径和防洪模式之间切换", + "appSettings_autoRouteRotationSubtitle": "在最佳路径和泛洪模式之间切换", "appSettings_autoRouteRotationEnabled": "自动路径轮换已启用", "appSettings_autoRouteRotationDisabled": "自动路径轮换已禁用", "appSettings_battery": "电池", - "appSettings_batteryChemistry": "电池化学", + "appSettings_batteryChemistry": "电池类型", "appSettings_batteryChemistryPerDevice": "为每个设备设置 ({deviceName})", "@appSettings_batteryChemistryPerDevice": { "placeholders": { @@ -212,20 +222,20 @@ } } }, - "appSettings_batteryChemistryConnectFirst": "连接到设备以进行选择", - "appSettings_batteryNmc": "18650 型号,NMC 电池(3.0-4.2V)", + "appSettings_batteryChemistryConnectFirst": "请先连接设备", + "appSettings_batteryNmc": "18650 NMC 电池 (3.0-4.2V)", "appSettings_batteryLifepo4": "磷酸铁锂 (2.6-3.65V)", - "appSettings_batteryLipo": "锂离子电池 (3.0-4.2V)", - "appSettings_mapDisplay": "地图展示", - "appSettings_showRepeaters": "显示重复", - "appSettings_showRepeatersSubtitle": "在地图上显示重复节点", + "appSettings_batteryLipo": "锂聚合物电池 (3.0-4.2V)", + "appSettings_mapDisplay": "地图显示", + "appSettings_showRepeaters": "显示转发节点", + "appSettings_showRepeatersSubtitle": "在地图上显示转发节点", "appSettings_showChatNodes": "显示聊天节点", "appSettings_showChatNodesSubtitle": "在地图上显示聊天节点", "appSettings_showOtherNodes": "显示其他节点", "appSettings_showOtherNodesSubtitle": "在地图上显示其他节点类型", "appSettings_timeFilter": "时间过滤器", "appSettings_timeFilterShowAll": "显示所有节点", - "appSettings_timeFilterShowLast": "Show nodes from last {hours} hours", + "appSettings_timeFilterShowLast": "显示过去 {hours} 小时内的节点", "@appSettings_timeFilterShowLast": { "placeholders": { "hours": { @@ -234,7 +244,7 @@ } }, "appSettings_mapTimeFilter": "地图时间筛选", - "appSettings_showNodesDiscoveredWithin": "显示在以下范围内发现的节点:", + "appSettings_showNodesDiscoveredWithin": "显示在此时间段内发现的节点:", "appSettings_allTime": "所有时间", "appSettings_lastHour": "过去一小时", "appSettings_last6Hours": "过去6小时", @@ -242,7 +252,7 @@ "appSettings_lastWeek": "上周", "appSettings_offlineMapCache": "离线地图缓存", "appSettings_noAreaSelected": "未选择任何区域", - "appSettings_areaSelectedZoom": "已选择区域(缩放至 {minZoom} - {maxZoom})", + "appSettings_areaSelectedZoom": "已选择区域(缩放 {minZoom} - {maxZoom})", "@appSettings_areaSelectedZoom": { "placeholders": { "minZoom": { @@ -254,18 +264,18 @@ } }, "appSettings_debugCard": "调试", - "appSettings_appDebugLogging": "应用程序调试日志", - "appSettings_appDebugLoggingSubtitle": "用于故障排除的日志应用程序调试消息", + "appSettings_appDebugLogging": "应用调试日志", + "appSettings_appDebugLoggingSubtitle": "记录应用调试消息以进行故障排除。", "appSettings_appDebugLoggingEnabled": "调试日志已启用", - "appSettings_appDebugLoggingDisabled": "应用程序调试日志已禁用", - "contacts_title": "联系方式", - "contacts_noContacts": "目前还没有联系人", - "contacts_contactsWillAppear": "当设备发布广告时,联系方式会显示。", + "appSettings_appDebugLoggingDisabled": "应用调试日志已禁用", + "contacts_title": "联系人", + "contacts_noContacts": "暂无联系人", + "contacts_contactsWillAppear": "当设备发送广播时,联系人将显示。", "contacts_searchContacts": "搜索联系人...", - "contacts_noUnreadContacts": "没有未读通讯", - "contacts_noContactsFound": "未找到任何联系人或群组", + "contacts_noUnreadContacts": "没有未读内容", + "contacts_noContactsFound": "未找到任何联系人或群聊", "contacts_deleteContact": "删除联系人", - "contacts_removeConfirm": "Remove {contactName} from contacts?", + "contacts_removeConfirm": "从联系人中移除 {contactName}?", "@contacts_removeConfirm": { "placeholders": { "contactName": { @@ -273,13 +283,13 @@ } } }, - "contacts_manageRepeater": "管理重复器", + "contacts_manageRepeater": "管理转发节点", "contacts_manageRoom": "管理房间服务器", "contacts_roomLogin": "服务器登录", - "contacts_openChat": "开放聊天", - "contacts_editGroup": "编辑小组", - "contacts_deleteGroup": "删除群组", - "contacts_deleteGroupConfirm": "删除\"{groupName}\"?", + "contacts_openChat": "打开聊天", + "contacts_editGroup": "编辑群聊", + "contacts_deleteGroup": "删除群聊", + "contacts_deleteGroupConfirm": "删除群聊 \"{groupName}\"?", "@contacts_deleteGroupConfirm": { "placeholders": { "groupName": { @@ -287,10 +297,10 @@ } } }, - "contacts_newGroup": "新的团体", - "contacts_groupName": "团体名称", - "contacts_groupNameRequired": "需要提供组名称", - "contacts_groupAlreadyExists": "名为\"{name}\"的组已经存在", + "contacts_newGroup": "新建群聊", + "contacts_groupName": "群聊名称", + "contacts_groupNameRequired": "请输入群聊名称", + "contacts_groupAlreadyExists": "名为 \"{name}\" 的群聊已存在", "@contacts_groupAlreadyExists": { "placeholders": { "name": { @@ -299,10 +309,10 @@ } }, "contacts_filterContacts": "筛选联系人...", - "contacts_noContactsMatchFilter": "未找到符合您筛选条件的联系人", - "contacts_noMembers": "没有会员", - "contacts_lastSeenNow": "最后一次被看到的时间", - "contacts_lastSeenMinsAgo": "Last seen {minutes} mins ago", + "contacts_noContactsMatchFilter": "没有符合条件的联系人", + "contacts_noMembers": "暂无成员", + "contacts_lastSeenNow": "刚刚", + "contacts_lastSeenMinsAgo": "最后在线 {minutes} 分钟前", "@contacts_lastSeenMinsAgo": { "placeholders": { "minutes": { @@ -310,8 +320,8 @@ } } }, - "contacts_lastSeenHourAgo": "最后一次被看到的时间:1小时前", - "contacts_lastSeenHoursAgo": "Last seen {hours} hours ago", + "contacts_lastSeenHourAgo": "最后在线 1小时前", + "contacts_lastSeenHoursAgo": "最后在线 {hours} 小时前", "@contacts_lastSeenHoursAgo": { "placeholders": { "hours": { @@ -319,8 +329,8 @@ } } }, - "contacts_lastSeenDayAgo": "最后一次被看到的时间是1天前", - "contacts_lastSeenDaysAgo": "Last seen {days} days ago", + "contacts_lastSeenDayAgo": "最后在线 1天前", + "contacts_lastSeenDaysAgo": "最后在线 {days} 天前", "@contacts_lastSeenDaysAgo": { "placeholders": { "days": { @@ -341,14 +351,16 @@ } } }, - "channels_hashtagChannel": "话题标签频道", - "channels_public": "公众", - "channels_private": "私人", + "channels_hashtagChannel": "标签频道", + "channels_public": "公共", + "channels_private": "私有", "channels_publicChannel": "公共频道", - "channels_privateChannel": "私密频道", + "channels_privateChannel": "私有频道", "channels_editChannel": "编辑频道", + "channels_muteChannel": "静音频道", + "channels_unmuteChannel": "取消静音频道", "channels_deleteChannel": "删除频道", - "channels_deleteChannelConfirm": "Delete \"{name}\"? This cannot be undone.", + "channels_deleteChannelConfirm": "删除频道 \"{name}\"?此操作不可撤销。", "@channels_deleteChannelConfirm": { "placeholders": { "name": { @@ -356,7 +368,7 @@ } } }, - "channels_channelDeleted": "删除频道 \"{name}\"", + "channels_channelDeleted": "已删除频道 \"{name}\"", "@channels_channelDeleted": { "placeholders": { "name": { @@ -368,12 +380,12 @@ "channels_channelIndexLabel": "频道索引", "channels_channelName": "频道名称", "channels_usePublicChannel": "使用公共频道", - "channels_standardPublicPsk": "标准公共PSK", + "channels_standardPublicPsk": "标准公共 PSK", "channels_pskHex": "PSK (十六进制)", - "channels_generateRandomPsk": "生成随机的PSK(正交相移键控)", - "channels_enterChannelName": "请在此处输入频道名称", - "channels_pskMustBe32Hex": "PSK 必须包含 32 个十六进制字符。", - "channels_channelAdded": "添加频道 \"{name}\"", + "channels_generateRandomPsk": "生成随机 PSK", + "channels_enterChannelName": "请输入频道名称", + "channels_pskMustBe32Hex": "PSK 必须为 32 个十六进制字符", + "channels_channelAdded": "已添加频道 \"{name}\"", "@channels_channelAdded": { "placeholders": { "name": { @@ -399,27 +411,27 @@ } }, "channels_publicChannelAdded": "已添加公共频道", - "channels_sortBy": "按排序", - "channels_sortManual": "手册", - "channels_sortAZ": "A 到 Z", + "channels_sortBy": "排序方式", + "channels_sortManual": "手动", + "channels_sortAZ": "A-Z", "channels_sortLatestMessages": "最新消息", "channels_sortUnread": "未读", - "channels_createPrivateChannel": "创建私密频道", - "channels_createPrivateChannelDesc": "使用秘密密钥进行保护。", - "channels_joinPrivateChannel": "加入私密频道", + "channels_createPrivateChannel": "创建私有频道", + "channels_createPrivateChannelDesc": "使用密钥保护。", + "channels_joinPrivateChannel": "加入私有频道", "channels_joinPrivateChannelDesc": "手动输入密钥。", "channels_joinPublicChannel": "加入公共频道", - "channels_joinPublicChannelDesc": "任何人都可以加入这个频道。", - "channels_joinHashtagChannel": "加入一个带有特定标签的频道", - "channels_joinHashtagChannelDesc": "任何人都可以加入带有特定标签的频道。", + "channels_joinPublicChannelDesc": "任何人都可以加入。", + "channels_joinHashtagChannel": "加入标签频道", + "channels_joinHashtagChannelDesc": "任何人都可以加入标签频道。", "channels_scanQrCode": "扫描二维码", - "channels_scanQrCodeComingSoon": "即将发布", + "channels_scanQrCodeComingSoon": "即将推出", "channels_enterHashtag": "输入标签", "channels_hashtagHint": "例如:#团队", - "chat_noMessages": "目前还没有收到任何消息。", - "chat_sendMessageToStart": "发送消息以开始", - "chat_originalMessageNotFound": "无法找到原始消息", - "chat_replyingTo": "Replying to {name}", + "chat_noMessages": "暂无消息", + "chat_sendMessageToStart": "发送消息开始对话", + "chat_originalMessageNotFound": "找不到原始消息", + "chat_replyingTo": "正在回复 {name}", "@chat_replyingTo": { "placeholders": { "name": { @@ -427,7 +439,7 @@ } } }, - "chat_replyTo": "Reply to {name}", + "chat_replyTo": "回复 {name}", "@chat_replyTo": { "placeholders": { "name": { @@ -435,8 +447,8 @@ } } }, - "chat_location": "地点", - "chat_sendMessageTo": "Send a message to {contactName}", + "chat_location": "位置", + "chat_sendMessageTo": "发送消息给 {contactName}", "@chat_sendMessageTo": { "placeholders": { "contactName": { @@ -445,7 +457,7 @@ } }, "chat_typeMessage": "输入消息...", - "chat_messageTooLong": "消息内容过长(最大 {maxBytes} 字节)。", + "chat_messageTooLong": "消息过长(最多 {maxBytes} 字节)", "@chat_messageTooLong": { "placeholders": { "maxBytes": { @@ -455,8 +467,8 @@ }, "chat_messageCopied": "消息已复制", "chat_messageDeleted": "消息已删除", - "chat_retryingMessage": "重试消息", - "chat_retryCount": "Retry {current}/{max}", + "chat_retryingMessage": "正在重试消息", + "chat_retryCount": "重试 {current}/{max}", "@chat_retryCount": { "placeholders": { "current": { @@ -467,32 +479,32 @@ } } }, - "chat_sendGif": "发送 GIF 动画", + "chat_sendGif": "发送 GIF", "chat_reply": "回复", - "chat_addReaction": "添加评论", + "chat_addReaction": "添加表情", "chat_me": "我", - "emojiCategorySmileys": "表情符号", + "emojiCategorySmileys": "表情", "emojiCategoryGestures": "手势", - "emojiCategoryHearts": "心脏", - "emojiCategoryObjects": "物体", - "gifPicker_title": "选择一个 GIF 动画", - "gifPicker_searchHint": "搜索 GIF 动画...", - "gifPicker_poweredBy": "由 GIPHY 提供支持", - "gifPicker_noGifsFound": "未找到 GIF 动画", - "gifPicker_failedLoad": "无法加载 GIF 动画", - "gifPicker_failedSearch": "未能搜索 GIF 动画", - "gifPicker_noInternet": "没有互联网连接", - "debugLog_appTitle": "应用程序调试日志", + "emojiCategoryHearts": "爱心", + "emojiCategoryObjects": "物品", + "gifPicker_title": "选择 GIF", + "gifPicker_searchHint": "搜索 GIF...", + "gifPicker_poweredBy": "由 GIPHY 提供", + "gifPicker_noGifsFound": "未找到 GIF", + "gifPicker_failedLoad": "加载 GIF 失败", + "gifPicker_failedSearch": "搜索 GIF 失败", + "gifPicker_noInternet": "无网络连接", + "debugLog_appTitle": "应用调试日志", "debugLog_bleTitle": "BLE 调试日志", "debugLog_copyLog": "复制日志", - "debugLog_clearLog": "清晰的日志", + "debugLog_clearLog": "清除日志", "debugLog_copied": "调试日志已复制", "debugLog_bleCopied": "BLE 日志已复制", - "debugLog_noEntries": "目前还没有调试日志", - "debugLog_enableInSettings": "在设置中启用应用程序调试日志功能。", - "debugLog_frames": "框架", - "debugLog_rawLogRx": "原始日志-RX", - "debugLog_noBleActivity": "目前尚未有蓝牙低功耗(BLE)活动。", + "debugLog_noEntries": "暂无调试日志", + "debugLog_enableInSettings": "请在设置中启用应用调试日志。", + "debugLog_frames": "帧", + "debugLog_rawLogRx": "原始日志 RX", + "debugLog_noBleActivity": "暂无 BLE 活动", "debugFrame_length": "帧长度:{count} 字节", "@debugFrame_length": { "placeholders": { @@ -509,7 +521,7 @@ } } }, - "debugFrame_textMessageHeader": "短信模板:", + "debugFrame_textMessageHeader": "文本消息:", "debugFrame_destinationPubKey": "- 目标公钥:{pubKey}", "@debugFrame_destinationPubKey": { "placeholders": { @@ -518,7 +530,7 @@ } } }, - "debugFrame_timestamp": "- Timestamp: {timestamp}", + "debugFrame_timestamp": "- 时间戳:{timestamp}", "@debugFrame_timestamp": { "placeholders": { "timestamp": { @@ -534,7 +546,7 @@ } } }, - "debugFrame_textType": "- Text Type: {type} ({label})", + "debugFrame_textType": "- 文本类型:{type} ({label})", "@debugFrame_textType": { "placeholders": { "type": { @@ -545,8 +557,8 @@ } } }, - "debugFrame_textTypeCli": "命令行界面", - "debugFrame_textTypePlain": "简单", + "debugFrame_textTypeCli": "命令行", + "debugFrame_textTypePlain": "纯文本", "debugFrame_text": "- 文本:“{text}”", "@debugFrame_text": { "placeholders": { @@ -558,13 +570,13 @@ "debugFrame_hexDump": "十六进制数据:", "chat_pathManagement": "路径管理", "chat_routingMode": "路由模式", - "chat_autoUseSavedPath": "自动(使用已保存的路径)", - "chat_forceFloodMode": "强制洪水模式", + "chat_autoUseSavedPath": "自动(使用保存的路径)", + "chat_forceFloodMode": "强制泛洪模式", "chat_recentAckPaths": "最近使用的 ACK 路径(点击使用):", - "chat_pathHistoryFull": "路径历史已满。删除条目以添加新的条目。", - "chat_hopSingular": "跳跃", - "chat_hopPlural": "啤酒花", - "chat_hopsCount": "{count} {count, plural, =1{hop} other{hops}}", + "chat_pathHistoryFull": "路径历史已满,请删除后再添加。", + "chat_hopSingular": "跳", + "chat_hopPlural": "跳", + "chat_hopsCount": "{count} 跳", "@chat_hopsCount": { "placeholders": { "count": { @@ -573,19 +585,19 @@ } }, "chat_successes": "成功", - "chat_removePath": "删除路径", - "chat_noPathHistoryYet": "目前还没有历史记录。\n发送消息以查找路径。", + "chat_removePath": "移除路径", + "chat_noPathHistoryYet": "暂无路径历史。\n发送消息以探索路径。", "chat_pathActions": "路径操作:", "chat_setCustomPath": "设置自定义路径", "chat_setCustomPathSubtitle": "手动指定路由路径", - "chat_clearPath": "明确的道路", - "chat_clearPathSubtitle": "在下一次发送时,重新尝试。", - "chat_pathCleared": "路径已清理。下一条消息将重新确定路线。", - "chat_floodModeSubtitle": "使用应用程序栏中的路由切换功能", - "chat_floodModeEnabled": "防洪模式已启用。通过应用程序栏中的路由图标进行切换。", + "chat_clearPath": "清除路径", + "chat_clearPathSubtitle": "清除当前路径,下次发送将重新尝试。", + "chat_pathCleared": "路径已清除。下一条消息将重新路由。", + "chat_floodModeSubtitle": "在应用栏中切换路由模式。", + "chat_floodModeEnabled": "泛洪模式已启用。可通过应用栏的路由图标切换。", "chat_fullPath": "完整路径", - "chat_pathDetailsNotAvailable": "路径信息尚未提供。请尝试发送消息以刷新。", - "chat_pathSetHops": "Path set: {hopCount} {hopCount, plural, =1{hop} other{hops}} - {status}", + "chat_pathDetailsNotAvailable": "路径信息暂不可用,请尝试发送消息刷新。", + "chat_pathSetHops": "路径设置:{hopCount} 跳 - {status}", "@chat_pathSetHops": { "placeholders": { "hopCount": { @@ -596,16 +608,16 @@ } } }, - "chat_pathSavedLocally": "已本地保存。连接以进行同步。", + "chat_pathSavedLocally": "已本地保存,连接设备后可同步。", "chat_pathDeviceConfirmed": "设备已确认。", - "chat_pathDeviceNotConfirmed": "该设备尚未得到确认。", + "chat_pathDeviceNotConfirmed": "设备尚未确认。", "chat_type": "类型", "chat_path": "路径", "chat_publicKey": "公钥", "chat_compressOutgoingMessages": "压缩发送的消息", - "chat_floodForced": "洪水(被迫)", - "chat_directForced": "直接(强制性的)", - "chat_hopsForced": "{count} 根啤酒花(人工种植)", + "chat_floodForced": "泛洪(强制)", + "chat_directForced": "直连(强制)", + "chat_hopsForced": "{count} 跳(强制)", "@chat_hopsForced": { "placeholders": { "count": { @@ -613,10 +625,10 @@ } } }, - "chat_floodAuto": "自动洪水", - "chat_direct": "直接", + "chat_floodAuto": "自动泛洪", + "chat_direct": "直连", "chat_poiShared": "共享位置", - "chat_unread": "Unread: {count}", + "chat_unread": "未读:{count}", "@chat_unread": { "placeholders": { "count": { @@ -625,9 +637,9 @@ } }, "chat_openLink": "打开链接?", - "chat_openLinkConfirmation": "您想用浏览器打开这个链接吗?", - "chat_open": "开放", - "chat_couldNotOpenLink": "[保存:{url}]\n无法打开链接:{url}", + "chat_openLinkConfirmation": "是否使用浏览器打开此链接?", + "chat_open": "打开", + "chat_couldNotOpenLink": "无法打开链接:{url}", "@chat_couldNotOpenLink": { "placeholders": { "url": { @@ -636,10 +648,10 @@ } }, "chat_invalidLink": "无效的链接格式", - "map_title": "节点图", + "map_title": "节点地图", "map_noNodesWithLocation": "没有包含位置信息的节点", - "map_nodesNeedGps": "节点需要共享其 GPS 坐标,以便在地图上显示", - "map_nodesCount": "Nodes: {count}", + "map_nodesNeedGps": "节点需要共享 GPS 坐标才能在地图上显示", + "map_nodesCount": "节点:{count}", "@map_nodesCount": { "placeholders": { "count": { @@ -647,7 +659,7 @@ } } }, - "map_pinsCount": "Pins: {count}", + "map_pinsCount": "标记:{count}", "@map_pinsCount": { "placeholders": { "count": { @@ -656,26 +668,26 @@ } }, "map_chat": "聊天", - "map_repeater": "重复器", + "map_repeater": "转发节点", "map_room": "房间", "map_sensor": "传感器", - "map_pinDm": "PIN (直接消息)", - "map_pinPrivate": "私密", - "map_pinPublic": "公开", - "map_lastSeen": "最后一次被看到", - "map_disconnectConfirm": "您确定要断开与此设备的连接吗?", - "map_from": "从", + "map_pinDm": "标记(私信)", + "map_pinPrivate": "私有", + "map_pinPublic": "公共", + "map_lastSeen": "最后在线", + "map_disconnectConfirm": "确定要断开与此设备的连接吗?", + "map_from": "来自", "map_source": "来源", - "map_flags": "旗帜", + "map_flags": "标志", "map_shareMarkerHere": "在此分享标记", "map_pinLabel": "标签", "map_label": "标签", - "map_pointOfInterest": "值得参观的地方", - "map_sendToContact": "发送给联系", + "map_pointOfInterest": "兴趣点", + "map_sendToContact": "发送给联系人", "map_sendToChannel": "发送到频道", "map_noChannelsAvailable": "没有可用的频道", - "map_publicLocationShare": "公共场所共享", - "map_publicLocationShareConfirm": "[保存:{channelLabel}]\n您即将分享一个位置,该位置位于 {channelLabel}。 此频道是公开的,任何拥有 PSK 的人都可以看到它。", + "map_publicLocationShare": "公共位置共享", + "map_publicLocationShareConfirm": "您即将在 {channelLabel} 上分享一个位置。此频道是公开的,任何拥有 PSK 的人都可以看到。", "@map_publicLocationShareConfirm": { "placeholders": { "channelLabel": { @@ -687,22 +699,22 @@ "map_filterNodes": "过滤节点", "map_nodeTypes": "节点类型", "map_chatNodes": "聊天节点", - "map_repeaters": "重复器", + "map_repeaters": "转发节点", "map_otherNodes": "其他节点", - "map_keyPrefix": "关键前缀", - "map_filterByKeyPrefix": "按关键前缀筛选", - "map_publicKeyPrefix": "公钥前缀", + "map_keyPrefix": "关键字前缀", + "map_filterByKeyPrefix": "按关键字前缀筛选", + "map_publicKeyPrefix": "关键字前缀", "map_markers": "标记", "map_showSharedMarkers": "显示共享标记", - "map_lastSeenTime": "最后一次被看到的时间", - "map_sharedPin": "共享密码", + "map_lastSeenTime": "最后在线时间", + "map_sharedPin": "共享标记", "map_joinRoom": "加入房间", - "map_manageRepeater": "管理重复器", + "map_manageRepeater": "管理转发节点", "mapCache_title": "离线地图缓存", - "mapCache_selectAreaFirst": "选择一个用于缓存的区域", - "mapCache_noTilesToDownload": "此区域没有可下载的瓦片。", - "mapCache_downloadTilesTitle": "下载瓷砖", - "mapCache_downloadTilesPrompt": "[保存:{count}]\n下载 {count} 个图片用于离线使用?", + "mapCache_selectAreaFirst": "请先选择要缓存的区域", + "mapCache_noTilesToDownload": "此区域没有可下载的瓦片", + "mapCache_downloadTilesTitle": "下载瓦片", + "mapCache_downloadTilesPrompt": "这需要下载 {count} 个瓦片", "@mapCache_downloadTilesPrompt": { "placeholders": { "count": { @@ -711,7 +723,7 @@ } }, "mapCache_downloadAction": "下载", - "mapCache_cachedTiles": "缓存 {count} 个瓦片", + "mapCache_cachedTiles": "已缓存 {count} 个瓦片", "@mapCache_cachedTiles": { "placeholders": { "count": { @@ -719,7 +731,7 @@ } } }, - "mapCache_cachedTilesWithFailed": "Cached {downloaded} tiles ({failed} failed)", + "mapCache_cachedTilesWithFailed": "已缓存 {downloaded} 个瓦片({failed} 个失败)", "@mapCache_cachedTilesWithFailed": { "placeholders": { "downloaded": { @@ -733,11 +745,11 @@ "mapCache_clearOfflineCacheTitle": "清除离线缓存", "mapCache_clearOfflineCachePrompt": "清除所有缓存的地图瓦片", "mapCache_offlineCacheCleared": "离线缓存已清除", - "mapCache_noAreaSelected": "未选择任何区域", + "mapCache_noAreaSelected": "未选择区域", "mapCache_cacheArea": "缓存区域", "mapCache_useCurrentView": "使用当前视图", - "mapCache_zoomRange": "变焦范围", - "mapCache_estimatedTiles": "Estimated tiles: {count}", + "mapCache_zoomRange": "缩放范围", + "mapCache_estimatedTiles": "估计瓦片数:{count}", "@mapCache_estimatedTiles": { "placeholders": { "count": { @@ -745,7 +757,7 @@ } } }, - "mapCache_downloadedTiles": "Downloaded {completed} / {total}", + "mapCache_downloadedTiles": "已下载 {completed}/{total}", "@mapCache_downloadedTiles": { "placeholders": { "completed": { @@ -756,9 +768,9 @@ } } }, - "mapCache_downloadTilesButton": "下载瓷砖", + "mapCache_downloadTilesButton": "下载瓦片", "mapCache_clearCacheButton": "清除缓存", - "mapCache_failedDownloads": "Failed downloads: {count}", + "mapCache_failedDownloads": "下载失败:{count}", "@mapCache_failedDownloads": { "placeholders": { "count": { @@ -766,7 +778,7 @@ } } }, - "mapCache_boundsLabel": "N {north}, S {south}, E {east}, W {west}", + "mapCache_boundsLabel": "北 {north}, 南 {south}, 东 {east}, 西 {west}", "@mapCache_boundsLabel": { "placeholders": { "north": { @@ -784,7 +796,7 @@ } }, "time_justNow": "刚才", - "time_minutesAgo": "{minutes}m ago", + "time_minutesAgo": "{minutes}分钟前", "@time_minutesAgo": { "placeholders": { "minutes": { @@ -792,7 +804,7 @@ } } }, - "time_hoursAgo": "{hours}h ago", + "time_hoursAgo": "{hours}小时前", "@time_hoursAgo": { "placeholders": { "hours": { @@ -810,31 +822,31 @@ }, "time_hour": "小时", "time_hours": "小时", - "time_day": "一天", + "time_day": "天", "time_days": "天", - "time_week": "一周", + "time_week": "周", "time_weeks": "周", - "time_month": "月份", - "time_months": "月份", + "time_month": "月", + "time_months": "月", "time_minutes": "分钟", "time_allTime": "所有时间", "dialog_disconnect": "断开", - "dialog_disconnectConfirm": "您确定要断开与此设备的连接吗?", - "login_repeaterLogin": "重复登录", - "login_roomLogin": "服务器登录", + "dialog_disconnectConfirm": "确定要断开与此设备的连接吗?", + "login_repeaterLogin": "转发节点登录", + "login_roomLogin": "房间服务器登录", "login_password": "密码", "login_enterPassword": "请输入密码", "login_savePassword": "保存密码", - "login_savePasswordSubtitle": "密码将安全地存储在 данном设备上", - "login_repeaterDescription": "输入重复器密码,即可访问设置和状态。", - "login_roomDescription": "输入密码进入房间,即可访问设置和状态。", + "login_savePasswordSubtitle": "密码将安全地存储在此设备上", + "login_repeaterDescription": "输入转发节点密码以访问设置和状态。", + "login_roomDescription": "输入房间服务器密码以访问设置和状态。", "login_routing": "路由", "login_routingMode": "路由模式", - "login_autoUseSavedPath": "自动(使用已保存的路径)", - "login_forceFloodMode": "强制洪水模式", + "login_autoUseSavedPath": "自动(使用保存的路径)", + "login_forceFloodMode": "强制泛洪模式", "login_managePaths": "管理路径", "login_login": "登录", - "login_attempt": "Attempt {current}/{max}", + "login_attempt": "尝试 {current}/{max}", "@login_attempt": { "placeholders": { "current": { @@ -845,7 +857,7 @@ } } }, - "login_failed": "Login failed: {error}", + "login_failed": "登录失败:{error}", "@login_failed": { "placeholders": { "error": { @@ -853,10 +865,10 @@ } } }, - "login_failedMessage": "登录失败。可能是密码错误,也可能是无法连接到服务器。", + "login_failedMessage": "登录失败。可能是密码错误或无法连接到服务器。", "common_reload": "重新加载", - "common_clear": "清晰", - "path_currentPath": "Current path: {path}", + "common_clear": "清除", + "path_currentPath": "当前路径:{path}", "@path_currentPath": { "placeholders": { "path": { @@ -864,7 +876,7 @@ } } }, - "path_usingHopsPath": "使用 {count} {count, plural, =1{hop} other{hops}} 条路径", + "path_usingHopsPath": "使用 {count} 跳路径", "@path_usingHopsPath": { "placeholders": { "count": { @@ -874,14 +886,14 @@ }, "path_enterCustomPath": "输入自定义路径", "path_currentPathLabel": "当前路径", - "path_hexPrefixInstructions": "请输入每个跳跃步骤的 2 个字符的十六进制前缀,用逗号分隔。", - "path_hexPrefixExample": "例如:A1, F2, 3C (每个节点使用其公钥的第一字节)", + "path_hexPrefixInstructions": "请输入每个中继节点的2字符十六进制前缀,用逗号分隔。", + "path_hexPrefixExample": "例如:A1, F2, 3C(每个节点使用其公钥的第一字节)", "path_labelHexPrefixes": "路径(十六进制前缀)", - "path_helperMaxHops": "最大 64 个“hop”(跳跃)。每个前缀由 2 个十六进制字符(1 字节)组成。", - "path_selectFromContacts": "或者从联系人列表中选择:", - "path_noRepeatersFound": "未找到任何重复设备或房间服务器。", - "path_customPathsRequire": "自定义路径需要中间节点,这些节点可以转发消息。", - "path_invalidHexPrefixes": "Invalid hex prefixes: {prefixes}", + "path_helperMaxHops": "最多 64 跳。每个前缀由 2 个十六进制字符(1 字节)组成。", + "path_selectFromContacts": "或从联系人列表中选择:", + "path_noRepeatersFound": "未找到任何转发节点或房间服务器。", + "path_customPathsRequire": "自定义路径需要中间节点转发消息。", + "path_invalidHexPrefixes": "无效的十六进制前缀:{prefixes}", "@path_invalidHexPrefixes": { "placeholders": { "prefixes": { @@ -889,29 +901,29 @@ } } }, - "path_tooLong": "路径太长。允许的最大跳跃次数为 64 次。", + "path_tooLong": "路径过长,最多允许 64 跳。", "path_setPath": "设置路径", - "repeater_management": "重复器管理", - "room_management": "服务器管理", + "repeater_management": "转发节点管理", + "room_management": "房间服务器管理", "repeater_managementTools": "管理工具", "repeater_status": "状态", - "repeater_statusSubtitle": "查看重复器状态、统计信息和邻居", - "repeater_telemetry": "远程监控", - "repeater_telemetrySubtitle": "查看传感器和系统状态的数据。", - "repeater_cli": "命令行界面", - "repeater_cliSubtitle": "向复用器发送指令", - "repeater_neighbours": "邻居", - "repeater_neighboursSubtitle": "查看邻居节点(无需中间节点)。", + "repeater_statusSubtitle": "查看转发节点状态、统计和邻居", + "repeater_telemetry": "遥测", + "repeater_telemetrySubtitle": "查看传感器和系统状态数据", + "repeater_cli": "命令行", + "repeater_cliSubtitle": "向转发节点发送命令", + "repeater_neighbors": "邻居", + "repeater_neighborsSubtitle": "查看邻居节点(零跳)", "repeater_settings": "设置", - "repeater_settingsSubtitle": "配置重复器参数", - "repeater_statusTitle": "重复器状态", + "repeater_settingsSubtitle": "配置转发节点参数", + "repeater_statusTitle": "转发节点状态", "repeater_routingMode": "路由模式", - "repeater_autoUseSavedPath": "自动(使用已保存的路径)", - "repeater_forceFloodMode": "强制洪水模式", + "repeater_autoUseSavedPath": "自动(使用保存的路径)", + "repeater_forceFloodMode": "强制泛洪模式", "repeater_pathManagement": "路径管理", - "repeater_refresh": "更新", - "repeater_statusRequestTimeout": "状态请求超时。", - "repeater_errorLoadingStatus": "Error loading status: {error}", + "repeater_refresh": "刷新", + "repeater_statusRequestTimeout": "状态请求超时", + "repeater_errorLoadingStatus": "加载状态时出错:{error}", "@repeater_errorLoadingStatus": { "placeholders": { "error": { @@ -921,19 +933,19 @@ }, "repeater_systemInformation": "系统信息", "repeater_battery": "电池", - "repeater_clockAtLogin": "登录时的时间", - "repeater_uptime": "正常运行时间", - "repeater_queueLength": "排队长度", + "repeater_clockAtLogin": "登录时的时钟", + "repeater_uptime": "运行时间", + "repeater_queueLength": "队列长度", "repeater_debugFlags": "调试标志", - "repeater_radioStatistics": "广播统计", - "repeater_lastRssi": "上次的 RSSI 值", - "repeater_lastSnr": "最后一次信噪比", - "repeater_noiseFloor": "噪声水平", - "repeater_txAirtime": "TX 频道预留时间", - "repeater_rxAirtime": "RX 空时", + "repeater_radioStatistics": "无线电统计", + "repeater_lastRssi": "上次 RSSI", + "repeater_lastSnr": "上次 SNR", + "repeater_noiseFloor": "底噪", + "repeater_txAirtime": "发送空中时间", + "repeater_rxAirtime": "接收空中时间", "repeater_packetStatistics": "数据包统计", "repeater_sent": "发送", - "repeater_received": "已收到", + "repeater_received": "接收", "repeater_duplicates": "重复", "repeater_daysHoursMinsSecs": "{days}天 {hours}小时 {minutes}分 {seconds}秒", "@repeater_daysHoursMinsSecs": { @@ -952,7 +964,7 @@ } } }, - "repeater_packetTxTotal": "Total: {total}, Flood: {flood}, Direct: {direct}", + "repeater_packetTxTotal": "总计:{total},泛洪:{flood},直连:{direct}", "@repeater_packetTxTotal": { "placeholders": { "total": { @@ -966,7 +978,7 @@ } } }, - "repeater_packetRxTotal": "Total: {total}, Flood: {flood}, Direct: {direct}", + "repeater_packetRxTotal": "总计:{total},泛洪:{flood},直连:{direct}", "@repeater_packetRxTotal": { "placeholders": { "total": { @@ -980,7 +992,7 @@ } } }, - "repeater_duplicatesFloodDirect": "Flood: {flood}, Direct: {direct}", + "repeater_duplicatesFloodDirect": "泛洪:{flood},直连:{direct}", "@repeater_duplicatesFloodDirect": { "placeholders": { "flood": { @@ -991,7 +1003,7 @@ } } }, - "repeater_duplicatesTotal": "Total: {total}", + "repeater_duplicatesTotal": "总计:{total}", "@repeater_duplicatesTotal": { "placeholders": { "total": { @@ -999,37 +1011,37 @@ } } }, - "repeater_settingsTitle": "重复器设置", + "repeater_settingsTitle": "转发节点设置", "repeater_basicSettings": "基本设置", - "repeater_repeaterName": "重复器名称", - "repeater_repeaterNameHelper": "此复播器的显示名称", + "repeater_repeaterName": "转发节点名称", + "repeater_repeaterNameHelper": "此转发节点的显示名称", "repeater_adminPassword": "管理员密码", "repeater_adminPasswordHelper": "完整访问密码", "repeater_guestPassword": "访客密码", "repeater_guestPasswordHelper": "只读访问密码", - "repeater_radioSettings": "收音机设置", + "repeater_radioSettings": "无线电设置", "repeater_frequencyMhz": "频率 (MHz)", - "repeater_frequencyHelper": "300-2500 兆赫", + "repeater_frequencyHelper": "300-2500 MHz", "repeater_txPower": "TX 功率", "repeater_txPowerHelper": "1-30 dBm", "repeater_bandwidth": "带宽", - "repeater_spreadingFactor": "传播系数", + "repeater_spreadingFactor": "扩频因子", "repeater_codingRate": "编码速率", "repeater_locationSettings": "位置设置", "repeater_latitude": "纬度", - "repeater_latitudeHelper": "十进制度(例如:37.7749)", + "repeater_latitudeHelper": "十进制,例如 37.7749", "repeater_longitude": "经度", - "repeater_longitudeHelper": "十进制度(例如:-122.4194)", - "repeater_features": "特点", + "repeater_longitudeHelper": "十进制,例如 -122.4194", + "repeater_features": "功能", "repeater_packetForwarding": "数据包转发", - "repeater_packetForwardingSubtitle": "启用重复器,使其能够转发数据包", + "repeater_packetForwardingSubtitle": "启用转发节点转发数据包", "repeater_guestAccess": "访客访问", - "repeater_guestAccessSubtitle": "允许访客仅限读取权限", + "repeater_guestAccessSubtitle": "允许访客只读权限", "repeater_privacyMode": "隐私模式", - "repeater_privacyModeSubtitle": "在广告中隐藏姓名/位置", - "repeater_advertisementSettings": "广告设置", - "repeater_localAdvertInterval": "本地广告投放时间段", - "repeater_localAdvertIntervalMinutes": "{minutes} minutes", + "repeater_privacyModeSubtitle": "在广播中隐藏姓名/位置", + "repeater_advertisementSettings": "广播设置", + "repeater_localAdvertInterval": "本地广播间隔", + "repeater_localAdvertIntervalMinutes": "{minutes} 分钟", "@repeater_localAdvertIntervalMinutes": { "placeholders": { "minutes": { @@ -1037,8 +1049,8 @@ } } }, - "repeater_floodAdvertInterval": "洪水广告播放间隔", - "repeater_floodAdvertIntervalHours": "{hours} hours", + "repeater_floodAdvertInterval": "泛洪广播间隔", + "repeater_floodAdvertIntervalHours": "{hours} 小时", "@repeater_floodAdvertIntervalHours": { "placeholders": { "hours": { @@ -1046,19 +1058,19 @@ } } }, - "repeater_encryptedAdvertInterval": "加密的广告投放时间段", - "repeater_dangerZone": "危险区域", - "repeater_rebootRepeater": "重启重复器", - "repeater_rebootRepeaterSubtitle": "重新启动重复器设备", - "repeater_rebootRepeaterConfirm": "您确定要重新启动这个中继器吗?", + "repeater_encryptedAdvertInterval": "加密广播间隔", + "repeater_dangerZone": "危险设置", + "repeater_rebootRepeater": "重启转发节点", + "repeater_rebootRepeaterSubtitle": "重启转发节点设备", + "repeater_rebootRepeaterConfirm": "确定要重启此转发节点吗?", "repeater_regenerateIdentityKey": "重新生成身份密钥", "repeater_regenerateIdentityKeySubtitle": "生成新的公钥/私钥对", - "repeater_regenerateIdentityKeyConfirm": "这将为复用器生成一个新的身份。继续吗?", - "repeater_eraseFileSystem": "删除文件系统", - "repeater_eraseFileSystemSubtitle": "格式化重复文件系统", - "repeater_eraseFileSystemConfirm": "警告:此操作将清除复用器上的所有数据。 无法恢复!", - "repeater_eraseSerialOnly": "“Erase”功能仅可通过串行控制台使用。", - "repeater_commandSent": "Command sent: {command}", + "repeater_regenerateIdentityKeyConfirm": "这将为转发节点生成新身份,继续吗?", + "repeater_eraseFileSystem": "擦除文件系统", + "repeater_eraseFileSystemSubtitle": "格式化转发节点文件系统", + "repeater_eraseFileSystemConfirm": "警告:此操作将清除转发节点上的所有数据,且无法恢复!", + "repeater_eraseSerialOnly": "擦除功能仅可通过串行控制台使用。", + "repeater_commandSent": "命令已发送:{command}", "@repeater_commandSent": { "placeholders": { "command": { @@ -1066,7 +1078,7 @@ } } }, - "repeater_errorSendingCommand": "Error sending command: {error}", + "repeater_errorSendingCommand": "发送命令时出错:{error}", "@repeater_errorSendingCommand": { "placeholders": { "error": { @@ -1075,8 +1087,8 @@ } }, "repeater_confirm": "确认", - "repeater_settingsSaved": "设置已成功保存", - "repeater_errorSavingSettings": "Error saving settings: {error}", + "repeater_settingsSaved": "设置保存成功", + "repeater_errorSavingSettings": "保存设置时出错:{error}", "@repeater_errorSavingSettings": { "placeholders": { "error": { @@ -1084,15 +1096,15 @@ } } }, - "repeater_refreshBasicSettings": "重置基本设置", - "repeater_refreshRadioSettings": "重置收音机设置", - "repeater_refreshTxPower": "重置 TX 电源", - "repeater_refreshLocationSettings": "重置位置设置", + "repeater_refreshBasicSettings": "刷新基本设置", + "repeater_refreshRadioSettings": "刷新无线电设置", + "repeater_refreshTxPower": "刷新 TX 功率", + "repeater_refreshLocationSettings": "刷新位置设置", "repeater_refreshPacketForwarding": "刷新包转发", - "repeater_refreshGuestAccess": "重新获取访客访问权限", - "repeater_refreshPrivacyMode": "重置隐私模式", - "repeater_refreshAdvertisementSettings": "重置广告设置", - "repeater_refreshed": "{label} refreshed", + "repeater_refreshGuestAccess": "刷新访客权限", + "repeater_refreshPrivacyMode": "刷新隐私模式", + "repeater_refreshAdvertisementSettings": "刷新广播设置", + "repeater_refreshed": "{label} 已刷新", "@repeater_refreshed": { "placeholders": { "label": { @@ -1100,7 +1112,7 @@ } } }, - "repeater_errorRefreshing": "[保存:{label}]\n刷新 {label} 时出错", + "repeater_errorRefreshing": "刷新 {label} 时出错", "@repeater_errorRefreshing": { "placeholders": { "label": { @@ -1108,18 +1120,18 @@ } } }, - "repeater_cliTitle": "重复器命令行界面", + "repeater_cliTitle": "转发节点命令行", "repeater_debugNextCommand": "调试下一条命令", "repeater_commandHelp": "帮助", - "repeater_clearHistory": "清晰的历史", - "repeater_noCommandsSent": "尚未发送任何指令", - "repeater_typeCommandOrUseQuick": "在下方输入命令,或使用快捷命令。", + "repeater_clearHistory": "清除历史", + "repeater_noCommandsSent": "尚未发送命令", + "repeater_typeCommandOrUseQuick": "输入命令或使用快捷命令", "repeater_enterCommandHint": "输入命令...", - "repeater_previousCommand": "之前的命令", - "repeater_nextCommand": "下一个指令", - "repeater_enterCommandFirst": "首先输入一个命令", - "repeater_cliCommandFrameTitle": "CLI 命令框架", - "repeater_cliCommandError": "Error: {error}", + "repeater_previousCommand": "上一条命令", + "repeater_nextCommand": "下一条命令", + "repeater_enterCommandFirst": "请先输入命令", + "repeater_cliCommandFrameTitle": "CLI 命令帧", + "repeater_cliCommandError": "错误:{error}", "@repeater_cliCommandError": { "placeholders": { "error": { @@ -1127,81 +1139,81 @@ } } }, - "repeater_cliQuickGetName": "获取姓名", - "repeater_cliQuickGetRadio": "收听广播", + "repeater_cliQuickGetName": "获取名称", + "repeater_cliQuickGetRadio": "获取无线电设置", "repeater_cliQuickGetTx": "获取 TX", "repeater_cliQuickNeighbors": "邻居", "repeater_cliQuickVersion": "版本", - "repeater_cliQuickAdvertise": "发布广告", + "repeater_cliQuickAdvertise": "发送广播", "repeater_cliQuickClock": "时钟", - "repeater_cliHelpAdvert": "发送广告资料包", - "repeater_cliHelpReboot": "重置设备。 (请注意,您可能会收到“超时”错误,这是正常的现象)", - "repeater_cliHelpClock": "显示每个设备的当前时间。", - "repeater_cliHelpPassword": "为设备设置新的管理员密码。", - "repeater_cliHelpVersion": "显示设备版本和固件构建日期。", - "repeater_cliHelpClearStats": "重置各种统计指标,将其设置为零。", - "repeater_cliHelpSetAf": "设置时间因素。", - "repeater_cliHelpSetTx": "设置 LoRa 传输功率,单位为 dBm (相对于参考值)。 (重启以应用更改)", - "repeater_cliHelpSetRepeat": "启用或禁用此节点的重复器功能。", - "repeater_cliHelpSetAllowReadOnly": "(房间服务器)如果设置为“开启”,则允许使用空密码登录,但无法向房间发送消息(只能进行读取)。", - "repeater_cliHelpSetFloodMax": "设置最大传入数据包的跳数(如果大于或等于最大值,则不进行转发)。", - "repeater_cliHelpSetIntThresh": "设置干扰阈值(以dB为单位)。默认值为14。将设置为0以禁用频道干扰检测。", - "repeater_cliHelpSetAgcResetInterval": "设置间隔时间,用于重置自动增益控制器。设置为 0 以禁用。", - "repeater_cliHelpSetMultiAcks": "启用或禁用“双重确认”功能。", - "repeater_cliHelpSetAdvertInterval": "设置定时器间隔,单位为分钟,用于发送本地(无中继)的广告数据包。 将设置为 0 以禁用。", - "repeater_cliHelpSetFloodAdvertInterval": "设置定时器间隔时间为小时,以便发送广告信息包。将设置为 0 以禁用。", - "repeater_cliHelpSetGuestPassword": "设置/更新访客密码。 (对于访客,登录请求可以发送“获取统计”请求)", - "repeater_cliHelpSetName": "设置广告名称。", - "repeater_cliHelpSetLat": "设置广告地图的纬度。(以十进制表示)", - "repeater_cliHelpSetLon": "设置广告地图的经度。 (十进制度)", - "repeater_cliHelpSetRadio": "完全重新设置无线电参数,并保存到偏好设置。需要执行“重启”命令才能生效。", - "repeater_cliHelpSetRxDelay": "设置(实验性):设置一个基础值(必须大于1才能生效),用于对接收到的数据包进行轻微延迟处理,该延迟值基于信号强度/评分。将该值设置为0以禁用。", - "repeater_cliHelpSetTxDelay": "通过将一个因子与“浮动模式”数据包的时间在空中停留时间相乘,并结合随机的“时隙”系统,来延迟其转发,从而降低数据包冲突的概率。", - "repeater_cliHelpSetDirectTxDelay": "与txdelay相同,但用于对直接模式数据包的转发进行随机延迟。", - "repeater_cliHelpSetBridgeEnabled": "启用/禁用桥接。", - "repeater_cliHelpSetBridgeDelay": "在重新发送数据包之前,设置延迟时间。", - "repeater_cliHelpSetBridgeSource": "选择桥接器是否会转发收到的数据包,还是转发发送的数据包。", - "repeater_cliHelpSetBridgeBaud": "为 RS232 桥接设置串行链路的波特率。", - "repeater_cliHelpSetBridgeSecret": "设置 ESPNOW 桥的秘密。", - "repeater_cliHelpSetAdcMultiplier": "设置自定义因子,用于调整报告的电池电压(仅在特定板上支持)。", - "repeater_cliHelpTempRadio": "设置临时收音机参数,持续指定分钟数,之后恢复到原始收音机参数。(不保存到偏好设置)。", - "repeater_cliHelpSetPerm": "修改 ACL。如果 \"permissions\" 的值为 0,则删除与 pubkey 相关的条目。如果 pubkey-hex 完整且当前不在 ACL 中,则添加新的条目。通过匹配 pubkey 相关的前缀来更新条目。不同固件角色的权限位有所不同,但低 2 位分别对应:0 (访客)、1 (只读)、2 (读写)、3 (管理员)。", - "repeater_cliHelpGetBridgeType": "支持桥接模式、RS232、ESPNOW。", - "repeater_cliHelpLogStart": "开始将数据包记录到文件系统。", - "repeater_cliHelpLogStop": "停止将数据包记录写入文件系统。", - "repeater_cliHelpLogErase": "从文件系统中删除所有已记录的包信息。", - "repeater_cliHelpNeighbors": "显示了通过零跳广告收到的其他复用节点列表。 每行包含:id-前缀-十六进制:时间戳:信噪比(4次)", - "repeater_cliHelpNeighborRemove": "从邻居列表中删除第一个匹配项(通过十六进制的 pubkey 前缀)。", - "repeater_cliHelpRegion": "(仅限序列)列出所有已定义的区域以及当前的防洪许可。", - "repeater_cliHelpRegionLoad": "请注意:这是一个特殊的、包含多个命令的调用方式。 之后的每个命令都是一个区域名称(使用空格进行缩进,以表示父级关系,至少需要一个空格)。 结束方式是通过发送一个空行/命令。", - "repeater_cliHelpRegionGet": "搜索具有指定名称前缀的区域(或使用“*”表示全局范围)。 返回结果为“-> region-name (parent-name) 'F'”", - "repeater_cliHelpRegionPut": "添加或更新一个区域定义,并指定其名称。", - "repeater_cliHelpRegionRemove": "删除具有指定名称的区域定义。 (必须与指定名称完全匹配,且不能有子区域)", - "repeater_cliHelpRegionAllowf": "为指定区域设置“洪水”权限。(“*”表示全局/旧版本范围)", - "repeater_cliHelpRegionDenyf": "移除指定区域的“洪水”权限。(请注意:目前不建议在全局/旧版本中使用此功能!!)", - "repeater_cliHelpRegionHome": "回复当前“主区域”。(此功能尚未应用,仅供未来使用)", - "repeater_cliHelpRegionHomeSet": "设置“主”区域。", - "repeater_cliHelpRegionSave": "将区域列表/地图保存到存储中。", - "repeater_cliHelpGps": "显示 GPS 状态。当 GPS 处于关闭状态时,它只会显示“关闭”;当 GPS 处于开启状态时,它会显示“开启”、“状态”、“定位”、“卫星数量”等信息。", - "repeater_cliHelpGpsOnOff": "切换 GPS 设备的电源状态。", - "repeater_cliHelpGpsSync": "将节点时间与 GPS 钟同步。", - "repeater_cliHelpGpsSetLoc": "将节点的坐标设置为 GPS 坐标,并保存设置。", - "repeater_cliHelpGpsAdvert": "设置节点的位置广告配置:\n- none:不将位置信息包含在广告中\n- share:共享 GPS 位置(从 SensorManager 获取)\n- prefs:在偏好设置中展示的位置", - "repeater_cliHelpGpsAdvertSet": "设置广告的位置配置。", + "repeater_cliHelpAdvert": "发送广播包", + "repeater_cliHelpReboot": "重启设备。(注意:可能会收到超时错误,属于正常现象)", + "repeater_cliHelpClock": "显示设备当前时间", + "repeater_cliHelpPassword": "设置新的管理员密码", + "repeater_cliHelpVersion": "显示设备版本和固件构建日期", + "repeater_cliHelpClearStats": "重置各种统计数据", + "repeater_cliHelpSetAf": "设置时间因子", + "repeater_cliHelpSetTx": "设置 LoRa 发射功率 (dBm)(重启生效)", + "repeater_cliHelpSetRepeat": "启用或禁用此节点的转发功能", + "repeater_cliHelpSetAllowReadOnly": "(房间服务器)设为“开”则允许空密码登录,但只能读(不能发送)", + "repeater_cliHelpSetFloodMax": "设置最大传入数据包跳数(≥该值则不转发)", + "repeater_cliHelpSetIntThresh": "设置干扰阈值 (dB),默认14,设为0禁用", + "repeater_cliHelpSetAgcResetInterval": "设置 AGC 重置间隔(秒),设为0禁用", + "repeater_cliHelpSetMultiAcks": "启用或禁用“多重确认”功能", + "repeater_cliHelpSetAdvertInterval": "设置本地广播间隔(分钟),设为0禁用", + "repeater_cliHelpSetFloodAdvertInterval": "设置泛洪广播间隔(小时),设为0禁用", + "repeater_cliHelpSetGuestPassword": "设置/更新访客密码", + "repeater_cliHelpSetName": "设置广播名称", + "repeater_cliHelpSetLat": "设置广播纬度(十进制)", + "repeater_cliHelpSetLon": "设置广播经度(十进制)", + "repeater_cliHelpSetRadio": "完全重设无线电参数并保存,需重启生效", + "repeater_cliHelpSetRxDelay": "(实验性)设置接收延迟基数,设为0禁用", + "repeater_cliHelpSetTxDelay": "通过因子和随机时隙延迟泛洪数据包转发,降低冲突", + "repeater_cliHelpSetDirectTxDelay": "同 txdelay,用于直连模式数据包", + "repeater_cliHelpSetBridgeEnabled": "启用/禁用桥接", + "repeater_cliHelpSetBridgeDelay": "设置桥接转发延迟", + "repeater_cliHelpSetBridgeSource": "选择桥接器转发接收或发送的数据包", + "repeater_cliHelpSetBridgeBaud": "设置 RS232 桥接串口波特率", + "repeater_cliHelpSetBridgeSecret": "设置 ESPNOW 桥接密钥", + "repeater_cliHelpSetAdcMultiplier": "设置电池电压校正系数(特定板支持)", + "repeater_cliHelpTempRadio": "临时设置无线电参数指定分钟,之后恢复(不保存)", + "repeater_cliHelpSetPerm": "修改 ACL,权限位:0访客、1只读、2读写、3管理员", + "repeater_cliHelpGetBridgeType": "支持桥接模式:RS232、ESPNOW", + "repeater_cliHelpLogStart": "开始记录数据包到文件系统", + "repeater_cliHelpLogStop": "停止记录数据包", + "repeater_cliHelpLogErase": "删除所有记录的数据包", + "repeater_cliHelpNeighbors": "显示零跳广播收到的其他转发节点列表", + "repeater_cliHelpNeighborRemove": "从邻居列表删除第一个匹配项(通过公钥前缀)", + "repeater_cliHelpRegion": "(仅串口)列出所有定义区域及当前泛洪权限", + "repeater_cliHelpRegionLoad": "特殊多命令调用,以空行结束", + "repeater_cliHelpRegionGet": "搜索指定前缀的区域", + "repeater_cliHelpRegionPut": "添加或更新区域定义", + "repeater_cliHelpRegionRemove": "删除指定区域定义", + "repeater_cliHelpRegionAllowf": "为区域设置“泛洪”权限", + "repeater_cliHelpRegionDenyf": "移除区域的“泛洪”权限", + "repeater_cliHelpRegionHome": "返回当前“主区域”(预留)", + "repeater_cliHelpRegionHomeSet": "设置“主”区域", + "repeater_cliHelpRegionSave": "保存区域列表到存储", + "repeater_cliHelpGps": "显示 GPS 状态", + "repeater_cliHelpGpsOnOff": "切换 GPS 电源", + "repeater_cliHelpGpsSync": "将节点时间与 GPS 同步", + "repeater_cliHelpGpsSetLoc": "将节点坐标设为 GPS 坐标并保存", + "repeater_cliHelpGpsAdvert": "设置位置广播配置:none/share/prefs", + "repeater_cliHelpGpsAdvertSet": "设置广播位置配置", "repeater_commandsListTitle": "命令列表", - "repeater_commandsListNote": "请注意:对于各种“set ...”命令,也存在“get ...”命令。", + "repeater_commandsListNote": "注意:多数 set 命令也有对应的 get 命令", "repeater_general": "通用", "repeater_settingsCategory": "设置", - "repeater_bridge": "桥", - "repeater_logging": "记录", - "repeater_neighborsRepeaterOnly": "邻居(仅限重复功能)", - "repeater_regionManagementRepeaterOnly": "区域管理(仅限重复站点)", - "repeater_regionNote": "区域命令已引入,用于管理区域定义和权限。", + "repeater_bridge": "桥接", + "repeater_logging": "日志", + "repeater_neighborsRepeaterOnly": "邻居(仅转发节点)", + "repeater_regionManagementRepeaterOnly": "区域管理(仅转发节点)", + "repeater_regionNote": "区域命令用于管理区域定义和权限", "repeater_gpsManagement": "GPS 管理", - "repeater_gpsNote": "已引入 GPS 命令,用于管理与位置相关的任务。", + "repeater_gpsNote": "GPS 命令用于位置相关任务", "telemetry_receivedData": "接收到的遥测数据", - "telemetry_requestTimeout": "遥测请求超时。", - "telemetry_errorLoading": "Error loading telemetry: {error}", + "telemetry_requestTimeout": "遥测请求超时", + "telemetry_errorLoading": "加载遥测数据时出错:{error}", "@telemetry_errorLoading": { "placeholders": { "error": { @@ -1209,7 +1221,7 @@ } } }, - "telemetry_noData": "没有可用的 telemetry 数据。", + "telemetry_noData": "暂无遥测数据", "telemetry_channelTitle": "频道 {channel}", "@telemetry_channelTitle": { "placeholders": { @@ -1220,9 +1232,9 @@ }, "telemetry_batteryLabel": "电池", "telemetry_voltageLabel": "电压", - "telemetry_mcuTemperatureLabel": "MCU 的温度", + "telemetry_mcuTemperatureLabel": "MCU 温度", "telemetry_temperatureLabel": "温度", - "telemetry_currentLabel": "当前", + "telemetry_currentLabel": "电流", "telemetry_batteryValue": "{percent}% / {volts}V", "@telemetry_batteryValue": { "placeholders": { @@ -1261,9 +1273,9 @@ } } }, - "neighbors_receivedData": "已收到邻居信息", - "neighbors_requestTimedOut": "邻居要求停止干扰。", - "neighbors_errorLoading": "Error loading neighbors: {error}", + "neighbors_receivedData": "已接收邻居信息", + "neighbors_requestTimedOut": "邻居请求超时", + "neighbors_errorLoading": "加载邻居时出错:{error}", "@neighbors_errorLoading": { "placeholders": { "error": { @@ -1271,9 +1283,9 @@ } } }, - "neighbors_repeatersNeighbours": "重复使用的邻居", - "neighbors_noData": "没有可用的邻居信息。", - "neighbors_unknownContact": "Unknown {pubkey}", + "neighbors_repeatersNeighbors": "转发节点的邻居", + "neighbors_noData": "暂无邻居信息", + "neighbors_unknownContact": "未知 {pubkey}", "@neighbors_unknownContact": { "placeholders": { "pubkey": { @@ -1281,7 +1293,7 @@ } } }, - "neighbors_heardAgo": "Heard: {time} ago", + "neighbors_heardAgo": "听到:{time}前", "@neighbors_heardAgo": { "placeholders": { "time": { @@ -1292,15 +1304,15 @@ "channelPath_title": "数据包路径", "channelPath_viewMap": "查看地图", "channelPath_otherObservedPaths": "其他观察到的路径", - "channelPath_repeaterHops": "复用跳跃", - "channelPath_noHopDetails": "对于此包,未提供详细信息。", + "channelPath_repeaterHops": "转发节点跳数", + "channelPath_noHopDetails": "此数据包未提供详细信息", "channelPath_messageDetails": "消息详情", - "channelPath_senderLabel": "发件人", + "channelPath_senderLabel": "发送者", "channelPath_timeLabel": "时间", "channelPath_repeatsLabel": "重复", "channelPath_pathLabel": "路径 {index}", "channelPath_observedLabel": "观察到的", - "channelPath_observedPathTitle": "Observed path {index} • {hops}", + "channelPath_observedPathTitle": "观察到的路径 {index} • {hops}", "@channelPath_observedPathTitle": { "placeholders": { "index": { @@ -1311,7 +1323,7 @@ } } }, - "channelPath_noLocationData": "没有位置信息", + "channelPath_noLocationData": "无位置信息", "channelPath_timeWithDate": "{day}/{month} {time}", "@channelPath_timeWithDate": { "placeholders": { @@ -1335,9 +1347,9 @@ } }, "channelPath_unknownPath": "未知", - "channelPath_floodPath": "洪水", - "channelPath_directPath": "直接", - "channelPath_observedZeroOf": "0 of {total} hops", + "channelPath_floodPath": "泛洪", + "channelPath_directPath": "直连", + "channelPath_observedZeroOf": "0 / {total} 跳", "@channelPath_observedZeroOf": { "placeholders": { "total": { @@ -1345,7 +1357,7 @@ } } }, - "channelPath_observedSomeOf": "{observed} of {total} hops", + "channelPath_observedSomeOf": "{observed} / {total} 跳", "@channelPath_observedSomeOf": { "placeholders": { "observed": { @@ -1356,9 +1368,9 @@ } } }, - "channelPath_mapTitle": "路线图", - "channelPath_noRepeaterLocations": "这条路径上没有可用的中继器位置。", - "channelPath_primaryPath": "路径 {index} (主要路径)", + "channelPath_mapTitle": "路径地图", + "channelPath_noRepeaterLocations": "此路径上没有可用的转发节点位置信息", + "channelPath_primaryPath": "路径 {index}(主要)", "@channelPath_primaryPath": { "placeholders": { "index": { @@ -1374,7 +1386,7 @@ } }, "channelPath_pathLabelTitle": "路径", - "channelPath_observedPathHeader": "观察路径", + "channelPath_observedPathHeader": "观察到的路径", "channelPath_selectedPathLabel": "{label} • {prefixes}", "@channelPath_selectedPathLabel": { "placeholders": { @@ -1386,14 +1398,14 @@ } } }, - "channelPath_noHopDetailsAvailable": "对于此包裹,尚无详细信息。", - "channelPath_unknownRepeater": "未知的重复设备", + "channelPath_noHopDetailsAvailable": "此数据包暂无详细信息", + "channelPath_unknownRepeater": "未知转发节点", "community_title": "社区", - "community_create": "建立社区", - "community_createDesc": "创建一个新的社群,并通过二维码进行分享。", + "community_create": "创建社区", + "community_createDesc": "创建新社区并通过二维码分享。", "community_join": "加入", "community_joinTitle": "加入社区", - "community_joinConfirmation": "Do you want to join the community \"{name}\"?", + "community_joinConfirmation": "是否加入社区 \"{name}\"?", "@community_joinConfirmation": { "placeholders": { "name": { @@ -1402,13 +1414,13 @@ } }, "community_scanQr": "扫描社区二维码", - "community_scanInstructions": "将相机对准社区的二维码。", + "community_scanInstructions": "将摄像头对准社区的二维码", "community_showQr": "显示二维码", - "community_publicChannel": "社区公共", - "community_hashtagChannel": "社区标签", + "community_publicChannel": "社区公共频道", + "community_hashtagChannel": "社区标签频道", "community_name": "社区名称", "community_enterName": "请输入社区名称", - "community_created": "Community \"{name}\" created", + "community_created": "社区 \"{name}\" 已创建", "@community_created": { "placeholders": { "name": { @@ -1416,7 +1428,7 @@ } } }, - "community_joined": "Joined community \"{name}\"", + "community_joined": "已加入社区 \"{name}\"", "@community_joined": { "placeholders": { "name": { @@ -1425,7 +1437,7 @@ } }, "community_qrTitle": "分享社区", - "community_qrInstructions": "Scan this QR code to join \"{name}\"", + "community_qrInstructions": "扫描此二维码加入 \"{name}\"", "@community_qrInstructions": { "placeholders": { "name": { @@ -1433,10 +1445,10 @@ } } }, - "community_hashtagPrivacyHint": "仅社区成员才能加入社区话题标签的频道。", + "community_hashtagPrivacyHint": "仅社区成员可加入社区标签频道。", "community_invalidQrCode": "无效的社区二维码", - "community_alreadyMember": "已经是会员", - "community_alreadyMemberMessage": "You are already a member of \"{name}\".", + "community_alreadyMember": "已是成员", + "community_alreadyMemberMessage": "您已是 \"{name}\" 的成员。", "@community_alreadyMemberMessage": { "placeholders": { "name": { @@ -1445,12 +1457,12 @@ } }, "community_addPublicChannel": "添加公共频道", - "community_addPublicChannelHint": "自动添加该社区的公共频道", - "community_noCommunities": "目前还没有任何社区加入。", - "community_scanOrCreate": "扫描二维码或创建社群,即可开始。", + "community_addPublicChannelHint": "自动添加此社区的公共频道", + "community_noCommunities": "尚未加入任何社区。", + "community_scanOrCreate": "扫描二维码或创建社区以开始。", "community_manageCommunities": "管理社区", "community_delete": "退出社区", - "community_deleteConfirm": "是否要删除\"{name}\"?", + "community_deleteConfirm": "是否退出 \"{name}\"?", "@community_deleteConfirm": { "placeholders": { "name": { @@ -1466,7 +1478,7 @@ } } }, - "community_deleted": "Left community \"{name}\"", + "community_deleted": "已退出社区 \"{name}\"", "@community_deleted": { "placeholders": { "name": { @@ -1474,8 +1486,8 @@ } } }, - "community_regenerateSecret": "恢复秘密", - "community_regenerateSecretConfirm": "[保存:{name}]\n是否需要重新生成\"{name}\"的密钥?所有成员都需要扫描新的二维码才能继续进行通信。", + "community_regenerateSecret": "重新生成密钥", + "community_regenerateSecretConfirm": "是否为 \"{name}\" 重新生成密钥?所有成员需扫描新的二维码才能继续通信。", "@community_regenerateSecretConfirm": { "placeholders": { "name": { @@ -1483,8 +1495,8 @@ } } }, - "community_regenerate": "再生", - "community_secretRegenerated": "[保护对象:{name}]\n秘密已恢复至\"{name}\"", + "community_regenerate": "重新生成", + "community_secretRegenerated": "已为 \"{name}\" 重新生成密钥", "@community_secretRegenerated": { "placeholders": { "name": { @@ -1492,8 +1504,8 @@ } } }, - "community_updateSecret": "更新秘密", - "community_secretUpdated": "“{name}”的秘密已更新", + "community_updateSecret": "更新密钥", + "community_secretUpdated": "“{name}”的密钥已更新", "@community_secretUpdated": { "placeholders": { "name": { @@ -1501,7 +1513,7 @@ } } }, - "community_scanToUpdateSecret": "Scan the new QR code to update the secret for \"{name}\"", + "community_scanToUpdateSecret": "扫描新二维码以更新 \"{name}\" 的密钥", "@community_scanToUpdateSecret": { "placeholders": { "name": { @@ -1509,14 +1521,14 @@ } } }, - "community_addHashtagChannel": "添加社区标签", - "community_addHashtagChannelDesc": "为这个社区创建一个带有话题标签的频道", + "community_addHashtagChannel": "添加标签频道", + "community_addHashtagChannelDesc": "为此社区创建标签频道", "community_selectCommunity": "选择社区", - "community_regularHashtag": "常用标签", - "community_regularHashtagDesc": "公共话题标签(任何人都可以参与)", + "community_regularHashtag": "普通标签", + "community_regularHashtagDesc": "公共标签频道(任何人都可参与)", "community_communityHashtag": "社区标签", "community_communityHashtagDesc": "仅限社区成员", - "community_forCommunity": "For {name}", + "community_forCommunity": "为 {name}", "@community_forCommunity": { "placeholders": { "name": { @@ -1524,30 +1536,30 @@ } } }, - "listFilter_tooltip": "筛选和排序", - "listFilter_sortBy": "按排序", + "listFilter_tooltip": "筛选与排序", + "listFilter_sortBy": "排序方式", "listFilter_latestMessages": "最新消息", - "listFilter_heardRecently": "最近听到的", - "listFilter_az": "A 到 Z", - "listFilter_filters": "过滤器", + "listFilter_heardRecently": "最近听到", + "listFilter_az": "A-Z", + "listFilter_filters": "筛选", "listFilter_all": "全部", "listFilter_users": "用户", - "listFilter_repeaters": "重复器", + "listFilter_repeaters": "转发节点", "listFilter_roomServers": "房间服务器", - "listFilter_unreadOnly": "仅显示未读消息", - "listFilter_newGroup": "新的团体", - "pathTrace_you": "您", + "listFilter_unreadOnly": "仅显示未读", + "listFilter_newGroup": "新建群聊", + "pathTrace_you": "我自己", "pathTrace_failed": "路径追踪失败。", "pathTrace_notAvailable": "无法获取路径信息。", - "pathTrace_refreshTooltip": "重新绘制路径。", + "pathTrace_refreshTooltip": "刷新路径追踪", "contacts_pathTrace": "路径追踪", - "contacts_ping": "乒", - "contacts_repeaterPathTrace": "追踪路径至中继器", - "contacts_repeaterPing": "中继器", - "contacts_roomPathTrace": "追踪到房间服务器", - "contacts_roomPing": "会议室服务器", - "contacts_chatTraceRoute": "路径跟踪路线", - "contacts_pathTraceTo": "追踪路径至 {name}", + "contacts_ping": "Ping", + "contacts_repeaterPathTrace": "Trace 转发节点", + "contacts_repeaterPing": "Ping 转发节点", + "contacts_roomPathTrace": "Trace 房间服务器", + "contacts_roomPing": "Ping 房间服务器", + "contacts_chatTraceRoute": "路由追踪", + "contacts_pathTraceTo": "追踪至 {name} 的路径", "@contacts_pathTraceTo": { "placeholders": { "name": { @@ -1555,42 +1567,242 @@ } } }, - "contacts_clipboardEmpty": "剪贴板为空。", - "contacts_invalidAdvertFormat": "无效的联系信息", - "contacts_contactImported": "已建立联系。", - "contacts_contactImportFailed": "未能导入联系人。", - "contacts_zeroHopAdvert": "零跳广告", - "contacts_floodAdvert": "防洪广告", - "contacts_copyAdvertToClipboard": "复制广告到剪贴板", + "contacts_clipboardEmpty": "剪贴板为空", + "contacts_invalidAdvertFormat": "无效的联系人信息格式", + "contacts_contactImported": "联系人已导入", + "contacts_contactImportFailed": "导入联系人失败。", + "contacts_zeroHopAdvert": "发送零跳广播", + "contacts_floodAdvert": "发送泛洪广播", + "contacts_copyAdvertToClipboard": "复制广播到剪贴板", "contacts_addContactFromClipboard": "从剪贴板添加联系人", - "contacts_ShareContact": "复制联系方式到剪贴板", - "contacts_ShareContactZeroHop": "通过广告分享联系方式", - "contacts_zeroHopContactAdvertSent": "通过广告获取联系方式。", - "contacts_zeroHopContactAdvertFailed": "发送联系方式失败。", - "contacts_contactAdvertCopied": "广告内容已复制到剪贴板。", - "contacts_contactAdvertCopyFailed": "将广告复制到剪贴板操作失败。", + "contacts_ShareContact": "复制联系人信息到剪贴板", + "contacts_ShareContactZeroHop": "通过广播分享联系人", + "contacts_zeroHopContactAdvertSent": "零跳广播已发送", + "contacts_zeroHopContactAdvertFailed": "发送联系人广播失败。", + "contacts_contactAdvertCopied": "广播已复制到剪贴板。", + "contacts_contactAdvertCopyFailed": "复制广播到剪贴板失败。", "notification_activityTitle": "MeshCore 活动", "notification_messagesCount": "{count} 条消息", "notification_channelMessagesCount": "{count} 条频道消息", "notification_newNodesCount": "{count} 个新节点", "notification_newTypeDiscovered": "发现新 {contactType}", "notification_receivedNewMessage": "收到新消息", - "contacts_contactAdvertCopyFailed": "将广告复制到剪贴板操作失败。", - "settings_gpxExportRepeaters": "导出重复器/房间服务器到GPX", - "settings_gpxExportRepeatersSubtitle": "导出带有位置的重复器/房间服务器到GPX文件。", - "settings_gpxExportContactsSubtitle": "导出带有位置的伙伴到GPX文件。", + "settings_gpxExportRepeaters": "导出转发节点/房间服务器到 GPX", + "settings_gpxExportRepeatersSubtitle": "导出带位置的转发节点/房间服务器到 GPX 文件", + "settings_gpxExportContactsSubtitle": "导出带位置的伙伴到 GPX 文件", "settings_gpxExportNotAvailable": "您的设备/操作系统不支持", - "settings_gpxExportSuccess": "成功导出GPX文件", - "settings_gpxExportError": "导出时发生错误", - "settings_gpxExportRepeatersRoom": "重复器和房间服务器位置", - "settings_gpxExportChat": "伴侣位置", - "settings_gpxExportAll": "导出所有联系人到GPX", - "settings_gpxExportContacts": "导出伴侣到GPX", - "settings_gpxExportAllSubtitle": "导出所有带有位置的联系人到GPX文件。", + "settings_gpxExportSuccess": "GPX 文件导出成功", + "settings_gpxExportError": "导出时出错", + "settings_gpxExportRepeatersRoom": "转发节点与房间服务器位置", + "settings_gpxExportChat": "伙伴位置", + "settings_gpxExportAll": "导出所有联系人到 GPX", + "settings_gpxExportContacts": "导出伙伴到 GPX", + "settings_gpxExportAllSubtitle": "导出所有带位置的联系人到 GPX 文件", "settings_gpxExportAllContacts": "所有联系人位置", - "settings_gpxExportNoContacts": "没有联系人可导出", - "settings_gpxExportShareText": "来自meshcore-open的导出地图数据", - "settings_gpxExportShareSubject": "meshcore-open GPX 地图数据导出", - "pathTrace_someHopsNoLocation": "其中一个或多个啤酒花缺少位置!" - + "settings_gpxExportNoContacts": "没有可导出的联系人", + "settings_gpxExportShareText": "来自 MeshCore Open 的地图数据导出", + "settings_gpxExportShareSubject": "MeshCore Open GPX 地图数据导出", + "pathTrace_someHopsNoLocation": "某些跳缺少位置信息!", + "map_tapToAdd": "点击节点以添加到路径", + "pathTrace_clearTooltip": "清除路径", + "map_pathTraceCancelled": "路径追踪已取消", + "map_removeLast": "移除最后一个", + "map_runTrace": "运行路径追踪", + "scanner_bluetoothOffMessage": "请开启蓝牙以搜索设备", + "scanner_bluetoothOff": "蓝牙已关闭", + "scanner_enableBluetooth": "启用蓝牙", + "snrIndicator_lastSeen": "最近访问", + "snrIndicator_nearByRepeaters": "附近的重复器", + "chat_ShowAllPaths": "显示所有路径", + "settings_clientRepeat": "离网重复", + "settings_clientRepeatSubtitle": "允许此设备重复发送网状数据包给其他设备", + "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": "从收藏中移除", + "@contacts_searchFavorites": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchUsers": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchRepeaters": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "@contacts_searchRoomServers": { + "placeholders": { + "number": { + "type": "int" + }, + "str": { + "type": "String" + } + } + }, + "contacts_searchUsers": "搜索 {number}{str} 位用户...", + "contacts_unread": "未读", + "contacts_searchRepeaters": "搜索 {number}{str} 重复器...", + "contacts_searchContactsNoNumber": "搜索联系人...", + "contacts_searchRoomServers": "搜索 {number}{str} 房间服务器...", + "contacts_searchFavorites": "搜索 {number}{str} 收藏..." } diff --git a/lib/main.dart b/lib/main.dart index 8ee0ca47..5a11188c 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 3edb68fa..62ba9ca6 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 a98580f3..5e532e6f 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/models/radio_settings.dart b/lib/models/radio_settings.dart index 20b7771f..37ef3ccb 100644 --- a/lib/models/radio_settings.dart +++ b/lib/models/radio_settings.dart @@ -59,46 +59,200 @@ class RadioSettings { required this.txPowerDbm, }); - // Preset configurations - static RadioSettings get preset915MHz => RadioSettings( - frequencyMHz: 915.0, - bandwidth: LoRaBandwidth.bw125, - spreadingFactor: LoRaSpreadingFactor.sf7, - codingRate: LoRaCodingRate.cr4_5, - txPowerDbm: 20, - ); - - static RadioSettings get preset868MHz => RadioSettings( - frequencyMHz: 868.0, - bandwidth: LoRaBandwidth.bw125, - spreadingFactor: LoRaSpreadingFactor.sf7, - codingRate: LoRaCodingRate.cr4_5, - txPowerDbm: 14, - ); - - static RadioSettings get preset433MHz => RadioSettings( - frequencyMHz: 433.0, - bandwidth: LoRaBandwidth.bw125, - spreadingFactor: LoRaSpreadingFactor.sf7, - codingRate: LoRaCodingRate.cr4_5, - txPowerDbm: 20, - ); - - static RadioSettings get presetLongRange => RadioSettings( - frequencyMHz: 915.0, - bandwidth: LoRaBandwidth.bw125, - spreadingFactor: LoRaSpreadingFactor.sf12, - codingRate: LoRaCodingRate.cr4_8, - txPowerDbm: 20, - ); - - static RadioSettings get presetFastSpeed => RadioSettings( - frequencyMHz: 915.0, - bandwidth: LoRaBandwidth.bw500, - spreadingFactor: LoRaSpreadingFactor.sf7, - codingRate: LoRaCodingRate.cr4_5, - txPowerDbm: 20, - ); + // Regional preset configurations + static final List<(String, RadioSettings)> presets = [ + ( + 'Australia', + RadioSettings( + frequencyMHz: 915.8, + bandwidth: LoRaBandwidth.bw250, + spreadingFactor: LoRaSpreadingFactor.sf10, + codingRate: LoRaCodingRate.cr4_5, + txPowerDbm: 20, + ), + ), + ( + 'Australia (Narrow)', + RadioSettings( + frequencyMHz: 916.575, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf7, + codingRate: LoRaCodingRate.cr4_5, + txPowerDbm: 20, + ), + ), + ( + 'Australia SA, WA, QLD', + RadioSettings( + frequencyMHz: 923.125, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_5, + txPowerDbm: 20, + ), + ), + ( + 'Czech Republic', + RadioSettings( + frequencyMHz: 869.432, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf7, + codingRate: LoRaCodingRate.cr4_5, + txPowerDbm: 14, + ), + ), + ( + 'EU 433MHz', + RadioSettings( + frequencyMHz: 433.650, + bandwidth: LoRaBandwidth.bw250, + spreadingFactor: LoRaSpreadingFactor.sf11, + codingRate: LoRaCodingRate.cr4_5, + txPowerDbm: 20, + ), + ), + ( + 'EU/UK (Long Range)', + RadioSettings( + frequencyMHz: 869.525, + bandwidth: LoRaBandwidth.bw250, + spreadingFactor: LoRaSpreadingFactor.sf11, + codingRate: LoRaCodingRate.cr4_5, + txPowerDbm: 14, + ), + ), + ( + 'EU/UK (Medium Range)', + RadioSettings( + frequencyMHz: 869.525, + bandwidth: LoRaBandwidth.bw250, + spreadingFactor: LoRaSpreadingFactor.sf10, + codingRate: LoRaCodingRate.cr4_5, + txPowerDbm: 14, + ), + ), + ( + 'EU/UK (Narrow)', + RadioSettings( + frequencyMHz: 869.618, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_5, + txPowerDbm: 14, + ), + ), + ( + 'New Zealand', + RadioSettings( + frequencyMHz: 917.375, + bandwidth: LoRaBandwidth.bw250, + spreadingFactor: LoRaSpreadingFactor.sf11, + codingRate: LoRaCodingRate.cr4_5, + txPowerDbm: 20, + ), + ), + ( + 'New Zealand (Narrow)', + RadioSettings( + frequencyMHz: 917.375, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf7, + codingRate: LoRaCodingRate.cr4_5, + txPowerDbm: 20, + ), + ), + ( + 'Portugal 433', + RadioSettings( + frequencyMHz: 433.375, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf9, + codingRate: LoRaCodingRate.cr4_5, + txPowerDbm: 20, + ), + ), + ( + 'Portugal 869', + RadioSettings( + frequencyMHz: 869.618, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf7, + codingRate: LoRaCodingRate.cr4_5, + txPowerDbm: 14, + ), + ), + ( + 'Switzerland', + RadioSettings( + frequencyMHz: 869.618, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_5, + txPowerDbm: 14, + ), + ), + ( + 'USA Arizona', + RadioSettings( + frequencyMHz: 908.205, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf10, + codingRate: LoRaCodingRate.cr4_5, + txPowerDbm: 20, + ), + ), + ( + 'USA/Canada', + RadioSettings( + frequencyMHz: 910.525, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf7, + codingRate: LoRaCodingRate.cr4_5, + txPowerDbm: 20, + ), + ), + ( + 'Vietnam', + RadioSettings( + frequencyMHz: 920.250, + bandwidth: LoRaBandwidth.bw250, + spreadingFactor: LoRaSpreadingFactor.sf11, + codingRate: LoRaCodingRate.cr4_5, + txPowerDbm: 20, + ), + ), + // Off-grid repeat presets (valid client_repeat frequencies) + ( + 'Off-Grid 433', + RadioSettings( + frequencyMHz: 433.0, + bandwidth: LoRaBandwidth.bw250, + spreadingFactor: LoRaSpreadingFactor.sf11, + codingRate: LoRaCodingRate.cr4_5, + txPowerDbm: 20, + ), + ), + ( + 'Off-Grid 869', + RadioSettings( + frequencyMHz: 869.0, + bandwidth: LoRaBandwidth.bw250, + spreadingFactor: LoRaSpreadingFactor.sf11, + codingRate: LoRaCodingRate.cr4_5, + txPowerDbm: 14, + ), + ), + ( + 'Off-Grid 918', + RadioSettings( + frequencyMHz: 918.0, + bandwidth: LoRaBandwidth.bw250, + spreadingFactor: LoRaSpreadingFactor.sf11, + codingRate: LoRaCodingRate.cr4_5, + txPowerDbm: 20, + ), + ), + ]; int get frequencyHz => (frequencyMHz * 1000).round(); int get bandwidthHz => bandwidth.hz; diff --git a/lib/screens/app_debug_log_screen.dart b/lib/screens/app_debug_log_screen.dart index e8a0aa49..48770388 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( @@ -55,7 +56,7 @@ class AppDebugLogScreen extends StatelessWidget { child: hasEntries ? ListView.separated( itemCount: entries.length, - separatorBuilder: (_, __) => const Divider(height: 1), + separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, index) { final entry = entries[index]; return ListTile( diff --git a/lib/screens/app_settings_screen.dart b/lib/screens/app_settings_screen.dart index 135babd4..a2c920e7 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), @@ -384,6 +410,7 @@ class AppSettingsScreen extends StatelessWidget { ); } + // Fixed rendering issues Widget _buildBatteryCard( BuildContext context, AppSettingsService settingsService, @@ -399,6 +426,7 @@ class AppSettingsScreen extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + const SizedBox(height: 4), Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Text( @@ -406,6 +434,8 @@ class AppSettingsScreen extends StatelessWidget { style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), ), + + // Main tile (icon + text only) ListTile( leading: const Icon(Icons.battery_full), title: Text(context.l10n.appSettings_batteryChemistry), @@ -416,8 +446,19 @@ class AppSettingsScreen extends StatelessWidget { ) : context.l10n.appSettings_batteryChemistryConnectFirst, ), - trailing: DropdownButton( - value: selection, + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + ), + + // Dropdown (separate full-width row) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: DropdownButtonFormField( + initialValue: selection, + isExpanded: true, + decoration: const InputDecoration( + border: UnderlineInputBorder(), + isDense: true, + ), onChanged: isConnected ? (value) { if (value != null) { @@ -691,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 7675caea..88f734bc 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, @@ -100,7 +101,7 @@ class _BleDebugLogScreenState extends State { itemCount: showingFrames ? entries.length : rawEntries.length, - separatorBuilder: (_, __) => const Divider(height: 1), + separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, index) { if (showingFrames) { final entry = entries[index]; diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index c82356d2..4e3743d8 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 8dea4758..44dfe797 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), ), ), @@ -594,7 +726,7 @@ class _ChannelMessagePathMapScreenState : ListView.separated( padding: const EdgeInsets.symmetric(vertical: 4), itemCount: hops.length, - separatorBuilder: (_, __) => const Divider(height: 1), + separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, index) { final hop = hops[index]; return ListTile( diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 6d8ef038..b6e1b36b 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, ); }, @@ -354,6 +358,7 @@ class _ChannelsScreenState extends State Widget _buildChannelTile( BuildContext context, MeshCoreConnector connector, + ChannelMessageStore channelMessageStore, Channel channel, { bool showDragHandle = false, int? dragIndex, @@ -470,7 +475,12 @@ class _ChannelsScreenState extends State ); } }, - onLongPress: () => _showChannelActions(context, connector, channel), + onLongPress: () => _showChannelActions( + context, + connector, + channelMessageStore, + channel, + ), ), ); } @@ -478,11 +488,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: [ @@ -490,10 +505,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); } }, ), @@ -504,10 +539,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, + ); } }, ), @@ -1417,7 +1457,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(); @@ -1434,13 +1474,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), ), @@ -1453,6 +1505,7 @@ class _ChannelsScreenState extends State void _confirmDeleteChannel( BuildContext context, MeshCoreConnector connector, + ChannelMessageStore channelMessageStore, Channel channel, ) { showDialog( @@ -1468,16 +1521,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 f00f242a..7c8fcfb9 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 a2914a19..9f8602d3 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 97e0d513..374e1c6d 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'; @@ -91,79 +92,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', + ); } }); } @@ -172,14 +184,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 { @@ -206,7 +221,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( @@ -230,9 +245,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( @@ -398,6 +411,41 @@ class _ContactsScreenState extends State ? const [] : _filterAndSortGroups(_groups, contacts); + String hintText = ""; + + switch (_typeFilter) { + case ContactTypeFilter.all: + hintText = context.l10n.contacts_searchContacts( + filteredAndSorted.length, + _showUnreadOnly ? " ${context.l10n.contacts_unread}" : "", + ); + break; + case ContactTypeFilter.users: + hintText = context.l10n.contacts_searchUsers( + filteredAndSorted.length, + _showUnreadOnly ? " ${context.l10n.contacts_unread}" : "", + ); + break; + case ContactTypeFilter.repeaters: + hintText = context.l10n.contacts_searchRepeaters( + filteredAndSorted.length, + _showUnreadOnly ? " ${context.l10n.contacts_unread}" : "", + ); + break; + case ContactTypeFilter.rooms: + hintText = context.l10n.contacts_searchRoomServers( + filteredAndSorted.length, + _showUnreadOnly ? " ${context.l10n.contacts_unread}" : "", + ); + break; + case ContactTypeFilter.favorites: + hintText = context.l10n.contacts_searchFavorites( + filteredAndSorted.length, + _showUnreadOnly ? " ${context.l10n.contacts_unread}" : "", + ); + break; + } + return Column( children: [ Padding( @@ -405,7 +453,7 @@ class _ContactsScreenState extends State child: TextField( controller: _searchController, decoration: InputDecoration( - hintText: context.l10n.contacts_searchContacts, + hintText: hintText, prefixIcon: const Icon(Icons.search), suffixIcon: Row( mainAxisSize: MainAxisSize.min, @@ -477,6 +525,7 @@ class _ContactsScreenState extends State contact: contact, lastSeen: _resolveLastSeen(contact), unreadCount: unreadCount, + isFavorite: contact.isFavorite, onTap: () => _openChat(context, contact), onLongPress: () => _showContactOptions(context, connector, contact), @@ -513,6 +562,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; @@ -606,6 +657,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: @@ -996,6 +1049,7 @@ class _ContactsScreenState extends State ) { final isRepeater = contact.type == advTypeRepeater; final isRoom = contact.type == advTypeRoom; + final isFavorite = contact.isFavorite; showModalBottomSheet( context: context, @@ -1102,6 +1156,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), @@ -1170,6 +1239,7 @@ class _ContactTile extends StatelessWidget { final Contact contact; final DateTime lastSeen; final int unreadCount; + final bool isFavorite; final VoidCallback onTap; final VoidCallback onLongPress; @@ -1177,6 +1247,7 @@ class _ContactTile extends StatelessWidget { required this.contact, required this.lastSeen, required this.unreadCount, + required this.isFavorite, required this.onTap, required this.onLongPress, }); @@ -1188,12 +1259,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 @@ -1204,26 +1280,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 00000000..ec8a391f --- /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 3f611095..1391660e 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 2e51047b..a76adf22 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -1,13 +1,17 @@ import 'dart:math'; +import 'dart:typed_data'; 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'; @@ -15,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'; @@ -24,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; @@ -44,13 +49,21 @@ 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 = {}; Set _removedMarkerIds = {}; + bool _isBuildingPathTrace = false; bool _isSelectingPoi = false; bool _hasInitializedMap = false; bool _removedMarkersLoaded = false; + final List _pathTrace = []; + final List _points = []; + final List _polylines = []; + bool _legendExpanded = false; + bool _showNodeLabels = true; @override void initState() { @@ -98,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); @@ -147,6 +160,19 @@ class _MapScreenState extends State { .where((c) => c.hasLocation) .toList(); + _polylines.clear(); + _polylines.addAll( + _points.length > 1 + ? [ + Polyline( + points: _points, + strokeWidth: 4, + color: Colors.blueAccent, + ), + ] + : [], + ); + // Calculate center and zoom of all nodes, or default to (0, 0) LatLng center = const LatLng(0, 0); double initialZoom = 10.0; @@ -227,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) { @@ -242,11 +269,57 @@ 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: [ + if (!_isBuildingPathTrace) + IconButton( + icon: const Icon(Icons.radar), + 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( @@ -325,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( @@ -334,6 +415,8 @@ class _MapScreenState extends State { MapTileCacheService.userAgentPackageName, maxZoom: 19, ), + if (_polylines.isNotEmpty && _isBuildingPathTrace) + PolylineLayer(polylines: _polylines), MarkerLayer( markers: [ if (highlightPosition != null) @@ -347,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) @@ -356,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( @@ -376,21 +463,34 @@ 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, + ), ], ), ], ), - _buildLegend(contactsWithLocation.length, sharedMarkers.length), + if (!_isBuildingPathTrace) + _buildLegend( + contactsWithLocation, + settings, + sharedMarkers.length, + ), + if (_isBuildingPathTrace) _buildPathTraceOverlay(), ], ), bottomNavigationBar: SafeArea( @@ -414,20 +514,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; } @@ -436,7 +544,11 @@ class _MapScreenState extends State { width: 35, height: 35, child: GestureDetector( - onTap: () => _showNodeInfo(context, contact), + onLongPress: () => + _isBuildingPathTrace ? _showNodeInfo(context, contact) : null, + onTap: () => _isBuildingPathTrace + ? _addToPath(context, contact) + : _showNodeInfo(context, contact), child: Column( children: [ Container( @@ -465,11 +577,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: @@ -500,65 +655,126 @@ 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, child: Card( - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - context.l10n.map_nodesCount(nodeCount), - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () { + setState(() { + _legendExpanded = !_legendExpanded; + }); + }, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.map_nodesCount(nodeCount), + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + Text( + context.l10n.map_pinsCount(markerCount), + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ], + ), + const SizedBox(width: 8), + AnimatedRotation( + turns: _legendExpanded ? 0.5 : 0, + duration: const Duration(milliseconds: 200), + child: const Icon(Icons.expand_more, size: 20), + ), + ], ), ), - Text( - context.l10n.map_pinsCount(markerCount), - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 12, + ), + AnimatedCrossFade( + firstChild: const SizedBox.shrink(), + secondChild: Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 6), + _buildLegendItem( + Icons.person, + context.l10n.map_chat, + Colors.blue, + ), + _buildLegendItem( + Icons.router, + context.l10n.map_repeater, + Colors.green, + ), + _buildLegendItem( + Icons.meeting_room, + context.l10n.map_room, + Colors.purple, + ), + _buildLegendItem( + Icons.sensors, + context.l10n.map_sensor, + Colors.orange, + ), + _buildLegendItem( + Icons.flag, + context.l10n.map_pinDm, + Colors.blue, + ), + _buildLegendItem( + Icons.flag, + context.l10n.map_pinPrivate, + Colors.purple, + ), + _buildLegendItem( + Icons.flag, + context.l10n.map_pinPublic, + Colors.orange, + ), + ], ), ), - const SizedBox(height: 8), - _buildLegendItem( - Icons.person, - context.l10n.map_chat, - Colors.blue, - ), - _buildLegendItem( - Icons.router, - context.l10n.map_repeater, - Colors.green, - ), - _buildLegendItem( - Icons.meeting_room, - context.l10n.map_room, - Colors.purple, - ), - _buildLegendItem( - Icons.sensors, - context.l10n.map_sensor, - Colors.orange, - ), - _buildLegendItem(Icons.flag, context.l10n.map_pinDm, Colors.blue), - _buildLegendItem( - Icons.flag, - context.l10n.map_pinPrivate, - Colors.purple, - ), - _buildLegendItem( - Icons.flag, - context.l10n.map_pinPublic, - Colors.orange, - ), - ], - ), + crossFadeState: _legendExpanded + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + duration: const Duration(milliseconds: 200), + ), + ], ), ), ); @@ -566,7 +782,7 @@ class _MapScreenState extends State { Widget _buildLegendItem(IconData icon, String label, Color color) { return Padding( - padding: const EdgeInsets.symmetric(vertical: 4.0), + padding: const EdgeInsets.symmetric(vertical: 1.0), child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -749,7 +965,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( @@ -920,7 +1136,7 @@ class _MapScreenState extends State { ), ), const SizedBox(height: 2), - Text(value, style: const TextStyle(fontSize: 14)), + SelectableText(value, style: const TextStyle(fontSize: 14)), ], ), ); @@ -1087,7 +1303,8 @@ class _MapScreenState extends State { padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), child: TextField( decoration: InputDecoration( - hintText: context.l10n.contacts_searchContacts, + hintText: + context.l10n.contacts_searchContactsNoNumber, prefixIcon: const Icon(Icons.search), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), @@ -1414,6 +1631,119 @@ class _MapScreenState extends State { return context.l10n.time_allTime; } } + + void _addToPath(BuildContext context, Contact contact) { + setState(() { + _pathTrace.add( + contact.publicKey[0], + ); // Add first 16 bytes of public key to path trace + _points.add(LatLng(contact.latitude!, contact.longitude!)); + }); + } + + void _startPath() { + setState(() { + _isBuildingPathTrace = true; + _pathTrace.clear(); + _points.clear(); + _polylines.clear(); + }); + } + + void _removePath() { + setState(() { + _pathTrace.removeLast(); // Remove last node from path trace + _points.removeLast(); // Remove last point from points list + _polylines.clear(); // Clear polylines + }); + } + + Widget _buildPathTraceOverlay() { + final l10n = context.l10n; + final isImperial = + context.read().settings.unitSystem == + UnitSystem.imperial; + return Positioned( + top: 16, + left: 16, + right: 16, + child: Card( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.contacts_pathTrace, + style: TextStyle(fontWeight: FontWeight.bold), + ), + if (_pathTrace.isEmpty) const SizedBox(height: 8), + if (_pathTrace.isEmpty) + Text(l10n.map_tapToAdd, style: TextStyle(fontSize: 12)), + const SizedBox(height: 6), + if (_pathTrace.isNotEmpty) + Text( + "${l10n.path_currentPathLabel} ${formatDistance(getPathDistanceMeters(_points), isImperial: isImperial)}", + style: TextStyle(fontSize: 12, color: Colors.grey[700]), + ), + SelectableText( + _pathTrace + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(','), + style: TextStyle(fontSize: 18), + ), + const SizedBox(height: 6), + Wrap( + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 8, + children: [ + if (_pathTrace.isNotEmpty) + ElevatedButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PathTraceMapScreen( + title: l10n.contacts_pathTrace, + path: Uint8List.fromList(_pathTrace), + ), + ), + ); + setState(() { + _isBuildingPathTrace = false; + }); + }, + child: Text(l10n.map_runTrace), + ), + if (_pathTrace.isNotEmpty) + ElevatedButton( + onPressed: _removePath, + child: Text(l10n.map_removeLast), + ), + if (_pathTrace.isEmpty) + ElevatedButton( + onPressed: () { + setState(() { + _isBuildingPathTrace = false; + _pathTrace.clear(); + _points.clear(); + _polylines.clear(); + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.map_pathTraceCancelled)), + ); + }, + child: Text(l10n.common_cancel), + ), + ], + ), + ], + ), + ), + ), + ); + } } class _MarkerPayload { 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 b6061883..3dee3391 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 39de31e9..5f86cc16 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -8,14 +8,37 @@ 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'; +double getPathDistanceMeters(List points) { + if (points.length <= 1) return 0.0; + + double distanceMeters = 0.0; + final distanceCalculator = Distance(); + + for (int i = 0; i < points.length - 1; i++) { + distanceMeters += distanceCalculator(points[i], points[i + 1]); + } + + return distanceMeters; +} + +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({ @@ -28,6 +51,7 @@ class PathTraceData { class PathTraceMapScreen extends StatefulWidget { final String title; final Uint8List path; + final int? repeaterId; final bool flipPathRound; final bool reversePathRound; @@ -35,6 +59,7 @@ class PathTraceMapScreen extends StatefulWidget { super.key, required this.title, required this.path, + this.repeaterId, this.flipPathRound = false, this.reversePathRound = false, }); @@ -44,13 +69,14 @@ class PathTraceMapScreen extends StatefulWidget { } class _PathTraceMapScreenState extends State { + static const double _labelZoomThreshold = 8.5; + StreamSubscription? _frameSubscription; Timer? _timeoutTimer; bool _isLoading = false; bool _failed2Loaded = false; bool _hasData = false; - bool _noLocationErr = false; PathTraceData? _traceData; List _points = []; List _polylines = []; @@ -58,7 +84,8 @@ class _PathTraceMapScreenState extends State { double _initialZoom = 2.0; LatLngBounds? _bounds; ValueKey _mapKey = const ValueKey('initial'); - double _pathDistance = 0.0; + double _pathDistanceMeters = 0.0; + bool _showNodeLabels = true; String _formatPathPrefixes(Uint8List pathBytes) { return pathBytes @@ -80,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); @@ -93,23 +120,11 @@ class _PathTraceMapScreenState extends State { return traceBytes; } - double getPathDistance() { - double totalDistance = 0.0; - final distanceCalculator = Distance(); - - for (int i = 0; i < _points.length - 1; i++) { - totalDistance += distanceCalculator(_points[i], _points[i + 1]); - } - - return totalDistance; - } - Future _doPathTrace() async { if (mounted) { setState(() { _isLoading = true; _failed2Loaded = false; - _noLocationErr = false; }); } @@ -120,7 +135,7 @@ class _PathTraceMapScreenState extends State { : widget.path; if (widget.flipPathRound) { - path = addReturnpath(pathTmp); + path = addReturnPath(pathTmp); } else { path = pathTmp; } @@ -142,34 +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; }); - }); - } + } - // Check if it's a binary response - if (frame.length > 8 && - code == pushCodeTraceData && - listEquals(frame.sublist(4, 8), tagData)) { + // 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; - frameBuffer.skipBytes(3); //reserved + path length + flag - if (listEquals(frameBuffer.readBytes(4), tagData)) { - _handleTraceResponse(frame); - } + setState(() { + _isLoading = false; + _failed2Loaded = true; + }); + // Handle any parsing errors gracefully + appLogger.error('Error parsing frame: $e', tag: 'PathTraceMapScreen'); } }); } @@ -178,71 +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!)); - } else { - _noLocationErr = true; + 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', ); - _pathDistance = getPathDistance(); - }); + 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( @@ -279,20 +337,7 @@ class _PathTraceMapScreenState extends State { top: false, child: Stack( children: [ - if (_noLocationErr) - Center( - child: Card( - color: Colors.red, - child: Padding( - padding: EdgeInsets.all(12), - child: Text( - context.l10n.pathTrace_someHopsNoLocation, - style: TextStyle(color: Colors.white), - ), - ), - ), - ), - if (!_hasData && !_noLocationErr) + if (!_hasData) Center( child: Column( mainAxisSize: MainAxisSize.min, @@ -304,43 +349,11 @@ class _PathTraceMapScreenState extends State { ], ), ), - if (_hasData && !_noLocationErr) - FlutterMap( - key: _mapKey, - options: MapOptions( - initialCenter: _initialCenter!, - initialZoom: _initialZoom, - initialCameraFit: _bounds == null - ? null - : CameraFit.bounds( - bounds: _bounds!, - padding: const EdgeInsets.all(64), - maxZoom: 16, - ), - minZoom: 2.0, - maxZoom: 18.0, - ), - children: [ - TileLayer( - urlTemplate: kMapTileUrlTemplate, - tileProvider: tileCache.tileProvider, - userAgentPackageName: - MapTileCacheService.userAgentPackageName, - maxZoom: 19, - ), - if (_polylines.isNotEmpty) - PolylineLayer(polylines: _polylines), - if (_traceData!.pathData.isNotEmpty) - MarkerLayer( - markers: _buildHopMarkers(_traceData!.pathData), - ), - ], - ), + if (_hasData) _buildMapPathTrace(context, tileCache), if (_points.isEmpty && !_hasData && !_isLoading && - !_failed2Loaded && - !_noLocationErr) + !_failed2Loaded) Center( child: Card( color: Colors.white.withValues(alpha: 0.9), @@ -352,8 +365,8 @@ class _PathTraceMapScreenState extends State { ), ), ), - if (_hasData && !_noLocationErr) - _buildLegendCard(context, _traceData!), + if (_hasData) + _buildLegendCard(context, _traceData!, isImperial), ], ), ), @@ -362,54 +375,61 @@ class _PathTraceMapScreenState extends State { ); } - List _buildHopMarkers(List pathData) { - return [ - for (final hop in pathData) - if (_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( @@ -437,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) { @@ -453,7 +519,9 @@ class _PathTraceMapScreenState extends State { .toRadixString(16) .padLeft(2, '0') .toUpperCase(); - return contactName != null ? "$hex: $contactName" : hex; + return contactName != null + ? "$hex: $contactName" + : "$hex: ${context.l10n.channelPath_unknownRepeater}"; } } else { final contactName = @@ -462,7 +530,9 @@ class _PathTraceMapScreenState extends State { .toRadixString(16) .padLeft(2, '0') .toUpperCase(); - return contactName != null ? "$hex: $contactName" : hex; + return contactName != null + ? "$hex: $contactName" + : "$hex: ${context.l10n.channelPath_unknownRepeater}"; } } @@ -475,7 +545,9 @@ class _PathTraceMapScreenState extends State { .toRadixString(16) .padLeft(2, '0') .toUpperCase(); - return contactName != null ? "$hex: $contactName" : hex; + return contactName != null + ? "$hex: $contactName" + : "$hex: ${context.l10n.channelPath_unknownRepeater}"; } else { return context.l10n.pathTrace_you; } @@ -486,11 +558,64 @@ class _PathTraceMapScreenState extends State { .toRadixString(16) .padLeft(2, '0') .toUpperCase(); - return contactName != null ? "$hex: $contactName" : hex; + return contactName != null + ? "$hex: $contactName" + : "$hex: ${context.l10n.channelPath_unknownRepeater}"; } } - Widget _buildLegendCard(BuildContext context, PathTraceData pathTraceData) { + Widget _buildMapPathTrace( + BuildContext context, + MapTileCacheService tileCache, + ) { + return FlutterMap( + key: _mapKey, + options: MapOptions( + interactionOptions: InteractionOptions(flags: ~InteractiveFlag.rotate), + initialCenter: _initialCenter!, + initialZoom: _initialZoom, + initialCameraFit: _bounds == null + ? null + : CameraFit.bounds( + bounds: _bounds!, + padding: const EdgeInsets.all(64), + maxZoom: 16, + ), + minZoom: 2.0, + maxZoom: 18.0, + onPositionChanged: (camera, hasGesture) { + final shouldShow = camera.zoom >= _labelZoomThreshold; + if (shouldShow != _showNodeLabels && mounted) { + setState(() { + _showNodeLabels = shouldShow; + }); + } + }, + ), + children: [ + TileLayer( + urlTemplate: kMapTileUrlTemplate, + tileProvider: tileCache.tileProvider, + userAgentPackageName: MapTileCacheService.userAgentPackageName, + maxZoom: 19, + ), + if (_polylines.isNotEmpty) PolylineLayer(polylines: _polylines), + if (_traceData!.pathData.isNotEmpty) + MarkerLayer( + markers: _buildHopMarkers( + _traceData!.pathData, + showLabels: _showNodeLabels, + ), + ), + ], + ); + } + + 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); @@ -509,7 +634,7 @@ class _PathTraceMapScreenState extends State { 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(_pathDistanceMeters, isImperial: isImperial)}', style: const TextStyle(fontWeight: FontWeight.w600), ), ), @@ -523,8 +648,14 @@ class _PathTraceMapScreenState extends State { child: ListView.separated( padding: const EdgeInsets.symmetric(vertical: 4), itemCount: pathTraceData.pathData.length + 1, - separatorBuilder: (_, __) => const Divider(height: 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( @@ -543,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 abfb06aa..1c7ff438 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 903f89e6..fd2da8e4 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 472b0137..95254f43 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 75819a0a..af9d75ef 100644 --- a/lib/screens/scanner_screen.dart +++ b/lib/screens/scanner_screen.dart @@ -1,9 +1,13 @@ +import 'dart:async'; +import 'dart:io' show Platform; + import 'package:flutter/material.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; 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'; @@ -18,6 +22,8 @@ class ScannerScreen extends StatefulWidget { class _ScannerScreenState extends State { bool _changedNavigation = false; late final VoidCallback _connectionListener; + BluetoothAdapterState _bluetoothState = BluetoothAdapterState.unknown; + late StreamSubscription _bluetoothStateSubscription; @override void initState() { @@ -39,12 +45,25 @@ class _ScannerScreenState extends State { }; connector.addListener(_connectionListener); + + _bluetoothStateSubscription = FlutterBluePlus.adapterState.listen((state) { + if (mounted) { + setState(() { + _bluetoothState = state; + }); + // Cancel scan if Bluetooth turns off while scanning + if (state != BluetoothAdapterState.on) { + unawaited(connector.stopScan()); + } + } + }); } @override void dispose() { final connector = Provider.of(context, listen: false); connector.removeListener(_connectionListener); + unawaited(_bluetoothStateSubscription.cancel()); super.dispose(); } @@ -52,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, ), @@ -62,6 +81,10 @@ class _ScannerScreenState extends State { builder: (context, connector, child) { return Column( children: [ + // Bluetooth off warning + if (_bluetoothState == BluetoothAdapterState.off) + _bluetoothOffWarning(context), + // Status bar _buildStatusBar(context, connector), @@ -76,15 +99,18 @@ class _ScannerScreenState extends State { builder: (context, connector, child) { final isScanning = connector.state == MeshCoreConnectionState.scanning; + final isBluetoothOff = _bluetoothState == BluetoothAdapterState.off; return FloatingActionButton.extended( - onPressed: () { - if (isScanning) { - connector.stopScan(); - } else { - connector.startScan(); - } - }, + onPressed: isBluetoothOff + ? null + : () { + if (isScanning) { + connector.stopScan(); + } else { + connector.startScan(); + } + }, icon: isScanning ? const SizedBox( width: 20, @@ -205,4 +231,47 @@ class _ScannerScreenState extends State { } } } + + Widget _bluetoothOffWarning(BuildContext context) { + final errorColor = Theme.of(context).colorScheme.error; + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + color: errorColor.withValues(alpha: 0.15), + child: Row( + children: [ + Icon(Icons.bluetooth_disabled, size: 24, color: errorColor), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.scanner_bluetoothOff, + style: TextStyle( + color: errorColor, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + const SizedBox(height: 4), + Text( + context.l10n.scanner_bluetoothOffMessage, + style: TextStyle( + color: errorColor.withValues(alpha: 0.85), + fontSize: 12, + ), + ), + ], + ), + ), + if (Platform.isAndroid) + TextButton( + onPressed: () => FlutterBluePlus.turnOn(), + child: Text(context.l10n.scanner_enableBluetooth), + ), + ], + ), + ); + } } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 2212b8db..a198f991 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'; @@ -21,6 +22,7 @@ class SettingsScreen extends StatefulWidget { class _SettingsScreenState extends State { bool _showBatteryVoltage = false; + bool _deviceInfoExpanded = false; String _appVersion = ''; @override @@ -40,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( @@ -74,43 +79,84 @@ class _SettingsScreenState extends State { MeshCoreConnector connector, ) { final l10n = context.l10n; + return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.settings_deviceInfo, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 16), - _buildInfoRow(l10n.settings_infoName, connector.deviceDisplayName), - _buildInfoRow(l10n.settings_infoId, connector.deviceIdLabel), - _buildInfoRow( - l10n.settings_infoStatus, - connector.isConnected - ? l10n.common_connected - : l10n.common_disconnected, - ), - _buildBatteryInfoRow(context, connector), - if (connector.selfName != null) - _buildInfoRow(l10n.settings_nodeName, connector.selfName!), - if (connector.selfPublicKey != null) - _buildInfoRow( - l10n.settings_infoPublicKey, - '${pubKeyToHex(connector.selfPublicKey!).substring(0, 16)}...', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () { + setState(() { + _deviceInfoExpanded = !_deviceInfoExpanded; + }); + }, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), + child: Row( + children: [ + Expanded( + child: Text( + l10n.settings_deviceInfo, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + AnimatedRotation( + turns: _deviceInfoExpanded ? 0.5 : 0, + duration: const Duration(milliseconds: 200), + child: const Icon(Icons.expand_more), + ), + ], ), - _buildInfoRow( - l10n.settings_infoContactsCount, - '${connector.contacts.length}', ), - _buildInfoRow( - l10n.settings_infoChannelCount, - '${connector.channels.length}', + ), + + AnimatedCrossFade( + firstChild: const SizedBox.shrink(), + secondChild: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoRow( + l10n.settings_infoName, + connector.deviceDisplayName, + ), + _buildInfoRow(l10n.settings_infoId, connector.deviceIdLabel), + _buildInfoRow( + l10n.settings_infoStatus, + connector.isConnected + ? l10n.common_connected + : l10n.common_disconnected, + ), + _buildBatteryInfoRow(context, connector), + if (connector.selfName != null) + _buildInfoRow(l10n.settings_nodeName, connector.selfName!), + if (connector.selfPublicKey != null) + _buildInfoRow( + l10n.settings_infoPublicKey, + '${pubKeyToHex(connector.selfPublicKey!).substring(0, 16)}...', + ), + _buildInfoRow( + l10n.settings_infoContactsCount, + '${connector.contacts.length}', + ), + _buildInfoRow( + l10n.settings_infoChannelCount, + '${connector.channels.length}', + ), + ], + ), ), - ], - ), + crossFadeState: _deviceInfoExpanded + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + duration: const Duration(milliseconds: 200), + ), + ], ), ); } @@ -355,22 +401,33 @@ class _SettingsScreenState extends State { Color? valueColor, VoidCallback? onTap, }) { + final theme = Theme.of(context); + final row = Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + padding: const EdgeInsets.symmetric(vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ if (leading != null) ...[leading, const SizedBox(width: 8)], - Text(label, style: TextStyle(color: Colors.grey[600])), + Expanded( + child: Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + ), ], ), - Flexible( - child: Text( - value, - style: TextStyle(fontWeight: FontWeight.w500, color: valueColor), - overflow: TextOverflow.ellipsis, + const SizedBox(height: 4), + Text( + value, + style: theme.textTheme.bodyLarge?.copyWith( + color: valueColor, + fontWeight: FontWeight.w500, ), ), ], @@ -379,11 +436,12 @@ class _SettingsScreenState extends State { if (onTap != null) { return InkWell( + borderRadius: BorderRadius.circular(6), onTap: onTap, - borderRadius: BorderRadius.circular(4), child: row, ); } + return row; } @@ -688,7 +746,7 @@ class _SettingsScreenState extends State { ); } - _gpxExport( + Future _gpxExport( GpxExport exporter, String name, String description, @@ -728,7 +786,7 @@ class _SettingsScreenState extends State { } } - _buildExportCard(MeshCoreConnector connector) { + Widget _buildExportCard(MeshCoreConnector connector) { final l10n = context.l10n; return Card( child: Column( @@ -808,6 +866,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { LoRaSpreadingFactor _spreadingFactor = LoRaSpreadingFactor.sf7; LoRaCodingRate _codingRate = LoRaCodingRate.cr4_5; final _txPowerController = TextEditingController(text: '20'); + bool _clientRepeat = false; @override void initState() { @@ -857,6 +916,8 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { if (widget.connector.currentTxPower != null) { _txPowerController.text = widget.connector.currentTxPower.toString(); } + + _clientRepeat = widget.connector.clientRepeat ?? false; } @override @@ -906,9 +967,29 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { widget.connector.currentCr, ); + // if the client repeat isnt null then we know its supported + //otherwise we leave it out of the frame to avoid accidentally enabling + final knownRepeat = widget.connector.clientRepeat != null; + + if (knownRepeat) { + const validRepeatFreqsKHz = {433000, 869000, 918000}; + if (_clientRepeat && !validRepeatFreqsKHz.contains(freqHz)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.settings_clientRepeatFreqWarning)), + ); + return; + } + } + try { await widget.connector.sendFrame( - buildSetRadioParamsFrame(freqHz, bwHz, sf, cr), + buildSetRadioParamsFrame( + freqHz, + bwHz, + sf, + cr, + clientRepeat: knownRepeat ? _clientRepeat : null, + ), ); await widget.connector.sendFrame(buildSetRadioTxPowerFrame(txPower)); await widget.connector.refreshDeviceInfo(); @@ -947,37 +1028,25 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - l10n.settings_presets, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: [ - _PresetChip( - label: l10n.settings_preset915Mhz, - onTap: () => _applyPreset(RadioSettings.preset915MHz), - ), - _PresetChip( - label: l10n.settings_preset868Mhz, - onTap: () => _applyPreset(RadioSettings.preset868MHz), - ), - _PresetChip( - label: l10n.settings_preset433Mhz, - onTap: () => _applyPreset(RadioSettings.preset433MHz), - ), - _PresetChip( - label: l10n.settings_longRange, - onTap: () => _applyPreset(RadioSettings.presetLongRange), - ), - _PresetChip( - label: l10n.settings_fastSpeed, - onTap: () => _applyPreset(RadioSettings.presetFastSpeed), - ), + DropdownButtonFormField( + decoration: InputDecoration( + labelText: l10n.settings_presets, + border: const OutlineInputBorder(), + ), + items: [ + for (var i = 0; i < RadioSettings.presets.length; i++) + DropdownMenuItem( + value: i, + child: Text(RadioSettings.presets[i].$1), + ), ], + onChanged: (index) { + if (index != null) { + _applyPreset(RadioSettings.presets[index].$2); + } + }, ), - const SizedBox(height: 24), + const SizedBox(height: 16), TextField( controller: _frequencyController, decoration: InputDecoration( @@ -1049,6 +1118,16 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { ), keyboardType: TextInputType.number, ), + if (widget.connector.clientRepeat != null) ...[ + const SizedBox(height: 16), + SwitchListTile( + title: Text(l10n.settings_clientRepeat), + subtitle: Text(l10n.settings_clientRepeatSubtitle), + value: _clientRepeat, + onChanged: (value) => setState(() => _clientRepeat = value), + contentPadding: EdgeInsets.zero, + ), + ], ], ), ), @@ -1062,15 +1141,3 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { ); } } - -class _PresetChip extends StatelessWidget { - final String label; - final VoidCallback onTap; - - const _PresetChip({required this.label, required this.onTap}); - - @override - Widget build(BuildContext context) { - return ActionChip(label: Text(label), onPressed: onTap); - } -} diff --git a/lib/screens/telemetry_screen.dart b/lib/screens/telemetry_screen.dart index 8770938c..3f95ccdb 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 c1e8fc62..eacf26f9 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/background_service.dart b/lib/services/background_service.dart index fce77a1d..0edd3935 100644 --- a/lib/services/background_service.dart +++ b/lib/services/background_service.dart @@ -1,4 +1,3 @@ -import 'dart:isolate'; import 'dart:io'; import 'package:flutter_foreground_task/flutter_foreground_task.dart'; @@ -15,20 +14,14 @@ class BackgroundService { channelDescription: 'Keeps MeshCore running in the background.', channelImportance: NotificationChannelImportance.LOW, priority: NotificationPriority.LOW, - iconData: const NotificationIconData( - resType: ResourceType.mipmap, - resPrefix: ResourcePrefix.ic, - name: 'launcher', - ), ), iosNotificationOptions: const IOSNotificationOptions( showNotification: false, playSound: false, ), - foregroundTaskOptions: const ForegroundTaskOptions( - interval: 5000, + foregroundTaskOptions: ForegroundTaskOptions( + eventAction: ForegroundTaskEventAction.repeat(5000), autoRunOnBoot: false, - allowWakeLock: true, allowWifiLock: false, ), ); @@ -64,13 +57,13 @@ void startCallback() { class _MeshCoreTaskHandler extends TaskHandler { @override - void onStart(DateTime timestamp, SendPort? sendPort) {} + Future onStart(DateTime timestamp, TaskStarter starter) async {} @override - void onRepeatEvent(DateTime timestamp, SendPort? sendPort) {} + void onRepeatEvent(DateTime timestamp) {} @override - void onDestroy(DateTime timestamp, SendPort? sendPort) {} + Future onDestroy(DateTime timestamp, bool isTimeout) async {} @override void onNotificationButtonPressed(String id) {} diff --git a/lib/services/ble_debug_log_service.dart b/lib/services/ble_debug_log_service.dart index 0a9aeaef..bc46b599 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 00000000..21d6a5f8 --- /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 00000000..7f056c81 --- /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 9cbd68f7..694a6162 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 57331aa2..0b59bbcc 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -58,16 +58,22 @@ 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 { await _notifications.initialize( - initSettings, + settings: initSettings, onDidReceiveNotificationResponse: _onNotificationTapped, ); _isInitialized = true; @@ -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( - contactId?.hashCode ?? 0, - contactName, - message, - 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( - contactId?.hashCode ?? DateTime.now().millisecondsSinceEpoch, - _l10n.notification_newTypeDiscovered(contactType), - contactName, - 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( - channelIndex?.hashCode ?? DateTime.now().millisecondsSinceEpoch, - channelName, - body, - 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. @@ -285,7 +304,7 @@ class NotificationService { } Future cancel(int id) async { - await _notifications.cancel(id); + await _notifications.cancel(id: id); } // ───────────────────────────────────────────────────────────────── @@ -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( - 'batch_summary'.hashCode, - _l10n.notification_activityTitle, - parts.join(', '), - 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 08d158b4..504ff165 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 00000000..2bf4a5df --- /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 00000000..12363dd7 --- /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 00000000..e1cda778 --- /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 78374150..ccea59dd 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 00000000..f0c6815e --- /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 4b233708..63ab5807 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; const int _actionTogglePrioritizeUsers = 10; class ContactsFilterMenu extends StatelessWidget { @@ -164,6 +165,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, @@ -211,6 +217,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 00000000..0689f0b5 --- /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 483697f1..c2b6d12a 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/qr_scanner_widget.dart b/lib/widgets/qr_scanner_widget.dart index 4dc2ee51..5b6cf5e3 100644 --- a/lib/widgets/qr_scanner_widget.dart +++ b/lib/widgets/qr_scanner_widget.dart @@ -156,7 +156,7 @@ class _QrScannerWidgetState extends State MobileScanner( controller: _controller, onDetect: _handleDetection, - errorBuilder: (context, error, child) { + errorBuilder: (context, error) { return _buildErrorWidget(context, error); }, ), diff --git a/lib/widgets/snr_indicator.dart b/lib/widgets/snr_indicator.dart index da68a65a..db4fb8e1 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 65fed266..8224cfba 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 2e3329c4..00000000 --- a/pubspec.lock +++ /dev/null @@ -1,1143 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - archive: - dependency: transitive - description: - name: archive - sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff - url: "https://pub.dev" - source: hosted - version: "4.0.9" - 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: "4fba86c513feab2c5cdb9497da0910ed5b50c0fa8d6cec4a26ffb1a558a24eb8" - url: "https://pub.dev" - source: hosted - version: "2.2.1" - flutter_blue_plus_android: - dependency: transitive - description: - name: flutter_blue_plus_android - sha256: "2a73e264685574d1d29dcdd565bad9ecfdf237630237c508ae8b47f5cc791f1d" - url: "https://pub.dev" - source: hosted - version: "8.2.1" - flutter_blue_plus_darwin: - dependency: transitive - description: - name: flutter_blue_plus_darwin - sha256: cfef171db550670cf8110f6eb25baf15d9bc8bad2af29550f9bbc0d8fceaf285 - url: "https://pub.dev" - source: hosted - version: "8.2.1" - flutter_blue_plus_linux: - dependency: transitive - description: - name: flutter_blue_plus_linux - sha256: "5add6c14d2f90672c5e3ded1455b9ca8e6fe44adf9b53cdc60eb3417d38f34fe" - url: "https://pub.dev" - source: hosted - version: "8.2.1" - flutter_blue_plus_platform_interface: - dependency: transitive - description: - name: flutter_blue_plus_platform_interface - sha256: "226fb6753a74a407e3b9975c0fc00de02c490ae655b31c6508cb5790ad30965d" - url: "https://pub.dev" - source: hosted - version: "8.2.1" - flutter_blue_plus_web: - dependency: transitive - description: - name: flutter_blue_plus_web - sha256: "10a7465ccfc50138280abf32c8ab314f5029aa19039628ad9b4d0ed786e0021f" - url: "https://pub.dev" - source: hosted - version: "8.2.1" - 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_svg: - dependency: "direct main" - description: - name: flutter_svg - sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" - url: "https://pub.dev" - source: hosted - version: "2.2.3" - 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: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce - url: "https://pub.dev" - source: hosted - version: "4.8.0" - 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: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 - url: "https://pub.dev" - source: hosted - version: "4.11.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" - material_symbols_icons: - dependency: "direct main" - description: - name: material_symbols_icons - sha256: c62b15f2b3de98d72cbff0148812f5ef5159f05e61fc9f9a089ec2bb234df082 - url: "https://pub.dev" - source: hosted - version: "4.2906.0" - 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: c92c26bf2231695b6d3477c8dcf435f51e28f87b1745966b1fe4c47a286171ce - url: "https://pub.dev" - source: hosted - version: "7.2.0" - 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_parsing: - dependency: transitive - description: - name: path_parsing - sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - 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: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" - url: "https://pub.dev" - source: hosted - version: "7.0.2" - 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: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" - url: "https://pub.dev" - source: hosted - version: "6.5.0" - 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: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41" - url: "https://pub.dev" - source: hosted - version: "2.4.21" - 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: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" - url: "https://pub.dev" - source: hosted - version: "4.5.3" - vector_graphics: - dependency: transitive - description: - name: vector_graphics - sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 - url: "https://pub.dev" - source: hosted - version: "1.1.19" - vector_graphics_codec: - dependency: transitive - description: - name: vector_graphics_codec - sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" - url: "https://pub.dev" - source: hosted - version: "1.1.13" - vector_graphics_compiler: - dependency: transitive - description: - name: vector_graphics_compiler - sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74" - url: "https://pub.dev" - source: hosted - version: "1.2.0" - 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: "direct main" - 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 6474c5f9..fb68b00a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 5.0.0+6 +version: 6.0.0+7 environment: sdk: ^3.9.2 @@ -41,25 +41,28 @@ dependencies: provider: ^6.1.5+1 shared_preferences: ^2.2.2 uuid: ^4.3.3 - flutter_map: ^7.0.2 + flutter_map: ^8.2.2 latlong2: ^0.9.1 - flutter_local_notifications: ^18.0.1 + flutter_local_notifications: ^20.1.0 crypto: ^3.0.3 - pointycastle: ^3.7.4 + pointycastle: ^4.0.0 http: ^1.2.0 cached_network_image: ^3.4.1 flutter_cache_manager: ^3.4.1 - flutter_foreground_task: ^6.1.2 - wakelock_plus: ^1.2.8 + flutter_foreground_task: ^9.2.0 + wakelock_plus: ^1.4.0 characters: ^1.4.0 - package_info_plus: ^8.0.0 - mobile_scanner: ^6.0.0 # QR/barcode scanning + package_info_plus: ^9.0.0 + mobile_scanner: ^7.1.4 # QR/barcode scanning qr_flutter: ^4.1.0 # QR code generation url_launcher: ^6.3.0 # Launch URLs in system browser flutter_linkify: ^6.0.0 # Auto-detect and linkify URLs in text 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: @@ -70,8 +73,8 @@ dev_dependencies: # activated in the `analysis_options.yaml` file located at the root of your # package. See that file for information about deactivating specific lint # rules and activating additional ones. - flutter_lints: ^5.0.0 - flutter_launcher_icons: ^0.13.1 + flutter_lints: ^6.0.0 + flutter_launcher_icons: ^0.14.4 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -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 00000000..267a70ba --- /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 00000000..65dec1ec --- /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'), + ); + }); + }); +} diff --git a/untranslated.json b/untranslated.json index 29112d61..9e26dfee 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1,53 +1 @@ -{ - "bg": [ - "listFilter_usersFirst" - ], - - "de": [ - "listFilter_usersFirst" - ], - - "es": [ - "listFilter_usersFirst" - ], - - "fr": [ - "listFilter_usersFirst" - ], - - "it": [ - "listFilter_usersFirst" - ], - - "nl": [ - "listFilter_usersFirst" - ], - - "pl": [ - "listFilter_usersFirst" - ], - - "pt": [ - "listFilter_usersFirst" - ], - - "ru": [ - "listFilter_usersFirst" - ], - - "sk": [ - "listFilter_usersFirst" - ], - - "sl": [ - "listFilter_usersFirst" - ], - - "sv": [ - "listFilter_usersFirst" - ], - - "zh": [ - "listFilter_usersFirst" - ] -} +{} \ No newline at end of file