mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-15 07:04:26 +10:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e930ef008e | |||
| c76796cdc3 | |||
| 888cf43fef | |||
| d5f19051b2 | |||
| ed12f1b058 | |||
| 35178852d3 | |||
| 4ce7878539 | |||
| 5dfccb9a94 | |||
| 6946b2050e | |||
| 882abf3879 | |||
| 9dbf374ac6 | |||
| bde9a029c1 | |||
| 411cd3f8d2 | |||
| 38f4de80b6 | |||
| 7de07c023f | |||
| c272c60f9a | |||
| eca78453d6 | |||
| 3754cf14ea | |||
| 834850fb51 | |||
| e7e2bb91b8 | |||
| 4c492f69ef | |||
| 50f2a8b439 | |||
| 2c8a15538e | |||
| 68eeefa04e | |||
| ebbc367fec | |||
| 2da8995d0b | |||
| 1c376b0056 | |||
| da70d5fc08 | |||
| f63bc4b787 | |||
| 9b1f1e1994 | |||
| 5f475fce4d | |||
| 7eff1df6e2 | |||
| bd030153c1 | |||
| 5140ff383d | |||
| dc57f9b9c0 | |||
| 53cd3f4461 | |||
| 35e296f1cd | |||
| 532401cc94 | |||
| 5321974cbb | |||
| 7c16dde989 | |||
| 9a75c912af | |||
| 14f3429eb5 | |||
| e49e80d330 | |||
| d07372c7e0 | |||
| 990f2bd33d | |||
| 29660d520e | |||
| 4f609f160f | |||
| e313bea3fc | |||
| 77be2b8e6f | |||
| c81c3efe7c | |||
| cac0cc15eb | |||
| b88e5e647a | |||
| 87d11c2e6b | |||
| 7b3c099736 | |||
| 11cb14a925 | |||
| d2df2b0bed | |||
| 723bf7293c |
+2
-1
@@ -58,6 +58,7 @@ secrets.dart
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
macos/Flutter/GeneratedPluginRegistrant.swift
|
||||
|
||||
# iOS
|
||||
**/ios/Pods/
|
||||
@@ -85,4 +86,4 @@ keystore.properties
|
||||
.vscode/settings.json
|
||||
|
||||
# Cloudflare Wrangler
|
||||
.wrangler
|
||||
.wrangler
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -202,13 +202,15 @@ const int cmdGetChannel = 31;
|
||||
const int cmdSetChannel = 32;
|
||||
const int cmdSendTracePath = 36;
|
||||
const int cmdSetOtherParams = 38;
|
||||
const int cmdSendAnonReq = 57;
|
||||
const int cmdSendTelemetryReq = 39;
|
||||
const int cmdGetCustomVar = 40;
|
||||
const int cmdSetCustomVar = 41;
|
||||
const int cmdSendBinaryReq = 50;
|
||||
const int cmdGetStats = 56;
|
||||
const int cmdSendAnonReq = 57;
|
||||
const int cmdSetAutoAddConfig = 58;
|
||||
const int cmdGetAutoAddConfig = 59;
|
||||
const int cmdSetPathHashMode = 61;
|
||||
|
||||
// Text message types
|
||||
const int txtTypePlain = 0;
|
||||
@@ -245,6 +247,11 @@ const int respCodeChannelMsgRecvV3 = 17;
|
||||
const int respCodeChannelInfo = 18;
|
||||
const int respCodeCustomVars = 21;
|
||||
const int respCodeAutoAddConfig = 25;
|
||||
const int respCodeStats = 24;
|
||||
|
||||
const int statsTypeCore = 0;
|
||||
const int statsTypeRadio = 1;
|
||||
const int statsTypePackets = 2;
|
||||
|
||||
// Push codes (async from device)
|
||||
const int pushCodeAdvert = 0x80;
|
||||
@@ -554,6 +561,17 @@ Uint8List buildGetBattAndStorageFrame() {
|
||||
return Uint8List.fromList([cmdGetBattAndStorage]);
|
||||
}
|
||||
|
||||
/// Companion radio stats: [56][statsType] where statsType is statsTypeCore/Radio/Packets.
|
||||
Uint8List buildGetStatsFrame(int statsType) {
|
||||
return Uint8List.fromList([cmdGetStats, statsType & 0xFF]);
|
||||
}
|
||||
|
||||
/// Path hash width on air: [61][0][mode], mode 0..2 → (mode+1) bytes per hop hash.
|
||||
Uint8List buildSetPathHashModeFrame(int mode) {
|
||||
final m = mode.clamp(0, 2);
|
||||
return Uint8List.fromList([cmdSetPathHashMode, 0, m]);
|
||||
}
|
||||
|
||||
// Build CMD_SET_DEVICE_TIME frame
|
||||
Uint8List buildSetDeviceTimeFrame(int timestamp) {
|
||||
final writer = BufferWriter();
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
class MeshCoreUuids {
|
||||
static const String service = "6e400001-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
static const String rxCharacteristic = "6e400002-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
static const String txCharacteristic = "6e400003-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
|
||||
static const List<String> deviceNamePrefixes = [
|
||||
"MeshCore-",
|
||||
"Whisper-",
|
||||
"WisCore-",
|
||||
"HT-",
|
||||
];
|
||||
}
|
||||
@@ -5,6 +5,14 @@ import '../l10n/l10n.dart';
|
||||
import '../utils/platform_info.dart';
|
||||
|
||||
class LinkHandler {
|
||||
static TextStyle defaultLinkStyle(BuildContext context, TextStyle base) {
|
||||
final brightness = Theme.of(context).brightness;
|
||||
final orange = brightness == Brightness.dark
|
||||
? const Color(0xFFFFB74D)
|
||||
: const Color(0xFFE65100);
|
||||
return base.copyWith(color: orange, decoration: TextDecoration.underline);
|
||||
}
|
||||
|
||||
/// Returns a [SelectableLinkify] on desktop or a [Linkify] on mobile.
|
||||
static Widget buildLinkifyText({
|
||||
required BuildContext context,
|
||||
@@ -12,14 +20,9 @@ class LinkHandler {
|
||||
required TextStyle style,
|
||||
TextStyle? linkStyle,
|
||||
}) {
|
||||
final effectiveLinkStyle =
|
||||
linkStyle ??
|
||||
style.copyWith(
|
||||
color: Colors.green,
|
||||
decoration: TextDecoration.underline,
|
||||
);
|
||||
final effectiveLinkStyle = linkStyle ?? defaultLinkStyle(context, style);
|
||||
const options = LinkifyOptions(humanize: false, defaultToHttps: false);
|
||||
const linkifiers = [UrlLinkifier()];
|
||||
const linkifiers = [UrlLinkifier(), EmailLinkifier()];
|
||||
void onOpen(LinkableElement link) => handleLinkTap(context, link.url);
|
||||
|
||||
if (PlatformInfo.isDesktop) {
|
||||
|
||||
+79
-1
@@ -1941,5 +1941,83 @@
|
||||
"appSettings_maxMessageRetriesSubtitle": "Брой опити за повторно изпращане, преди съобщението да бъде маркирано като неуспешно.",
|
||||
"path_routeWeight": "{weight}/{max}",
|
||||
"settings_multiAck": "Мулти-потвърди: {value}",
|
||||
"settings_telemetryModeUpdated": "Режим на телеметрията е обновен"
|
||||
"settings_telemetryModeUpdated": "Режим на телеметрията е обновен",
|
||||
"map_showOverlaps": "Покриване на ключа на повтаряча",
|
||||
"map_runTraceWithReturnPath": "Върни се по същия път.",
|
||||
"@radioStats_noiseFloor": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastRssi": {
|
||||
"placeholders": {
|
||||
"rssiDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastSnr": {
|
||||
"placeholders": {
|
||||
"snr": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_txAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_rxAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_stripNoise": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_sendCooldown": "Моля, изчакайте малко, преди да изпратите отново.",
|
||||
"appSettings_languageHu": "Унгарски",
|
||||
"appSettings_jumpToOldestUnread": "Преминете към най-старата непочетена статия",
|
||||
"appSettings_jumpToOldestUnreadSubtitle": "Когато отворите чат с непрочетени съобщения, плъзнете надолу, за да видите първото непрочетено съобщение, вместо най-новото.",
|
||||
"appSettings_languageJa": "Японски",
|
||||
"appSettings_languageKo": "Корейски",
|
||||
"radioStats_tooltip": "Статистика за радио и мрежа",
|
||||
"radioStats_screenTitle": "Статистически данни за радиопредаванията",
|
||||
"radioStats_notConnected": "Свържете се с устройство, за да видите статистически данни за радиопредаване.",
|
||||
"radioStats_firmwareTooOld": "Статистиката на радиостанцията изисква съвместимо софтуерно решение версия 8 или по-нова.",
|
||||
"radioStats_waiting": "Изчакване на данни…",
|
||||
"radioStats_noiseFloor": "Ниво на шума: {noiseDbm} dBm",
|
||||
"radioStats_lastRssi": "Последен RSSI: {rssiDbm} dBm",
|
||||
"radioStats_lastSnr": "Последна стойност на SNR: {snr} dB",
|
||||
"radioStats_txAir": "Време на въздух (общо): {seconds} секунди",
|
||||
"radioStats_rxAir": "Общо време на използване на RX (в секунди): {seconds} с",
|
||||
"radioStats_chartCaption": "Ниво на шума (dBm) за последните измервания.",
|
||||
"radioStats_stripNoise": "Ниво на шума: {noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "Извличане на данни за радиото…",
|
||||
"radioStats_settingsTile": "Статистически данни за радиостанции",
|
||||
"radioStats_settingsSubtitle": "Ниво на шума, RSSI, SNR и време на пренос",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingHidePin": "Скрий ПИН",
|
||||
"scanner_linuxPairingShowPin": "Покажи PIN",
|
||||
"scanner_linuxPairingPinTitle": "PIN код за сдвояване на Bluetooth",
|
||||
"scanner_linuxPairingPinPrompt": "Въведете ПИН за {deviceName} (оставете празно, ако няма).",
|
||||
"repeater_cliQuickClockSync": "Синхронизация на часовника",
|
||||
"repeater_cliQuickDiscovery": "Открий Съседи"
|
||||
}
|
||||
+79
-1
@@ -1969,5 +1969,83 @@
|
||||
"appSettings_maxMessageRetriesSubtitle": "Anzahl der Versuche, eine Nachricht erneut zu senden, bevor sie als fehlgeschlagen markiert wird.",
|
||||
"path_routeWeight": "{weight}/{max}",
|
||||
"settings_telemetryModeUpdated": "Telemetriemodus aktualisiert",
|
||||
"settings_multiAck": "Mehrfach-Bestätigungen: {value}"
|
||||
"settings_multiAck": "Mehrfach-Bestätigungen: {value}",
|
||||
"map_showOverlaps": "Überlappungen der Repeater-Taste",
|
||||
"map_runTraceWithReturnPath": "Auf dem gleichen Pfad zurückkehren.",
|
||||
"@radioStats_noiseFloor": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastRssi": {
|
||||
"placeholders": {
|
||||
"rssiDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastSnr": {
|
||||
"placeholders": {
|
||||
"snr": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_txAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_rxAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_stripNoise": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_sendCooldown": "Bitte warten Sie einen Moment, bevor Sie erneut senden.",
|
||||
"appSettings_jumpToOldestUnread": "Zum ältesten, nicht gelesenen Eintrag springen",
|
||||
"appSettings_languageHu": "Ungarisch",
|
||||
"appSettings_jumpToOldestUnreadSubtitle": "Wenn Sie ein Chatfenster öffnen, in dem Nachrichten vorhanden sind, die noch nicht gelesen wurden, scrollen Sie zu der ersten unlesenen Nachricht, anstatt zur neuesten.",
|
||||
"appSettings_languageJa": "Japanisch",
|
||||
"appSettings_languageKo": "Koreanisch",
|
||||
"radioStats_tooltip": "Daten zu Radio- und Mesh-Netzwerken",
|
||||
"radioStats_screenTitle": "Senderinformationen",
|
||||
"radioStats_notConnected": "Verbinden Sie ein Gerät, um Radiostatisiken anzuzeigen.",
|
||||
"radioStats_firmwareTooOld": "Für die Verwendung der Funkstatistiken ist die Firmware-Version 8 oder höher erforderlich.",
|
||||
"radioStats_waiting": "Warte auf Daten…",
|
||||
"radioStats_noiseFloor": "Rauschpegel: {noiseDbm} dBm",
|
||||
"radioStats_lastRssi": "Letzter RSSI-Wert: {rssiDbm} dBm",
|
||||
"radioStats_lastSnr": "Letzter SNR: {snr} dB",
|
||||
"radioStats_txAir": "Gesamt-TX-Zeit: {seconds} s",
|
||||
"radioStats_rxAir": "Gesamt-RX-Zeit: {seconds} s",
|
||||
"radioStats_chartCaption": "Rauschpegel (dBm) basierend auf den letzten Messwerten.",
|
||||
"radioStats_stripNoise": "Rauschpegel: {noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "Abrufen von Radiostatus…",
|
||||
"radioStats_settingsTile": "Senderinformationen",
|
||||
"radioStats_settingsSubtitle": "Rauschpegel, RSSI, Signal-Rausch-Verhältnis (SNR) und Nutzzeit",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingShowPin": "PIN anzeigen",
|
||||
"scanner_linuxPairingHidePin": "PIN ausblenden",
|
||||
"scanner_linuxPairingPinTitle": "Bluetooth-Paarungs-PIN",
|
||||
"scanner_linuxPairingPinPrompt": "Geben Sie die PIN für {deviceName} ein (leer lassen, falls keine).",
|
||||
"repeater_cliQuickClockSync": "Uhr Synchronisieren",
|
||||
"repeater_cliQuickDiscovery": "Entdecke Nachbarn"
|
||||
}
|
||||
+87
-4
@@ -127,6 +127,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"scanner_stop": "Stop",
|
||||
"scanner_scan": "Scan",
|
||||
"scanner_bluetoothOff": "Bluetooth is off",
|
||||
@@ -302,8 +303,12 @@
|
||||
"path_routeWeight": "{weight}/{max}",
|
||||
"@path_routeWeight": {
|
||||
"placeholders": {
|
||||
"weight": { "type": "String" },
|
||||
"max": { "type": "String" }
|
||||
"weight": {
|
||||
"type": "String"
|
||||
},
|
||||
"max": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_battery": "Battery",
|
||||
@@ -878,6 +883,7 @@
|
||||
"map_chatNodes": "Chat Nodes",
|
||||
"map_repeaters": "Repeaters",
|
||||
"map_otherNodes": "Other Nodes",
|
||||
"map_showOverlaps": "Repeater Key Overlaps",
|
||||
"map_keyPrefix": "Key Prefix",
|
||||
"map_filterByKeyPrefix": "Filter by key prefix",
|
||||
"map_publicKeyPrefix": "Public key prefix",
|
||||
@@ -891,7 +897,8 @@
|
||||
"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_runTrace": "Run path trace",
|
||||
"map_runTraceWithReturnPath": "Return back on the same path.",
|
||||
"map_removeLast": "Remove Last",
|
||||
"map_pathTraceCancelled": "Path trace cancelled.",
|
||||
"mapCache_title": "Offline Map Cache",
|
||||
@@ -1330,6 +1337,8 @@
|
||||
"repeater_cliQuickVersion": "Version",
|
||||
"repeater_cliQuickAdvertise": "Advertise",
|
||||
"repeater_cliQuickClock": "Clock",
|
||||
"repeater_cliQuickClockSync": "Clock Sync",
|
||||
"repeater_cliQuickDiscovery": "Discover Neighbors",
|
||||
"repeater_cliHelpAdvert": "Sends an advertisement packet",
|
||||
"repeater_cliHelpReboot": "Reboots the device. (note, you'll prob get 'Timeout' which is normal)",
|
||||
"repeater_cliHelpClock": "Displays current time per device's clock.",
|
||||
@@ -1975,5 +1984,79 @@
|
||||
"discoveredContacts_copyContact": "Copy Contact to clipboard",
|
||||
"discoveredContacts_deleteContact": "Delete Discovered Contact",
|
||||
"discoveredContacts_deleteContactAll": "Delete All Discovered Contacts",
|
||||
"discoveredContacts_deleteContactAllContent": "Are you sure you want to delete all discovered contacts?"
|
||||
"discoveredContacts_deleteContactAllContent": "Are you sure you want to delete all discovered contacts?",
|
||||
"chat_sendCooldown": "Please wait a moment before sending again.",
|
||||
"appSettings_jumpToOldestUnread": "Jump to oldest unread",
|
||||
"appSettings_jumpToOldestUnreadSubtitle": "When opening a chat with unread messages, scroll to the first unread instead of the latest.",
|
||||
"appSettings_languageHu": "Hungarian",
|
||||
"appSettings_languageJa": "Japanese",
|
||||
"appSettings_languageKo": "Korean",
|
||||
"radioStats_tooltip": "Radio & mesh stats",
|
||||
"radioStats_screenTitle": "Radio stats",
|
||||
"radioStats_notConnected": "Connect to a device to view radio statistics.",
|
||||
"radioStats_firmwareTooOld": "Radio statistics require companion firmware v8 or newer.",
|
||||
"radioStats_waiting": "Waiting for data…",
|
||||
"radioStats_noiseFloor": "Noise floor: {noiseDbm} dBm",
|
||||
"@radioStats_noiseFloor": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"radioStats_lastRssi": "Last RSSI: {rssiDbm} dBm",
|
||||
"@radioStats_lastRssi": {
|
||||
"placeholders": {
|
||||
"rssiDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"radioStats_lastSnr": "Last SNR: {snr} dB",
|
||||
"@radioStats_lastSnr": {
|
||||
"placeholders": {
|
||||
"snr": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"radioStats_txAir": "TX airtime (total): {seconds} s",
|
||||
"@radioStats_txAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"radioStats_rxAir": "RX airtime (total): {seconds} s",
|
||||
"@radioStats_rxAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"radioStats_chartCaption": "Noise floor (dBm) over recent samples.",
|
||||
"radioStats_stripNoise": "Noise floor: {noiseDbm} dBm",
|
||||
"@radioStats_stripNoise": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"radioStats_stripWaiting": "Fetching radio stats…",
|
||||
"radioStats_settingsTile": "Radio stats",
|
||||
"radioStats_settingsSubtitle": "Noise floor, RSSI, SNR, and airtime",
|
||||
"scanner_linuxPairingShowPin": "Show PIN",
|
||||
"scanner_linuxPairingHidePin": "Hide PIN",
|
||||
"scanner_linuxPairingPinTitle": "Bluetooth Pairing PIN",
|
||||
"scanner_linuxPairingPinPrompt": "Enter PIN for {deviceName} (leave blank if none).",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+79
-1
@@ -1969,5 +1969,83 @@
|
||||
"appSettings_maxMessageRetriesSubtitle": "Número de intentos de reintento antes de marcar un mensaje como fallido.",
|
||||
"path_routeWeight": "{weight}/{max}",
|
||||
"settings_telemetryModeUpdated": "Modo de telemetría actualizado",
|
||||
"settings_multiAck": "Multi-ACKs: {value}"
|
||||
"settings_multiAck": "Multi-ACKs: {value}",
|
||||
"map_showOverlaps": "Superposiciones de tecla repetidora",
|
||||
"map_runTraceWithReturnPath": "Volver atrás por el mismo camino.",
|
||||
"@radioStats_noiseFloor": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastRssi": {
|
||||
"placeholders": {
|
||||
"rssiDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastSnr": {
|
||||
"placeholders": {
|
||||
"snr": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_txAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_rxAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_stripNoise": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_jumpToOldestUnread": "Salta a los mensajes más antiguos sin leer",
|
||||
"chat_sendCooldown": "Por favor, espere un momento antes de reenviar.",
|
||||
"appSettings_languageHu": "Húngaro",
|
||||
"appSettings_jumpToOldestUnreadSubtitle": "Cuando abras una conversación con mensajes sin leer, desplázate hacia el primer mensaje sin leer en lugar del más reciente.",
|
||||
"appSettings_languageJa": "Japonés",
|
||||
"appSettings_languageKo": "Coreano",
|
||||
"radioStats_tooltip": "Estadísticas de radio y malla",
|
||||
"radioStats_screenTitle": "Estadísticas de radio",
|
||||
"radioStats_notConnected": "Conéctese a un dispositivo para visualizar estadísticas de radio.",
|
||||
"radioStats_firmwareTooOld": "Las estadísticas de radio requieren un firmware compatible v8 o posterior.",
|
||||
"radioStats_waiting": "Esperando datos…",
|
||||
"radioStats_noiseFloor": "Nivel de ruido: {noiseDbm} dBm",
|
||||
"radioStats_lastRssi": "Último RSSI: {rssiDbm} dBm",
|
||||
"radioStats_lastSnr": "Último SNR: {snr} dB",
|
||||
"radioStats_txAir": "Tiempo de emisión en Texas (total): {seconds} s",
|
||||
"radioStats_rxAir": "Tiempo de transmisión de RX (total): {seconds} s",
|
||||
"radioStats_chartCaption": "Nivel de ruido (dBm) en muestras recientes.",
|
||||
"radioStats_stripNoise": "Nivel de ruido: {noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "Obteniendo estadísticas de la radio…",
|
||||
"radioStats_settingsTile": "Estadísticas de radio",
|
||||
"radioStats_settingsSubtitle": "Nivel de ruido, RSSI, SNR y tiempo de transmisión",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingShowPin": "Mostrar PIN",
|
||||
"scanner_linuxPairingPinTitle": "PIN de emparejamiento Bluetooth",
|
||||
"scanner_linuxPairingHidePin": "Ocultar PIN",
|
||||
"scanner_linuxPairingPinPrompt": "Introduzca el PIN para {deviceName} (déjelo en blanco si no hay ninguno).",
|
||||
"repeater_cliQuickDiscovery": "Descubrir Vecinos",
|
||||
"repeater_cliQuickClockSync": "Sincronización del reloj"
|
||||
}
|
||||
+79
-1
@@ -1941,5 +1941,83 @@
|
||||
"appSettings_maxMessageRetriesSubtitle": "Nombre de tentatives de relance avant de marquer un message comme ayant échoué.",
|
||||
"path_routeWeight": "{weight}/{max}",
|
||||
"settings_multiAck": "Multi-ACKs : {value}",
|
||||
"settings_telemetryModeUpdated": "Le mode télémétrie a été mis à jour"
|
||||
"settings_telemetryModeUpdated": "Le mode télémétrie a été mis à jour",
|
||||
"map_showOverlaps": "Chevauchement de la touche répétitive",
|
||||
"map_runTraceWithReturnPath": "Revenir sur le même chemin.",
|
||||
"@radioStats_noiseFloor": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastRssi": {
|
||||
"placeholders": {
|
||||
"rssiDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastSnr": {
|
||||
"placeholders": {
|
||||
"snr": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_txAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_rxAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_stripNoise": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_sendCooldown": "Veuillez patienter un instant avant de réessayer.",
|
||||
"appSettings_jumpToOldestUnread": "Accéder au message le plus ancien non lu",
|
||||
"appSettings_languageHu": "Hongrois",
|
||||
"appSettings_jumpToOldestUnreadSubtitle": "Lorsque vous ouvrez une conversation contenant des messages non lus, faites défiler la page jusqu'au premier message non lu, plutôt que jusqu'au dernier.",
|
||||
"appSettings_languageJa": "Japonais",
|
||||
"appSettings_languageKo": "Coréen",
|
||||
"radioStats_tooltip": "Statistiques des radios et des réseaux sans fil",
|
||||
"radioStats_screenTitle": "Statistiques de radio",
|
||||
"radioStats_notConnected": "Connectez-vous à un appareil pour visualiser les statistiques de la radio.",
|
||||
"radioStats_firmwareTooOld": "Les statistiques radio nécessitent un firmware compatible v8 ou une version ultérieure.",
|
||||
"radioStats_waiting": "En attente des données…",
|
||||
"radioStats_noiseFloor": "Niveau de bruit : {noiseDbm} dBm",
|
||||
"radioStats_lastRssi": "Dernier RSSI : {rssiDbm} dBm",
|
||||
"radioStats_lastSnr": "Dernier SNR : {snr} dB",
|
||||
"radioStats_txAir": "Temps d'antenne à la télévision du Texas (total) : {seconds} s",
|
||||
"radioStats_rxAir": "Temps d'utilisation de l'appareil RX (total) : {seconds} s",
|
||||
"radioStats_chartCaption": "Niveau de bruit (dBm) sur les échantillons récents.",
|
||||
"radioStats_stripNoise": "Niveau de bruit : {noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "Récupération des statistiques de la radio…",
|
||||
"radioStats_settingsTile": "Statistiques de radio",
|
||||
"radioStats_settingsSubtitle": "Niveau de bruit, RSSI, rapport signal/bruit (SNR) et temps d'antenne",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingShowPin": "Afficher le code PIN",
|
||||
"scanner_linuxPairingHidePin": "Masquer le code PIN",
|
||||
"scanner_linuxPairingPinTitle": "Code PIN d’appairage Bluetooth",
|
||||
"scanner_linuxPairingPinPrompt": "Entrez le code PIN pour {deviceName} (laissez vide si aucun).",
|
||||
"repeater_cliQuickClockSync": "Synchronisation de l'horloge",
|
||||
"repeater_cliQuickDiscovery": "Découvrir les voisins"
|
||||
}
|
||||
+2061
File diff suppressed because it is too large
Load Diff
+79
-1
@@ -1941,5 +1941,83 @@
|
||||
"appSettings_maxMessageRetriesSubtitle": "Numero di tentativi di riprova prima di considerare un messaggio come fallito.",
|
||||
"path_routeWeight": "{weight}/{max}",
|
||||
"settings_telemetryModeUpdated": "Modalità telemetria aggiornata",
|
||||
"settings_multiAck": "Multi-ACKs: {value}"
|
||||
"settings_multiAck": "Multi-ACKs: {value}",
|
||||
"map_showOverlaps": "Sovrapposizioni della chiave ripetitore",
|
||||
"map_runTraceWithReturnPath": "Tornare indietro sullo stesso percorso",
|
||||
"@radioStats_noiseFloor": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastRssi": {
|
||||
"placeholders": {
|
||||
"rssiDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastSnr": {
|
||||
"placeholders": {
|
||||
"snr": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_txAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_rxAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_stripNoise": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_jumpToOldestUnreadSubtitle": "Quando si apre una chat con messaggi non letti, scorrete verso l'alto fino al primo messaggio non letto, invece che al più recente.",
|
||||
"chat_sendCooldown": "Si prega di attendere un momento prima di inviare nuovamente.",
|
||||
"appSettings_jumpToOldestUnread": "Vai al messaggio più vecchio non letto",
|
||||
"appSettings_languageHu": "Ungherese",
|
||||
"appSettings_languageJa": "Giapponese",
|
||||
"appSettings_languageKo": "Coreano",
|
||||
"radioStats_tooltip": "Statistiche per radio e reti",
|
||||
"radioStats_screenTitle": "Statistiche radio",
|
||||
"radioStats_notConnected": "Connettiti a un dispositivo per visualizzare le statistiche radio.",
|
||||
"radioStats_firmwareTooOld": "Le statistiche radio richiedono il firmware versione 8 o successiva.",
|
||||
"radioStats_noiseFloor": "Livello di rumore: {noiseDbm} dBm",
|
||||
"radioStats_waiting": "In attesa dei dati…",
|
||||
"radioStats_lastRssi": "Ultimo valore RSSI: {rssiDbm} dBm",
|
||||
"radioStats_lastSnr": "Ultimo SNR: {snr} dB",
|
||||
"radioStats_txAir": "Tempo di trasmissione in diretta (totale): {seconds} s",
|
||||
"radioStats_rxAir": "Tempo di trasmissione RX (totale): {seconds} s",
|
||||
"radioStats_chartCaption": "Livello di rumore (dBm) misurato su campioni recenti.",
|
||||
"radioStats_stripNoise": "Livello di rumore: {noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "Recupero delle statistiche radio…",
|
||||
"radioStats_settingsTile": "Statistiche radio",
|
||||
"radioStats_settingsSubtitle": "Livello di rumore, RSSI, rapporto segnale/rumore (SNR) e tempo di trasmissione",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingShowPin": "Mostra PIN",
|
||||
"scanner_linuxPairingHidePin": "Nascondi PIN",
|
||||
"scanner_linuxPairingPinTitle": "PIN di associazione Bluetooth",
|
||||
"scanner_linuxPairingPinPrompt": "Inserisci il PIN per {deviceName} (lascia vuoto se non ce n'è).",
|
||||
"repeater_cliQuickClockSync": "Sincronizzazione dell'orologio",
|
||||
"repeater_cliQuickDiscovery": "Scopri i Vicini"
|
||||
}
|
||||
+2061
File diff suppressed because it is too large
Load Diff
+2061
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,10 @@ import 'app_localizations_de.dart';
|
||||
import 'app_localizations_en.dart';
|
||||
import 'app_localizations_es.dart';
|
||||
import 'app_localizations_fr.dart';
|
||||
import 'app_localizations_hu.dart';
|
||||
import 'app_localizations_it.dart';
|
||||
import 'app_localizations_ja.dart';
|
||||
import 'app_localizations_ko.dart';
|
||||
import 'app_localizations_nl.dart';
|
||||
import 'app_localizations_pl.dart';
|
||||
import 'app_localizations_pt.dart';
|
||||
@@ -112,7 +115,10 @@ abstract class AppLocalizations {
|
||||
Locale('en'),
|
||||
Locale('es'),
|
||||
Locale('fr'),
|
||||
Locale('hu'),
|
||||
Locale('it'),
|
||||
Locale('ja'),
|
||||
Locale('ko'),
|
||||
Locale('nl'),
|
||||
Locale('pl'),
|
||||
Locale('pt'),
|
||||
@@ -3052,6 +3058,12 @@ abstract class AppLocalizations {
|
||||
/// **'Other Nodes'**
|
||||
String get map_otherNodes;
|
||||
|
||||
/// No description provided for @map_showOverlaps.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Repeater Key Overlaps'**
|
||||
String get map_showOverlaps;
|
||||
|
||||
/// No description provided for @map_keyPrefix.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -3133,9 +3145,15 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @map_runTrace.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Run Path Trace'**
|
||||
/// **'Run path trace'**
|
||||
String get map_runTrace;
|
||||
|
||||
/// No description provided for @map_runTraceWithReturnPath.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Return back on the same path.'**
|
||||
String get map_runTraceWithReturnPath;
|
||||
|
||||
/// No description provided for @map_removeLast.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -4304,6 +4322,18 @@ abstract class AppLocalizations {
|
||||
/// **'Clock'**
|
||||
String get repeater_cliQuickClock;
|
||||
|
||||
/// No description provided for @repeater_cliQuickClockSync.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Clock Sync'**
|
||||
String get repeater_cliQuickClockSync;
|
||||
|
||||
/// No description provided for @repeater_cliQuickDiscovery.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Discover Neighbors'**
|
||||
String get repeater_cliQuickDiscovery;
|
||||
|
||||
/// No description provided for @repeater_cliHelpAdvert.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -6004,6 +6034,156 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'Are you sure you want to delete all discovered contacts?'**
|
||||
String get discoveredContacts_deleteContactAllContent;
|
||||
|
||||
/// No description provided for @chat_sendCooldown.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Please wait a moment before sending again.'**
|
||||
String get chat_sendCooldown;
|
||||
|
||||
/// No description provided for @appSettings_jumpToOldestUnread.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Jump to oldest unread'**
|
||||
String get appSettings_jumpToOldestUnread;
|
||||
|
||||
/// No description provided for @appSettings_jumpToOldestUnreadSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'When opening a chat with unread messages, scroll to the first unread instead of the latest.'**
|
||||
String get appSettings_jumpToOldestUnreadSubtitle;
|
||||
|
||||
/// No description provided for @appSettings_languageHu.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hungarian'**
|
||||
String get appSettings_languageHu;
|
||||
|
||||
/// No description provided for @appSettings_languageJa.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Japanese'**
|
||||
String get appSettings_languageJa;
|
||||
|
||||
/// No description provided for @appSettings_languageKo.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Korean'**
|
||||
String get appSettings_languageKo;
|
||||
|
||||
/// No description provided for @radioStats_tooltip.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Radio & mesh stats'**
|
||||
String get radioStats_tooltip;
|
||||
|
||||
/// No description provided for @radioStats_screenTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Radio stats'**
|
||||
String get radioStats_screenTitle;
|
||||
|
||||
/// No description provided for @radioStats_notConnected.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Connect to a device to view radio statistics.'**
|
||||
String get radioStats_notConnected;
|
||||
|
||||
/// No description provided for @radioStats_firmwareTooOld.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Radio statistics require companion firmware v8 or newer.'**
|
||||
String get radioStats_firmwareTooOld;
|
||||
|
||||
/// No description provided for @radioStats_waiting.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Waiting for data…'**
|
||||
String get radioStats_waiting;
|
||||
|
||||
/// No description provided for @radioStats_noiseFloor.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Noise floor: {noiseDbm} dBm'**
|
||||
String radioStats_noiseFloor(int noiseDbm);
|
||||
|
||||
/// No description provided for @radioStats_lastRssi.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Last RSSI: {rssiDbm} dBm'**
|
||||
String radioStats_lastRssi(int rssiDbm);
|
||||
|
||||
/// No description provided for @radioStats_lastSnr.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Last SNR: {snr} dB'**
|
||||
String radioStats_lastSnr(String snr);
|
||||
|
||||
/// No description provided for @radioStats_txAir.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'TX airtime (total): {seconds} s'**
|
||||
String radioStats_txAir(int seconds);
|
||||
|
||||
/// No description provided for @radioStats_rxAir.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'RX airtime (total): {seconds} s'**
|
||||
String radioStats_rxAir(int seconds);
|
||||
|
||||
/// No description provided for @radioStats_chartCaption.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Noise floor (dBm) over recent samples.'**
|
||||
String get radioStats_chartCaption;
|
||||
|
||||
/// No description provided for @radioStats_stripNoise.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Noise floor: {noiseDbm} dBm'**
|
||||
String radioStats_stripNoise(int noiseDbm);
|
||||
|
||||
/// No description provided for @radioStats_stripWaiting.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Fetching radio stats…'**
|
||||
String get radioStats_stripWaiting;
|
||||
|
||||
/// No description provided for @radioStats_settingsTile.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Radio stats'**
|
||||
String get radioStats_settingsTile;
|
||||
|
||||
/// No description provided for @radioStats_settingsSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Noise floor, RSSI, SNR, and airtime'**
|
||||
String get radioStats_settingsSubtitle;
|
||||
|
||||
/// No description provided for @scanner_linuxPairingShowPin.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show PIN'**
|
||||
String get scanner_linuxPairingShowPin;
|
||||
|
||||
/// No description provided for @scanner_linuxPairingHidePin.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hide PIN'**
|
||||
String get scanner_linuxPairingHidePin;
|
||||
|
||||
/// No description provided for @scanner_linuxPairingPinTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Bluetooth Pairing PIN'**
|
||||
String get scanner_linuxPairingPinTitle;
|
||||
|
||||
/// No description provided for @scanner_linuxPairingPinPrompt.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enter PIN for {deviceName} (leave blank if none).'**
|
||||
String scanner_linuxPairingPinPrompt(String deviceName);
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
@@ -6022,7 +6202,10 @@ class _AppLocalizationsDelegate
|
||||
'en',
|
||||
'es',
|
||||
'fr',
|
||||
'hu',
|
||||
'it',
|
||||
'ja',
|
||||
'ko',
|
||||
'nl',
|
||||
'pl',
|
||||
'pt',
|
||||
@@ -6051,8 +6234,14 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
|
||||
return AppLocalizationsEs();
|
||||
case 'fr':
|
||||
return AppLocalizationsFr();
|
||||
case 'hu':
|
||||
return AppLocalizationsHu();
|
||||
case 'it':
|
||||
return AppLocalizationsIt();
|
||||
case 'ja':
|
||||
return AppLocalizationsJa();
|
||||
case 'ko':
|
||||
return AppLocalizationsKo();
|
||||
case 'nl':
|
||||
return AppLocalizationsNl();
|
||||
case 'pl':
|
||||
|
||||
@@ -1689,6 +1689,9 @@ class AppLocalizationsBg extends AppLocalizations {
|
||||
@override
|
||||
String get map_otherNodes => 'Други възли';
|
||||
|
||||
@override
|
||||
String get map_showOverlaps => 'Покриване на ключа на повтаряча';
|
||||
|
||||
@override
|
||||
String get map_keyPrefix => 'Префикс на ключа';
|
||||
|
||||
@@ -1733,6 +1736,9 @@ class AppLocalizationsBg extends AppLocalizations {
|
||||
@override
|
||||
String get map_runTrace => 'Изпълни Път на Следване';
|
||||
|
||||
@override
|
||||
String get map_runTraceWithReturnPath => 'Върни се по същия път.';
|
||||
|
||||
@override
|
||||
String get map_removeLast => 'Премахни Последно';
|
||||
|
||||
@@ -2423,6 +2429,12 @@ class AppLocalizationsBg extends AppLocalizations {
|
||||
@override
|
||||
String get repeater_cliQuickClock => 'Часовник';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickClockSync => 'Синхронизация на часовника';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickDiscovery => 'Открий Съседи';
|
||||
|
||||
@override
|
||||
String get repeater_cliHelpAdvert => 'Изпраща рекламен пакет';
|
||||
|
||||
@@ -3478,4 +3490,102 @@ class AppLocalizationsBg extends AppLocalizations {
|
||||
@override
|
||||
String get discoveredContacts_deleteContactAllContent =>
|
||||
'Сигурни ли сте, че искате да изтриете всички открити контакти?';
|
||||
|
||||
@override
|
||||
String get chat_sendCooldown =>
|
||||
'Моля, изчакайте малко, преди да изпратите отново.';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnread =>
|
||||
'Преминете към най-старата непочетена статия';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnreadSubtitle =>
|
||||
'Когато отворите чат с непрочетени съобщения, плъзнете надолу, за да видите първото непрочетено съобщение, вместо най-новото.';
|
||||
|
||||
@override
|
||||
String get appSettings_languageHu => 'Унгарски';
|
||||
|
||||
@override
|
||||
String get appSettings_languageJa => 'Японски';
|
||||
|
||||
@override
|
||||
String get appSettings_languageKo => 'Корейски';
|
||||
|
||||
@override
|
||||
String get radioStats_tooltip => 'Статистика за радио и мрежа';
|
||||
|
||||
@override
|
||||
String get radioStats_screenTitle =>
|
||||
'Статистически данни за радиопредаванията';
|
||||
|
||||
@override
|
||||
String get radioStats_notConnected =>
|
||||
'Свържете се с устройство, за да видите статистически данни за радиопредаване.';
|
||||
|
||||
@override
|
||||
String get radioStats_firmwareTooOld =>
|
||||
'Статистиката на радиостанцията изисква съвместимо софтуерно решение версия 8 или по-нова.';
|
||||
|
||||
@override
|
||||
String get radioStats_waiting => 'Изчакване на данни…';
|
||||
|
||||
@override
|
||||
String radioStats_noiseFloor(int noiseDbm) {
|
||||
return 'Ниво на шума: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastRssi(int rssiDbm) {
|
||||
return 'Последен RSSI: $rssiDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastSnr(String snr) {
|
||||
return 'Последна стойност на SNR: $snr dB';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_txAir(int seconds) {
|
||||
return 'Време на въздух (общо): $seconds секунди';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_rxAir(int seconds) {
|
||||
return 'Общо време на използване на RX (в секунди): $seconds с';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_chartCaption =>
|
||||
'Ниво на шума (dBm) за последните измервания.';
|
||||
|
||||
@override
|
||||
String radioStats_stripNoise(int noiseDbm) {
|
||||
return 'Ниво на шума: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_stripWaiting => 'Извличане на данни за радиото…';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsTile => 'Статистически данни за радиостанции';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Ниво на шума, RSSI, SNR и време на пренос';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Покажи PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Скрий ПИН';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle =>
|
||||
'PIN код за сдвояване на Bluetooth';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Въведете ПИН за $deviceName (оставете празно, ако няма).';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1686,6 +1686,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get map_otherNodes => 'Andere Knoten';
|
||||
|
||||
@override
|
||||
String get map_showOverlaps => 'Überlappungen der Repeater-Taste';
|
||||
|
||||
@override
|
||||
String get map_keyPrefix => 'Schlüsselpräfix';
|
||||
|
||||
@@ -1730,6 +1733,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get map_runTrace => 'Pfadverlauf ausführen';
|
||||
|
||||
@override
|
||||
String get map_runTraceWithReturnPath =>
|
||||
'Auf dem gleichen Pfad zurückkehren.';
|
||||
|
||||
@override
|
||||
String get map_removeLast => 'Letztes Entfernen';
|
||||
|
||||
@@ -2422,6 +2429,12 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get repeater_cliQuickClock => 'Uhr';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickClockSync => 'Uhr Synchronisieren';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickDiscovery => 'Entdecke Nachbarn';
|
||||
|
||||
@override
|
||||
String get repeater_cliHelpAdvert => 'Sendet eine Ankündigung';
|
||||
|
||||
@@ -3487,4 +3500,100 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get discoveredContacts_deleteContactAllContent =>
|
||||
'Sind Sie sicher, dass Sie alle gefundenen Kontakte löschen möchten?';
|
||||
|
||||
@override
|
||||
String get chat_sendCooldown =>
|
||||
'Bitte warten Sie einen Moment, bevor Sie erneut senden.';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnread =>
|
||||
'Zum ältesten, nicht gelesenen Eintrag springen';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnreadSubtitle =>
|
||||
'Wenn Sie ein Chatfenster öffnen, in dem Nachrichten vorhanden sind, die noch nicht gelesen wurden, scrollen Sie zu der ersten unlesenen Nachricht, anstatt zur neuesten.';
|
||||
|
||||
@override
|
||||
String get appSettings_languageHu => 'Ungarisch';
|
||||
|
||||
@override
|
||||
String get appSettings_languageJa => 'Japanisch';
|
||||
|
||||
@override
|
||||
String get appSettings_languageKo => 'Koreanisch';
|
||||
|
||||
@override
|
||||
String get radioStats_tooltip => 'Daten zu Radio- und Mesh-Netzwerken';
|
||||
|
||||
@override
|
||||
String get radioStats_screenTitle => 'Senderinformationen';
|
||||
|
||||
@override
|
||||
String get radioStats_notConnected =>
|
||||
'Verbinden Sie ein Gerät, um Radiostatisiken anzuzeigen.';
|
||||
|
||||
@override
|
||||
String get radioStats_firmwareTooOld =>
|
||||
'Für die Verwendung der Funkstatistiken ist die Firmware-Version 8 oder höher erforderlich.';
|
||||
|
||||
@override
|
||||
String get radioStats_waiting => 'Warte auf Daten…';
|
||||
|
||||
@override
|
||||
String radioStats_noiseFloor(int noiseDbm) {
|
||||
return 'Rauschpegel: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastRssi(int rssiDbm) {
|
||||
return 'Letzter RSSI-Wert: $rssiDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastSnr(String snr) {
|
||||
return 'Letzter SNR: $snr dB';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_txAir(int seconds) {
|
||||
return 'Gesamt-TX-Zeit: $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_rxAir(int seconds) {
|
||||
return 'Gesamt-RX-Zeit: $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_chartCaption =>
|
||||
'Rauschpegel (dBm) basierend auf den letzten Messwerten.';
|
||||
|
||||
@override
|
||||
String radioStats_stripNoise(int noiseDbm) {
|
||||
return 'Rauschpegel: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_stripWaiting => 'Abrufen von Radiostatus…';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsTile => 'Senderinformationen';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Rauschpegel, RSSI, Signal-Rausch-Verhältnis (SNR) und Nutzzeit';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'PIN anzeigen';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'PIN ausblenden';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'Bluetooth-Paarungs-PIN';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Geben Sie die PIN für $deviceName ein (leer lassen, falls keine).';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1656,6 +1656,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get map_otherNodes => 'Other Nodes';
|
||||
|
||||
@override
|
||||
String get map_showOverlaps => 'Repeater Key Overlaps';
|
||||
|
||||
@override
|
||||
String get map_keyPrefix => 'Key Prefix';
|
||||
|
||||
@@ -1696,7 +1699,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get map_tapToAdd => 'Tap on nodes to add them to the path.';
|
||||
|
||||
@override
|
||||
String get map_runTrace => 'Run Path Trace';
|
||||
String get map_runTrace => 'Run path trace';
|
||||
|
||||
@override
|
||||
String get map_runTraceWithReturnPath => 'Return back on the same path.';
|
||||
|
||||
@override
|
||||
String get map_removeLast => 'Remove Last';
|
||||
@@ -2373,6 +2379,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get repeater_cliQuickClock => 'Clock';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickClockSync => 'Clock Sync';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickDiscovery => 'Discover Neighbors';
|
||||
|
||||
@override
|
||||
String get repeater_cliHelpAdvert => 'Sends an advertisement packet';
|
||||
|
||||
@@ -3415,4 +3427,98 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get discoveredContacts_deleteContactAllContent =>
|
||||
'Are you sure you want to delete all discovered contacts?';
|
||||
|
||||
@override
|
||||
String get chat_sendCooldown => 'Please wait a moment before sending again.';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnread => 'Jump to oldest unread';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnreadSubtitle =>
|
||||
'When opening a chat with unread messages, scroll to the first unread instead of the latest.';
|
||||
|
||||
@override
|
||||
String get appSettings_languageHu => 'Hungarian';
|
||||
|
||||
@override
|
||||
String get appSettings_languageJa => 'Japanese';
|
||||
|
||||
@override
|
||||
String get appSettings_languageKo => 'Korean';
|
||||
|
||||
@override
|
||||
String get radioStats_tooltip => 'Radio & mesh stats';
|
||||
|
||||
@override
|
||||
String get radioStats_screenTitle => 'Radio stats';
|
||||
|
||||
@override
|
||||
String get radioStats_notConnected =>
|
||||
'Connect to a device to view radio statistics.';
|
||||
|
||||
@override
|
||||
String get radioStats_firmwareTooOld =>
|
||||
'Radio statistics require companion firmware v8 or newer.';
|
||||
|
||||
@override
|
||||
String get radioStats_waiting => 'Waiting for data…';
|
||||
|
||||
@override
|
||||
String radioStats_noiseFloor(int noiseDbm) {
|
||||
return 'Noise floor: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastRssi(int rssiDbm) {
|
||||
return 'Last RSSI: $rssiDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastSnr(String snr) {
|
||||
return 'Last SNR: $snr dB';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_txAir(int seconds) {
|
||||
return 'TX airtime (total): $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_rxAir(int seconds) {
|
||||
return 'RX airtime (total): $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_chartCaption =>
|
||||
'Noise floor (dBm) over recent samples.';
|
||||
|
||||
@override
|
||||
String radioStats_stripNoise(int noiseDbm) {
|
||||
return 'Noise floor: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_stripWaiting => 'Fetching radio stats…';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsTile => 'Radio stats';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Noise floor, RSSI, SNR, and airtime';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Show PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Hide PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'Bluetooth Pairing PIN';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Enter PIN for $deviceName (leave blank if none).';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1685,6 +1685,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get map_otherNodes => 'Otros Nodos';
|
||||
|
||||
@override
|
||||
String get map_showOverlaps => 'Superposiciones de tecla repetidora';
|
||||
|
||||
@override
|
||||
String get map_keyPrefix => 'Prefijo de clave';
|
||||
|
||||
@@ -1728,6 +1731,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get map_runTrace => 'Ejecutar Rastreo de Ruta';
|
||||
|
||||
@override
|
||||
String get map_runTraceWithReturnPath => 'Volver atrás por el mismo camino.';
|
||||
|
||||
@override
|
||||
String get map_removeLast => 'Eliminar último';
|
||||
|
||||
@@ -2417,6 +2423,12 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get repeater_cliQuickClock => 'Reloj';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickClockSync => 'Sincronización del reloj';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickDiscovery => 'Descubrir Vecinos';
|
||||
|
||||
@override
|
||||
String get repeater_cliHelpAdvert => 'Envía un paquete de publicidad';
|
||||
|
||||
@@ -3481,4 +3493,100 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get discoveredContacts_deleteContactAllContent =>
|
||||
'¿Está seguro de que desea eliminar todos los contactos descubiertos!';
|
||||
|
||||
@override
|
||||
String get chat_sendCooldown =>
|
||||
'Por favor, espere un momento antes de reenviar.';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnread =>
|
||||
'Salta a los mensajes más antiguos sin leer';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnreadSubtitle =>
|
||||
'Cuando abras una conversación con mensajes sin leer, desplázate hacia el primer mensaje sin leer en lugar del más reciente.';
|
||||
|
||||
@override
|
||||
String get appSettings_languageHu => 'Húngaro';
|
||||
|
||||
@override
|
||||
String get appSettings_languageJa => 'Japonés';
|
||||
|
||||
@override
|
||||
String get appSettings_languageKo => 'Coreano';
|
||||
|
||||
@override
|
||||
String get radioStats_tooltip => 'Estadísticas de radio y malla';
|
||||
|
||||
@override
|
||||
String get radioStats_screenTitle => 'Estadísticas de radio';
|
||||
|
||||
@override
|
||||
String get radioStats_notConnected =>
|
||||
'Conéctese a un dispositivo para visualizar estadísticas de radio.';
|
||||
|
||||
@override
|
||||
String get radioStats_firmwareTooOld =>
|
||||
'Las estadísticas de radio requieren un firmware compatible v8 o posterior.';
|
||||
|
||||
@override
|
||||
String get radioStats_waiting => 'Esperando datos…';
|
||||
|
||||
@override
|
||||
String radioStats_noiseFloor(int noiseDbm) {
|
||||
return 'Nivel de ruido: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastRssi(int rssiDbm) {
|
||||
return 'Último RSSI: $rssiDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastSnr(String snr) {
|
||||
return 'Último SNR: $snr dB';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_txAir(int seconds) {
|
||||
return 'Tiempo de emisión en Texas (total): $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_rxAir(int seconds) {
|
||||
return 'Tiempo de transmisión de RX (total): $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_chartCaption =>
|
||||
'Nivel de ruido (dBm) en muestras recientes.';
|
||||
|
||||
@override
|
||||
String radioStats_stripNoise(int noiseDbm) {
|
||||
return 'Nivel de ruido: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_stripWaiting => 'Obteniendo estadísticas de la radio…';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsTile => 'Estadísticas de radio';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Nivel de ruido, RSSI, SNR y tiempo de transmisión';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Mostrar PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Ocultar PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'PIN de emparejamiento Bluetooth';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Introduzca el PIN para $deviceName (déjelo en blanco si no hay ninguno).';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1695,6 +1695,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get map_otherNodes => 'Autres nœuds';
|
||||
|
||||
@override
|
||||
String get map_showOverlaps => 'Chevauchement de la touche répétitive';
|
||||
|
||||
@override
|
||||
String get map_keyPrefix => 'Préfixe clé';
|
||||
|
||||
@@ -1739,6 +1742,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get map_runTrace => 'Exécuter la traçage de chemin';
|
||||
|
||||
@override
|
||||
String get map_runTraceWithReturnPath => 'Revenir sur le même chemin.';
|
||||
|
||||
@override
|
||||
String get map_removeLast => 'Supprimer le dernier';
|
||||
|
||||
@@ -2436,6 +2442,12 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get repeater_cliQuickClock => 'Horloge';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickClockSync => 'Synchronisation de l\'horloge';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickDiscovery => 'Découvrir les voisins';
|
||||
|
||||
@override
|
||||
String get repeater_cliHelpAdvert => 'Envoie un paquet d\'annonce';
|
||||
|
||||
@@ -3505,4 +3517,102 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get discoveredContacts_deleteContactAllContent =>
|
||||
'Êtes-vous sûr de vouloir supprimer tous les contacts découverts ?';
|
||||
|
||||
@override
|
||||
String get chat_sendCooldown =>
|
||||
'Veuillez patienter un instant avant de réessayer.';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnread =>
|
||||
'Accéder au message le plus ancien non lu';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnreadSubtitle =>
|
||||
'Lorsque vous ouvrez une conversation contenant des messages non lus, faites défiler la page jusqu\'au premier message non lu, plutôt que jusqu\'au dernier.';
|
||||
|
||||
@override
|
||||
String get appSettings_languageHu => 'Hongrois';
|
||||
|
||||
@override
|
||||
String get appSettings_languageJa => 'Japonais';
|
||||
|
||||
@override
|
||||
String get appSettings_languageKo => 'Coréen';
|
||||
|
||||
@override
|
||||
String get radioStats_tooltip =>
|
||||
'Statistiques des radios et des réseaux sans fil';
|
||||
|
||||
@override
|
||||
String get radioStats_screenTitle => 'Statistiques de radio';
|
||||
|
||||
@override
|
||||
String get radioStats_notConnected =>
|
||||
'Connectez-vous à un appareil pour visualiser les statistiques de la radio.';
|
||||
|
||||
@override
|
||||
String get radioStats_firmwareTooOld =>
|
||||
'Les statistiques radio nécessitent un firmware compatible v8 ou une version ultérieure.';
|
||||
|
||||
@override
|
||||
String get radioStats_waiting => 'En attente des données…';
|
||||
|
||||
@override
|
||||
String radioStats_noiseFloor(int noiseDbm) {
|
||||
return 'Niveau de bruit : $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastRssi(int rssiDbm) {
|
||||
return 'Dernier RSSI : $rssiDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastSnr(String snr) {
|
||||
return 'Dernier SNR : $snr dB';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_txAir(int seconds) {
|
||||
return 'Temps d\'antenne à la télévision du Texas (total) : $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_rxAir(int seconds) {
|
||||
return 'Temps d\'utilisation de l\'appareil RX (total) : $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_chartCaption =>
|
||||
'Niveau de bruit (dBm) sur les échantillons récents.';
|
||||
|
||||
@override
|
||||
String radioStats_stripNoise(int noiseDbm) {
|
||||
return 'Niveau de bruit : $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_stripWaiting =>
|
||||
'Récupération des statistiques de la radio…';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsTile => 'Statistiques de radio';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Niveau de bruit, RSSI, rapport signal/bruit (SNR) et temps d\'antenne';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Afficher le code PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Masquer le code PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'Code PIN d’appairage Bluetooth';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Entrez le code PIN pour $deviceName (laissez vide si aucun).';
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1687,6 +1687,9 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get map_otherNodes => 'Altri Nodi';
|
||||
|
||||
@override
|
||||
String get map_showOverlaps => 'Sovrapposizioni della chiave ripetitore';
|
||||
|
||||
@override
|
||||
String get map_keyPrefix => 'Prefisso Chiave';
|
||||
|
||||
@@ -1729,6 +1732,10 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get map_runTrace => 'Esegui Path Trace';
|
||||
|
||||
@override
|
||||
String get map_runTraceWithReturnPath =>
|
||||
'Tornare indietro sullo stesso percorso';
|
||||
|
||||
@override
|
||||
String get map_removeLast => 'Rimuovi ultimo';
|
||||
|
||||
@@ -2419,6 +2426,12 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get repeater_cliQuickClock => 'Orologio';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickClockSync => 'Sincronizzazione dell\'orologio';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickDiscovery => 'Scopri i Vicini';
|
||||
|
||||
@override
|
||||
String get repeater_cliHelpAdvert => 'Invia un pacchetto pubblicitario';
|
||||
|
||||
@@ -3484,4 +3497,100 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get discoveredContacts_deleteContactAllContent =>
|
||||
'Sei sicuro di voler eliminare tutti i contatti scoperti?';
|
||||
|
||||
@override
|
||||
String get chat_sendCooldown =>
|
||||
'Si prega di attendere un momento prima di inviare nuovamente.';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnread =>
|
||||
'Vai al messaggio più vecchio non letto';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnreadSubtitle =>
|
||||
'Quando si apre una chat con messaggi non letti, scorrete verso l\'alto fino al primo messaggio non letto, invece che al più recente.';
|
||||
|
||||
@override
|
||||
String get appSettings_languageHu => 'Ungherese';
|
||||
|
||||
@override
|
||||
String get appSettings_languageJa => 'Giapponese';
|
||||
|
||||
@override
|
||||
String get appSettings_languageKo => 'Coreano';
|
||||
|
||||
@override
|
||||
String get radioStats_tooltip => 'Statistiche per radio e reti';
|
||||
|
||||
@override
|
||||
String get radioStats_screenTitle => 'Statistiche radio';
|
||||
|
||||
@override
|
||||
String get radioStats_notConnected =>
|
||||
'Connettiti a un dispositivo per visualizzare le statistiche radio.';
|
||||
|
||||
@override
|
||||
String get radioStats_firmwareTooOld =>
|
||||
'Le statistiche radio richiedono il firmware versione 8 o successiva.';
|
||||
|
||||
@override
|
||||
String get radioStats_waiting => 'In attesa dei dati…';
|
||||
|
||||
@override
|
||||
String radioStats_noiseFloor(int noiseDbm) {
|
||||
return 'Livello di rumore: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastRssi(int rssiDbm) {
|
||||
return 'Ultimo valore RSSI: $rssiDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastSnr(String snr) {
|
||||
return 'Ultimo SNR: $snr dB';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_txAir(int seconds) {
|
||||
return 'Tempo di trasmissione in diretta (totale): $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_rxAir(int seconds) {
|
||||
return 'Tempo di trasmissione RX (totale): $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_chartCaption =>
|
||||
'Livello di rumore (dBm) misurato su campioni recenti.';
|
||||
|
||||
@override
|
||||
String radioStats_stripNoise(int noiseDbm) {
|
||||
return 'Livello di rumore: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_stripWaiting => 'Recupero delle statistiche radio…';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsTile => 'Statistiche radio';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Livello di rumore, RSSI, rapporto segnale/rumore (SNR) e tempo di trasmissione';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Mostra PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Nascondi PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'PIN di associazione Bluetooth';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Inserisci il PIN per $deviceName (lascia vuoto se non ce n\'è).';
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1674,6 +1674,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get map_otherNodes => 'Andere Nodes';
|
||||
|
||||
@override
|
||||
String get map_showOverlaps => 'Herhalingssleutel overlapt';
|
||||
|
||||
@override
|
||||
String get map_keyPrefix => 'Prefix sleutel';
|
||||
|
||||
@@ -1718,6 +1721,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get map_runTrace => 'Padeshulp traceren';
|
||||
|
||||
@override
|
||||
String get map_runTraceWithReturnPath => 'Terugkeren op hetzelfde pad.';
|
||||
|
||||
@override
|
||||
String get map_removeLast => 'Verwijder Laatste';
|
||||
|
||||
@@ -2403,6 +2409,12 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get repeater_cliQuickClock => 'Tijd';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickClockSync => 'Kloksynchronisatie';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickDiscovery => 'Ontdek Buren';
|
||||
|
||||
@override
|
||||
String get repeater_cliHelpAdvert => 'Verstuurt een advertentiepakket';
|
||||
|
||||
@@ -3463,4 +3475,100 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get discoveredContacts_deleteContactAllContent =>
|
||||
'Weet u zeker dat u alle ontdekte contacten wilt verwijderen?';
|
||||
|
||||
@override
|
||||
String get chat_sendCooldown =>
|
||||
'Gelieve even te wachten voordat u opnieuw verzendt.';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnread =>
|
||||
'Ga naar het oudste ongelezen bericht';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnreadSubtitle =>
|
||||
'Bij het openen van een chat met ongelezen berichten, scroll dan naar het eerste ongelezen bericht, in plaats van naar het meest recente.';
|
||||
|
||||
@override
|
||||
String get appSettings_languageHu => 'Hongaars';
|
||||
|
||||
@override
|
||||
String get appSettings_languageJa => 'Japanisch';
|
||||
|
||||
@override
|
||||
String get appSettings_languageKo => 'Koreaans';
|
||||
|
||||
@override
|
||||
String get radioStats_tooltip => 'Statistieken voor radio en mesh-netwerken';
|
||||
|
||||
@override
|
||||
String get radioStats_screenTitle => 'Statistieken over radio';
|
||||
|
||||
@override
|
||||
String get radioStats_notConnected =>
|
||||
'Verbind met een apparaat om radio-statistieken te bekijken.';
|
||||
|
||||
@override
|
||||
String get radioStats_firmwareTooOld =>
|
||||
'Om de statistieken via radio te kunnen gebruiken, is firmware versie 8 of een nieuwere vereist.';
|
||||
|
||||
@override
|
||||
String get radioStats_waiting => 'Wacht op gegevens…';
|
||||
|
||||
@override
|
||||
String radioStats_noiseFloor(int noiseDbm) {
|
||||
return 'Ruisfrequentie: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastRssi(int rssiDbm) {
|
||||
return 'Laatste RSSI-waarde: $rssiDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastSnr(String snr) {
|
||||
return 'Laatste SNR: $snr dB';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_txAir(int seconds) {
|
||||
return 'TX-tijd (totaal): $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_rxAir(int seconds) {
|
||||
return 'Tijd besteed met RX (totaal): $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_chartCaption =>
|
||||
'Ruisfrequentie (dBm) over recente metingen.';
|
||||
|
||||
@override
|
||||
String radioStats_stripNoise(int noiseDbm) {
|
||||
return 'Ruisfrequentie: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_stripWaiting => 'Radio-statistieken ophalen…';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsTile => 'Statistieken over radio';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Ruimtelijke ruis, RSSI, SNR en beschikbare tijd';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Toon PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'PIN verbergen';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'Bluetooth‑koppelings‑PIN';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Voer PIN in voor $deviceName (laat leeg als er geen is).';
|
||||
}
|
||||
}
|
||||
|
||||
+349
-228
File diff suppressed because it is too large
Load Diff
@@ -1686,6 +1686,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get map_otherNodes => 'Outros Nós';
|
||||
|
||||
@override
|
||||
String get map_showOverlaps => 'Sobreposições da Chave Repeater';
|
||||
|
||||
@override
|
||||
String get map_keyPrefix => 'Prefixo Chave';
|
||||
|
||||
@@ -1729,6 +1732,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get map_runTrace => 'Executar Traçado de Caminho';
|
||||
|
||||
@override
|
||||
String get map_runTraceWithReturnPath => 'Retornar ao mesmo caminho.';
|
||||
|
||||
@override
|
||||
String get map_removeLast => 'Remover Último';
|
||||
|
||||
@@ -2417,6 +2423,12 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get repeater_cliQuickClock => 'Relógio';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickClockSync => 'Sincronização do Relógio';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickDiscovery => 'Descobrir Vizinhos';
|
||||
|
||||
@override
|
||||
String get repeater_cliHelpAdvert => 'Envia um pacote de anúncios';
|
||||
|
||||
@@ -3478,4 +3490,100 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get discoveredContacts_deleteContactAllContent =>
|
||||
'Tem certeza de que deseja excluir todos os contatos descobertos?';
|
||||
|
||||
@override
|
||||
String get chat_sendCooldown =>
|
||||
'Por favor, aguarde um momento antes de reenviar.';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnread =>
|
||||
'Vá para a mensagem mais antiga não lida';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnreadSubtitle =>
|
||||
'Ao abrir uma conversa com mensagens não lidas, role para a primeira mensagem não lida, em vez da mais recente.';
|
||||
|
||||
@override
|
||||
String get appSettings_languageHu => 'Húngaro';
|
||||
|
||||
@override
|
||||
String get appSettings_languageJa => 'Japonês';
|
||||
|
||||
@override
|
||||
String get appSettings_languageKo => 'Coreano';
|
||||
|
||||
@override
|
||||
String get radioStats_tooltip => 'Estatísticas de rádio e malha';
|
||||
|
||||
@override
|
||||
String get radioStats_screenTitle => 'Estatísticas de rádio';
|
||||
|
||||
@override
|
||||
String get radioStats_notConnected =>
|
||||
'Conecte-se a um dispositivo para visualizar estatísticas de rádio.';
|
||||
|
||||
@override
|
||||
String get radioStats_firmwareTooOld =>
|
||||
'As estatísticas de rádio exigem o firmware v8 ou uma versão mais recente.';
|
||||
|
||||
@override
|
||||
String get radioStats_waiting => 'Aguardando dados…';
|
||||
|
||||
@override
|
||||
String radioStats_noiseFloor(int noiseDbm) {
|
||||
return 'Nível de ruído: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastRssi(int rssiDbm) {
|
||||
return 'Último RSSI: $rssiDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastSnr(String snr) {
|
||||
return 'Último SNR: $snr dB';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_txAir(int seconds) {
|
||||
return 'Tempo de transmissão da TX (total): $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_rxAir(int seconds) {
|
||||
return 'Tempo de uso do RX (total): $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_chartCaption =>
|
||||
'Nível de ruído (dBm) em amostras recentes.';
|
||||
|
||||
@override
|
||||
String radioStats_stripNoise(int noiseDbm) {
|
||||
return 'Nível de ruído: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_stripWaiting => 'Obtendo estatísticas de rádio…';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsTile => 'Estatísticas de rádio';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Nível de ruído, RSSI, SNR e tempo de transmissão';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Mostrar PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Ocultar PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'PIN de emparelhamento Bluetooth';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Insira o PIN para $deviceName (deixe em branco se não houver).';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1689,6 +1689,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get map_otherNodes => 'Другие ноды';
|
||||
|
||||
@override
|
||||
String get map_showOverlaps => 'Перекрытия ключа повтора';
|
||||
|
||||
@override
|
||||
String get map_keyPrefix => 'Префикс ключа';
|
||||
|
||||
@@ -1732,6 +1735,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get map_runTrace => 'Запустить трассировку пути';
|
||||
|
||||
@override
|
||||
String get map_runTraceWithReturnPath => 'Вернуться обратно по тому же пути';
|
||||
|
||||
@override
|
||||
String get map_removeLast => 'Удалить последний';
|
||||
|
||||
@@ -2421,6 +2427,12 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get repeater_cliQuickClock => 'Время';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickClockSync => 'Синхронизация часов';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickDiscovery => 'Обнаружить Соседей';
|
||||
|
||||
@override
|
||||
String get repeater_cliHelpAdvert => 'Отправляет пакет анонсирования';
|
||||
|
||||
@@ -3492,4 +3504,100 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get discoveredContacts_deleteContactAllContent =>
|
||||
'Вы уверены, что хотите удалить все обнаруженные контакты?';
|
||||
|
||||
@override
|
||||
String get chat_sendCooldown =>
|
||||
'Пожалуйста, подождите немного, прежде чем отправлять сообщение снова.';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnread =>
|
||||
'Перейти к самому старому непрочитанному сообщению';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnreadSubtitle =>
|
||||
'При открытии чата с непрочитанными сообщениями, прокрутите страницу, чтобы увидеть первое непрочитанное сообщение, а не последнее.';
|
||||
|
||||
@override
|
||||
String get appSettings_languageHu => 'Венгерский';
|
||||
|
||||
@override
|
||||
String get appSettings_languageJa => 'Японский';
|
||||
|
||||
@override
|
||||
String get appSettings_languageKo => 'Корейский';
|
||||
|
||||
@override
|
||||
String get radioStats_tooltip => 'Статистика радио и беспроводной сети';
|
||||
|
||||
@override
|
||||
String get radioStats_screenTitle => 'Статистика радиовещания';
|
||||
|
||||
@override
|
||||
String get radioStats_notConnected =>
|
||||
'Подключитесь к устройству, чтобы просмотреть статистику радио.';
|
||||
|
||||
@override
|
||||
String get radioStats_firmwareTooOld =>
|
||||
'Для работы радиостатистики требуется установленная версия прошивки v8 или более новая.';
|
||||
|
||||
@override
|
||||
String get radioStats_waiting => 'Ожидаем данных…';
|
||||
|
||||
@override
|
||||
String radioStats_noiseFloor(int noiseDbm) {
|
||||
return 'Уровень шума: $noiseDbm дБм';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastRssi(int rssiDbm) {
|
||||
return 'Последнее значение RSSI: $rssiDbm дБм';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastSnr(String snr) {
|
||||
return 'Последнее значение SNR: $snr дБ';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_txAir(int seconds) {
|
||||
return 'Время эфира на телеканале TX (общее): $seconds секунд';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_rxAir(int seconds) {
|
||||
return 'Общее время использования RX (в секундах): $seconds с';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_chartCaption =>
|
||||
'Уровень шума (дБм) на основе последних измерений.';
|
||||
|
||||
@override
|
||||
String radioStats_stripNoise(int noiseDbm) {
|
||||
return 'Уровень шума: $noiseDbm дБм';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_stripWaiting => 'Получение данных о радио…';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsTile => 'Статистика радиовещания';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Уровень шума, RSSI, SNR и время передачи';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Показать PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Скрыть PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'PIN‑код сопряжения Bluetooth';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Введите PIN‑код для $deviceName (оставьте пустым, если нет).';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1675,6 +1675,9 @@ class AppLocalizationsSk extends AppLocalizations {
|
||||
@override
|
||||
String get map_otherNodes => 'Ostatné uzly';
|
||||
|
||||
@override
|
||||
String get map_showOverlaps => 'Prekrývanie opakovača kľúča';
|
||||
|
||||
@override
|
||||
String get map_keyPrefix => 'Päťciferné predpona';
|
||||
|
||||
@@ -1718,6 +1721,9 @@ class AppLocalizationsSk extends AppLocalizations {
|
||||
@override
|
||||
String get map_runTrace => 'Spustiť trasovaním cesty';
|
||||
|
||||
@override
|
||||
String get map_runTraceWithReturnPath => 'Vráťte sa späť po tej istej ceste.';
|
||||
|
||||
@override
|
||||
String get map_removeLast => 'Odstrániť posledný';
|
||||
|
||||
@@ -2400,6 +2406,12 @@ class AppLocalizationsSk extends AppLocalizations {
|
||||
@override
|
||||
String get repeater_cliQuickClock => 'Hodiny';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickClockSync => 'Synchronizácia hodin';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickDiscovery => 'Objaviť susedov';
|
||||
|
||||
@override
|
||||
String get repeater_cliHelpAdvert => 'Odosiela reklamnú balíček.';
|
||||
|
||||
@@ -3458,4 +3470,98 @@ class AppLocalizationsSk extends AppLocalizations {
|
||||
@override
|
||||
String get discoveredContacts_deleteContactAllContent =>
|
||||
'Ste si istí, že chcete zmazať všetky objavené kontakty?';
|
||||
|
||||
@override
|
||||
String get chat_sendCooldown => 'Prosím, počkajte chvíľu, než zašlete znova.';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnread => 'Presk oceň';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnreadSubtitle =>
|
||||
'Pri otvorení chatu s neprečítanými správami, prejdite do prvého neprečítaného, namiesto poslednej.';
|
||||
|
||||
@override
|
||||
String get appSettings_languageHu => 'Maďarský';
|
||||
|
||||
@override
|
||||
String get appSettings_languageJa => 'Japonský';
|
||||
|
||||
@override
|
||||
String get appSettings_languageKo => 'Kórejský';
|
||||
|
||||
@override
|
||||
String get radioStats_tooltip => 'Statistiky rádiových a sieťových kanálov';
|
||||
|
||||
@override
|
||||
String get radioStats_screenTitle => 'Štatistiky rádiových vysielaní';
|
||||
|
||||
@override
|
||||
String get radioStats_notConnected =>
|
||||
'Pripojte sa k zariadeniu, aby ste mohli sledovať štatistiky rádiového vysielania.';
|
||||
|
||||
@override
|
||||
String get radioStats_firmwareTooOld =>
|
||||
'Statistické údaje z rádia vyžadujú sprievodný softvér verzie v8 alebo novšej.';
|
||||
|
||||
@override
|
||||
String get radioStats_waiting => 'Čakám na údaje…';
|
||||
|
||||
@override
|
||||
String radioStats_noiseFloor(int noiseDbm) {
|
||||
return 'Úroveň hluku: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastRssi(int rssiDbm) {
|
||||
return 'Posledný údaj RSSI: $rssiDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastSnr(String snr) {
|
||||
return 'Posledná hodnota SNR: $snr dB';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_txAir(int seconds) {
|
||||
return 'Čas vysielania na TX (celkový): $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_rxAir(int seconds) {
|
||||
return 'Čas RX (celkový): $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_chartCaption =>
|
||||
'Úroveň šumu (dBm) pre posledné vzorky.';
|
||||
|
||||
@override
|
||||
String radioStats_stripNoise(int noiseDbm) {
|
||||
return 'Úroveň hluku: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_stripWaiting => 'Získavanie údajov o rádiu…';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsTile => 'Štatistiky rádiových vysielaní';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Úroveň hluku, RSSI, SNR a časové rozloženie';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Zobraziť PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Skryť PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'Bluetooth párovací PIN';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Zadajte PIN pre $deviceName (ak nie je, nechajte prázdne).';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1671,6 +1671,9 @@ class AppLocalizationsSl extends AppLocalizations {
|
||||
@override
|
||||
String get map_otherNodes => 'Druge vozlišča';
|
||||
|
||||
@override
|
||||
String get map_showOverlaps => 'Prekrivanje ključa ponovnega predvajanja';
|
||||
|
||||
@override
|
||||
String get map_keyPrefix => 'Predpona ključa';
|
||||
|
||||
@@ -1713,6 +1716,9 @@ class AppLocalizationsSl extends AppLocalizations {
|
||||
@override
|
||||
String get map_runTrace => 'Zaženi sledenje poti';
|
||||
|
||||
@override
|
||||
String get map_runTraceWithReturnPath => 'Vrni se nazaj po isti poti.';
|
||||
|
||||
@override
|
||||
String get map_removeLast => 'Odstrani Zadnji';
|
||||
|
||||
@@ -2403,6 +2409,12 @@ class AppLocalizationsSl extends AppLocalizations {
|
||||
@override
|
||||
String get repeater_cliQuickClock => 'Ura';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickClockSync => 'Usklajevanje ure';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickDiscovery => 'Odkrijte sosede';
|
||||
|
||||
@override
|
||||
String get repeater_cliHelpAdvert => 'Pošlje paket oglasov';
|
||||
|
||||
@@ -3461,4 +3473,100 @@ class AppLocalizationsSl extends AppLocalizations {
|
||||
@override
|
||||
String get discoveredContacts_deleteContactAllContent =>
|
||||
'Ste prepričani, da želite izbrisati vse odkrite kontakte?';
|
||||
|
||||
@override
|
||||
String get chat_sendCooldown =>
|
||||
'Prosimo, počakajte trenutek, preden pošljete ponovno.';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnread =>
|
||||
'Pritisnite za najstarejše nepročitano sporočilo';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnreadSubtitle =>
|
||||
'Ko odpirate klepet z neprebranimi sporočili, se premaknite na prvo neprebrano sporočilo, namesto najnovejšega.';
|
||||
|
||||
@override
|
||||
String get appSettings_languageHu => 'Madžarski';
|
||||
|
||||
@override
|
||||
String get appSettings_languageJa => 'Japonski';
|
||||
|
||||
@override
|
||||
String get appSettings_languageKo => 'Korejski';
|
||||
|
||||
@override
|
||||
String get radioStats_tooltip => 'Statistike za radio in mrežo';
|
||||
|
||||
@override
|
||||
String get radioStats_screenTitle => 'Radijske statistike';
|
||||
|
||||
@override
|
||||
String get radioStats_notConnected =>
|
||||
'Povežite se z napravo, da si ogledate statistiko o radiju.';
|
||||
|
||||
@override
|
||||
String get radioStats_firmwareTooOld =>
|
||||
'Statistika za radio zahteva združljivo programsko opremo v8 ali kasnejše.';
|
||||
|
||||
@override
|
||||
String get radioStats_waiting => 'Čakam na podatke…';
|
||||
|
||||
@override
|
||||
String radioStats_noiseFloor(int noiseDbm) {
|
||||
return 'Število šuma: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastRssi(int rssiDbm) {
|
||||
return 'Najkasnejše vrednost RSSI: $rssiDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastSnr(String snr) {
|
||||
return 'Najkasnejše vrednost SNR: $snr dB';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_txAir(int seconds) {
|
||||
return 'Čas na TX (skupno): $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_rxAir(int seconds) {
|
||||
return 'Čas, namenjen RX-ju (skupno): $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_chartCaption =>
|
||||
'Ravnovredna raven šuma (dBm) za nedavne vzorce.';
|
||||
|
||||
@override
|
||||
String radioStats_stripNoise(int noiseDbm) {
|
||||
return 'Število šuma: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_stripWaiting => 'Prejemanje statistike o radiju…';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsTile => 'Radijske statistike';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Število šumov, RSSI, SNR in čas, ki ga je napolnila oprema';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Prikaži PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Skrij PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'Bluetooth PIN za seznanjanje';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Vnesite PIN za $deviceName (pustite prazno, če ga ni).';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1664,6 +1664,9 @@ class AppLocalizationsSv extends AppLocalizations {
|
||||
@override
|
||||
String get map_otherNodes => 'Andra noder';
|
||||
|
||||
@override
|
||||
String get map_showOverlaps => 'Repeater-nyckelöverlappningar';
|
||||
|
||||
@override
|
||||
String get map_keyPrefix => 'Nyckelprefix';
|
||||
|
||||
@@ -1707,6 +1710,9 @@ class AppLocalizationsSv extends AppLocalizations {
|
||||
@override
|
||||
String get map_runTrace => 'Kör spårsökning';
|
||||
|
||||
@override
|
||||
String get map_runTraceWithReturnPath => 'Gå tillbaka på samma väg';
|
||||
|
||||
@override
|
||||
String get map_removeLast => 'Ta bort sista';
|
||||
|
||||
@@ -2388,6 +2394,12 @@ class AppLocalizationsSv extends AppLocalizations {
|
||||
@override
|
||||
String get repeater_cliQuickClock => 'Klocka';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickClockSync => 'Synkronisera klocka';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickDiscovery => 'Upptäck grannar';
|
||||
|
||||
@override
|
||||
String get repeater_cliHelpAdvert => 'Skickar ett annonspaket';
|
||||
|
||||
@@ -3438,4 +3450,100 @@ class AppLocalizationsSv extends AppLocalizations {
|
||||
@override
|
||||
String get discoveredContacts_deleteContactAllContent =>
|
||||
'Är du säker på att du vill ta bort alla upptäckta kontakter?';
|
||||
|
||||
@override
|
||||
String get chat_sendCooldown =>
|
||||
'Vänligen vänta en stund innan du skickar igen.';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnread =>
|
||||
'Gå direkt till det äldsta, obesvarade meddelandet';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnreadSubtitle =>
|
||||
'När du öppnar en chatt med oinlästa meddelanden, scrolla till det första oinlästa meddelandet istället för det senaste.';
|
||||
|
||||
@override
|
||||
String get appSettings_languageHu => 'Ungerskt';
|
||||
|
||||
@override
|
||||
String get appSettings_languageJa => 'Japanska';
|
||||
|
||||
@override
|
||||
String get appSettings_languageKo => 'Koreanska';
|
||||
|
||||
@override
|
||||
String get radioStats_tooltip => 'Radio- och mesh-statistik';
|
||||
|
||||
@override
|
||||
String get radioStats_screenTitle => 'Radiostation';
|
||||
|
||||
@override
|
||||
String get radioStats_notConnected =>
|
||||
'Anslut till en enhet för att visa radiostatistik.';
|
||||
|
||||
@override
|
||||
String get radioStats_firmwareTooOld =>
|
||||
'Radio statistik kräver kompatibel firmware version 8 eller senare.';
|
||||
|
||||
@override
|
||||
String get radioStats_waiting => 'Väntar på data…';
|
||||
|
||||
@override
|
||||
String radioStats_noiseFloor(int noiseDbm) {
|
||||
return 'Bakgrundsnivå: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastRssi(int rssiDbm) {
|
||||
return 'Senaste RSSI-värde: $rssiDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastSnr(String snr) {
|
||||
return 'Senaste SNR: $snr dB';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_txAir(int seconds) {
|
||||
return 'TX-tid (total): $seconds sekunder';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_rxAir(int seconds) {
|
||||
return 'RX-tid (total): $seconds s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_chartCaption =>
|
||||
'Ljudnivå (dBm) baserat på de senaste mätningarna.';
|
||||
|
||||
@override
|
||||
String radioStats_stripNoise(int noiseDbm) {
|
||||
return 'Bakgrundsnivå: $noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_stripWaiting => 'Hämtar radiostatistik…';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsTile => 'Radiostation';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Bakgrundsnivå, RSSI, SNR och tillgänglig tid';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Visa PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Dölj PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'Bluetooth‑parnings‑PIN';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Ange PIN för $deviceName (lämna tomt om ingen).';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1684,6 +1684,9 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get map_otherNodes => 'Інші вузли';
|
||||
|
||||
@override
|
||||
String get map_showOverlaps => 'Перекриття ключа повторювача';
|
||||
|
||||
@override
|
||||
String get map_keyPrefix => 'Префікс ключа';
|
||||
|
||||
@@ -1727,6 +1730,9 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get map_runTrace => 'Виконати трасування шляху';
|
||||
|
||||
@override
|
||||
String get map_runTraceWithReturnPath => 'Повернутися назад тим же шляхом';
|
||||
|
||||
@override
|
||||
String get map_removeLast => 'Видалити останній';
|
||||
|
||||
@@ -2421,6 +2427,12 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get repeater_cliQuickClock => 'Годинник';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickClockSync => 'Синхронізація годинника';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickDiscovery => 'Відкрити сусідів';
|
||||
|
||||
@override
|
||||
String get repeater_cliHelpAdvert => 'Надсилає пакет оголошення';
|
||||
|
||||
@@ -3495,4 +3507,100 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get discoveredContacts_deleteContactAllContent =>
|
||||
'Ви впевнені, що хочете видалити всі виявлені контакти?';
|
||||
|
||||
@override
|
||||
String get chat_sendCooldown =>
|
||||
'Будь ласка, зачекайте трохи, перш ніж відправляти знову.';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnread =>
|
||||
'Перейти до найстарішого непрочитаного повідомлення';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnreadSubtitle =>
|
||||
'При відкритті чату з не прочитаними повідомленнями, прокрутіть до першого не прочитаного повідомлення, а не до останнього.';
|
||||
|
||||
@override
|
||||
String get appSettings_languageHu => 'Угорський';
|
||||
|
||||
@override
|
||||
String get appSettings_languageJa => 'Японська';
|
||||
|
||||
@override
|
||||
String get appSettings_languageKo => 'Кореєська';
|
||||
|
||||
@override
|
||||
String get radioStats_tooltip => 'Статистика радіо та мережі';
|
||||
|
||||
@override
|
||||
String get radioStats_screenTitle => 'Дані про радіостанції';
|
||||
|
||||
@override
|
||||
String get radioStats_notConnected =>
|
||||
'Підключіться до пристрою, щоб переглядати статистику радіопередач.';
|
||||
|
||||
@override
|
||||
String get radioStats_firmwareTooOld =>
|
||||
'Статистика радіо приймача вимагає супутнього програмного забезпечення версії 8 або новішої.';
|
||||
|
||||
@override
|
||||
String get radioStats_waiting => 'Очікую на отримання даних…';
|
||||
|
||||
@override
|
||||
String radioStats_noiseFloor(int noiseDbm) {
|
||||
return 'Рівень шуму: $noiseDbm дБм';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastRssi(int rssiDbm) {
|
||||
return 'Останній показник RSSI: $rssiDbm дБм';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastSnr(String snr) {
|
||||
return 'Останній показник SNR: $snr дБ';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_txAir(int seconds) {
|
||||
return 'Час трансляції на телеканалі TX (загальний): $seconds секунд';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_rxAir(int seconds) {
|
||||
return 'Загальний час використання RX: $seconds секунд';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_chartCaption =>
|
||||
'Рівень шуму (дБм) на основі останніх вимірювань.';
|
||||
|
||||
@override
|
||||
String radioStats_stripNoise(int noiseDbm) {
|
||||
return 'Рівень шуму: $noiseDbm дБм';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_stripWaiting => 'Отримано статистику радіо…';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsTile => 'Дані про радіостанції';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsSubtitle =>
|
||||
'Рівень шуму, RSSI, SNR та час, протягом якого пристрій використовує радіоканал.';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => 'Показати PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => 'Приховати PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => 'PIN‑код спарювання Bluetooth';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Введіть PIN для $deviceName (залиште порожнім, якщо його немає).';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1582,6 +1582,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get map_otherNodes => '其他节点';
|
||||
|
||||
@override
|
||||
String get map_showOverlaps => '重复键重叠';
|
||||
|
||||
@override
|
||||
String get map_keyPrefix => '关键字前缀';
|
||||
|
||||
@@ -1624,6 +1627,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get map_runTrace => '运行路径追踪';
|
||||
|
||||
@override
|
||||
String get map_runTraceWithReturnPath => '沿着相同的路径返回';
|
||||
|
||||
@override
|
||||
String get map_removeLast => '移除最后一个';
|
||||
|
||||
@@ -2271,6 +2277,12 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get repeater_cliQuickClock => '时钟';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickClockSync => '同步时钟';
|
||||
|
||||
@override
|
||||
String get repeater_cliQuickDiscovery => '发现邻居';
|
||||
|
||||
@override
|
||||
String get repeater_cliHelpAdvert => '发送广播包';
|
||||
|
||||
@@ -3216,4 +3228,94 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get discoveredContacts_deleteContactAllContent => '您确定要删除所有发现的联系人吗?';
|
||||
|
||||
@override
|
||||
String get chat_sendCooldown => '请稍等片刻后再尝试发送。';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnread => '跳转到最旧、未读的文章';
|
||||
|
||||
@override
|
||||
String get appSettings_jumpToOldestUnreadSubtitle =>
|
||||
'在打开包含未读消息的聊天时,请滚动到第一个未读消息,而不是最新的消息。';
|
||||
|
||||
@override
|
||||
String get appSettings_languageHu => '匈牙利';
|
||||
|
||||
@override
|
||||
String get appSettings_languageJa => '日语';
|
||||
|
||||
@override
|
||||
String get appSettings_languageKo => '韩语';
|
||||
|
||||
@override
|
||||
String get radioStats_tooltip => '无线电和网状结构统计数据';
|
||||
|
||||
@override
|
||||
String get radioStats_screenTitle => '广播统计数据';
|
||||
|
||||
@override
|
||||
String get radioStats_notConnected => '连接到设备以查看收音机统计信息。';
|
||||
|
||||
@override
|
||||
String get radioStats_firmwareTooOld => '使用无线电统计功能需要配合使用 v8 或更高版本的固件。';
|
||||
|
||||
@override
|
||||
String get radioStats_waiting => '正在等待数据…';
|
||||
|
||||
@override
|
||||
String radioStats_noiseFloor(int noiseDbm) {
|
||||
return '噪声水平:$noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastRssi(int rssiDbm) {
|
||||
return '上次 RSSI 值:$rssiDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_lastSnr(String snr) {
|
||||
return '上次 SNR:$snr dB';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_txAir(int seconds) {
|
||||
return 'TX 频道播出时间(总时长):$seconds 秒';
|
||||
}
|
||||
|
||||
@override
|
||||
String radioStats_rxAir(int seconds) {
|
||||
return 'RX 使用时长(总时长):$seconds 秒';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_chartCaption => '近期的噪声水平(dBm)。';
|
||||
|
||||
@override
|
||||
String radioStats_stripNoise(int noiseDbm) {
|
||||
return '噪声水平:$noiseDbm dBm';
|
||||
}
|
||||
|
||||
@override
|
||||
String get radioStats_stripWaiting => '正在获取收音机数据…';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsTile => '广播统计数据';
|
||||
|
||||
@override
|
||||
String get radioStats_settingsSubtitle => '噪声水平、RSSI、信噪比和空中时间';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingShowPin => '显示 PIN码';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingHidePin => '隐藏 PIN';
|
||||
|
||||
@override
|
||||
String get scanner_linuxPairingPinTitle => '蓝牙配对 PIN';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return '输入 $deviceName 的 PIN(如果没有,请留空)。';
|
||||
}
|
||||
}
|
||||
|
||||
+79
-1
@@ -1941,5 +1941,83 @@
|
||||
"appSettings_maxMessageRetriesSubtitle": "Aantal pogingen om een bericht opnieuw te versturen voordat het als mislukt wordt gemarkeerd",
|
||||
"path_routeWeight": "{weight}/{max}",
|
||||
"settings_telemetryModeUpdated": "Telemetrie-modus bijgewerkt",
|
||||
"settings_multiAck": "Multi-ACKs: {value}"
|
||||
"settings_multiAck": "Multi-ACKs: {value}",
|
||||
"map_showOverlaps": "Herhalingssleutel overlapt",
|
||||
"map_runTraceWithReturnPath": "Terugkeren op hetzelfde pad.",
|
||||
"@radioStats_noiseFloor": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastRssi": {
|
||||
"placeholders": {
|
||||
"rssiDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastSnr": {
|
||||
"placeholders": {
|
||||
"snr": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_txAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_rxAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_stripNoise": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_jumpToOldestUnread": "Ga naar het oudste ongelezen bericht",
|
||||
"appSettings_jumpToOldestUnreadSubtitle": "Bij het openen van een chat met ongelezen berichten, scroll dan naar het eerste ongelezen bericht, in plaats van naar het meest recente.",
|
||||
"chat_sendCooldown": "Gelieve even te wachten voordat u opnieuw verzendt.",
|
||||
"appSettings_languageHu": "Hongaars",
|
||||
"appSettings_languageJa": "Japanisch",
|
||||
"appSettings_languageKo": "Koreaans",
|
||||
"radioStats_tooltip": "Statistieken voor radio en mesh-netwerken",
|
||||
"radioStats_screenTitle": "Statistieken over radio",
|
||||
"radioStats_notConnected": "Verbind met een apparaat om radio-statistieken te bekijken.",
|
||||
"radioStats_firmwareTooOld": "Om de statistieken via radio te kunnen gebruiken, is firmware versie 8 of een nieuwere vereist.",
|
||||
"radioStats_waiting": "Wacht op gegevens…",
|
||||
"radioStats_noiseFloor": "Ruisfrequentie: {noiseDbm} dBm",
|
||||
"radioStats_lastRssi": "Laatste RSSI-waarde: {rssiDbm} dBm",
|
||||
"radioStats_lastSnr": "Laatste SNR: {snr} dB",
|
||||
"radioStats_txAir": "TX-tijd (totaal): {seconds} s",
|
||||
"radioStats_rxAir": "Tijd besteed met RX (totaal): {seconds} s",
|
||||
"radioStats_chartCaption": "Ruisfrequentie (dBm) over recente metingen.",
|
||||
"radioStats_stripNoise": "Ruisfrequentie: {noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "Radio-statistieken ophalen…",
|
||||
"radioStats_settingsTile": "Statistieken over radio",
|
||||
"radioStats_settingsSubtitle": "Ruimtelijke ruis, RSSI, SNR en beschikbare tijd",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingShowPin": "Toon PIN",
|
||||
"scanner_linuxPairingHidePin": "PIN verbergen",
|
||||
"scanner_linuxPairingPinPrompt": "Voer PIN in voor {deviceName} (laat leeg als er geen is).",
|
||||
"scanner_linuxPairingPinTitle": "Bluetooth‑koppelings‑PIN",
|
||||
"repeater_cliQuickDiscovery": "Ontdek Buren",
|
||||
"repeater_cliQuickClockSync": "Kloksynchronisatie"
|
||||
}
|
||||
+341
-225
File diff suppressed because it is too large
Load Diff
+79
-1
@@ -1941,5 +1941,83 @@
|
||||
"appSettings_maxMessageRetriesSubtitle": "Número de tentativas de reenvio antes de classificar uma mensagem como falha.",
|
||||
"path_routeWeight": "{weight}/{max}",
|
||||
"settings_telemetryModeUpdated": "Modo de telemetria atualizado",
|
||||
"settings_multiAck": "Multi-ACKs: {value}"
|
||||
"settings_multiAck": "Multi-ACKs: {value}",
|
||||
"map_showOverlaps": "Sobreposições da Chave Repeater",
|
||||
"map_runTraceWithReturnPath": "Retornar ao mesmo caminho.",
|
||||
"@radioStats_noiseFloor": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastRssi": {
|
||||
"placeholders": {
|
||||
"rssiDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastSnr": {
|
||||
"placeholders": {
|
||||
"snr": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_txAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_rxAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_stripNoise": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_jumpToOldestUnread": "Vá para a mensagem mais antiga não lida",
|
||||
"chat_sendCooldown": "Por favor, aguarde um momento antes de reenviar.",
|
||||
"appSettings_languageHu": "Húngaro",
|
||||
"appSettings_jumpToOldestUnreadSubtitle": "Ao abrir uma conversa com mensagens não lidas, role para a primeira mensagem não lida, em vez da mais recente.",
|
||||
"appSettings_languageJa": "Japonês",
|
||||
"appSettings_languageKo": "Coreano",
|
||||
"radioStats_tooltip": "Estatísticas de rádio e malha",
|
||||
"radioStats_screenTitle": "Estatísticas de rádio",
|
||||
"radioStats_notConnected": "Conecte-se a um dispositivo para visualizar estatísticas de rádio.",
|
||||
"radioStats_firmwareTooOld": "As estatísticas de rádio exigem o firmware v8 ou uma versão mais recente.",
|
||||
"radioStats_waiting": "Aguardando dados…",
|
||||
"radioStats_noiseFloor": "Nível de ruído: {noiseDbm} dBm",
|
||||
"radioStats_lastRssi": "Último RSSI: {rssiDbm} dBm",
|
||||
"radioStats_lastSnr": "Último SNR: {snr} dB",
|
||||
"radioStats_txAir": "Tempo de transmissão da TX (total): {seconds} s",
|
||||
"radioStats_rxAir": "Tempo de uso do RX (total): {seconds} s",
|
||||
"radioStats_chartCaption": "Nível de ruído (dBm) em amostras recentes.",
|
||||
"radioStats_stripNoise": "Nível de ruído: {noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "Obtendo estatísticas de rádio…",
|
||||
"radioStats_settingsTile": "Estatísticas de rádio",
|
||||
"radioStats_settingsSubtitle": "Nível de ruído, RSSI, SNR e tempo de transmissão",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingShowPin": "Mostrar PIN",
|
||||
"scanner_linuxPairingHidePin": "Ocultar PIN",
|
||||
"scanner_linuxPairingPinPrompt": "Insira o PIN para {deviceName} (deixe em branco se não houver).",
|
||||
"scanner_linuxPairingPinTitle": "PIN de emparelhamento Bluetooth",
|
||||
"repeater_cliQuickClockSync": "Sincronização do Relógio",
|
||||
"repeater_cliQuickDiscovery": "Descobrir Vizinhos"
|
||||
}
|
||||
+79
-1
@@ -1181,5 +1181,83 @@
|
||||
"appSettings_maxMessageRetriesSubtitle": "Количество попыток повторной отправки сообщения перед тем, как пометить его как неудачное.",
|
||||
"path_routeWeight": "{weight}/{max}",
|
||||
"settings_telemetryModeUpdated": "Режим телеметрии обновлен",
|
||||
"settings_multiAck": "Мульти-ACK: {value}"
|
||||
"settings_multiAck": "Мульти-ACK: {value}",
|
||||
"map_showOverlaps": "Перекрытия ключа повтора",
|
||||
"map_runTraceWithReturnPath": "Вернуться обратно по тому же пути",
|
||||
"@radioStats_noiseFloor": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastRssi": {
|
||||
"placeholders": {
|
||||
"rssiDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastSnr": {
|
||||
"placeholders": {
|
||||
"snr": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_txAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_rxAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_stripNoise": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_sendCooldown": "Пожалуйста, подождите немного, прежде чем отправлять сообщение снова.",
|
||||
"appSettings_jumpToOldestUnread": "Перейти к самому старому непрочитанному сообщению",
|
||||
"appSettings_languageHu": "Венгерский",
|
||||
"appSettings_jumpToOldestUnreadSubtitle": "При открытии чата с непрочитанными сообщениями, прокрутите страницу, чтобы увидеть первое непрочитанное сообщение, а не последнее.",
|
||||
"appSettings_languageJa": "Японский",
|
||||
"appSettings_languageKo": "Корейский",
|
||||
"radioStats_tooltip": "Статистика радио и беспроводной сети",
|
||||
"radioStats_screenTitle": "Статистика радиовещания",
|
||||
"radioStats_notConnected": "Подключитесь к устройству, чтобы просмотреть статистику радио.",
|
||||
"radioStats_firmwareTooOld": "Для работы радиостатистики требуется установленная версия прошивки v8 или более новая.",
|
||||
"radioStats_waiting": "Ожидаем данных…",
|
||||
"radioStats_noiseFloor": "Уровень шума: {noiseDbm} дБм",
|
||||
"radioStats_lastRssi": "Последнее значение RSSI: {rssiDbm} дБм",
|
||||
"radioStats_lastSnr": "Последнее значение SNR: {snr} дБ",
|
||||
"radioStats_txAir": "Время эфира на телеканале TX (общее): {seconds} секунд",
|
||||
"radioStats_rxAir": "Общее время использования RX (в секундах): {seconds} с",
|
||||
"radioStats_chartCaption": "Уровень шума (дБм) на основе последних измерений.",
|
||||
"radioStats_stripNoise": "Уровень шума: {noiseDbm} дБм",
|
||||
"radioStats_stripWaiting": "Получение данных о радио…",
|
||||
"radioStats_settingsTile": "Статистика радиовещания",
|
||||
"radioStats_settingsSubtitle": "Уровень шума, RSSI, SNR и время передачи",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingShowPin": "Показать PIN",
|
||||
"scanner_linuxPairingPinPrompt": "Введите PIN‑код для {deviceName} (оставьте пустым, если нет).",
|
||||
"scanner_linuxPairingHidePin": "Скрыть PIN",
|
||||
"scanner_linuxPairingPinTitle": "PIN‑код сопряжения Bluetooth",
|
||||
"repeater_cliQuickDiscovery": "Обнаружить Соседей",
|
||||
"repeater_cliQuickClockSync": "Синхронизация часов"
|
||||
}
|
||||
+79
-1
@@ -1941,5 +1941,83 @@
|
||||
"appSettings_maxMessageRetriesSubtitle": "Počet pokusov o odošleť pred označením správy ako neúspešnej",
|
||||
"path_routeWeight": "{weight}/{max}",
|
||||
"settings_telemetryModeUpdated": "Režim telemetrie bol aktualizovaný",
|
||||
"settings_multiAck": "Viaceré ACK: {value}"
|
||||
"settings_multiAck": "Viaceré ACK: {value}",
|
||||
"map_showOverlaps": "Prekrývanie opakovača kľúča",
|
||||
"map_runTraceWithReturnPath": "Vráťte sa späť po tej istej ceste.",
|
||||
"@radioStats_noiseFloor": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastRssi": {
|
||||
"placeholders": {
|
||||
"rssiDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastSnr": {
|
||||
"placeholders": {
|
||||
"snr": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_txAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_rxAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_stripNoise": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_sendCooldown": "Prosím, počkajte chvíľu, než zašlete znova.",
|
||||
"appSettings_jumpToOldestUnread": "Presk oceň",
|
||||
"appSettings_jumpToOldestUnreadSubtitle": "Pri otvorení chatu s neprečítanými správami, prejdite do prvého neprečítaného, namiesto poslednej.",
|
||||
"appSettings_languageHu": "Maďarský",
|
||||
"appSettings_languageJa": "Japonský",
|
||||
"appSettings_languageKo": "Kórejský",
|
||||
"radioStats_tooltip": "Statistiky rádiových a sieťových kanálov",
|
||||
"radioStats_screenTitle": "Štatistiky rádiových vysielaní",
|
||||
"radioStats_notConnected": "Pripojte sa k zariadeniu, aby ste mohli sledovať štatistiky rádiového vysielania.",
|
||||
"radioStats_firmwareTooOld": "Statistické údaje z rádia vyžadujú sprievodný softvér verzie v8 alebo novšej.",
|
||||
"radioStats_waiting": "Čakám na údaje…",
|
||||
"radioStats_noiseFloor": "Úroveň hluku: {noiseDbm} dBm",
|
||||
"radioStats_lastRssi": "Posledný údaj RSSI: {rssiDbm} dBm",
|
||||
"radioStats_lastSnr": "Posledná hodnota SNR: {snr} dB",
|
||||
"radioStats_txAir": "Čas vysielania na TX (celkový): {seconds} s",
|
||||
"radioStats_rxAir": "Čas RX (celkový): {seconds} s",
|
||||
"radioStats_chartCaption": "Úroveň šumu (dBm) pre posledné vzorky.",
|
||||
"radioStats_stripNoise": "Úroveň hluku: {noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "Získavanie údajov o rádiu…",
|
||||
"radioStats_settingsTile": "Štatistiky rádiových vysielaní",
|
||||
"radioStats_settingsSubtitle": "Úroveň hluku, RSSI, SNR a časové rozloženie",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingPinPrompt": "Zadajte PIN pre {deviceName} (ak nie je, nechajte prázdne).",
|
||||
"scanner_linuxPairingShowPin": "Zobraziť PIN",
|
||||
"scanner_linuxPairingHidePin": "Skryť PIN",
|
||||
"scanner_linuxPairingPinTitle": "Bluetooth párovací PIN",
|
||||
"repeater_cliQuickClockSync": "Synchronizácia hodin",
|
||||
"repeater_cliQuickDiscovery": "Objaviť susedov"
|
||||
}
|
||||
+79
-1
@@ -1941,5 +1941,83 @@
|
||||
"appSettings_maxMessageRetriesSubtitle": "Število poskusov ponovnega poslanja, preden se sporočilo označuje kot neuspešno",
|
||||
"path_routeWeight": "{weight}/{max}",
|
||||
"settings_multiAck": "Večkratni potrditvi: {value}",
|
||||
"settings_telemetryModeUpdated": "Način telemetrije posodobljen"
|
||||
"settings_telemetryModeUpdated": "Način telemetrije posodobljen",
|
||||
"map_showOverlaps": "Prekrivanje ključa ponovnega predvajanja",
|
||||
"map_runTraceWithReturnPath": "Vrni se nazaj po isti poti.",
|
||||
"@radioStats_noiseFloor": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastRssi": {
|
||||
"placeholders": {
|
||||
"rssiDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastSnr": {
|
||||
"placeholders": {
|
||||
"snr": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_txAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_rxAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_stripNoise": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_languageHu": "Madžarski",
|
||||
"appSettings_jumpToOldestUnreadSubtitle": "Ko odpirate klepet z neprebranimi sporočili, se premaknite na prvo neprebrano sporočilo, namesto najnovejšega.",
|
||||
"chat_sendCooldown": "Prosimo, počakajte trenutek, preden pošljete ponovno.",
|
||||
"appSettings_jumpToOldestUnread": "Pritisnite za najstarejše nepročitano sporočilo",
|
||||
"appSettings_languageJa": "Japonski",
|
||||
"appSettings_languageKo": "Korejski",
|
||||
"radioStats_tooltip": "Statistike za radio in mrežo",
|
||||
"radioStats_notConnected": "Povežite se z napravo, da si ogledate statistiko o radiju.",
|
||||
"radioStats_screenTitle": "Radijske statistike",
|
||||
"radioStats_firmwareTooOld": "Statistika za radio zahteva združljivo programsko opremo v8 ali kasnejše.",
|
||||
"radioStats_waiting": "Čakam na podatke…",
|
||||
"radioStats_noiseFloor": "Število šuma: {noiseDbm} dBm",
|
||||
"radioStats_lastRssi": "Najkasnejše vrednost RSSI: {rssiDbm} dBm",
|
||||
"radioStats_lastSnr": "Najkasnejše vrednost SNR: {snr} dB",
|
||||
"radioStats_txAir": "Čas na TX (skupno): {seconds} s",
|
||||
"radioStats_rxAir": "Čas, namenjen RX-ju (skupno): {seconds} s",
|
||||
"radioStats_chartCaption": "Ravnovredna raven šuma (dBm) za nedavne vzorce.",
|
||||
"radioStats_stripNoise": "Število šuma: {noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "Prejemanje statistike o radiju…",
|
||||
"radioStats_settingsTile": "Radijske statistike",
|
||||
"radioStats_settingsSubtitle": "Število šumov, RSSI, SNR in čas, ki ga je napolnila oprema",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingShowPin": "Prikaži PIN",
|
||||
"scanner_linuxPairingHidePin": "Skrij PIN",
|
||||
"scanner_linuxPairingPinPrompt": "Vnesite PIN za {deviceName} (pustite prazno, če ga ni).",
|
||||
"scanner_linuxPairingPinTitle": "Bluetooth PIN za seznanjanje",
|
||||
"repeater_cliQuickDiscovery": "Odkrijte sosede",
|
||||
"repeater_cliQuickClockSync": "Usklajevanje ure"
|
||||
}
|
||||
+79
-1
@@ -1941,5 +1941,83 @@
|
||||
"appSettings_maxMessageRetriesSubtitle": "Antal försök att skicka om ett meddelande innan det markeras som misslyckat.",
|
||||
"path_routeWeight": "{weight}/{max}",
|
||||
"settings_telemetryModeUpdated": "Telemetri-läge uppdaterat",
|
||||
"settings_multiAck": "Multi-ACKs: {value}"
|
||||
"settings_multiAck": "Multi-ACKs: {value}",
|
||||
"map_showOverlaps": "Repeater-nyckelöverlappningar",
|
||||
"map_runTraceWithReturnPath": "Gå tillbaka på samma väg",
|
||||
"@radioStats_noiseFloor": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastRssi": {
|
||||
"placeholders": {
|
||||
"rssiDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastSnr": {
|
||||
"placeholders": {
|
||||
"snr": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_txAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_rxAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_stripNoise": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_jumpToOldestUnreadSubtitle": "När du öppnar en chatt med oinlästa meddelanden, scrolla till det första oinlästa meddelandet istället för det senaste.",
|
||||
"chat_sendCooldown": "Vänligen vänta en stund innan du skickar igen.",
|
||||
"appSettings_jumpToOldestUnread": "Gå direkt till det äldsta, obesvarade meddelandet",
|
||||
"appSettings_languageHu": "Ungerskt",
|
||||
"appSettings_languageJa": "Japanska",
|
||||
"appSettings_languageKo": "Koreanska",
|
||||
"radioStats_tooltip": "Radio- och mesh-statistik",
|
||||
"radioStats_screenTitle": "Radiostation",
|
||||
"radioStats_notConnected": "Anslut till en enhet för att visa radiostatistik.",
|
||||
"radioStats_firmwareTooOld": "Radio statistik kräver kompatibel firmware version 8 eller senare.",
|
||||
"radioStats_waiting": "Väntar på data…",
|
||||
"radioStats_noiseFloor": "Bakgrundsnivå: {noiseDbm} dBm",
|
||||
"radioStats_lastRssi": "Senaste RSSI-värde: {rssiDbm} dBm",
|
||||
"radioStats_lastSnr": "Senaste SNR: {snr} dB",
|
||||
"radioStats_txAir": "TX-tid (total): {seconds} sekunder",
|
||||
"radioStats_rxAir": "RX-tid (total): {seconds} s",
|
||||
"radioStats_chartCaption": "Ljudnivå (dBm) baserat på de senaste mätningarna.",
|
||||
"radioStats_stripNoise": "Bakgrundsnivå: {noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "Hämtar radiostatistik…",
|
||||
"radioStats_settingsTile": "Radiostation",
|
||||
"radioStats_settingsSubtitle": "Bakgrundsnivå, RSSI, SNR och tillgänglig tid",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingShowPin": "Visa PIN",
|
||||
"scanner_linuxPairingPinTitle": "Bluetooth‑parnings‑PIN",
|
||||
"scanner_linuxPairingPinPrompt": "Ange PIN för {deviceName} (lämna tomt om ingen).",
|
||||
"scanner_linuxPairingHidePin": "Dölj PIN",
|
||||
"repeater_cliQuickDiscovery": "Upptäck grannar",
|
||||
"repeater_cliQuickClockSync": "Synkronisera klocka"
|
||||
}
|
||||
+79
-1
@@ -1941,5 +1941,83 @@
|
||||
"appSettings_maxMessageRetriesSubtitle": "Кількість спроб повторного відправлення повідомлення перед тим, як позначити його як невдале",
|
||||
"path_routeWeight": "{weight}/{max}",
|
||||
"settings_telemetryModeUpdated": "Режим телеметрії оновлено",
|
||||
"settings_multiAck": "Багатократне підтвердження: {value}"
|
||||
"settings_multiAck": "Багатократне підтвердження: {value}",
|
||||
"map_showOverlaps": "Перекриття ключа повторювача",
|
||||
"map_runTraceWithReturnPath": "Повернутися назад тим же шляхом",
|
||||
"@radioStats_noiseFloor": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastRssi": {
|
||||
"placeholders": {
|
||||
"rssiDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastSnr": {
|
||||
"placeholders": {
|
||||
"snr": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_txAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_rxAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_stripNoise": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_sendCooldown": "Будь ласка, зачекайте трохи, перш ніж відправляти знову.",
|
||||
"appSettings_languageHu": "Угорський",
|
||||
"appSettings_jumpToOldestUnreadSubtitle": "При відкритті чату з не прочитаними повідомленнями, прокрутіть до першого не прочитаного повідомлення, а не до останнього.",
|
||||
"appSettings_jumpToOldestUnread": "Перейти до найстарішого непрочитаного повідомлення",
|
||||
"appSettings_languageJa": "Японська",
|
||||
"appSettings_languageKo": "Кореєська",
|
||||
"radioStats_tooltip": "Статистика радіо та мережі",
|
||||
"radioStats_screenTitle": "Дані про радіостанції",
|
||||
"radioStats_notConnected": "Підключіться до пристрою, щоб переглядати статистику радіопередач.",
|
||||
"radioStats_firmwareTooOld": "Статистика радіо приймача вимагає супутнього програмного забезпечення версії 8 або новішої.",
|
||||
"radioStats_waiting": "Очікую на отримання даних…",
|
||||
"radioStats_noiseFloor": "Рівень шуму: {noiseDbm} дБм",
|
||||
"radioStats_lastRssi": "Останній показник RSSI: {rssiDbm} дБм",
|
||||
"radioStats_lastSnr": "Останній показник SNR: {snr} дБ",
|
||||
"radioStats_txAir": "Час трансляції на телеканалі TX (загальний): {seconds} секунд",
|
||||
"radioStats_rxAir": "Загальний час використання RX: {seconds} секунд",
|
||||
"radioStats_chartCaption": "Рівень шуму (дБм) на основі останніх вимірювань.",
|
||||
"radioStats_stripNoise": "Рівень шуму: {noiseDbm} дБм",
|
||||
"radioStats_stripWaiting": "Отримано статистику радіо…",
|
||||
"radioStats_settingsTile": "Дані про радіостанції",
|
||||
"radioStats_settingsSubtitle": "Рівень шуму, RSSI, SNR та час, протягом якого пристрій використовує радіоканал.",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingPinTitle": "PIN‑код спарювання Bluetooth",
|
||||
"scanner_linuxPairingShowPin": "Показати PIN",
|
||||
"scanner_linuxPairingPinPrompt": "Введіть PIN для {deviceName} (залиште порожнім, якщо його немає).",
|
||||
"scanner_linuxPairingHidePin": "Приховати PIN",
|
||||
"repeater_cliQuickClockSync": "Синхронізація годинника",
|
||||
"repeater_cliQuickDiscovery": "Відкрити сусідів"
|
||||
}
|
||||
+79
-1
@@ -1946,5 +1946,83 @@
|
||||
"appSettings_maxMessageRetriesSubtitle": "在将消息标记为失败之前,允许尝试的次数",
|
||||
"path_routeWeight": "{weight}/{max}",
|
||||
"settings_multiAck": "多重ACK:{value}",
|
||||
"settings_telemetryModeUpdated": "遥测模式已更新"
|
||||
"settings_telemetryModeUpdated": "遥测模式已更新",
|
||||
"map_showOverlaps": "重复键重叠",
|
||||
"map_runTraceWithReturnPath": "沿着相同的路径返回",
|
||||
"@radioStats_noiseFloor": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastRssi": {
|
||||
"placeholders": {
|
||||
"rssiDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_lastSnr": {
|
||||
"placeholders": {
|
||||
"snr": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_txAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_rxAir": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radioStats_stripNoise": {
|
||||
"placeholders": {
|
||||
"noiseDbm": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_sendCooldown": "请稍等片刻后再尝试发送。",
|
||||
"appSettings_jumpToOldestUnreadSubtitle": "在打开包含未读消息的聊天时,请滚动到第一个未读消息,而不是最新的消息。",
|
||||
"appSettings_jumpToOldestUnread": "跳转到最旧、未读的文章",
|
||||
"appSettings_languageHu": "匈牙利",
|
||||
"appSettings_languageJa": "日语",
|
||||
"appSettings_languageKo": "韩语",
|
||||
"radioStats_tooltip": "无线电和网状结构统计数据",
|
||||
"radioStats_screenTitle": "广播统计数据",
|
||||
"radioStats_notConnected": "连接到设备以查看收音机统计信息。",
|
||||
"radioStats_firmwareTooOld": "使用无线电统计功能需要配合使用 v8 或更高版本的固件。",
|
||||
"radioStats_waiting": "正在等待数据…",
|
||||
"radioStats_noiseFloor": "噪声水平:{noiseDbm} dBm",
|
||||
"radioStats_lastRssi": "上次 RSSI 值:{rssiDbm} dBm",
|
||||
"radioStats_lastSnr": "上次 SNR:{snr} dB",
|
||||
"radioStats_txAir": "TX 频道播出时间(总时长):{seconds} 秒",
|
||||
"radioStats_rxAir": "RX 使用时长(总时长):{seconds} 秒",
|
||||
"radioStats_chartCaption": "近期的噪声水平(dBm)。",
|
||||
"radioStats_stripNoise": "噪声水平:{noiseDbm} dBm",
|
||||
"radioStats_stripWaiting": "正在获取收音机数据…",
|
||||
"radioStats_settingsTile": "广播统计数据",
|
||||
"radioStats_settingsSubtitle": "噪声水平、RSSI、信噪比和空中时间",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingShowPin": "显示 PIN码",
|
||||
"scanner_linuxPairingPinPrompt": "输入 {deviceName} 的 PIN(如果没有,请留空)。",
|
||||
"scanner_linuxPairingPinTitle": "蓝牙配对 PIN",
|
||||
"scanner_linuxPairingHidePin": "隐藏 PIN",
|
||||
"repeater_cliQuickDiscovery": "发现邻居",
|
||||
"repeater_cliQuickClockSync": "同步时钟"
|
||||
}
|
||||
@@ -18,6 +18,7 @@ class AppSettings {
|
||||
final bool mapShowRepeaters;
|
||||
final bool mapShowChatNodes;
|
||||
final bool mapShowOtherNodes;
|
||||
final bool mapShowOverlaps;
|
||||
final double mapTimeFilterHours; // 0 = all time
|
||||
final bool mapKeyPrefixEnabled;
|
||||
final String mapKeyPrefix;
|
||||
@@ -47,12 +48,14 @@ class AppSettings {
|
||||
final bool mapShowDiscoveryContacts;
|
||||
final String tcpServerAddress;
|
||||
final int tcpServerPort;
|
||||
final bool jumpToOldestUnread;
|
||||
|
||||
AppSettings({
|
||||
this.clearPathOnMaxRetry = false,
|
||||
this.mapShowRepeaters = true,
|
||||
this.mapShowChatNodes = true,
|
||||
this.mapShowOtherNodes = true,
|
||||
this.mapShowOverlaps = false,
|
||||
this.mapTimeFilterHours = 0, // Default to all time
|
||||
this.mapKeyPrefixEnabled = false,
|
||||
this.mapKeyPrefix = '',
|
||||
@@ -82,6 +85,7 @@ class AppSettings {
|
||||
this.mapShowDiscoveryContacts = true,
|
||||
this.tcpServerAddress = '',
|
||||
this.tcpServerPort = 0,
|
||||
this.jumpToOldestUnread = false,
|
||||
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {},
|
||||
batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {},
|
||||
mutedChannels = mutedChannels ?? {};
|
||||
@@ -92,6 +96,7 @@ class AppSettings {
|
||||
'map_show_repeaters': mapShowRepeaters,
|
||||
'map_show_chat_nodes': mapShowChatNodes,
|
||||
'map_show_other_nodes': mapShowOtherNodes,
|
||||
'map_show_overlaps': mapShowOverlaps,
|
||||
'map_time_filter_hours': mapTimeFilterHours,
|
||||
'map_key_prefix_enabled': mapKeyPrefixEnabled,
|
||||
'map_key_prefix': mapKeyPrefix,
|
||||
@@ -121,6 +126,7 @@ class AppSettings {
|
||||
'map_show_discovery_contacts': mapShowDiscoveryContacts,
|
||||
'tcp_server_address': tcpServerAddress,
|
||||
'tcp_server_port': tcpServerPort,
|
||||
'jump_to_oldest_unread': jumpToOldestUnread,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -137,6 +143,7 @@ class AppSettings {
|
||||
mapShowRepeaters: json['map_show_repeaters'] as bool? ?? true,
|
||||
mapShowChatNodes: json['map_show_chat_nodes'] as bool? ?? true,
|
||||
mapShowOtherNodes: json['map_show_other_nodes'] as bool? ?? true,
|
||||
mapShowOverlaps: json['map_show_overlaps'] as bool? ?? false,
|
||||
mapTimeFilterHours:
|
||||
(json['map_time_filter_hours'] as num?)?.toDouble() ?? 0,
|
||||
mapKeyPrefixEnabled: json['map_key_prefix_enabled'] as bool? ?? false,
|
||||
@@ -188,6 +195,7 @@ class AppSettings {
|
||||
json['map_show_discovery_contacts'] as bool? ?? true,
|
||||
tcpServerAddress: json['tcp_server_address'] as String? ?? '',
|
||||
tcpServerPort: json['tcp_server_port'] as int? ?? 0,
|
||||
jumpToOldestUnread: json['jump_to_oldest_unread'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -196,6 +204,7 @@ class AppSettings {
|
||||
bool? mapShowRepeaters,
|
||||
bool? mapShowChatNodes,
|
||||
bool? mapShowOtherNodes,
|
||||
bool? mapShowOverlaps,
|
||||
double? mapTimeFilterHours,
|
||||
bool? mapKeyPrefixEnabled,
|
||||
String? mapKeyPrefix,
|
||||
@@ -225,12 +234,14 @@ class AppSettings {
|
||||
bool? mapShowDiscoveryContacts,
|
||||
String? tcpServerAddress,
|
||||
int? tcpServerPort,
|
||||
bool? jumpToOldestUnread,
|
||||
}) {
|
||||
return AppSettings(
|
||||
clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry,
|
||||
mapShowRepeaters: mapShowRepeaters ?? this.mapShowRepeaters,
|
||||
mapShowChatNodes: mapShowChatNodes ?? this.mapShowChatNodes,
|
||||
mapShowOtherNodes: mapShowOtherNodes ?? this.mapShowOtherNodes,
|
||||
mapShowOverlaps: mapShowOverlaps ?? this.mapShowOverlaps,
|
||||
mapTimeFilterHours: mapTimeFilterHours ?? this.mapTimeFilterHours,
|
||||
mapKeyPrefixEnabled: mapKeyPrefixEnabled ?? this.mapKeyPrefixEnabled,
|
||||
mapKeyPrefix: mapKeyPrefix ?? this.mapKeyPrefix,
|
||||
@@ -272,6 +283,7 @@ class AppSettings {
|
||||
mapShowDiscoveryContacts ?? this.mapShowDiscoveryContacts,
|
||||
tcpServerAddress: tcpServerAddress ?? this.tcpServerAddress,
|
||||
tcpServerPort: tcpServerPort ?? this.tcpServerPort,
|
||||
jumpToOldestUnread: jumpToOldestUnread ?? this.jumpToOldestUnread,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
/// Parsed `RESP_CODE_STATS` + `STATS_TYPE_RADIO` (14 bytes total).
|
||||
class CompanionRadioStats {
|
||||
final int noiseFloorDbm;
|
||||
final int lastRssiDbm;
|
||||
final double lastSnrDb;
|
||||
final int txAirSecs;
|
||||
final int rxAirSecs;
|
||||
final DateTime receivedAt;
|
||||
|
||||
const CompanionRadioStats({
|
||||
required this.noiseFloorDbm,
|
||||
required this.lastRssiDbm,
|
||||
required this.lastSnrDb,
|
||||
required this.txAirSecs,
|
||||
required this.rxAirSecs,
|
||||
required this.receivedAt,
|
||||
});
|
||||
|
||||
static CompanionRadioStats? tryParse(Uint8List frame) {
|
||||
if (frame.length < 14) return null;
|
||||
if (frame[0] != respCodeStats || frame[1] != statsTypeRadio) return null;
|
||||
try {
|
||||
final reader = BufferReader(frame);
|
||||
reader.skipBytes(2);
|
||||
final noise = reader.readInt16LE();
|
||||
final rssi = reader.readInt8();
|
||||
final snrRaw = reader.readInt8();
|
||||
final txAir = reader.readUInt32LE();
|
||||
final rxAir = reader.readUInt32LE();
|
||||
return CompanionRadioStats(
|
||||
noiseFloorDbm: noise,
|
||||
lastRssiDbm: rssi,
|
||||
lastSnrDb: snrRaw / 4.0,
|
||||
txAirSecs: txAir,
|
||||
rxAirSecs: rxAir,
|
||||
receivedAt: DateTime.now(),
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.warn('CompanionRadioStats parse error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-12
@@ -119,15 +119,14 @@ class Contact {
|
||||
);
|
||||
}
|
||||
|
||||
String get pathIdList {
|
||||
/// Formats path bytes into comma-separated hex groups of [hashByteWidth] bytes.
|
||||
String pathFormattedIdList(int hashByteWidth) {
|
||||
final pathBytes = pathBytesForDisplay;
|
||||
if (pathBytes.isEmpty) return '';
|
||||
final w = hashByteWidth.clamp(1, 8);
|
||||
final parts = <String>[];
|
||||
final groupSize = pathHashSize;
|
||||
for (int i = 0; i < pathBytes.length; i += groupSize) {
|
||||
final end = (i + groupSize) <= pathBytes.length
|
||||
? (i + groupSize)
|
||||
: pathBytes.length;
|
||||
for (int i = 0; i < pathBytes.length; i += w) {
|
||||
final end = (i + w) <= pathBytes.length ? (i + w) : pathBytes.length;
|
||||
final chunk = pathBytes.sublist(i, end);
|
||||
parts.add(
|
||||
chunk
|
||||
@@ -138,6 +137,9 @@ class Contact {
|
||||
return parts.join(',');
|
||||
}
|
||||
|
||||
/// Default grouping uses legacy single-byte hop hash width.
|
||||
String get pathIdList => pathFormattedIdList(pathHashSize);
|
||||
|
||||
String get shortPubKeyHex {
|
||||
return "<${publicKeyHex.substring(0, 8)}...${publicKeyHex.substring(publicKeyHex.length - 8)}>";
|
||||
}
|
||||
@@ -183,12 +185,13 @@ class Contact {
|
||||
final lastMod = reader.readUInt32LE();
|
||||
|
||||
double? lat, lon;
|
||||
final latRaw = reader.readInt32LE();
|
||||
final lonRaw = reader.readInt32LE();
|
||||
|
||||
if (latRaw != 0 || lonRaw != 0) {
|
||||
lat = latRaw / 1e6;
|
||||
lon = lonRaw / 1e6;
|
||||
if (reader.remaining >= 8) {
|
||||
final latRaw = reader.readInt32LE();
|
||||
final lonRaw = reader.readInt32LE();
|
||||
if (latRaw != 0 || lonRaw != 0) {
|
||||
lat = latRaw / 1e6;
|
||||
lon = lonRaw / 1e6;
|
||||
}
|
||||
}
|
||||
|
||||
return Contact(
|
||||
|
||||
@@ -291,6 +291,14 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.vertical_align_top),
|
||||
title: Text(context.l10n.appSettings_jumpToOldestUnread),
|
||||
subtitle: Text(context.l10n.appSettings_jumpToOldestUnreadSubtitle),
|
||||
value: settingsService.settings.jumpToOldestUnread,
|
||||
onChanged: settingsService.setJumpToOldestUnread,
|
||||
),
|
||||
const Divider(height: 1),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.alt_route),
|
||||
title: Text(context.l10n.appSettings_autoRouteRotation),
|
||||
@@ -689,6 +697,12 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
return context.l10n.appSettings_languageRu;
|
||||
case 'uk':
|
||||
return context.l10n.appSettings_languageUk;
|
||||
case 'hu':
|
||||
return context.l10n.appSettings_languageHu;
|
||||
case 'ja':
|
||||
return context.l10n.appSettings_languageJa;
|
||||
case 'ko':
|
||||
return context.l10n.appSettings_languageKo;
|
||||
default:
|
||||
return context.l10n.appSettings_languageSystem;
|
||||
}
|
||||
@@ -776,6 +790,18 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
title: Text(context.l10n.appSettings_languageUk),
|
||||
value: 'uk',
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: Text(context.l10n.appSettings_languageHu),
|
||||
value: 'hu',
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: Text(context.l10n.appSettings_languageJa),
|
||||
value: 'ja',
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: Text(context.l10n.appSettings_languageKo),
|
||||
value: 'ko',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -26,6 +26,7 @@ import '../widgets/gif_message.dart';
|
||||
import '../widgets/jump_to_bottom_button.dart';
|
||||
import '../widgets/gif_picker.dart';
|
||||
import '../widgets/message_status_icon.dart';
|
||||
import '../widgets/radio_stats_entry.dart';
|
||||
import 'channel_message_path_screen.dart';
|
||||
import 'map_screen.dart';
|
||||
|
||||
@@ -47,6 +48,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
bool _isLoadingOlder = false;
|
||||
|
||||
MeshCoreConnector? _connector;
|
||||
DateTime? _lastChannelSendAt;
|
||||
bool _channelSkipNextBottomSnap = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -55,11 +58,45 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
_scrollController.onScrollNearTop = _loadOlderMessages;
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_connector = context.read<MeshCoreConnector>();
|
||||
_connector?.setActiveChannel(widget.channel.index);
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final settings = context.read<AppSettingsService>().settings;
|
||||
final idx = widget.channel.index;
|
||||
final unread = connector.getUnreadCountForChannelIndex(idx);
|
||||
ChannelMessage? anchor;
|
||||
if (settings.jumpToOldestUnread && unread > 0) {
|
||||
anchor = _findOldestUnreadChannelAnchor(
|
||||
connector.getChannelMessages(widget.channel),
|
||||
unread,
|
||||
);
|
||||
}
|
||||
connector.setActiveChannel(idx);
|
||||
_connector = connector;
|
||||
if (anchor != null) {
|
||||
_channelSkipNextBottomSnap = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_scrollToMessage(anchor!.messageId);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ChannelMessage? _findOldestUnreadChannelAnchor(
|
||||
List<ChannelMessage> messages,
|
||||
int unreadCount,
|
||||
) {
|
||||
if (unreadCount <= 0 || messages.isEmpty) return null;
|
||||
var n = 0;
|
||||
ChannelMessage? oldest;
|
||||
for (final m in messages.reversed) {
|
||||
if (m.isOutgoing) continue;
|
||||
n++;
|
||||
oldest = m;
|
||||
if (n >= unreadCount) break;
|
||||
}
|
||||
return oldest;
|
||||
}
|
||||
|
||||
void _onTextFieldFocusChange() {
|
||||
if (_textFieldFocusNode.hasFocus && mounted) {
|
||||
_scrollController.handleKeyboardOpen();
|
||||
@@ -167,6 +204,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
const RadioStatsIconButton(),
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
onSelected: (value) {
|
||||
@@ -243,6 +281,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
|
||||
// Auto-scroll to bottom if user is already at bottom
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_channelSkipNextBottomSnap) {
|
||||
_channelSkipNextBottomSnap = false;
|
||||
return;
|
||||
}
|
||||
_scrollController.scrollToBottomIfAtBottom();
|
||||
});
|
||||
|
||||
@@ -468,11 +510,6 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
style: TextStyle(
|
||||
fontSize: bodyFontSize * textScale,
|
||||
),
|
||||
linkStyle: TextStyle(
|
||||
fontSize: bodyFontSize * textScale,
|
||||
color: Colors.green,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!enableTracing && isOutgoing) ...[
|
||||
@@ -1079,6 +1116,16 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
final text = _textController.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
|
||||
final now = DateTime.now();
|
||||
if (_lastChannelSendAt != null &&
|
||||
now.difference(_lastChannelSendAt!) < const Duration(seconds: 1)) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.chat_sendCooldown)));
|
||||
return;
|
||||
}
|
||||
_lastChannelSendAt = now;
|
||||
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
|
||||
String messageText = text;
|
||||
|
||||
@@ -40,8 +40,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
final primaryPath = !channelMessage && !message.isOutgoing
|
||||
? Uint8List.fromList(primaryPathTmp.reversed.toList())
|
||||
: primaryPathTmp;
|
||||
final contacts = connector.allContacts;
|
||||
final hops = _buildPathHops(primaryPath, contacts, l10n);
|
||||
final hops = _buildPathHops(primaryPath, connector, l10n);
|
||||
final hasHopDetails = primaryPath.isNotEmpty;
|
||||
final observedLabel = _formatObservedHops(
|
||||
primaryPath.length,
|
||||
@@ -65,6 +64,9 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
flipPathAround: true,
|
||||
reversePathAround:
|
||||
!(!channelMessage && !message.isOutgoing),
|
||||
pathHashByteWidth: context
|
||||
.read<MeshCoreConnector>()
|
||||
.pathHashByteWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -303,10 +305,12 @@ class _ChannelMessagePathMapScreenState
|
||||
extends State<ChannelMessagePathMapScreen> {
|
||||
static const double _labelZoomThreshold = 8.5;
|
||||
|
||||
final MapController _mapController = MapController();
|
||||
Uint8List? _selectedPath;
|
||||
double _pathDistance = 0.0;
|
||||
bool _showNodeLabels = true;
|
||||
bool _didReceivePositionUpdate = false;
|
||||
int? _focusedHopIndex;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -337,6 +341,22 @@ class _ChannelMessagePathMapScreenState
|
||||
return totalDistance;
|
||||
}
|
||||
|
||||
void _focusHop(_PathHop hop) {
|
||||
if (!hop.hasLocation) return;
|
||||
final targetZoom = _didReceivePositionUpdate
|
||||
? max(_mapController.camera.zoom, 10.0)
|
||||
: 12.0;
|
||||
_mapController.move(hop.position!, targetZoom);
|
||||
}
|
||||
|
||||
void _onHopTapped(_PathHop hop) {
|
||||
_focusHop(hop);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_focusedHopIndex = hop.index;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MeshCoreConnector>(
|
||||
@@ -365,8 +385,7 @@ class _ChannelMessagePathMapScreenState
|
||||
: selectedPathTmp;
|
||||
|
||||
final selectedIndex = _indexForPath(selectedPath, observedPaths);
|
||||
final contacts = connector.allContacts;
|
||||
final hops = _buildPathHops(selectedPath, contacts, context.l10n);
|
||||
final hops = _buildPathHops(selectedPath, connector, context.l10n);
|
||||
|
||||
final points = <LatLng>[];
|
||||
|
||||
@@ -421,6 +440,7 @@ class _ChannelMessagePathMapScreenState
|
||||
children: [
|
||||
FlutterMap(
|
||||
key: mapKey,
|
||||
mapController: _mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: initialCenter,
|
||||
initialZoom: initialZoom,
|
||||
@@ -472,6 +492,7 @@ class _ChannelMessagePathMapScreenState
|
||||
) {
|
||||
setState(() {
|
||||
_selectedPath = observedPaths[index].pathBytes;
|
||||
_focusedHopIndex = null;
|
||||
});
|
||||
}),
|
||||
if (points.isEmpty)
|
||||
@@ -727,8 +748,17 @@ class _ChannelMessagePathMapScreenState
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final hop = hops[index];
|
||||
final isFocused = _focusedHopIndex == hop.index;
|
||||
return ListTile(
|
||||
dense: true,
|
||||
enabled: hop.hasLocation,
|
||||
selected: isFocused,
|
||||
selectedTileColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.12),
|
||||
onTap: hop.hasLocation
|
||||
? () => _onHopTapped(hop)
|
||||
: null,
|
||||
leading: CircleAvatar(
|
||||
radius: 14,
|
||||
child: Text(
|
||||
@@ -787,19 +817,83 @@ class _ObservedPath {
|
||||
|
||||
List<_PathHop> _buildPathHops(
|
||||
Uint8List pathBytes,
|
||||
List<Contact> contacts,
|
||||
MeshCoreConnector connector,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
if (pathBytes.isEmpty) return const [];
|
||||
final candidatesByPrefix = <int, List<Contact>>{};
|
||||
final allContacts = connector.allContacts;
|
||||
for (final contact in allContacts) {
|
||||
if (contact.publicKey.isEmpty) continue;
|
||||
if (contact.type != advTypeRepeater && contact.type != advTypeRoom) {
|
||||
continue;
|
||||
}
|
||||
final prefix = contact.publicKey.first;
|
||||
candidatesByPrefix.putIfAbsent(prefix, () => <Contact>[]).add(contact);
|
||||
}
|
||||
for (final candidates in candidatesByPrefix.values) {
|
||||
candidates.sort((a, b) => b.lastSeen.compareTo(a.lastSeen));
|
||||
}
|
||||
final startPoint =
|
||||
(connector.selfLatitude != null && connector.selfLongitude != null)
|
||||
? LatLng(connector.selfLatitude!, connector.selfLongitude!)
|
||||
: null;
|
||||
var previousPosition = startPoint;
|
||||
final distance = Distance();
|
||||
var lastDistance = 0.0;
|
||||
var bestDistance = 0.0;
|
||||
final hops = <_PathHop>[];
|
||||
for (var i = 0; i < pathBytes.length; i++) {
|
||||
final prefix = pathBytes[i];
|
||||
final contact = _matchContactForPrefix(contacts, prefix);
|
||||
final searchPoint = i == 0 ? startPoint : previousPosition;
|
||||
final candidates = candidatesByPrefix[pathBytes[i]];
|
||||
Contact? contact;
|
||||
if (candidates != null && candidates.isNotEmpty) {
|
||||
var bestIndex = 0;
|
||||
if (searchPoint != null) {
|
||||
bestDistance = double.infinity;
|
||||
for (var j = 0; j < candidates.length; j++) {
|
||||
final candidate = candidates[j];
|
||||
if (!candidate.hasLocation ||
|
||||
candidate.latitude == null ||
|
||||
candidate.longitude == null) {
|
||||
continue;
|
||||
}
|
||||
final currentDistance = distance(
|
||||
searchPoint,
|
||||
LatLng(candidate.latitude!, candidate.longitude!),
|
||||
);
|
||||
if (currentDistance < bestDistance) {
|
||||
bestDistance = currentDistance;
|
||||
bestIndex = j;
|
||||
}
|
||||
}
|
||||
}
|
||||
contact = candidates.removeAt(bestIndex);
|
||||
if (candidates.isEmpty) {
|
||||
candidatesByPrefix.remove(pathBytes[i]);
|
||||
}
|
||||
}
|
||||
|
||||
final resolvedPosition = _resolvePosition(contact);
|
||||
if (resolvedPosition != null) {
|
||||
previousPosition = resolvedPosition;
|
||||
}
|
||||
// If the best candidate is much farther than the previous hop, it's likely not the correct match.
|
||||
if (lastDistance + bestDistance > 70000 &&
|
||||
candidates != null &&
|
||||
candidates.isNotEmpty) {
|
||||
i--;
|
||||
lastDistance = bestDistance;
|
||||
continue;
|
||||
}
|
||||
lastDistance = bestDistance;
|
||||
|
||||
hops.add(
|
||||
_PathHop(
|
||||
index: i + 1,
|
||||
prefix: prefix,
|
||||
prefix: pathBytes[i],
|
||||
contact: contact,
|
||||
position: _resolvePosition(contact),
|
||||
position: resolvedPosition,
|
||||
l10n: l10n,
|
||||
),
|
||||
);
|
||||
@@ -807,42 +901,13 @@ List<_PathHop> _buildPathHops(
|
||||
return hops;
|
||||
}
|
||||
|
||||
Contact? _matchContactForPrefix(List<Contact> contacts, int prefix) {
|
||||
final matches = contacts
|
||||
.where(
|
||||
(contact) =>
|
||||
(contact.type == advTypeRepeater || contact.type == advTypeRoom) &&
|
||||
contact.publicKey.isNotEmpty &&
|
||||
contact.publicKey[0] == prefix,
|
||||
)
|
||||
.toList();
|
||||
if (matches.isEmpty) return null;
|
||||
|
||||
Contact? pickWhere(bool Function(Contact) predicate) {
|
||||
for (final contact in matches) {
|
||||
if (predicate(contact)) return contact;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return pickWhere((c) => c.type == advTypeRepeater && _hasValidLocation(c)) ??
|
||||
pickWhere((c) => c.type == advTypeRepeater) ??
|
||||
pickWhere(_hasValidLocation) ??
|
||||
matches.first;
|
||||
}
|
||||
|
||||
LatLng? _resolvePosition(Contact? contact) {
|
||||
if (contact == null) return null;
|
||||
if (!_hasValidLocation(contact)) return null;
|
||||
return LatLng(contact.latitude!, contact.longitude!);
|
||||
}
|
||||
|
||||
bool _hasValidLocation(Contact contact) {
|
||||
final lat = contact.latitude;
|
||||
final lon = contact.longitude;
|
||||
if (lat == null || lon == null) return false;
|
||||
if (lat == 0 && lon == 0) return false;
|
||||
return true;
|
||||
if (!contact.hasLocation) return null;
|
||||
final latitude = contact.latitude;
|
||||
final longitude = contact.longitude;
|
||||
if (latitude == null || longitude == null) return null;
|
||||
return LatLng(latitude, longitude);
|
||||
}
|
||||
|
||||
String _formatPrefix(int prefix) {
|
||||
|
||||
@@ -36,6 +36,7 @@ import '../widgets/gif_message.dart';
|
||||
import '../widgets/jump_to_bottom_button.dart';
|
||||
import '../widgets/gif_picker.dart';
|
||||
import '../widgets/path_selection_dialog.dart';
|
||||
import '../widgets/radio_stats_entry.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import 'telemetry_screen.dart';
|
||||
@@ -53,8 +54,11 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
final _textController = TextEditingController();
|
||||
final _scrollController = ChatScrollController();
|
||||
final _textFieldFocusNode = FocusNode();
|
||||
final GlobalKey _unreadScrollKey = GlobalKey();
|
||||
bool _isLoadingOlder = false;
|
||||
MeshCoreConnector? _connector;
|
||||
Message? _pendingUnreadScrollTarget;
|
||||
DateTime? _lastTextSendAt;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -63,11 +67,50 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
_scrollController.onScrollNearTop = _loadOlderMessages;
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_connector = context.read<MeshCoreConnector>();
|
||||
_connector?.setActiveContact(widget.contact.publicKeyHex);
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final settings = context.read<AppSettingsService>().settings;
|
||||
final keyHex = widget.contact.publicKeyHex;
|
||||
final unread = connector.getUnreadCountForContactKey(keyHex);
|
||||
Message? anchor;
|
||||
if (settings.jumpToOldestUnread && unread > 0) {
|
||||
anchor = _findOldestUnreadAnchor(
|
||||
connector.getMessages(widget.contact),
|
||||
unread,
|
||||
);
|
||||
}
|
||||
connector.setActiveContact(keyHex);
|
||||
_connector = connector;
|
||||
if (anchor != null) {
|
||||
setState(() => _pendingUnreadScrollTarget = anchor);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final ctx = _unreadScrollKey.currentContext;
|
||||
if (ctx != null) {
|
||||
Scrollable.ensureVisible(
|
||||
ctx,
|
||||
duration: const Duration(milliseconds: 350),
|
||||
alignment: 0.15,
|
||||
);
|
||||
}
|
||||
setState(() => _pendingUnreadScrollTarget = null);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Message? _findOldestUnreadAnchor(List<Message> messages, int unreadCount) {
|
||||
if (unreadCount <= 0 || messages.isEmpty) return null;
|
||||
var n = 0;
|
||||
Message? oldest;
|
||||
for (final m in messages.reversed) {
|
||||
if (m.isOutgoing || m.isCli) continue;
|
||||
n++;
|
||||
oldest = m;
|
||||
if (n >= unreadCount) break;
|
||||
}
|
||||
return oldest;
|
||||
}
|
||||
|
||||
void _onTextFieldFocusChange() {
|
||||
if (_textFieldFocusNode.hasFocus && mounted) {
|
||||
_scrollController.handleKeyboardOpen();
|
||||
@@ -247,6 +290,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
tooltip: context.l10n.chat_pathManagement,
|
||||
onPressed: () => _showPathHistory(context),
|
||||
),
|
||||
const RadioStatsIconButton(),
|
||||
Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, _) {
|
||||
return PopupMenuButton<String>(
|
||||
@@ -378,6 +422,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
// Auto-scroll to bottom if user is already at bottom
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (_pendingUnreadScrollTarget != null) return;
|
||||
_scrollController.scrollToBottomIfAtBottom();
|
||||
});
|
||||
|
||||
@@ -424,7 +469,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
(service) => service.scale,
|
||||
);
|
||||
final resolvedContact = _resolveContact(connector);
|
||||
return _MessageBubble(
|
||||
final bubble = _MessageBubble(
|
||||
message: message,
|
||||
senderName: resolvedContact.type == advTypeRoom
|
||||
? "${contact.name} [$fourByteHex]"
|
||||
@@ -436,6 +481,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
onRetryReaction: (msg, emoji) =>
|
||||
_sendReaction(msg, contact, emoji),
|
||||
);
|
||||
if (identical(message, _pendingUnreadScrollTarget)) {
|
||||
return KeyedSubtree(key: _unreadScrollKey, child: bubble);
|
||||
}
|
||||
return bubble;
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -561,6 +610,16 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
final text = _textController.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
|
||||
final now = DateTime.now();
|
||||
if (_lastTextSendAt != null &&
|
||||
now.difference(_lastTextSendAt!) < const Duration(seconds: 1)) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.chat_sendCooldown)));
|
||||
return;
|
||||
}
|
||||
_lastTextSendAt = now;
|
||||
|
||||
final maxBytes = maxContactMessageBytes();
|
||||
if (utf8.encode(text).length > maxBytes) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -950,6 +1009,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
path: Uint8List.fromList(pathBytes),
|
||||
flipPathAround: true,
|
||||
targetContact: widget.contact,
|
||||
pathHashByteWidth: connector.pathHashByteWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1212,7 +1272,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
connector.getContacts();
|
||||
}
|
||||
|
||||
final pathForInput = currentContact.pathIdList;
|
||||
final pathForInput = currentContact.pathFormattedIdList(
|
||||
connector.pathHashByteWidth,
|
||||
);
|
||||
final currentPathLabel = _currentPathLabel(currentContact);
|
||||
|
||||
// Filter out the current contact from available contacts
|
||||
@@ -1607,11 +1669,6 @@ class _MessageBubble extends StatelessWidget {
|
||||
color: textColor,
|
||||
fontSize: bodyFontSize * textScale,
|
||||
),
|
||||
linkStyle: TextStyle(
|
||||
color: Colors.green,
|
||||
decoration: TextDecoration.underline,
|
||||
fontSize: bodyFontSize * textScale,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!enableTracing && isOutgoing) ...[
|
||||
@@ -1640,7 +1697,10 @@ class _MessageBubble extends StatelessWidget {
|
||||
child: Text(
|
||||
context.l10n.chat_retryCount(
|
||||
message.retryCount,
|
||||
4,
|
||||
context
|
||||
.read<AppSettingsService>()
|
||||
.settings
|
||||
.maxMessageRetries,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_connector.dart';
|
||||
import 'package:meshcore_open/models/companion_radio_stats.dart';
|
||||
import 'package:meshcore_open/l10n/l10n.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class CompanionRadioStatsScreen extends StatefulWidget {
|
||||
const CompanionRadioStatsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CompanionRadioStatsScreen> createState() =>
|
||||
_CompanionRadioStatsScreenState();
|
||||
}
|
||||
|
||||
class _CompanionRadioStatsScreenState extends State<CompanionRadioStatsScreen> {
|
||||
final List<double> _noiseHistory = [];
|
||||
static const int _maxSamples = 120;
|
||||
MeshCoreConnector? _connector;
|
||||
DateTime? _lastChartSampleAt;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final c = context.read<MeshCoreConnector>();
|
||||
_connector = c;
|
||||
c.acquireRadioStatsPolling();
|
||||
c.setPollingInterval(1);
|
||||
c.radioStatsNotifier.addListener(_onStatsUpdate);
|
||||
}
|
||||
|
||||
void _onStatsUpdate() {
|
||||
final s = _connector?.radioStatsNotifier.value;
|
||||
if (s == null || !mounted) return;
|
||||
if (_lastChartSampleAt == s.receivedAt) return;
|
||||
_lastChartSampleAt = s.receivedAt;
|
||||
setState(() {
|
||||
_noiseHistory.add(s.noiseFloorDbm.toDouble());
|
||||
while (_noiseHistory.length > _maxSamples) {
|
||||
_noiseHistory.removeAt(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_connector?.radioStatsNotifier.removeListener(_onStatsUpdate);
|
||||
_connector?.releaseRadioStatsPolling();
|
||||
_connector?.setPollingInterval(30);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.radioStats_screenTitle),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: Selector<MeshCoreConnector, ({bool connected, bool supported})>(
|
||||
selector: (_, c) => (
|
||||
connected: c.isConnected,
|
||||
supported: c.supportsCompanionRadioStats,
|
||||
),
|
||||
builder: (context, state, _) {
|
||||
if (!state.connected) {
|
||||
return Center(child: Text(l10n.radioStats_notConnected));
|
||||
}
|
||||
if (!state.supported) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
l10n.radioStats_firmwareTooOld,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final tt = Theme.of(context).textTheme;
|
||||
|
||||
return ValueListenableBuilder<CompanionRadioStats?>(
|
||||
valueListenable: connector.radioStatsNotifier,
|
||||
builder: (context, stats, _) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (stats != null) ...[
|
||||
Text(
|
||||
l10n.radioStats_noiseFloor(stats.noiseFloorDbm),
|
||||
style: tt.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(l10n.radioStats_lastRssi(stats.lastRssiDbm)),
|
||||
Text(
|
||||
l10n.radioStats_lastSnr(
|
||||
stats.lastSnrDb.toStringAsFixed(1),
|
||||
),
|
||||
),
|
||||
Text(l10n.radioStats_txAir(stats.txAirSecs)),
|
||||
Text(l10n.radioStats_rxAir(stats.rxAirSecs)),
|
||||
const SizedBox(height: 16),
|
||||
] else
|
||||
Text(l10n.radioStats_waiting),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: CustomPaint(
|
||||
painter: _NoiseChartPainter(
|
||||
samples: List<double>.from(_noiseHistory),
|
||||
colorScheme: scheme,
|
||||
textTheme: tt,
|
||||
),
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.radioStats_chartCaption,
|
||||
style: tt.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NoiseChartPainter extends CustomPainter {
|
||||
final List<double> samples;
|
||||
final ColorScheme colorScheme;
|
||||
final TextTheme textTheme;
|
||||
|
||||
_NoiseChartPainter({
|
||||
required this.samples,
|
||||
required this.colorScheme,
|
||||
required this.textTheme,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final bg = Paint()..color = colorScheme.surfaceContainerHighest;
|
||||
final border = Paint()
|
||||
..color = colorScheme.outlineVariant
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1;
|
||||
final grid = Paint()
|
||||
..color = colorScheme.outlineVariant.withValues(alpha: 0.5)
|
||||
..strokeWidth = 1;
|
||||
final line = Paint()
|
||||
..color = colorScheme.primary
|
||||
..strokeWidth = 2
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
final rect = Rect.fromLTWH(0, 0, size.width, size.height);
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(rect, const Radius.circular(8)),
|
||||
bg,
|
||||
);
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(rect, const Radius.circular(8)),
|
||||
border,
|
||||
);
|
||||
|
||||
const padL = 40.0;
|
||||
const padR = 8.0;
|
||||
const padT = 8.0;
|
||||
const padB = 24.0;
|
||||
final chart = Rect.fromLTRB(
|
||||
padL,
|
||||
padT,
|
||||
size.width - padR,
|
||||
size.height - padB,
|
||||
);
|
||||
|
||||
for (var i = 0; i <= 4; i++) {
|
||||
final y = chart.top + (chart.height * i / 4);
|
||||
canvas.drawLine(Offset(chart.left, y), Offset(chart.right, y), grid);
|
||||
}
|
||||
|
||||
if (samples.length < 2) {
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(
|
||||
text: '—',
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
tp.paint(
|
||||
canvas,
|
||||
Offset(chart.left + 4, chart.top + chart.height / 2 - tp.height / 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
double minV = samples.reduce((a, b) => a < b ? a : b);
|
||||
double maxV = samples.reduce((a, b) => a > b ? a : b);
|
||||
if ((maxV - minV).abs() < 1) {
|
||||
minV -= 2;
|
||||
maxV += 2;
|
||||
}
|
||||
final span = maxV - minV;
|
||||
|
||||
for (var i = 0; i <= 2; i++) {
|
||||
final v = maxV - span * i / 2;
|
||||
final tp = _yAxisLabel(v);
|
||||
final y = chart.top + (chart.height * i / 2) - tp.height / 2;
|
||||
tp.paint(canvas, Offset(4, y));
|
||||
}
|
||||
|
||||
final path = Path();
|
||||
for (var i = 0; i < samples.length; i++) {
|
||||
final x = chart.left + (chart.width * i / (samples.length - 1));
|
||||
final t = (samples[i] - minV) / span;
|
||||
final y = chart.bottom - t * chart.height;
|
||||
if (i == 0) {
|
||||
path.moveTo(x, y);
|
||||
} else {
|
||||
path.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
canvas.drawPath(path, line);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _NoiseChartPainter oldDelegate) {
|
||||
return oldDelegate.samples.length != samples.length ||
|
||||
oldDelegate.colorScheme != colorScheme;
|
||||
}
|
||||
|
||||
TextPainter _yAxisLabel(double v) {
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(
|
||||
text: v.round().toString(),
|
||||
style: textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
return tp;
|
||||
}
|
||||
}
|
||||
@@ -1240,20 +1240,19 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
if (isRepeater) ...[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.radar, color: Colors.green),
|
||||
title: contact.pathBytesForDisplay.isNotEmpty
|
||||
? Text(context.l10n.contacts_pathTrace)
|
||||
: Text(context.l10n.contacts_ping),
|
||||
title: Text(context.l10n.contacts_ping),
|
||||
onTap: () {
|
||||
final hw = context
|
||||
.read<MeshCoreConnector>()
|
||||
.pathHashByteWidth;
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PathTraceMapScreen(
|
||||
title: contact.pathBytesForDisplay.isNotEmpty
|
||||
? context.l10n.contacts_repeaterPathTrace
|
||||
: context.l10n.contacts_repeaterPing,
|
||||
path: contact.pathBytesForDisplay,
|
||||
flipPathAround: true,
|
||||
title: context.l10n.contacts_repeaterPing,
|
||||
path: Uint8List.fromList([contact.publicKey.first]),
|
||||
targetContact: contact,
|
||||
pathHashByteWidth: hw,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1270,10 +1269,11 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
] else if (isRoom) ...[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.radar, color: Colors.green),
|
||||
title: contact.pathLength > 0
|
||||
? Text(context.l10n.contacts_pathTrace)
|
||||
: Text(context.l10n.contacts_ping),
|
||||
title: Text(context.l10n.contacts_pathTrace),
|
||||
onTap: () {
|
||||
final hw = context
|
||||
.read<MeshCoreConnector>()
|
||||
.pathHashByteWidth;
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
@@ -1281,9 +1281,12 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
title: contact.pathBytesForDisplay.isNotEmpty
|
||||
? context.l10n.contacts_roomPathTrace
|
||||
: context.l10n.contacts_roomPing,
|
||||
path: contact.pathBytesForDisplay,
|
||||
path: contact.pathBytesForDisplay.isNotEmpty
|
||||
? contact.pathBytesForDisplay
|
||||
: Uint8List.fromList([contact.publicKey.first]),
|
||||
flipPathAround: contact.pathBytesForDisplay.isNotEmpty,
|
||||
targetContact: contact,
|
||||
pathHashByteWidth: hw,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1318,6 +1321,9 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
leading: const Icon(Icons.radar, color: Colors.green),
|
||||
title: Text(context.l10n.contacts_chatTraceRoute),
|
||||
onTap: () {
|
||||
final hw = context
|
||||
.read<MeshCoreConnector>()
|
||||
.pathHashByteWidth;
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
@@ -1328,6 +1334,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
path: contact.pathBytesForDisplay,
|
||||
flipPathAround: true,
|
||||
targetContact: contact,
|
||||
pathHashByteWidth: hw,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../utils/dialog_utils.dart';
|
||||
import '../utils/disconnect_navigation_mixin.dart';
|
||||
import '../utils/route_transitions.dart';
|
||||
import '../widgets/quick_switch_bar.dart';
|
||||
import 'channels_screen.dart';
|
||||
import 'contacts_screen.dart';
|
||||
import 'map_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
|
||||
/// Main hub screen after connecting to a MeshCore device
|
||||
class DeviceScreen extends StatefulWidget {
|
||||
const DeviceScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DeviceScreen> createState() => _DeviceScreenState();
|
||||
}
|
||||
|
||||
class _DeviceScreenState extends State<DeviceScreen>
|
||||
with DisconnectNavigationMixin {
|
||||
bool _showBatteryVoltage = false;
|
||||
int _quickIndex = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, child) {
|
||||
// Auto-navigate back to scanner if disconnected
|
||||
if (!checkConnectionAndNavigate(connector)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: _buildBatteryIndicator(connector, context),
|
||||
titleSpacing: 16,
|
||||
centerTitle: false,
|
||||
title: _buildAppBarTitle(connector, theme),
|
||||
automaticallyImplyLeading: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bluetooth_disabled),
|
||||
tooltip: context.l10n.common_disconnect,
|
||||
onPressed: () => _disconnect(context, connector),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.tune),
|
||||
tooltip: context.l10n.common_settings,
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const SettingsScreen(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||
children: [
|
||||
_buildConnectionCard(connector, context),
|
||||
const SizedBox(height: 16),
|
||||
_buildSectionLabel(theme, context.l10n.device_quickSwitch),
|
||||
const SizedBox(height: 12),
|
||||
_buildQuickSwitchBar(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBarTitle(MeshCoreConnector connector, ThemeData theme) {
|
||||
final colorScheme = theme.colorScheme;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.device_meshcore,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.8,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
connector.deviceDisplayName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionLabel(ThemeData theme, String text) {
|
||||
return Text(
|
||||
text,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.6,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConnectionCard(
|
||||
MeshCoreConnector connector,
|
||||
BuildContext context,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor: colorScheme.primaryContainer,
|
||||
child: Icon(
|
||||
Icons.wifi_tethering_rounded,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
connector.deviceDisplayName,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
connector.deviceIdLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Chip(
|
||||
avatar: Icon(
|
||||
Icons.check_circle,
|
||||
size: 18,
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
),
|
||||
label: Text(context.l10n.common_connected),
|
||||
backgroundColor: colorScheme.secondaryContainer,
|
||||
labelStyle: theme.textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
_buildBatteryIndicator(connector, context),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuickSwitchBar(BuildContext context) {
|
||||
return QuickSwitchBar(
|
||||
selectedIndex: _quickIndex,
|
||||
onDestinationSelected: (index) {
|
||||
_openQuickDestination(index, context);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBatteryIndicator(
|
||||
MeshCoreConnector connector,
|
||||
BuildContext context,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final percent = connector.batteryPercent;
|
||||
final millivolts = connector.batteryMillivolts;
|
||||
final percentLabel = percent != null ? '$percent%' : '--%';
|
||||
final voltageLabel = millivolts == null
|
||||
? '-- V'
|
||||
: '${(millivolts / 1000.0).toStringAsFixed(2)} V';
|
||||
final displayLabel = _showBatteryVoltage ? voltageLabel : percentLabel;
|
||||
final icon = _batteryIcon(percent);
|
||||
|
||||
return ActionChip(
|
||||
avatar: Icon(icon, size: 16, color: colorScheme.onSecondaryContainer),
|
||||
label: Text(displayLabel),
|
||||
labelStyle: theme.textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
backgroundColor: colorScheme.secondaryContainer,
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showBatteryVoltage = !_showBatteryVoltage;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
IconData _batteryIcon(int? percent) {
|
||||
if (percent == null) return Icons.battery_unknown;
|
||||
if (percent <= 15) return Icons.battery_alert;
|
||||
return Icons.battery_full;
|
||||
}
|
||||
|
||||
void _openQuickDestination(int index, BuildContext context) {
|
||||
if (_quickIndex != index) {
|
||||
setState(() {
|
||||
_quickIndex = index;
|
||||
});
|
||||
}
|
||||
switch (index) {
|
||||
case 0:
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
buildQuickSwitchRoute(const ContactsScreen(hideBackButton: true)),
|
||||
);
|
||||
break;
|
||||
case 1:
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
buildQuickSwitchRoute(const ChannelsScreen(hideBackButton: true)),
|
||||
);
|
||||
break;
|
||||
case 2:
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
buildQuickSwitchRoute(const MapScreen(hideBackButton: true)),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disconnect(
|
||||
BuildContext context,
|
||||
MeshCoreConnector connector,
|
||||
) async {
|
||||
await showDisconnectDialog(context, connector);
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,13 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
DateTime _resolveLastSeen(Contact contact) {
|
||||
if (contact.type != advTypeChat) return contact.lastSeen;
|
||||
return contact.lastMessageAt.isAfter(contact.lastSeen)
|
||||
? contact.lastMessageAt
|
||||
: contact.lastSeen;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
@@ -108,11 +115,56 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Text(
|
||||
_formatLastSeen(context, contact.lastSeen),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
// Clamp text scaling in trailing section to prevent overflow while
|
||||
// maintaining accessibility. Primary content (title/subtitle) scales normally.
|
||||
trailing: MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
textScaler: TextScaler.linear(
|
||||
MediaQuery.textScalerOf(
|
||||
context,
|
||||
).scale(1.0).clamp(1.0, 1.3),
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: 120,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
_formatLastSeen(
|
||||
context,
|
||||
_resolveLastSeen(contact),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.right,
|
||||
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],
|
||||
),
|
||||
if (contact.rawPacket != null)
|
||||
const SizedBox(width: 2),
|
||||
if (contact.rawPacket != null)
|
||||
Icon(
|
||||
Icons.cell_tower,
|
||||
size: 14,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
|
||||
+262
-83
@@ -1,3 +1,4 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
@@ -52,7 +53,7 @@ class MapScreen extends StatefulWidget {
|
||||
|
||||
class _MapScreenState extends State<MapScreen> {
|
||||
// Zoom level at which node labels start to appear
|
||||
static const double _labelZoomThreshold = 12.0;
|
||||
static const double _labelZoomThreshold = 14.0;
|
||||
|
||||
final MapController _mapController = MapController();
|
||||
final MapMarkerService _markerService = MapMarkerService();
|
||||
@@ -63,6 +64,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
bool _hasInitializedMap = false;
|
||||
bool _removedMarkersLoaded = false;
|
||||
final List<int> _pathTrace = [];
|
||||
final List<Contact> _pathTraceContacts = [];
|
||||
final List<LatLng> _points = [];
|
||||
final List<Polyline> _polylines = [];
|
||||
bool _legendExpanded = false;
|
||||
@@ -329,7 +331,9 @@ class _MapScreenState extends State<MapScreen> {
|
||||
if (!_isBuildingPathTrace)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.radar),
|
||||
onPressed: () => _startPath(),
|
||||
onPressed: () => _startPath(
|
||||
LatLng(connector.selfLatitude!, connector.selfLongitude!),
|
||||
),
|
||||
tooltip: context.l10n.contacts_pathTrace,
|
||||
),
|
||||
if (!_isBuildingPathTrace)
|
||||
@@ -477,13 +481,15 @@ class _MapScreenState extends State<MapScreen> {
|
||||
point: highlightPosition,
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Icon(
|
||||
Icons.location_on_outlined,
|
||||
color: Colors.red[600],
|
||||
size: 34,
|
||||
child: IgnorePointer(
|
||||
child: Icon(
|
||||
Icons.location_on_outlined,
|
||||
color: Colors.red[600],
|
||||
size: 34,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!_isBuildingPathTrace)
|
||||
if (!settings.mapShowOverlaps)
|
||||
..._buildGuessedMarker(
|
||||
guessedLocations,
|
||||
showLabels: _showNodeLabels,
|
||||
@@ -503,28 +509,33 @@ class _MapScreenState extends State<MapScreen> {
|
||||
),
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.teal,
|
||||
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),
|
||||
child: IgnorePointer(
|
||||
ignoring: true,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.teal,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Colors.white,
|
||||
width: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Icon(
|
||||
Icons.person_pin_circle,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Icon(
|
||||
Icons.person_pin_circle,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -544,6 +555,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
),
|
||||
if (!_isBuildingPathTrace)
|
||||
_buildLegend(
|
||||
contacts,
|
||||
contactsWithLocation,
|
||||
settings,
|
||||
sharedMarkers.length,
|
||||
@@ -580,6 +592,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
// Index known-location repeaters by their 1-byte hash.
|
||||
// null value = two repeaters share the same hash byte (ambiguous collision).
|
||||
final repeaterByHash = <int, Contact?>{};
|
||||
|
||||
for (final c in withLocation) {
|
||||
if (c.type == advTypeRepeater) {
|
||||
if (repeaterByHash.containsKey(c.publicKey[0])) {
|
||||
@@ -595,6 +608,11 @@ class _MapScreenState extends State<MapScreen> {
|
||||
|
||||
for (final contact in allContacts) {
|
||||
if (contact.hasLocation) continue;
|
||||
if (contact.lastSeen.isBefore(
|
||||
DateTime.now().subtract(const Duration(hours: 24)),
|
||||
)) {
|
||||
continue; // skip stale contacts
|
||||
}
|
||||
|
||||
final anchorSet = <LatLng>{};
|
||||
|
||||
@@ -641,10 +659,19 @@ class _MapScreenState extends State<MapScreen> {
|
||||
continue; // discard implausible guesses near (0, 0)
|
||||
}
|
||||
} else {
|
||||
double lat = 0, lon = 0;
|
||||
double lat = 0, lon = 0, weight = 1.0;
|
||||
int counted = 0;
|
||||
for (final a in anchors) {
|
||||
lat += a.latitude;
|
||||
lon += a.longitude;
|
||||
if (counted == 0) {
|
||||
lat = a.latitude;
|
||||
lon = a.longitude;
|
||||
} else {
|
||||
lat += a.latitude * weight;
|
||||
lon += a.longitude * weight;
|
||||
}
|
||||
// weight subsequent anchors less to create a bias towards the first (if more than 2)
|
||||
weight = weight / 2;
|
||||
counted++;
|
||||
}
|
||||
position = _offsetGuessedPosition(
|
||||
LatLng(lat / anchors.length, lon / anchors.length),
|
||||
@@ -762,17 +789,26 @@ class _MapScreenState extends State<MapScreen> {
|
||||
final markers = <Marker>[];
|
||||
|
||||
for (final guess in guessed) {
|
||||
if (guess.contact.type == advTypeChat && _isBuildingPathTrace) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final color = _getNodeColor(guess.contact.type);
|
||||
final marker = Marker(
|
||||
point: guess.position,
|
||||
width: 35,
|
||||
height: 35,
|
||||
child: GestureDetector(
|
||||
onTap: () => _showNodeInfo(
|
||||
context,
|
||||
guess.contact,
|
||||
guessedPosition: guess.position,
|
||||
),
|
||||
onLongPress: () => _isBuildingPathTrace
|
||||
? _showNodeInfo(context, guess.contact)
|
||||
: null,
|
||||
onTap: () => _isBuildingPathTrace
|
||||
? _addToPath(context, guess.contact, position: guess.position)
|
||||
: _showNodeInfo(
|
||||
context,
|
||||
guess.contact,
|
||||
guessedPosition: guess.position,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
@@ -812,31 +848,76 @@ class _MapScreenState extends State<MapScreen> {
|
||||
return markers;
|
||||
}
|
||||
|
||||
List<Contact> _filterContactsBySettings(
|
||||
List<Contact> contacts,
|
||||
dynamic settings, {
|
||||
bool noLocations = false,
|
||||
}) {
|
||||
List<Contact> filtered = [];
|
||||
bool addContact = false;
|
||||
for (final contact in contacts) {
|
||||
addContact = false;
|
||||
if (!contact.hasLocation && !noLocations) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply node type filters
|
||||
if (contact.type == advTypeRepeater &&
|
||||
(settings.mapShowRepeaters ||
|
||||
_isBuildingPathTrace ||
|
||||
settings.mapShowOverlaps)) {
|
||||
addContact = true;
|
||||
}
|
||||
if (contact.type == advTypeChat &&
|
||||
(settings.mapShowChatNodes || _isBuildingPathTrace)) {
|
||||
addContact = true;
|
||||
}
|
||||
if (contact.type != advTypeChat &&
|
||||
contact.type != advTypeRepeater &&
|
||||
(settings.mapShowOtherNodes ||
|
||||
_isBuildingPathTrace ||
|
||||
settings.mapShowOverlaps)) {
|
||||
addContact = true;
|
||||
}
|
||||
|
||||
if (contact.type == advTypeChat && _isBuildingPathTrace) {
|
||||
addContact = false;
|
||||
}
|
||||
|
||||
if (settings.mapShowOverlaps) {
|
||||
final hasOverlap = contacts
|
||||
.where(
|
||||
(c) =>
|
||||
c.publicKeyHex != contact.publicKeyHex &&
|
||||
c.publicKey.first == contact.publicKey.first &&
|
||||
(c.type == advTypeRepeater || c.type == advTypeRoom) &&
|
||||
(contact.type == advTypeRepeater ||
|
||||
contact.type == advTypeRoom),
|
||||
)
|
||||
.firstOrNull;
|
||||
|
||||
if (hasOverlap == null &&
|
||||
settings.mapShowOverlaps &&
|
||||
!_isBuildingPathTrace) {
|
||||
addContact = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (addContact) {
|
||||
filtered.add(contact);
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
List<Marker> _buildMarkers(
|
||||
List<Contact> contacts,
|
||||
settings, {
|
||||
required bool showLabels,
|
||||
}) {
|
||||
final markers = <Marker>[];
|
||||
|
||||
for (final contact in contacts) {
|
||||
if (!contact.hasLocation) continue;
|
||||
|
||||
// Apply node type filters
|
||||
if (contact.type == advTypeRepeater &&
|
||||
(!settings.mapShowRepeaters && !_isBuildingPathTrace)) {
|
||||
continue;
|
||||
}
|
||||
if (contact.type == advTypeChat &&
|
||||
!(settings.mapShowChatNodes && !_isBuildingPathTrace)) {
|
||||
continue;
|
||||
}
|
||||
if (contact.type != advTypeChat &&
|
||||
contact.type != advTypeRepeater &&
|
||||
(!settings.mapShowOtherNodes && !_isBuildingPathTrace)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final filteredContacts = _filterContactsBySettings(contacts, settings);
|
||||
for (final contact in filteredContacts) {
|
||||
final marker = Marker(
|
||||
point: LatLng(contact.latitude!, contact.longitude!),
|
||||
width: 35,
|
||||
@@ -852,7 +933,9 @@ class _MapScreenState extends State<MapScreen> {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: _getNodeColor(contact.type),
|
||||
color: settings.mapShowOverlaps && !_isBuildingPathTrace
|
||||
? Colors.red
|
||||
: _getNodeColor(contact.type),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: [
|
||||
@@ -879,7 +962,9 @@ class _MapScreenState extends State<MapScreen> {
|
||||
markers.add(
|
||||
_buildNodeLabelMarker(
|
||||
point: LatLng(contact.latitude!, contact.longitude!),
|
||||
label: contact.name,
|
||||
label: settings.mapShowOverlaps && !_isBuildingPathTrace
|
||||
? "${contact.publicKeyHex.substring(0, 2)}:${contact.name}"
|
||||
: contact.name,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -954,25 +1039,25 @@ class _MapScreenState extends State<MapScreen> {
|
||||
}
|
||||
|
||||
Widget _buildLegend(
|
||||
List<Contact> contacts,
|
||||
List<Contact> contactsWithLocation,
|
||||
settings,
|
||||
int markerCount,
|
||||
int guessedCount,
|
||||
) {
|
||||
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++;
|
||||
}
|
||||
final filteredContacts = _filterContactsBySettings(
|
||||
contacts,
|
||||
settings,
|
||||
noLocations: false,
|
||||
);
|
||||
final filteredContactsAll = _filterContactsBySettings(
|
||||
contacts,
|
||||
settings,
|
||||
noLocations: true,
|
||||
);
|
||||
|
||||
final nodeCount = filteredContacts.length;
|
||||
final nodeCountAll = filteredContactsAll.length;
|
||||
|
||||
return Positioned(
|
||||
top: 16,
|
||||
@@ -1008,6 +1093,54 @@ class _MapScreenState extends State<MapScreen> {
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.location_on,
|
||||
size: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
Text(
|
||||
": $nodeCount",
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.wrong_location,
|
||||
size: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
Text(
|
||||
": ${nodeCountAll - nodeCount}",
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.add_outlined,
|
||||
size: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
Text(
|
||||
": $nodeCountAll",
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
context.l10n.map_pinsCount(markerCount),
|
||||
style: const TextStyle(
|
||||
@@ -1846,6 +1979,15 @@ class _MapScreenState extends State<MapScreen> {
|
||||
},
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: Text(context.l10n.map_showOverlaps),
|
||||
value: settings.mapShowOverlaps,
|
||||
onChanged: (value) {
|
||||
service.setMapShowOverlaps(value ?? true);
|
||||
},
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.map_keyPrefix,
|
||||
@@ -1995,26 +2137,35 @@ class _MapScreenState extends State<MapScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _addToPath(BuildContext context, Contact contact) {
|
||||
void _addToPath(BuildContext context, Contact contact, {LatLng? position}) {
|
||||
setState(() {
|
||||
_pathTrace.add(
|
||||
contact.publicKey[0],
|
||||
); // Add first 16 bytes of public key to path trace
|
||||
_points.add(LatLng(contact.latitude!, contact.longitude!));
|
||||
_pathTraceContacts.add(
|
||||
contact.copyWith(
|
||||
latitude: position?.latitude ?? contact.latitude,
|
||||
longitude: position?.longitude ?? contact.longitude,
|
||||
),
|
||||
); // Add contact to path trace contacts
|
||||
_points.add(position ?? LatLng(contact.latitude!, contact.longitude!));
|
||||
});
|
||||
}
|
||||
|
||||
void _startPath() {
|
||||
void _startPath(LatLng position) {
|
||||
setState(() {
|
||||
_isBuildingPathTrace = true;
|
||||
_pathTrace.clear();
|
||||
_pathTraceContacts.clear();
|
||||
_points.clear();
|
||||
_polylines.clear();
|
||||
_points.add(position);
|
||||
});
|
||||
}
|
||||
|
||||
void _removePath() {
|
||||
setState(() {
|
||||
_pathTraceContacts.removeLast();
|
||||
_pathTrace.removeLast(); // Remove last node from path trace
|
||||
_points.removeLast(); // Remove last point from points list
|
||||
_polylines.clear(); // Clear polylines
|
||||
@@ -2055,21 +2206,26 @@ class _MapScreenState extends State<MapScreen> {
|
||||
.join(','),
|
||||
style: TextStyle(fontSize: 18),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
// const SizedBox(height: 6),
|
||||
Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
spacing: 1,
|
||||
runSpacing: 1,
|
||||
children: [
|
||||
if (_pathTrace.isNotEmpty)
|
||||
ElevatedButton(
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
final hashW = context
|
||||
.read<MeshCoreConnector>()
|
||||
.pathHashByteWidth;
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PathTraceMapScreen(
|
||||
title: l10n.contacts_pathTrace,
|
||||
path: Uint8List.fromList(_pathTrace),
|
||||
pathHashByteWidth: hashW,
|
||||
pathContacts: _pathTraceContacts,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -2077,15 +2233,37 @@ class _MapScreenState extends State<MapScreen> {
|
||||
_isBuildingPathTrace = false;
|
||||
});
|
||||
},
|
||||
child: Text(l10n.map_runTrace),
|
||||
tooltip: l10n.map_runTrace,
|
||||
icon: const Icon(Icons.arrow_forward_outlined),
|
||||
),
|
||||
if (_pathTrace.isNotEmpty)
|
||||
ElevatedButton(
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PathTraceMapScreen(
|
||||
title: l10n.contacts_pathTrace,
|
||||
path: Uint8List.fromList(_pathTrace),
|
||||
flipPathAround: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
setState(() {
|
||||
_isBuildingPathTrace = false;
|
||||
});
|
||||
},
|
||||
tooltip: l10n.map_runTraceWithReturnPath,
|
||||
icon: const Icon(Icons.replay),
|
||||
),
|
||||
if (_pathTrace.isNotEmpty)
|
||||
IconButton(
|
||||
onPressed: _removePath,
|
||||
child: Text(l10n.map_removeLast),
|
||||
tooltip: l10n.map_removeLast,
|
||||
icon: const Icon(Icons.undo),
|
||||
),
|
||||
if (_pathTrace.isEmpty)
|
||||
ElevatedButton(
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isBuildingPathTrace = false;
|
||||
@@ -2097,7 +2275,8 @@ class _MapScreenState extends State<MapScreen> {
|
||||
SnackBar(content: Text(l10n.map_pathTraceCancelled)),
|
||||
);
|
||||
},
|
||||
child: Text(l10n.common_cancel),
|
||||
tooltip: l10n.common_cancel,
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -55,6 +55,8 @@ class PathTraceMapScreen extends StatefulWidget {
|
||||
final bool flipPathAround;
|
||||
final bool reversePathAround;
|
||||
final Contact? targetContact;
|
||||
final int pathHashByteWidth;
|
||||
final List<Contact>? pathContacts;
|
||||
|
||||
const PathTraceMapScreen({
|
||||
super.key,
|
||||
@@ -64,6 +66,8 @@ class PathTraceMapScreen extends StatefulWidget {
|
||||
this.flipPathAround = false,
|
||||
this.reversePathAround = false,
|
||||
this.targetContact,
|
||||
this.pathHashByteWidth = pathHashSize,
|
||||
this.pathContacts,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -72,6 +76,8 @@ class PathTraceMapScreen extends StatefulWidget {
|
||||
|
||||
class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
static const double _labelZoomThreshold = 8.5;
|
||||
//miles to meters conversion for filtering out repeaters that are too far from the last known GPS hop to be a likely match, to avoid false matches that throw off the inferred positions of other hops in the path
|
||||
static const double _maxRepeaterMatchDistanceMeters = 40 * 1609.344;
|
||||
|
||||
StreamSubscription<Uint8List>? _frameSubscription;
|
||||
Timer? _timeoutTimer;
|
||||
@@ -119,8 +125,13 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
Uint8List traceBytes;
|
||||
|
||||
if (pathBytes.isEmpty) {
|
||||
final pk = widget.targetContact?.publicKey;
|
||||
final n = widget.pathHashByteWidth.clamp(1, pubKeySize);
|
||||
if (pk != null && pk.length >= n) {
|
||||
return Uint8List.fromList(pk.sublist(0, n));
|
||||
}
|
||||
traceBytes = Uint8List(1);
|
||||
traceBytes[0] = widget.targetContact?.publicKey[0] ?? 0;
|
||||
traceBytes[0] = pk?[0] ?? 0;
|
||||
return traceBytes;
|
||||
}
|
||||
|
||||
@@ -259,17 +270,43 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
.toList();
|
||||
|
||||
Map<int, Contact> pathContacts = {};
|
||||
final contacts = connector.allContacts;
|
||||
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;
|
||||
Contact lastContact = Contact(
|
||||
path: Uint8List(0),
|
||||
pathLength: 0,
|
||||
publicKey: connector.selfPublicKey ?? Uint8List(0),
|
||||
name: context.l10n.pathTrace_you,
|
||||
type: advTypeChat,
|
||||
latitude: connector.selfLatitude,
|
||||
longitude: connector.selfLongitude,
|
||||
lastSeen: DateTime.now(),
|
||||
);
|
||||
if (widget.pathContacts != null) {
|
||||
pathContacts = {for (var c in widget.pathContacts!) c.publicKey[0]: c};
|
||||
} else {
|
||||
final contacts = connector.allContactsUnfiltered;
|
||||
contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
|
||||
if (lastContact.latitude != null &&
|
||||
lastContact.longitude != null &&
|
||||
repeater.hasLocation &&
|
||||
lastContact.hasLocation &&
|
||||
Distance().distance(
|
||||
LatLng(lastContact.latitude!, lastContact.longitude!),
|
||||
LatLng(repeater.latitude!, repeater.longitude!),
|
||||
) >
|
||||
_maxRepeaterMatchDistanceMeters) {
|
||||
return; //skip reapeaters that are far away from the last one with known GPS, to avoid false matches
|
||||
}
|
||||
}
|
||||
});
|
||||
for (var repeaterData in pathData) {
|
||||
if (listEquals(
|
||||
repeater.publicKey.sublist(0, 1),
|
||||
Uint8List.fromList([repeaterData]),
|
||||
)) {
|
||||
pathContacts[repeaterData] = repeater;
|
||||
lastContact = repeater;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// For hops with no GPS contact, infer position from other contacts
|
||||
// with known GPS that share the same last-hop byte.
|
||||
|
||||
@@ -35,13 +35,15 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
|
||||
// Common commands for quick access
|
||||
late final List<Map<String, String>> _quickCommands = [
|
||||
{'labelKey': 'advertise', 'command': 'advert'},
|
||||
{'labelKey': 'getName', 'command': 'get name'},
|
||||
{'labelKey': 'getRadio', 'command': 'get radio'},
|
||||
{'labelKey': 'getTx', 'command': 'get tx'},
|
||||
{'labelKey': 'discovery', 'command': 'discover.neighbors'},
|
||||
{'labelKey': 'neighbors', 'command': 'neighbors'},
|
||||
{'labelKey': 'version', 'command': 'ver'},
|
||||
{'labelKey': 'advertise', 'command': 'advert'},
|
||||
{'labelKey': 'clock', 'command': 'clock'},
|
||||
{'labelKey': 'clock sync', 'command': 'clock sync'},
|
||||
];
|
||||
|
||||
@override
|
||||
@@ -407,6 +409,10 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
return l10n.repeater_cliQuickAdvertise;
|
||||
case 'clock':
|
||||
return l10n.repeater_cliQuickClock;
|
||||
case 'clock sync':
|
||||
return l10n.repeater_cliQuickClockSync;
|
||||
case 'discovery':
|
||||
return l10n.repeater_cliQuickDiscovery;
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../services/linux_ble_error_classifier.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../widgets/adaptive_app_bar_title.dart';
|
||||
import '../widgets/device_tile.dart';
|
||||
@@ -288,12 +289,33 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||
MeshCoreConnector connector,
|
||||
ScanResult result,
|
||||
) async {
|
||||
final name = result.device.platformName.isNotEmpty
|
||||
? result.device.platformName
|
||||
: result.advertisementData.advName;
|
||||
try {
|
||||
final name = result.device.platformName.isNotEmpty
|
||||
? result.device.platformName
|
||||
: result.advertisementData.advName;
|
||||
await connector.connect(result.device, displayName: name);
|
||||
await connector.connect(
|
||||
result.device,
|
||||
displayName: name,
|
||||
linuxPairingPinProvider: PlatformInfo.isLinux
|
||||
? () async {
|
||||
if (!context.mounted) return null;
|
||||
return _promptLinuxPairingPin(context, name);
|
||||
}
|
||||
: null,
|
||||
);
|
||||
} catch (e) {
|
||||
final errorText = e.toString();
|
||||
final suppressTransientLinuxConnectError =
|
||||
PlatformInfo.isLinux &&
|
||||
connector.isAutoReconnectScheduled &&
|
||||
isLinuxBleConnectFailureText(errorText);
|
||||
if (suppressTransientLinuxConnectError) {
|
||||
appLogger.info(
|
||||
'Suppressing transient Linux connect error while auto-reconnect is active: $e',
|
||||
tag: 'ScannerScreen',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
@@ -305,6 +327,92 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _promptLinuxPairingPin(
|
||||
BuildContext context,
|
||||
String deviceName,
|
||||
) async {
|
||||
final l10n = context.l10n;
|
||||
var pinValue = '';
|
||||
var obscure = true;
|
||||
appLogger.info(
|
||||
'Showing Linux BLE pairing PIN prompt for $deviceName',
|
||||
tag: 'ScannerScreen',
|
||||
);
|
||||
final pin = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (dialogContext, setDialogState) {
|
||||
return AlertDialog(
|
||||
title: Text(l10n.scanner_linuxPairingPinTitle),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.scanner_linuxPairingPinPrompt(deviceName)),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.number,
|
||||
textInputAction: TextInputAction.done,
|
||||
obscureText: obscure,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
onChanged: (value) {
|
||||
pinValue = value.trim();
|
||||
},
|
||||
onSubmitted: (value) {
|
||||
Navigator.of(dialogContext).pop(value.trim());
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
suffixIcon: IconButton(
|
||||
onPressed: () {
|
||||
setDialogState(() {
|
||||
obscure = !obscure;
|
||||
});
|
||||
},
|
||||
icon: Icon(
|
||||
obscure ? Icons.visibility : Icons.visibility_off,
|
||||
),
|
||||
tooltip: obscure
|
||||
? l10n.scanner_linuxPairingShowPin
|
||||
: l10n.scanner_linuxPairingHidePin,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(null),
|
||||
child: Text(l10n.common_cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(pinValue),
|
||||
child: Text(l10n.common_connect),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
if (pin == null) {
|
||||
appLogger.info(
|
||||
'Linux BLE pairing PIN prompt cancelled for $deviceName',
|
||||
tag: 'ScannerScreen',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
appLogger.info(
|
||||
'Linux BLE pairing PIN prompt completed for $deviceName',
|
||||
tag: 'ScannerScreen',
|
||||
);
|
||||
return pin;
|
||||
}
|
||||
|
||||
Widget _bluetoothOffWarning(BuildContext context) {
|
||||
final errorColor = Theme.of(context).colorScheme.error;
|
||||
return Container(
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../widgets/app_bar.dart';
|
||||
import 'app_settings_screen.dart';
|
||||
import 'app_debug_log_screen.dart';
|
||||
import 'ble_debug_log_screen.dart';
|
||||
import '../widgets/radio_stats_entry.dart';
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
@@ -269,6 +270,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
onTap: () => _showRadioSettings(context, connector),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.sensors_outlined),
|
||||
title: Text(l10n.radioStats_settingsTile),
|
||||
subtitle: Text(l10n.radioStats_settingsSubtitle),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
enabled:
|
||||
connector.isConnected && connector.supportsCompanionRadioStats,
|
||||
onTap: () => pushCompanionRadioStatsScreen(context),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.location_on_outlined),
|
||||
title: Text(l10n.settings_location),
|
||||
@@ -311,10 +322,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.cell_tower),
|
||||
title: Text(l10n.settings_sendAdvertisement),
|
||||
subtitle: Text(l10n.settings_sendAdvertisementSubtitle),
|
||||
onTap: () => _sendAdvert(context, connector),
|
||||
leading: const Icon(Icons.delete_outline, color: Colors.red),
|
||||
title: Text("Delete All Paths"),
|
||||
subtitle: Text(
|
||||
"Clear all path data from contacts.",
|
||||
style: TextStyle(color: Colors.red[700]),
|
||||
),
|
||||
onTap: () => connector.deleteAllPaths(),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
@@ -657,14 +671,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _sendAdvert(BuildContext context, MeshCoreConnector connector) {
|
||||
final l10n = context.l10n;
|
||||
connector.sendSelfAdvert(flood: true);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.settings_advertisementSent)));
|
||||
}
|
||||
|
||||
void _syncTime(BuildContext context, MeshCoreConnector connector) {
|
||||
final l10n = context.l10n;
|
||||
connector.syncTime();
|
||||
|
||||
@@ -64,6 +64,10 @@ class AppSettingsService extends ChangeNotifier {
|
||||
await updateSettings(_settings.copyWith(mapShowOtherNodes: value));
|
||||
}
|
||||
|
||||
Future<void> setMapShowOverlaps(bool value) async {
|
||||
await updateSettings(_settings.copyWith(mapShowOverlaps: value));
|
||||
}
|
||||
|
||||
Future<void> setMapTimeFilterHours(double value) async {
|
||||
await updateSettings(_settings.copyWith(mapTimeFilterHours: value));
|
||||
}
|
||||
@@ -214,4 +218,8 @@ class AppSettingsService extends ChangeNotifier {
|
||||
Future<void> setTcpServerPort(int value) async {
|
||||
await updateSettings(_settings.copyWith(tcpServerPort: value));
|
||||
}
|
||||
|
||||
Future<void> setJumpToOldestUnread(bool value) async {
|
||||
await updateSettings(_settings.copyWith(jumpToOldestUnread: value));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
const String linuxConnectStageFailureMarker = 'linux connect stage failure';
|
||||
|
||||
bool isLinuxBleConnectFailureText(String errorText) {
|
||||
final lowerErrorText = errorText.toLowerCase();
|
||||
if (isLinuxBlePairingFailureText(errorText)) {
|
||||
return false;
|
||||
}
|
||||
return lowerErrorText.contains(linuxConnectStageFailureMarker) ||
|
||||
lowerErrorText.contains('| connect |') ||
|
||||
lowerErrorText.contains('linux connect hard-timeout') ||
|
||||
lowerErrorText.contains('org.bluez.error.failed') ||
|
||||
lowerErrorText.contains('org.bluez.error.inprogress') ||
|
||||
lowerErrorText.contains('le-connection-abort-by-local');
|
||||
}
|
||||
|
||||
bool isLinuxBlePairingFailureText(String errorText) {
|
||||
final lowerErrorText = errorText.toLowerCase();
|
||||
final isPairingSpecificStateError =
|
||||
lowerErrorText.contains('bad state: no element') &&
|
||||
(lowerErrorText.contains('pair') ||
|
||||
lowerErrorText.contains('bond') ||
|
||||
lowerErrorText.contains('trust'));
|
||||
return lowerErrorText.contains('authenticationfailed') ||
|
||||
lowerErrorText.contains('authentication failed') ||
|
||||
lowerErrorText.contains('notpermitted: not paired') ||
|
||||
lowerErrorText.contains('pairing fallback failed') ||
|
||||
lowerErrorText.contains('linux ble pairing did not complete') ||
|
||||
lowerErrorText.contains('linux ble trust repair did not complete') ||
|
||||
isPairingSpecificStateError ||
|
||||
isLikelyLinuxBlePairingTimeoutText(errorText);
|
||||
}
|
||||
|
||||
bool isLikelyLinuxBlePairingTimeoutText(String errorText) {
|
||||
final lowerErrorText = errorText.toLowerCase();
|
||||
return lowerErrorText.contains('timed out') &&
|
||||
(lowerErrorText.contains('pair') || lowerErrorText.contains('bond'));
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
typedef ProcessStartFn =
|
||||
Future<Process> Function(String executable, List<String> arguments);
|
||||
typedef ProcessRunFn =
|
||||
Future<ProcessResult> Function(String executable, List<String> arguments);
|
||||
|
||||
/// Best-effort Linux BLE pairing helper using bluetoothctl.
|
||||
///
|
||||
/// This is used only as a fallback when BlueZ pairing via flutter_blue_plus
|
||||
/// fails to surface agent prompts in-app.
|
||||
class LinuxBlePairingService {
|
||||
/// Maximum number of pairing attempts (initial + retries).
|
||||
/// Covers one remove-and-retry plus one proactive-PIN retry.
|
||||
static const int _maxAttempts = 3;
|
||||
|
||||
static const Duration _processExitTimeout = Duration(seconds: 6);
|
||||
static const Duration _pairingCleanupTimeout = Duration(seconds: 5);
|
||||
static const Duration _defaultPairingTimeout = Duration(seconds: 45);
|
||||
LinuxBlePairingService({
|
||||
ProcessStartFn? processStart,
|
||||
ProcessRunFn? processRun,
|
||||
}) : _processStart = processStart ?? Process.start,
|
||||
_processRun = processRun ?? Process.run;
|
||||
|
||||
final ProcessStartFn _processStart;
|
||||
final ProcessRunFn _processRun;
|
||||
|
||||
Future<bool> isBluetoothctlAvailable() async {
|
||||
try {
|
||||
final result = await _processRun('bluetoothctl', <String>['--version']);
|
||||
return result.exitCode == 0;
|
||||
} on ProcessException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disconnectDevice(
|
||||
String remoteId, {
|
||||
void Function(String message)? onLog,
|
||||
}) async {
|
||||
onLog?.call('Requesting BlueZ disconnect for $remoteId');
|
||||
Process process;
|
||||
try {
|
||||
process = await _processStart('bluetoothctl', <String>[]);
|
||||
} on ProcessException catch (error) {
|
||||
onLog?.call(
|
||||
'bluetoothctl unavailable, skipping BlueZ disconnect: $error',
|
||||
);
|
||||
return;
|
||||
}
|
||||
process.stdin.writeln('disconnect $remoteId');
|
||||
process.stdin.writeln('quit');
|
||||
try {
|
||||
await process.exitCode.timeout(_processExitTimeout);
|
||||
} catch (_) {
|
||||
process.kill();
|
||||
}
|
||||
onLog?.call('Issued bluetoothctl disconnect for $remoteId');
|
||||
}
|
||||
|
||||
Future<bool> isPairedAndTrusted(String remoteId) async {
|
||||
ProcessResult result;
|
||||
try {
|
||||
result = await _processRun('bluetoothctl', <String>['info', remoteId]);
|
||||
} on ProcessException {
|
||||
return false;
|
||||
}
|
||||
if (result.exitCode != 0) {
|
||||
return false;
|
||||
}
|
||||
final output = (result.stdout as String).toLowerCase();
|
||||
return output.contains('paired: yes') && output.contains('trusted: yes');
|
||||
}
|
||||
|
||||
Future<bool> trustDevice(
|
||||
String remoteId, {
|
||||
void Function(String message)? onLog,
|
||||
}) async {
|
||||
onLog?.call('Requesting BlueZ trust for $remoteId');
|
||||
ProcessResult result;
|
||||
try {
|
||||
result = await _processRun('bluetoothctl', <String>['trust', remoteId]);
|
||||
} on ProcessException catch (error) {
|
||||
onLog?.call('bluetoothctl unavailable, cannot trust $remoteId: $error');
|
||||
return false;
|
||||
}
|
||||
if (result.exitCode != 0) {
|
||||
onLog?.call('bluetoothctl trust failed for $remoteId: ${result.stderr}');
|
||||
return false;
|
||||
}
|
||||
final trusted = await isPairedAndTrusted(remoteId);
|
||||
onLog?.call(
|
||||
trusted
|
||||
? 'Verified BlueZ trust for $remoteId'
|
||||
: 'BlueZ trust verification failed for $remoteId',
|
||||
);
|
||||
return trusted;
|
||||
}
|
||||
|
||||
Future<bool> pairAndTrust({
|
||||
required String remoteId,
|
||||
Duration timeout = _defaultPairingTimeout,
|
||||
void Function(String message)? onLog,
|
||||
Future<String?> Function()? onRequestPin,
|
||||
}) async {
|
||||
var removeRetryUsed = false;
|
||||
var proactivePinRetryUsed = false;
|
||||
Future<String?> Function()? currentPinProvider = onRequestPin;
|
||||
|
||||
for (var attempt = 0; attempt < _maxAttempts; attempt++) {
|
||||
final result = await _runPairingAttempt(
|
||||
remoteId: remoteId,
|
||||
timeout: timeout,
|
||||
onLog: onLog,
|
||||
onRequestPin: currentPinProvider,
|
||||
);
|
||||
|
||||
if (result.success) return true;
|
||||
if (result.userCancelled) {
|
||||
onLog?.call('Pairing cancelled by user; skipping retry/remove flow');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (result.pairFailed) {
|
||||
if (!removeRetryUsed) {
|
||||
removeRetryUsed = true;
|
||||
onLog?.call(
|
||||
'Pairing failed; removing cached bond and retrying '
|
||||
'(attempt ${attempt + 1}/$_maxAttempts)',
|
||||
);
|
||||
await _removeDevice(remoteId, onLog: onLog);
|
||||
continue;
|
||||
}
|
||||
if (!result.pinSent &&
|
||||
!proactivePinRetryUsed &&
|
||||
currentPinProvider != null) {
|
||||
proactivePinRetryUsed = true;
|
||||
onLog?.call(
|
||||
'Pairing failed before PIN challenge; requesting PIN for '
|
||||
'proactive retry (attempt ${attempt + 1}/$_maxAttempts)',
|
||||
);
|
||||
final pin = await currentPinProvider();
|
||||
if (pin == null) {
|
||||
onLog?.call('PIN entry cancelled for proactive retry');
|
||||
return false;
|
||||
}
|
||||
final capturedPin = pin.trim();
|
||||
currentPinProvider = () async => capturedPin;
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Timeout path — pairing neither succeeded nor failed.
|
||||
onLog?.call('Pairing did not complete before timeout');
|
||||
if (!result.pinSent &&
|
||||
!proactivePinRetryUsed &&
|
||||
currentPinProvider != null) {
|
||||
proactivePinRetryUsed = true;
|
||||
onLog?.call(
|
||||
'No PIN challenge observed before timeout; requesting PIN for '
|
||||
'proactive retry (attempt ${attempt + 1}/$_maxAttempts)',
|
||||
);
|
||||
final pin = await currentPinProvider();
|
||||
if (pin == null) {
|
||||
onLog?.call('PIN entry cancelled for proactive retry after timeout');
|
||||
return false;
|
||||
}
|
||||
final capturedPin = pin.trim();
|
||||
currentPinProvider = () async => capturedPin;
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Runs a single bluetoothctl pairing attempt.
|
||||
///
|
||||
/// Uses a [Completer] to wake as soon as pairing succeeds or fails,
|
||||
/// instead of polling.
|
||||
Future<_PairingResult> _runPairingAttempt({
|
||||
required String remoteId,
|
||||
required Duration timeout,
|
||||
void Function(String message)? onLog,
|
||||
Future<String?> Function()? onRequestPin,
|
||||
}) async {
|
||||
onLog?.call('Starting bluetoothctl pairing flow for $remoteId');
|
||||
Process process;
|
||||
try {
|
||||
process = await _processStart('bluetoothctl', <String>[]);
|
||||
} on ProcessException catch (error) {
|
||||
onLog?.call('bluetoothctl unavailable, cannot run pairing flow: $error');
|
||||
return const _PairingResult();
|
||||
}
|
||||
final output = StringBuffer();
|
||||
var pinSent = false;
|
||||
var sessionClosed = false;
|
||||
var userCancelledPinEntry = false;
|
||||
var confirmationHandled = false;
|
||||
var successHandled = false;
|
||||
var failureHandled = false;
|
||||
var detectorBuffer = '';
|
||||
final pairingDone = Completer<void>();
|
||||
var pairSucceeded = false;
|
||||
var pairFailed = false;
|
||||
|
||||
void writeCmd(String cmd) {
|
||||
if (sessionClosed) return;
|
||||
try {
|
||||
process.stdin.writeln(cmd);
|
||||
} on StateError {
|
||||
sessionClosed = true;
|
||||
onLog?.call('bluetoothctl stdin already closed; ignoring "$cmd"');
|
||||
}
|
||||
}
|
||||
|
||||
unawaited(
|
||||
process.exitCode.then((_) {
|
||||
sessionClosed = true;
|
||||
if (!pairingDone.isCompleted) pairingDone.complete();
|
||||
}),
|
||||
);
|
||||
|
||||
void handleChunk(String chunk) {
|
||||
output.write(chunk);
|
||||
detectorBuffer += chunk.toLowerCase();
|
||||
if (detectorBuffer.length > 4096) {
|
||||
detectorBuffer = detectorBuffer.substring(detectorBuffer.length - 4096);
|
||||
}
|
||||
final lower = detectorBuffer;
|
||||
|
||||
if (!pinSent &&
|
||||
!sessionClosed &&
|
||||
(lower.contains('enter pin code') ||
|
||||
lower.contains('requestpin') ||
|
||||
lower.contains('input pin code') ||
|
||||
lower.contains('request passkey') ||
|
||||
lower.contains('requestpasskey') ||
|
||||
lower.contains('enter passkey'))) {
|
||||
pinSent = true;
|
||||
if (onRequestPin == null) {
|
||||
onLog?.call(
|
||||
'PIN/passkey requested but no onRequestPin callback; '
|
||||
'sending empty line to accept default pairing',
|
||||
);
|
||||
writeCmd('');
|
||||
} else {
|
||||
onLog?.call('Pairing agent is ready for PIN/passkey input');
|
||||
unawaited(
|
||||
Future<void>(() async {
|
||||
String? pin;
|
||||
try {
|
||||
pin = await onRequestPin();
|
||||
} catch (e) {
|
||||
onLog?.call('onRequestPin callback threw: $e');
|
||||
pairFailed = true;
|
||||
writeCmd('cancel');
|
||||
if (!pairingDone.isCompleted) pairingDone.complete();
|
||||
return;
|
||||
}
|
||||
if (pin == null) {
|
||||
if (sessionClosed) {
|
||||
onLog?.call(
|
||||
'PIN prompt resolved after pairing session closed',
|
||||
);
|
||||
return;
|
||||
}
|
||||
onLog?.call('PIN entry cancelled by user; cancelling pairing');
|
||||
userCancelledPinEntry = true;
|
||||
pairFailed = true;
|
||||
writeCmd('cancel');
|
||||
if (!pairingDone.isCompleted) pairingDone.complete();
|
||||
return;
|
||||
}
|
||||
if (sessionClosed) {
|
||||
onLog?.call(
|
||||
'PIN provided after pairing session closed; ignoring',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (pin.trim().isEmpty) {
|
||||
onLog?.call(
|
||||
'Blank PIN submitted; sending empty line to accept default pairing',
|
||||
);
|
||||
writeCmd('');
|
||||
} else {
|
||||
onLog?.call('Submitting PIN/passkey to pairing agent');
|
||||
writeCmd(pin.trim());
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!confirmationHandled &&
|
||||
(lower.contains('confirm passkey') ||
|
||||
lower.contains('requestconfirmation') ||
|
||||
lower.contains('[agent] confirm'))) {
|
||||
confirmationHandled = true;
|
||||
onLog?.call(
|
||||
'Pairing agent requested passkey confirmation; answering yes',
|
||||
);
|
||||
writeCmd('yes');
|
||||
}
|
||||
|
||||
if (!successHandled &&
|
||||
(lower.contains('pairing successful') ||
|
||||
lower.contains('already paired'))) {
|
||||
successHandled = true;
|
||||
onLog?.call('Pairing reported success');
|
||||
pairSucceeded = true;
|
||||
if (!pairingDone.isCompleted) pairingDone.complete();
|
||||
}
|
||||
|
||||
if (!failureHandled &&
|
||||
(lower.contains('failed to pair') ||
|
||||
lower.contains('authenticationfailed') ||
|
||||
lower.contains('authentication failed'))) {
|
||||
failureHandled = true;
|
||||
onLog?.call('Pairing reported authentication failure');
|
||||
pairFailed = true;
|
||||
if (!pairingDone.isCompleted) pairingDone.complete();
|
||||
}
|
||||
}
|
||||
|
||||
final stdoutSub = process.stdout
|
||||
.transform(utf8.decoder)
|
||||
.listen(handleChunk);
|
||||
final stderrSub = process.stderr
|
||||
.transform(utf8.decoder)
|
||||
.listen(handleChunk);
|
||||
|
||||
writeCmd('power on');
|
||||
writeCmd('agent KeyboardDisplay');
|
||||
writeCmd('default-agent');
|
||||
onLog?.call('Waiting for pairing challenge from bluetoothctl agent');
|
||||
writeCmd('pair $remoteId');
|
||||
|
||||
// Wait for the Completer to fire (success/failure/process exit) or timeout.
|
||||
await pairingDone.future.timeout(timeout, onTimeout: () {});
|
||||
|
||||
if (!pairFailed && pairSucceeded) {
|
||||
onLog?.call('Pair succeeded; trusting and connecting device');
|
||||
writeCmd('trust $remoteId');
|
||||
writeCmd('connect $remoteId');
|
||||
}
|
||||
writeCmd('quit');
|
||||
sessionClosed = true;
|
||||
|
||||
try {
|
||||
await process.exitCode.timeout(_pairingCleanupTimeout);
|
||||
} catch (_) {
|
||||
process.kill();
|
||||
}
|
||||
await stdoutSub.cancel();
|
||||
await stderrSub.cancel();
|
||||
|
||||
if (pairFailed) {
|
||||
return _PairingResult(
|
||||
pairFailed: true,
|
||||
pinSent: pinSent,
|
||||
userCancelled: userCancelledPinEntry,
|
||||
);
|
||||
}
|
||||
|
||||
final allOutput = output.toString().toLowerCase();
|
||||
final reportedSuccess =
|
||||
pairSucceeded ||
|
||||
allOutput.contains('pairing successful') ||
|
||||
allOutput.contains('already paired');
|
||||
if (reportedSuccess) {
|
||||
final trusted = await trustDevice(remoteId, onLog: onLog);
|
||||
if (!trusted) {
|
||||
onLog?.call('Pairing completed but BlueZ trust was not restored');
|
||||
}
|
||||
return _PairingResult(success: trusted, pinSent: pinSent);
|
||||
}
|
||||
|
||||
return _PairingResult(pinSent: pinSent);
|
||||
}
|
||||
|
||||
Future<void> _removeDevice(
|
||||
String remoteId, {
|
||||
void Function(String message)? onLog,
|
||||
}) async {
|
||||
Process process;
|
||||
try {
|
||||
process = await _processStart('bluetoothctl', <String>[]);
|
||||
} on ProcessException catch (error) {
|
||||
onLog?.call(
|
||||
'bluetoothctl unavailable, skipping remove for $remoteId: $error',
|
||||
);
|
||||
return;
|
||||
}
|
||||
process.stdin.writeln('remove $remoteId');
|
||||
process.stdin.writeln('quit');
|
||||
try {
|
||||
await process.exitCode.timeout(_processExitTimeout);
|
||||
} catch (_) {
|
||||
process.kill();
|
||||
}
|
||||
onLog?.call('Issued bluetoothctl remove for $remoteId');
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of a single bluetoothctl pairing attempt.
|
||||
class _PairingResult {
|
||||
final bool success;
|
||||
final bool pairFailed;
|
||||
final bool pinSent;
|
||||
final bool userCancelled;
|
||||
|
||||
const _PairingResult({
|
||||
this.success = false,
|
||||
this.pairFailed = false,
|
||||
this.pinSent = false,
|
||||
this.userCancelled = false,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/// No-op stub for web builds where dart:io is unavailable.
|
||||
///
|
||||
/// The real implementation lives in linux_ble_pairing_service.dart and is
|
||||
/// selected via conditional import in meshcore_connector.dart.
|
||||
class LinuxBlePairingService {
|
||||
LinuxBlePairingService();
|
||||
|
||||
Future<bool> isBluetoothctlAvailable() async => false;
|
||||
|
||||
Future<void> disconnectDevice(
|
||||
String remoteId, {
|
||||
void Function(String message)? onLog,
|
||||
}) async {}
|
||||
|
||||
Future<bool> isPairedAndTrusted(String remoteId) async => false;
|
||||
|
||||
Future<bool> trustDevice(
|
||||
String remoteId, {
|
||||
void Function(String message)? onLog,
|
||||
}) async => false;
|
||||
|
||||
Future<bool> pairAndTrust({
|
||||
required String remoteId,
|
||||
Duration timeout = const Duration(seconds: 45),
|
||||
void Function(String message)? onLog,
|
||||
Future<String?> Function()? onRequestPin,
|
||||
}) async => false;
|
||||
}
|
||||
@@ -21,11 +21,16 @@ class _AckHistoryEntry {
|
||||
});
|
||||
}
|
||||
|
||||
/// (messageId, timestamp, attemptIndex) — stored per ACK hash for O(1) lookup.
|
||||
/// (messageId, timestamp, attemptIndex, pathSelection) — stored per ACK hash
|
||||
/// for O(1) lookup. [pathSelection] snapshots the route used for this
|
||||
/// specific attempt so that a late PUSH_CODE_SEND_CONFIRMED credits the
|
||||
/// correct path even when the message has since been retried on a different
|
||||
/// route.
|
||||
typedef AckHashMapping = ({
|
||||
String messageId,
|
||||
DateTime timestamp,
|
||||
int attemptIndex,
|
||||
PathSelection? pathSelection,
|
||||
});
|
||||
|
||||
class RetryServiceConfig {
|
||||
@@ -382,6 +387,7 @@ class MessageRetryService extends ChangeNotifier {
|
||||
messageId: messageId,
|
||||
timestamp: DateTime.now(),
|
||||
attemptIndex: message.retryCount,
|
||||
pathSelection: _selectionFromMessage(message),
|
||||
);
|
||||
|
||||
// Add this ACK hash to the list of expected ACKs for this message (for history)
|
||||
@@ -395,14 +401,11 @@ class MessageRetryService extends ChangeNotifier {
|
||||
|
||||
int actualTimeout = timeoutMs;
|
||||
if (config.calculateTimeout != null) {
|
||||
final calculated = config.calculateTimeout!(
|
||||
actualTimeout = config.calculateTimeout!(
|
||||
pathLengthValue,
|
||||
message.text.length,
|
||||
contactKey: contact.publicKeyHex,
|
||||
);
|
||||
if (timeoutMs <= 0 || calculated < timeoutMs) {
|
||||
actualTimeout = calculated;
|
||||
}
|
||||
}
|
||||
|
||||
final updatedMessage = message.copyWith(
|
||||
@@ -569,6 +572,7 @@ class MessageRetryService extends ChangeNotifier {
|
||||
final config = _config;
|
||||
String? matchedMessageId;
|
||||
int? matchedAttemptIndex;
|
||||
PathSelection? matchedPathSelection;
|
||||
final ackHashHex = ackHash.toRadixString(16).padLeft(8, '0');
|
||||
|
||||
// Clean up old ACK hash mappings (older than 15 minutes)
|
||||
@@ -588,6 +592,7 @@ class MessageRetryService extends ChangeNotifier {
|
||||
if (mapping != null) {
|
||||
matchedMessageId = mapping.messageId;
|
||||
matchedAttemptIndex = mapping.attemptIndex;
|
||||
matchedPathSelection = mapping.pathSelection;
|
||||
} else {
|
||||
config?.debugLogService?.warn(
|
||||
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex not found in direct mapping, trying fallback',
|
||||
@@ -618,13 +623,13 @@ class MessageRetryService extends ChangeNotifier {
|
||||
}
|
||||
final contact = _pendingContacts[matchedMessageId];
|
||||
final ackedAttempt = matchedAttemptIndex ?? message.retryCount;
|
||||
final selection = _selectionFromMessage(message);
|
||||
final selection = matchedPathSelection ?? _selectionFromMessage(message);
|
||||
|
||||
final shortText = message.text.length > 20
|
||||
? '${message.text.substring(0, 20)}...'
|
||||
: message.text;
|
||||
config?.debugLogService?.info(
|
||||
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex ✓ "$shortText" delivered to ${contact?.name ?? "unknown"} on retry ${ackedAttempt + 1} in ${tripTimeMs}ms',
|
||||
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex ✓ "$shortText" delivered to ${contact?.name ?? "unknown"} on attempt $ackedAttempt in ${tripTimeMs}ms',
|
||||
tag: 'AckHash',
|
||||
);
|
||||
|
||||
@@ -636,6 +641,8 @@ class MessageRetryService extends ChangeNotifier {
|
||||
tripTimeMs: tripTimeMs,
|
||||
);
|
||||
|
||||
final wasAlreadyResolved = _resolvedMessages.contains(matchedMessageId);
|
||||
|
||||
_cleanupMessage(matchedMessageId);
|
||||
|
||||
config?.updateMessage(deliveredMessage);
|
||||
@@ -658,7 +665,9 @@ class MessageRetryService extends ChangeNotifier {
|
||||
tripTimeMs,
|
||||
);
|
||||
}
|
||||
_onMessageResolved(matchedMessageId, contact.publicKeyHex);
|
||||
if (!wasAlreadyResolved) {
|
||||
_onMessageResolved(matchedMessageId, contact.publicKeyHex);
|
||||
}
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
|
||||
@@ -565,6 +565,16 @@ class PathHistoryService extends ChangeNotifier {
|
||||
_floodStats.remove(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
void clearAllHistories() {
|
||||
_cache.clear();
|
||||
_cacheAccessOrder.clear();
|
||||
_autoRotationIndex.clear();
|
||||
_floodStats.clear();
|
||||
_storage.clearAllPathHistories();
|
||||
_version = 0;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
class _DeferredPathRecord {
|
||||
|
||||
@@ -273,7 +273,7 @@ class UsbSerialService {
|
||||
throw StateError('USB serial port is not open');
|
||||
}
|
||||
final packet = wrapUsbSerialTxFrame(data);
|
||||
_logFrameSummary('USB TX frame', data);
|
||||
// _logFrameSummary('USB TX frame', data);
|
||||
if (_useAndroidUsbHost) {
|
||||
try {
|
||||
await _androidMethodChannel.invokeMethod<void>('write', {
|
||||
@@ -447,16 +447,16 @@ class UsbSerialService {
|
||||
await _frameController.close();
|
||||
}
|
||||
|
||||
void _logFrameSummary(String prefix, Uint8List bytes) {
|
||||
if (bytes.isEmpty) {
|
||||
_debugLogService?.info('$prefix len=0', tag: 'USB Serial');
|
||||
return;
|
||||
}
|
||||
_debugLogService?.info(
|
||||
'$prefix code=${bytes[0]} len=${bytes.length}',
|
||||
tag: 'USB Serial',
|
||||
);
|
||||
}
|
||||
// void _logFrameSummary(String prefix, Uint8List bytes) {
|
||||
// if (bytes.isEmpty) {
|
||||
// _debugLogService?.info('$prefix len=0', tag: 'USB Serial');
|
||||
// return;
|
||||
// }
|
||||
// _debugLogService?.info(
|
||||
// '$prefix code=${bytes[0]} len=${bytes.length}',
|
||||
// tag: 'USB Serial',
|
||||
// );
|
||||
// }
|
||||
|
||||
/// Returns an ordered list of port paths to try for [portName].
|
||||
///
|
||||
|
||||
@@ -14,12 +14,13 @@ class ContactExport {
|
||||
final double lon;
|
||||
final String desc;
|
||||
final double? ele;
|
||||
|
||||
final String url;
|
||||
ContactExport({
|
||||
required this.name,
|
||||
required this.lat,
|
||||
required this.lon,
|
||||
required this.desc,
|
||||
required this.url,
|
||||
this.ele,
|
||||
});
|
||||
}
|
||||
@@ -40,6 +41,7 @@ class GpxExport {
|
||||
String name,
|
||||
double lat,
|
||||
double lon,
|
||||
String url,
|
||||
String desc, [
|
||||
double? ele,
|
||||
]) {
|
||||
@@ -50,55 +52,66 @@ class GpxExport {
|
||||
lon: lon,
|
||||
desc: desc.trim(),
|
||||
ele: ele,
|
||||
url: url,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void addRepeaters() {
|
||||
final contacts = _connector.contacts
|
||||
.where((c) => c.type == advTypeRepeater || c.type == advTypeRoom)
|
||||
.toList();
|
||||
final contacts = _connector.allContacts.where(
|
||||
(c) => c.type == advTypeRepeater || c.type == advTypeRoom,
|
||||
);
|
||||
for (var contact in contacts) {
|
||||
if (contact.latitude == null || contact.longitude == null) {
|
||||
continue;
|
||||
}
|
||||
final url = contact.rawPacket != null
|
||||
? "meshcore://${pubKeyToHex(contact.rawPacket!)}"
|
||||
: "";
|
||||
_addContact(
|
||||
contact.name,
|
||||
contact.latitude!,
|
||||
contact.longitude!,
|
||||
"Type: ${contact.typeLabel}\nPublic Key: ${contact.publicKeyHex}",
|
||||
url,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void addContacts() {
|
||||
final contacts = _connector.contacts
|
||||
.where((c) => c.type == advTypeChat)
|
||||
.toList();
|
||||
final contacts = _connector.allContacts.where((c) => c.type == advTypeChat);
|
||||
for (var contact in contacts) {
|
||||
if (contact.latitude == null || contact.longitude == null) {
|
||||
continue;
|
||||
}
|
||||
final url = contact.rawPacket != null
|
||||
? "meshcore://${pubKeyToHex(contact.rawPacket!)}"
|
||||
: "";
|
||||
_addContact(
|
||||
contact.name,
|
||||
contact.latitude!,
|
||||
contact.longitude!,
|
||||
"Type: ${contact.typeLabel}\nPublic Key: ${contact.publicKeyHex}",
|
||||
url,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void addAll() {
|
||||
final contacts = _connector.contacts;
|
||||
for (var contact in contacts.toList()) {
|
||||
final contacts = _connector.allContacts;
|
||||
for (var contact in contacts) {
|
||||
if (contact.latitude == null || contact.longitude == null) {
|
||||
continue;
|
||||
}
|
||||
final url = contact.rawPacket != null
|
||||
? "meshcore://${pubKeyToHex(contact.rawPacket!)}"
|
||||
: "";
|
||||
_addContact(
|
||||
contact.name,
|
||||
contact.latitude ?? 0.0,
|
||||
contact.longitude ?? 0.0,
|
||||
"Type: ${contact.typeLabel}\nPublic Key: ${contact.publicKeyHex}",
|
||||
url,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -138,6 +151,9 @@ class GpxExport {
|
||||
ele: c.ele,
|
||||
name: c.name,
|
||||
desc: c.desc,
|
||||
extensions: {
|
||||
"meshcore": {"url": c.url},
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:meshcore_open/connector/meshcore_connector.dart';
|
||||
import 'package:meshcore_open/widgets/battery_indicator.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'radio_stats_entry.dart';
|
||||
import 'snr_indicator.dart';
|
||||
|
||||
class AppBarTitle extends StatelessWidget {
|
||||
@@ -10,12 +11,14 @@ class AppBarTitle extends StatelessWidget {
|
||||
final Widget? leading;
|
||||
final Widget? trailing;
|
||||
final bool indicators;
|
||||
final bool showBatteryIndicator;
|
||||
final bool subtitle;
|
||||
const AppBarTitle(
|
||||
this.title, {
|
||||
this.leading,
|
||||
this.trailing,
|
||||
this.indicators = true,
|
||||
this.showBatteryIndicator = true,
|
||||
this.subtitle = true,
|
||||
super.key,
|
||||
});
|
||||
@@ -33,7 +36,7 @@ class AppBarTitle extends StatelessWidget {
|
||||
final compact = availableWidth < 170;
|
||||
final showSubtitle =
|
||||
!compact && connector.isConnected && selfName != null && subtitle;
|
||||
final showBattery = availableWidth >= 60;
|
||||
final showBattery = showBatteryIndicator && availableWidth >= 60;
|
||||
final showSnr = availableWidth >= 110;
|
||||
final showIndicators = (showBattery || showSnr) && indicators;
|
||||
|
||||
@@ -60,11 +63,13 @@ class AppBarTitle extends StatelessWidget {
|
||||
if (showIndicators) const SizedBox(width: 6),
|
||||
if (showIndicators)
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (showBattery) BatteryIndicator(connector: connector),
|
||||
if (showSnr) SNRIndicator(connector: connector),
|
||||
if (connector.supportsCompanionRadioStats)
|
||||
const RadioStatsIconButton(compact: true),
|
||||
],
|
||||
),
|
||||
trailing ?? const SizedBox.shrink(),
|
||||
|
||||
@@ -109,6 +109,7 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
|
||||
path: Uint8List.fromList(pathBytes),
|
||||
flipPathAround: true,
|
||||
targetContact: widget.contact,
|
||||
pathHashByteWidth: connector.pathHashByteWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -135,7 +136,9 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
|
||||
connector.getContacts();
|
||||
}
|
||||
|
||||
final pathForInput = currentContact.pathIdList;
|
||||
final pathForInput = currentContact.pathFormattedIdList(
|
||||
connector.pathHashByteWidth,
|
||||
);
|
||||
final availableContacts = connector.allContacts
|
||||
.where((c) => c.publicKeyHex != currentContact.publicKeyHex)
|
||||
.toList();
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_connector.dart';
|
||||
import 'package:meshcore_open/models/companion_radio_stats.dart';
|
||||
import 'package:meshcore_open/l10n/l10n.dart';
|
||||
import 'package:meshcore_open/screens/companion_radio_stats_screen.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
void pushCompanionRadioStatsScreen(BuildContext context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<void>(
|
||||
builder: (context) => const CompanionRadioStatsScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class RadioStatsIconButton extends StatefulWidget {
|
||||
final bool compact;
|
||||
|
||||
const RadioStatsIconButton({super.key, this.compact = false});
|
||||
|
||||
@override
|
||||
State<RadioStatsIconButton> createState() => _RadioStatsIconButtonState();
|
||||
}
|
||||
|
||||
class _RadioStatsIconButtonState extends State<RadioStatsIconButton> {
|
||||
MeshCoreConnector? _connector;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final c = context.read<MeshCoreConnector>();
|
||||
_connector = c;
|
||||
c.acquireRadioStatsPolling();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_connector?.releaseRadioStatsPolling();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Selector<MeshCoreConnector, ({bool connected, bool supported})>(
|
||||
selector: (_, c) =>
|
||||
(connected: c.isConnected, supported: c.supportsCompanionRadioStats),
|
||||
builder: (context, state, _) {
|
||||
if (!state.connected || !state.supported) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
return ValueListenableBuilder<CompanionRadioStats?>(
|
||||
valueListenable: connector.radioStatsNotifier,
|
||||
builder: (context, _, child) {
|
||||
final dot = AirActivityDot(
|
||||
active: connector.radioStatsAirActivityPulse,
|
||||
);
|
||||
if (widget.compact) {
|
||||
return GestureDetector(
|
||||
onTap: () => pushCompanionRadioStatsScreen(context),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: dot,
|
||||
),
|
||||
);
|
||||
}
|
||||
return Tooltip(
|
||||
message: context.l10n.radioStats_tooltip,
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: () => pushCompanionRadioStatsScreen(context),
|
||||
child: SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Center(child: dot),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AirActivityDot extends StatefulWidget {
|
||||
final bool active;
|
||||
|
||||
const AirActivityDot({super.key, required this.active});
|
||||
|
||||
@override
|
||||
State<AirActivityDot> createState() => AirActivityDotState();
|
||||
}
|
||||
|
||||
class AirActivityDotState extends State<AirActivityDot> {
|
||||
Timer? _timer;
|
||||
bool _blink = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.active) _startTimer();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AirActivityDot oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.active && !oldWidget.active) {
|
||||
_startTimer();
|
||||
} else if (!widget.active && oldWidget.active) {
|
||||
_stopTimer();
|
||||
_blink = true;
|
||||
}
|
||||
}
|
||||
|
||||
void _startTimer() {
|
||||
_timer ??= Timer.periodic(const Duration(milliseconds: 400), (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _blink = !_blink);
|
||||
});
|
||||
}
|
||||
|
||||
void _stopTimer() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopTimer();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final on = widget.active && _blink;
|
||||
return Icon(
|
||||
Icons.circle,
|
||||
size: 12,
|
||||
color: on ? scheme.primary : scheme.outline,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
||||
messageBytes: responseBytes,
|
||||
);
|
||||
final timeoutSeconds = (timeoutMs / 1000).ceil();
|
||||
final timeout = Duration(milliseconds: timeoutMs);
|
||||
final timeout = Duration(milliseconds: timeoutMs + 2000);
|
||||
final selectionLabel = selection.useFlood
|
||||
? 'flood'
|
||||
: '${selection.hopCount} hops';
|
||||
|
||||
@@ -108,7 +108,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
||||
messageBytes: responseBytes,
|
||||
);
|
||||
final timeoutSeconds = (timeoutMs / 1000).ceil();
|
||||
final timeout = Duration(milliseconds: timeoutMs);
|
||||
final timeout = Duration(milliseconds: timeoutMs + 2000);
|
||||
final selectionLabel = selection.useFlood
|
||||
? 'flood'
|
||||
: '${selection.hopCount} hops';
|
||||
|
||||
@@ -13,7 +13,6 @@ import share_plus
|
||||
import shared_preferences_foundation
|
||||
import sqflite_darwin
|
||||
import url_launcher_macos
|
||||
import wakelock_plus
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin"))
|
||||
@@ -24,5 +23,4 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
|
||||
}
|
||||
|
||||
+2
-2
@@ -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: 7.0.0+8
|
||||
version: 7.0.0+9
|
||||
|
||||
environment:
|
||||
sdk: ^3.9.2
|
||||
@@ -55,7 +55,6 @@ dependencies:
|
||||
cached_network_image: ^3.4.1
|
||||
flutter_cache_manager: ^3.4.1
|
||||
flutter_foreground_task: ^9.2.0
|
||||
wakelock_plus: ^1.4.0
|
||||
characters: ^1.4.0
|
||||
package_info_plus: ^9.0.0
|
||||
mobile_scanner: ^7.1.4 # QR/barcode scanning
|
||||
@@ -69,6 +68,7 @@ dependencies:
|
||||
material_symbols_icons: ^4.2906.0
|
||||
web: ^1.1.1
|
||||
flutter_svg: ^2.0.10+1
|
||||
flutter_blue_plus_platform_interface: ^8.2.1
|
||||
ml_algo: ^16.0.0
|
||||
ml_dataframe: ^1.0.0
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_protocol.dart';
|
||||
import 'package:meshcore_open/models/companion_radio_stats.dart';
|
||||
|
||||
void main() {
|
||||
test('CompanionRadioStats.tryParse golden 14-byte radio frame', () {
|
||||
// noise -90 (0xA6FF LE), rssi -70 (0xBA), snr raw 8 -> 2.0 dB,
|
||||
// tx_air 1000 LE, rx_air 2000 LE
|
||||
final frame = Uint8List.fromList([
|
||||
respCodeStats,
|
||||
statsTypeRadio,
|
||||
0xA6,
|
||||
0xFF,
|
||||
0xBA,
|
||||
0x08,
|
||||
0xE8,
|
||||
0x03,
|
||||
0x00,
|
||||
0x00,
|
||||
0xD0,
|
||||
0x07,
|
||||
0x00,
|
||||
0x00,
|
||||
]);
|
||||
final s = CompanionRadioStats.tryParse(frame);
|
||||
expect(s, isNotNull);
|
||||
expect(s!.noiseFloorDbm, -90);
|
||||
expect(s.lastRssiDbm, -70);
|
||||
expect(s.lastSnrDb, 2.0);
|
||||
expect(s.txAirSecs, 1000);
|
||||
expect(s.rxAirSecs, 2000);
|
||||
});
|
||||
|
||||
test('CompanionRadioStats.tryParse rejects short frame', () {
|
||||
expect(CompanionRadioStats.tryParse(Uint8List(10)), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_open/services/linux_ble_error_classifier.dart';
|
||||
|
||||
void main() {
|
||||
group('isLinuxBleConnectFailureText', () {
|
||||
test('matches flutter_blue_plus connect timeout error', () {
|
||||
expect(
|
||||
isLinuxBleConnectFailureText(
|
||||
'FlutterBluePlusException | connect | fbp-code: 1 | Timed out after 15s',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('matches hard-timeout marker', () {
|
||||
expect(
|
||||
isLinuxBleConnectFailureText(
|
||||
'TimeoutException: Linux connect hard-timeout after 8s',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('matches BlueZ local abort failure', () {
|
||||
expect(
|
||||
isLinuxBleConnectFailureText(
|
||||
'org.bluez.Error.Failed: le-connection-abort-by-local',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('matches BlueZ in-progress failure', () {
|
||||
expect(
|
||||
isLinuxBleConnectFailureText(
|
||||
'org.bluez.Error.InProgress: Operation already in progress',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('matches flutter_blue_plus null-detail connect failure', () {
|
||||
expect(
|
||||
isLinuxBleConnectFailureText(
|
||||
'FlutterBluePlusException | connect | linux-code: null | null',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('matches tagged connect-stage failure marker', () {
|
||||
expect(
|
||||
isLinuxBleConnectFailureText(
|
||||
'StateError: Linux connect stage failure: Bad state: No element',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not match connect-shaped pairing auth failure', () {
|
||||
expect(
|
||||
isLinuxBleConnectFailureText(
|
||||
'FlutterBluePlusException | connect | AuthenticationFailed',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not match explicit pair auth failure', () {
|
||||
expect(
|
||||
isLinuxBleConnectFailureText(
|
||||
'FlutterBluePlusException | pair | AuthenticationFailed',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('isLikelyLinuxBlePairingTimeoutText', () {
|
||||
test('matches pair timeout text', () {
|
||||
expect(
|
||||
isLikelyLinuxBlePairingTimeoutText('Timed out waiting for pair'),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('matches bond timeout text', () {
|
||||
expect(
|
||||
isLikelyLinuxBlePairingTimeoutText('Operation timed out during bond'),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not match generic timeout text', () {
|
||||
expect(
|
||||
isLikelyLinuxBlePairingTimeoutText('Timed out after 15s'),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('isLinuxBlePairingFailureText', () {
|
||||
test('matches connect-shaped authentication failure', () {
|
||||
expect(
|
||||
isLinuxBlePairingFailureText(
|
||||
'FlutterBluePlusException | connect | AuthenticationFailed',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('matches app pairing incomplete failure', () {
|
||||
expect(
|
||||
isLinuxBlePairingFailureText(
|
||||
'StateError: Linux BLE pairing did not complete',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not match generic bad state error', () {
|
||||
expect(isLinuxBlePairingFailureText('Bad state: No element'), isFalse);
|
||||
});
|
||||
|
||||
test('matches pair-context bad state error', () {
|
||||
expect(
|
||||
isLinuxBlePairingFailureText(
|
||||
'Pair request failed: Bad state: No element',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('matches app trust repair incomplete failure', () {
|
||||
expect(
|
||||
isLinuxBlePairingFailureText(
|
||||
'StateError: Linux BLE trust repair did not complete',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('matches pairing timeout text', () {
|
||||
expect(
|
||||
isLinuxBlePairingFailureText('Timed out waiting for pair'),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_open/services/linux_ble_pairing_service.dart';
|
||||
|
||||
class _FakeProcess implements Process {
|
||||
_FakeProcess({this.stdoutText = '', this.autoFinish = true}) {
|
||||
_stdin = IOSink(_stdinController.sink);
|
||||
_stdinController.stream.listen((chunk) {
|
||||
_stdinBuffer.write(utf8.decode(chunk));
|
||||
});
|
||||
|
||||
// Use Timer.run (event-loop tick) instead of microtask so that broadcast
|
||||
// listeners in _runPairingAttempt are attached before the event fires.
|
||||
Timer.run(() {
|
||||
if (_closed) {
|
||||
return;
|
||||
}
|
||||
if (stdoutText.isNotEmpty) {
|
||||
_stdoutController.add(utf8.encode(stdoutText));
|
||||
}
|
||||
});
|
||||
|
||||
if (autoFinish) {
|
||||
// Scheduled after the Timer.run above (FIFO order), so stdout is
|
||||
// emitted before the process exits.
|
||||
Timer(Duration.zero, () async {
|
||||
await _finish(exitStatus);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
final String stdoutText;
|
||||
final bool autoFinish;
|
||||
final int exitStatus = 0;
|
||||
final StreamController<List<int>> _stdinController =
|
||||
StreamController<List<int>>();
|
||||
final StreamController<List<int>> _stdoutController =
|
||||
StreamController<List<int>>.broadcast();
|
||||
final StreamController<List<int>> _stderrController =
|
||||
StreamController<List<int>>.broadcast();
|
||||
final Completer<int> _exitCodeCompleter = Completer<int>();
|
||||
final StringBuffer _stdinBuffer = StringBuffer();
|
||||
late final IOSink _stdin;
|
||||
bool _closed = false;
|
||||
|
||||
String get stdinText => _stdinBuffer.toString();
|
||||
|
||||
void emitStdout(String text) {
|
||||
if (!_closed) {
|
||||
_stdoutController.add(utf8.encode(text));
|
||||
}
|
||||
}
|
||||
|
||||
void finishProcess([int code = 0]) {
|
||||
unawaited(_finish(code));
|
||||
}
|
||||
|
||||
Future<void> _finish(int code) async {
|
||||
if (_closed) {
|
||||
return;
|
||||
}
|
||||
_closed = true;
|
||||
await _stdin.close();
|
||||
await _stdoutController.close();
|
||||
await _stderrController.close();
|
||||
if (!_exitCodeCompleter.isCompleted) {
|
||||
_exitCodeCompleter.complete(code);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> get exitCode => _exitCodeCompleter.future;
|
||||
|
||||
@override
|
||||
bool kill([ProcessSignal signal = ProcessSignal.sigterm]) {
|
||||
unawaited(_finish(exitStatus));
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
int get pid => 1;
|
||||
|
||||
@override
|
||||
IOSink get stdin => _stdin;
|
||||
|
||||
@override
|
||||
Stream<List<int>> get stderr => _stderrController.stream;
|
||||
|
||||
@override
|
||||
Stream<List<int>> get stdout => _stdoutController.stream;
|
||||
}
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'disconnectDevice skips gracefully when bluetoothctl is unavailable',
|
||||
() async {
|
||||
final logs = <String>[];
|
||||
final service = LinuxBlePairingService(
|
||||
processStart: (executable, arguments) async {
|
||||
throw const ProcessException(
|
||||
'bluetoothctl',
|
||||
<String>[],
|
||||
'not found',
|
||||
2,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await service.disconnectDevice('AA:BB:CC:DD:EE:FF', onLog: logs.add);
|
||||
|
||||
expect(
|
||||
logs.any((line) => line.contains('bluetoothctl unavailable')),
|
||||
isTrue,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'isPairedAndTrusted returns false when bluetoothctl is unavailable',
|
||||
() async {
|
||||
final service = LinuxBlePairingService(
|
||||
processRun: (executable, arguments) async {
|
||||
throw const ProcessException(
|
||||
'bluetoothctl',
|
||||
<String>[],
|
||||
'not found',
|
||||
2,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final trusted = await service.isPairedAndTrusted('AA:BB:CC:DD:EE:FF');
|
||||
expect(trusted, isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('isBluetoothctlAvailable returns false when unavailable', () async {
|
||||
final service = LinuxBlePairingService(
|
||||
processRun: (executable, arguments) async {
|
||||
throw const ProcessException(
|
||||
'bluetoothctl',
|
||||
<String>[],
|
||||
'not found',
|
||||
2,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final available = await service.isBluetoothctlAvailable();
|
||||
expect(available, isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'isBluetoothctlAvailable returns true when version command succeeds',
|
||||
() async {
|
||||
final service = LinuxBlePairingService(
|
||||
processRun: (executable, arguments) async {
|
||||
return ProcessResult(1234, 0, '5.72', '');
|
||||
},
|
||||
);
|
||||
|
||||
final available = await service.isBluetoothctlAvailable();
|
||||
expect(available, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'isPairedAndTrusted returns true when paired and trusted are yes',
|
||||
() async {
|
||||
final service = LinuxBlePairingService(
|
||||
processRun: (executable, arguments) async {
|
||||
return ProcessResult(1234, 0, '''
|
||||
Device AA:BB:CC:DD:EE:FF
|
||||
Paired: yes
|
||||
Trusted: yes
|
||||
''', '');
|
||||
},
|
||||
);
|
||||
|
||||
final trusted = await service.isPairedAndTrusted('AA:BB:CC:DD:EE:FF');
|
||||
expect(trusted, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test('pairAndTrust returns false when bluetoothctl is unavailable', () async {
|
||||
final service = LinuxBlePairingService(
|
||||
processStart: (executable, arguments) async {
|
||||
throw const ProcessException(
|
||||
'bluetoothctl',
|
||||
<String>[],
|
||||
'not found',
|
||||
2,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final paired = await service.pairAndTrust(remoteId: 'AA:BB:CC:DD:EE:FF');
|
||||
expect(paired, isFalse);
|
||||
});
|
||||
|
||||
test('trustDevice verifies trust after trust command succeeds', () async {
|
||||
final logs = <String>[];
|
||||
final service = LinuxBlePairingService(
|
||||
processRun: (executable, arguments) async {
|
||||
switch (arguments.first) {
|
||||
case 'trust':
|
||||
return ProcessResult(1234, 0, 'trust succeeded', '');
|
||||
case 'info':
|
||||
return ProcessResult(1234, 0, '''
|
||||
Device AA:BB:CC:DD:EE:FF
|
||||
Paired: yes
|
||||
Trusted: yes
|
||||
''', '');
|
||||
}
|
||||
fail('Unexpected bluetoothctl arguments: $arguments');
|
||||
},
|
||||
);
|
||||
|
||||
final trusted = await service.trustDevice(
|
||||
'AA:BB:CC:DD:EE:FF',
|
||||
onLog: logs.add,
|
||||
);
|
||||
|
||||
expect(trusted, isTrue);
|
||||
expect(logs.any((line) => line.contains('Verified BlueZ trust')), isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'trustDevice returns false when trust verification stays untrusted',
|
||||
() async {
|
||||
final logs = <String>[];
|
||||
final service = LinuxBlePairingService(
|
||||
processRun: (executable, arguments) async {
|
||||
switch (arguments.first) {
|
||||
case 'trust':
|
||||
return ProcessResult(1234, 0, 'trust succeeded', '');
|
||||
case 'info':
|
||||
return ProcessResult(1234, 0, '''
|
||||
Device AA:BB:CC:DD:EE:FF
|
||||
Paired: yes
|
||||
Trusted: no
|
||||
''', '');
|
||||
}
|
||||
fail('Unexpected bluetoothctl arguments: $arguments');
|
||||
},
|
||||
);
|
||||
|
||||
final trusted = await service.trustDevice(
|
||||
'AA:BB:CC:DD:EE:FF',
|
||||
onLog: logs.add,
|
||||
);
|
||||
|
||||
expect(trusted, isFalse);
|
||||
expect(
|
||||
logs.any((line) => line.contains('trust verification failed')),
|
||||
isTrue,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'pairAndTrust fails when pairing reports success but trust is not restored',
|
||||
() async {
|
||||
final logs = <String>[];
|
||||
final service = LinuxBlePairingService(
|
||||
processStart: (executable, arguments) async =>
|
||||
_FakeProcess(stdoutText: 'Pairing successful\n'),
|
||||
processRun: (executable, arguments) async {
|
||||
switch (arguments.first) {
|
||||
case 'trust':
|
||||
return ProcessResult(1234, 0, 'trust succeeded', '');
|
||||
case 'info':
|
||||
return ProcessResult(1234, 0, '''
|
||||
Device AA:BB:CC:DD:EE:FF
|
||||
Paired: yes
|
||||
Trusted: no
|
||||
''', '');
|
||||
}
|
||||
fail('Unexpected bluetoothctl arguments: $arguments');
|
||||
},
|
||||
);
|
||||
|
||||
final paired = await service.pairAndTrust(
|
||||
remoteId: 'AA:BB:CC:DD:EE:FF',
|
||||
onLog: logs.add,
|
||||
);
|
||||
|
||||
expect(paired, isFalse);
|
||||
expect(
|
||||
logs.any((line) => line.contains('trust was not restored')),
|
||||
isTrue,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'pairAndTrust succeeds without requesting proactive PIN after success',
|
||||
() async {
|
||||
final logs = <String>[];
|
||||
var pinRequests = 0;
|
||||
final service = LinuxBlePairingService(
|
||||
processStart: (executable, arguments) async =>
|
||||
_FakeProcess(stdoutText: 'Pairing successful\n'),
|
||||
processRun: (executable, arguments) async {
|
||||
switch (arguments.first) {
|
||||
case 'trust':
|
||||
return ProcessResult(1234, 0, 'trust succeeded', '');
|
||||
case 'info':
|
||||
return ProcessResult(1234, 0, '''
|
||||
Device AA:BB:CC:DD:EE:FF
|
||||
Paired: yes
|
||||
Trusted: yes
|
||||
''', '');
|
||||
}
|
||||
fail('Unexpected bluetoothctl arguments: $arguments');
|
||||
},
|
||||
);
|
||||
|
||||
final paired = await service.pairAndTrust(
|
||||
remoteId: 'AA:BB:CC:DD:EE:FF',
|
||||
onLog: logs.add,
|
||||
onRequestPin: () async {
|
||||
pinRequests++;
|
||||
return '123456';
|
||||
},
|
||||
);
|
||||
|
||||
expect(paired, isTrue);
|
||||
expect(pinRequests, 0);
|
||||
expect(
|
||||
logs.any((line) => line.contains('did not complete before timeout')),
|
||||
isFalse,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'pairAndTrust sends empty line when blank PIN is submitted (not cancel)',
|
||||
() async {
|
||||
final logs = <String>[];
|
||||
late final _FakeProcess fakeProc;
|
||||
final service = LinuxBlePairingService(
|
||||
processStart: (executable, arguments) async {
|
||||
fakeProc = _FakeProcess(stdoutText: '', autoFinish: false);
|
||||
// Emit PIN prompt after an event-loop tick (not microtask) so
|
||||
// broadcast listeners are attached first.
|
||||
Timer.run(() {
|
||||
fakeProc.emitStdout('Enter PIN code:\n');
|
||||
Future<void>.delayed(const Duration(milliseconds: 100), () {
|
||||
fakeProc.emitStdout('Pairing successful\n');
|
||||
Future<void>.delayed(const Duration(milliseconds: 50), () {
|
||||
fakeProc.finishProcess();
|
||||
});
|
||||
});
|
||||
});
|
||||
return fakeProc;
|
||||
},
|
||||
processRun: (executable, arguments) async {
|
||||
switch (arguments.first) {
|
||||
case 'trust':
|
||||
return ProcessResult(1234, 0, 'trust succeeded', '');
|
||||
case 'info':
|
||||
return ProcessResult(1234, 0, '''
|
||||
Device AA:BB:CC:DD:EE:FF
|
||||
Paired: yes
|
||||
Trusted: yes
|
||||
''', '');
|
||||
}
|
||||
fail('Unexpected bluetoothctl arguments: $arguments');
|
||||
},
|
||||
);
|
||||
|
||||
final paired = await service.pairAndTrust(
|
||||
remoteId: 'AA:BB:CC:DD:EE:FF',
|
||||
timeout: const Duration(seconds: 5),
|
||||
onLog: logs.add,
|
||||
onRequestPin: () async => '',
|
||||
);
|
||||
|
||||
expect(paired, isTrue);
|
||||
expect(logs.any((line) => line.contains('Blank PIN submitted')), isTrue);
|
||||
expect(logs.any((line) => line.contains('cancelling pairing')), isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('pairAndTrust cancels pairing when PIN dialog returns null', () async {
|
||||
final logs = <String>[];
|
||||
final service = LinuxBlePairingService(
|
||||
processStart: (executable, arguments) async {
|
||||
final proc = _FakeProcess(stdoutText: '', autoFinish: false);
|
||||
Timer.run(() {
|
||||
proc.emitStdout('Enter PIN code:\n');
|
||||
// Process will be killed/quit by the pairing service after cancel
|
||||
Future<void>.delayed(const Duration(milliseconds: 200), () {
|
||||
proc.finishProcess();
|
||||
});
|
||||
});
|
||||
return proc;
|
||||
},
|
||||
processRun: (executable, arguments) async {
|
||||
return ProcessResult(1234, 0, '', '');
|
||||
},
|
||||
);
|
||||
|
||||
final paired = await service.pairAndTrust(
|
||||
remoteId: 'AA:BB:CC:DD:EE:FF',
|
||||
timeout: const Duration(seconds: 3),
|
||||
onLog: logs.add,
|
||||
onRequestPin: () async => null,
|
||||
);
|
||||
|
||||
expect(paired, isFalse);
|
||||
expect(logs.any((line) => line.contains('cancelled by user')), isTrue);
|
||||
});
|
||||
}
|
||||
+65
-6
@@ -14,6 +14,10 @@ Usage:
|
||||
|
||||
# Translate all locales (missing strings only):
|
||||
python translate.py --in lib/l10n/app_en.arb --l10n-dir lib/l10n --missing-only
|
||||
|
||||
# New locales copied from app_en.arb still match English → --missing-only skips them.
|
||||
# Translate every key that still equals the template (e.g. hu, ja, ko):
|
||||
python translate.py --in lib/l10n/app_en.arb --l10n-dir lib/l10n --copy-of-template --only-locales hu,ja,ko
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -68,6 +72,7 @@ LOCALE_MAP = {
|
||||
"sk": ("Slovak", "sk"),
|
||||
"sl": ("Slovenian", "sl"),
|
||||
"bg": ("Bulgarian", "bg"),
|
||||
"hu": ("Hungarian", "hu"),
|
||||
"el": ("Greek", "el"),
|
||||
"he": ("Hebrew", "he"),
|
||||
"th": ("Thai", "th"),
|
||||
@@ -261,6 +266,25 @@ def find_missing_keys(source_data: Dict[str, Any], target_data: Dict[str, Any])
|
||||
return missing
|
||||
|
||||
|
||||
def find_keys_still_template_copy(source_data: Dict[str, Any], target_data: Dict[str, Any]) -> List[str]:
|
||||
"""Keys whose value is still exactly the same as the template (typical after cp app_en.arb → app_xx.arb)."""
|
||||
out: List[str] = []
|
||||
for key in source_data:
|
||||
if key == "@@locale" or key.startswith("@"):
|
||||
continue
|
||||
src = source_data.get(key)
|
||||
if not is_translatable_entry(key, src):
|
||||
continue
|
||||
if not isinstance(src, str):
|
||||
continue
|
||||
tgt = target_data.get(key)
|
||||
if not isinstance(tgt, str) or tgt.strip() == "":
|
||||
out.append(key)
|
||||
elif tgt == src:
|
||||
out.append(key)
|
||||
return out
|
||||
|
||||
|
||||
def get_all_locale_files(l10n_dir: str, template_file: str) -> List[Tuple[str, str]]:
|
||||
"""Find all locale .arb files excluding template. Returns [(locale_code, file_path)]."""
|
||||
locales = []
|
||||
@@ -434,6 +458,15 @@ def main() -> int:
|
||||
ap.add_argument("--to-locale", help="Target locale code (es, fr, de, etc.)")
|
||||
ap.add_argument("--l10n-dir", help="Directory with locale files (translates all locales)")
|
||||
ap.add_argument("--missing-only", action="store_true", help="Only translate missing keys")
|
||||
ap.add_argument(
|
||||
"--copy-of-template",
|
||||
action="store_true",
|
||||
help="Only translate keys whose target text still equals app_en (use for new locales copied from English)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--only-locales",
|
||||
help="Comma-separated locale codes to process with --l10n-dir (e.g. hu,ja,ko)",
|
||||
)
|
||||
ap.add_argument("--model", default="translategemma:latest", help="Ollama model (translategemma:latest or specific versions)")
|
||||
ap.add_argument("--fallback-model", help="Fallback model for failed translations (e.g., translategemma:27b)")
|
||||
ap.add_argument("--host", default="http://localhost:11434", help="Ollama host")
|
||||
@@ -458,6 +491,14 @@ def main() -> int:
|
||||
print("Input JSON must be an object at top-level.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.missing_only and args.copy_of_template:
|
||||
print("Use only one of --missing-only or --copy-of-template", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
only_locales: Optional[set] = None
|
||||
if args.only_locales:
|
||||
only_locales = {x.strip() for x in args.only_locales.split(",") if x.strip()}
|
||||
|
||||
# Process all locales if --l10n-dir is provided
|
||||
if args.l10n_dir:
|
||||
locales = get_all_locale_files(args.l10n_dir, args.in_path)
|
||||
@@ -465,6 +506,12 @@ def main() -> int:
|
||||
print(f"No locale files found in {args.l10n_dir}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if only_locales is not None:
|
||||
locales = [(c, p) for c, p in locales if c in only_locales]
|
||||
missing = only_locales - {c for c, _ in locales}
|
||||
if missing:
|
||||
print(f"Warning: no app_*.arb for locale code(s): {', '.join(sorted(missing))}", file=sys.stderr)
|
||||
|
||||
print(f"Found {len(locales)} locale file(s) to process")
|
||||
|
||||
total_translated = 0
|
||||
@@ -478,7 +525,14 @@ def main() -> int:
|
||||
print(f" [{locale_code}] Failed to read {locale_path}: {e}")
|
||||
continue
|
||||
|
||||
if args.missing_only:
|
||||
missing_keys: Optional[List[str]]
|
||||
if args.copy_of_template:
|
||||
missing_keys = find_keys_still_template_copy(source_data, target_data)
|
||||
if not missing_keys:
|
||||
print(f" [{locale_code}] No keys still matching template")
|
||||
continue
|
||||
print(f" [{locale_code}] {len(missing_keys)} key(s) still same as template")
|
||||
elif args.missing_only:
|
||||
missing_keys = find_missing_keys(source_data, target_data)
|
||||
if not missing_keys:
|
||||
print(f" [{locale_code}] No missing keys")
|
||||
@@ -509,18 +563,23 @@ def main() -> int:
|
||||
|
||||
lang_name, lang_code = LOCALE_MAP.get(args.to_locale, (args.to_locale, args.to_locale))
|
||||
|
||||
# Read existing target file if --missing-only
|
||||
# Read existing target file if --missing-only or --copy-of-template
|
||||
target_data: Dict[str, Any] = {}
|
||||
missing_keys: Optional[List[str]] = None
|
||||
if args.missing_only and os.path.exists(args.out_path):
|
||||
if (args.missing_only or args.copy_of_template) and os.path.exists(args.out_path):
|
||||
try:
|
||||
with open(args.out_path, "r", encoding="utf-8") as f:
|
||||
target_data = json.load(f)
|
||||
missing_keys = find_missing_keys(source_data, target_data)
|
||||
if args.copy_of_template:
|
||||
missing_keys = find_keys_still_template_copy(source_data, target_data)
|
||||
label = "still matching template"
|
||||
else:
|
||||
missing_keys = find_missing_keys(source_data, target_data)
|
||||
label = "missing"
|
||||
if not missing_keys:
|
||||
print(f"No missing keys in {args.out_path}")
|
||||
print(f"No {label} keys in {args.out_path}")
|
||||
return 0
|
||||
print(f"Found {len(missing_keys)} missing key(s) to translate")
|
||||
print(f"Found {len(missing_keys)} {label} key(s) to translate")
|
||||
except Exception as e:
|
||||
print(f"Failed to read target file: {e}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
Reference in New Issue
Block a user