merge main

This commit is contained in:
ericz 2026-03-28 17:16:16 +01:00
commit 2e6f4ebad8
150 changed files with 37506 additions and 3365 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,70 @@
import 'dart:async';
import 'dart:typed_data';
import '../services/app_debug_log_service.dart';
import '../services/tcp_transport_service.dart';
/// Manages TCP transport for MeshCore devices.
///
/// Owns the [TcpTransportService] and TCP-specific connection state.
/// The main [MeshCoreConnector] delegates all TCP operations here.
class MeshCoreTcpConnector {
final TcpTransportService _service = TcpTransportService();
AppDebugLogService? _debugLog;
StreamSubscription<Uint8List>? _frameSubscription;
// --- Getters ---
String? get activeEndpoint => _service.activeEndpoint;
bool get isConnected => _service.isConnected;
// --- Configuration ---
void setDebugLogService(AppDebugLogService? service) {
_debugLog = service;
_service.setDebugLogService(service);
}
// --- Connection lifecycle ---
Future<void> connect({required String host, required int port}) async {
_debugLog?.info('TcpConnector.connect endpoint=$host:$port', tag: 'TCP');
await _frameSubscription?.cancel();
_frameSubscription = null;
await _service.connect(host: host, port: port);
_debugLog?.info(
'TcpConnector.connect done, endpoint=${_service.activeEndpoint}',
tag: 'TCP',
);
}
StreamSubscription<Uint8List> listenFrames({
required void Function(Uint8List) onFrame,
required void Function(Object, StackTrace?) onError,
required void Function() onDone,
}) {
_frameSubscription = _service.frameStream.listen(
onFrame,
onError: onError,
onDone: onDone,
);
return _frameSubscription!;
}
Future<void> cancelFrameSubscription() async {
await _frameSubscription?.cancel();
_frameSubscription = null;
}
Future<void> disconnect() async {
if (!_service.isConnected && _frameSubscription == null) return;
_debugLog?.info('TcpConnector.disconnect', tag: 'TCP');
await _frameSubscription?.cancel();
_frameSubscription = null;
await _service.disconnect();
}
Future<void> write(Uint8List data) => _service.write(data);
void dispose() {
_frameSubscription?.cancel();
_service.dispose();
}
}

View file

@ -53,6 +53,9 @@ class MeshCoreUsbManager {
}
Future<void> disconnect() async {
if (!_service.isConnected && _activePortKey == null) {
return;
}
_debugLog?.info('UsbManager.disconnect', tag: 'USB');
await _service.disconnect();
_activePortKey = null;
@ -61,6 +64,8 @@ class MeshCoreUsbManager {
Future<void> write(Uint8List data) => _service.write(data);
Future<void> writeRaw(Uint8List data) => _service.writeRaw(data);
// --- Label management ---
void updateConnectedLabel(String selfName) {
_service.updateConnectedLabel(selfName);

View file

@ -1,6 +1,8 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/widgets.dart';
// Buffer Reader - sequential binary data reader with pointer tracking
class BufferReader {
int _pointer = 0;
@ -37,16 +39,6 @@ class BufferReader {
Uint8List readRemainingBytes() => readBytes(remaining);
String readString() {
_lastPointer = _pointer;
final value = readRemainingBytes();
try {
return utf8.decode(Uint8List.fromList(value), allowMalformed: true);
} catch (e) {
return String.fromCharCodes(value); // Latin-1 fallback
}
}
String readCStringGreedy(int maxLength) {
_lastPointer = _pointer;
final value = <int>[];
@ -62,11 +54,12 @@ class BufferReader {
}
}
String readCString(int maxLength) {
String readCString({int maxLength = -1}) {
final backupPointer = _pointer;
final value = <int>[];
int counter = 0;
while (counter < maxLength) {
final maxLen = maxLength >= 0 ? maxLength : remaining;
while (counter < maxLen) {
final byte = readByte();
if (byte == 0) break;
value.add(byte);
@ -148,6 +141,19 @@ class BufferWriter {
void writeHex(String hex) {
writeBytes(hex2Uint8List(hex));
}
void writeBytesPadded(Uint8List bytes, int totalLength) {
// Path data (64 bytes, zero-padded)
final bytesPadded = Uint8List(totalLength);
final len = bytes.length < totalLength ? bytes.length : totalLength;
if (bytes.isNotEmpty && len > 0) {
final copyLen = bytes.length < totalLength ? bytes.length : totalLength;
for (int i = 0; i < copyLen; i++) {
bytesPadded[i] = bytes[i];
}
}
writeBytes(bytesPadded);
}
}
Uint8List hex2Uint8List(String hex) {
@ -197,16 +203,19 @@ const int cmdSetChannel = 32;
const int cmdSendTracePath = 36;
const int cmdSetOtherParams = 38;
const int cmdSendAnonReq = 57;
const int cmdGetTelemetryReq = 39;
const int cmdSendTelemetryReq = 39;
const int cmdGetCustomVar = 40;
const int cmdSetCustomVar = 41;
const int cmdSendBinaryReq = 50;
const int cmdSetAutoAddConfig = 58;
const int cmdGetAutoAddConfig = 59;
const int cmdSetPathHashMode = 61;
const int cmdGetStats = 56;
// Text message types
const int txtTypePlain = 0;
const int txtTypeCliData = 1;
const int txtTypeSigned = 2;
// Repeater request types (for server requests)
const int reqTypeGetStatus = 0x01;
@ -238,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;
@ -259,6 +273,10 @@ const int advTypeRepeater = 2;
const int advTypeRoom = 3;
const int advTypeSensor = 4;
const int teleModeDeny = 0;
const int teleModeAllowFlags = 1; // use contact.flags
const int teleModeAllowAll = 2;
// Payload Types
const int payloadTypeREQ =
0x00; // request (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)
@ -297,6 +315,7 @@ const int autoAddSensorFlag =
// Sizes
const int pubKeySize = 32;
const int signatureSize = 64;
const int maxPathSize = 64;
const int pathHashSize = 1;
const int maxNameSize = 32;
@ -339,6 +358,9 @@ const int contactPubKeyOffset = 1;
const int contactTypeOffset = 33;
const int contactFlagsOffset = 34;
const int contactFlagFavorite = 0x01;
const int contactFlagTeleBase = 0x02; // 'base' permission includes battery
const int contactFlagTeleLoc = 0x04;
const int contactFlagTeleEnv = 0x08; //access environment sensors
const int contactPathLenOffset = 35;
const int contactPathOffset = 36;
const int contactNameOffset = 100;
@ -357,52 +379,44 @@ const int msgTextOffset = 38;
class ParsedContactText {
final Uint8List senderPrefix;
final String text;
const ParsedContactText({required this.senderPrefix, required this.text});
}
ParsedContactText? parseContactMessageText(Uint8List frame) {
if (frame.isEmpty) return null;
final code = frame[0];
if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
final message = BufferReader(frame);
try {
final code = message.readByte();
if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
return null;
}
// Companion radio layout:
// [code][snr?][res?][res?][prefix x6][path_len][txt_type][timestamp x4][extra?][text...]
if (code == respCodeContactMsgRecvV3) {
// Skip SNR and reserved bytes in v3 layout
message.skipBytes(3);
}
final senderPrefix = message.readBytes(6); // public key
message.skipBytes(1); // path length
final textType = message.readByte();
message.skipBytes(4); // timestamp (4 bytes)
final shiftedType = textType >> 2;
final isSigned = shiftedType == txtTypeSigned || textType == txtTypeSigned;
if (isSigned) {
// Signed messages have a 4-byte signature after the timestamp, before the text
message.skipBytes(4);
}
final text = message.readCString();
if (text.isEmpty) return null;
return ParsedContactText(senderPrefix: senderPrefix, text: text);
} catch (e) {
debugPrint('Error parsing contact message text: $e');
return null;
}
// Companion radio layout:
// [code][snr?][res?][res?][prefix x6][path_len][txt_type][timestamp x4][extra?][text...]
final isV3 = code == respCodeContactMsgRecvV3;
final prefixOffset = isV3 ? 4 : 1;
const prefixLen = 6;
final txtTypeOffset = prefixOffset + prefixLen + 1;
final timestampOffset = txtTypeOffset + 1;
final baseTextOffset = timestampOffset + 4;
if (frame.length <= baseTextOffset) return null;
final flags = frame[txtTypeOffset];
final shiftedType = flags >> 2;
final rawType = flags;
final isPlain = shiftedType == txtTypePlain || rawType == txtTypePlain;
final isCli = shiftedType == txtTypeCliData || rawType == txtTypeCliData;
if (!isPlain && !isCli) {
return null;
}
var text = readCString(
frame,
baseTextOffset,
frame.length - baseTextOffset,
).trim();
if (text.isEmpty && frame.length > baseTextOffset + 4) {
text = readCString(
frame,
baseTextOffset + 4,
frame.length - (baseTextOffset + 4),
).trim();
}
if (text.isEmpty) return null;
final senderPrefix = frame.sublist(prefixOffset, prefixOffset + prefixLen);
return ParsedContactText(senderPrefix: senderPrefix, text: text);
}
// Helper to read uint32 little-endian
@ -425,18 +439,9 @@ int readInt32LE(Uint8List data, int offset) {
return val;
}
// Helper to read null-terminated UTF-8 string
String readCString(Uint8List data, int offset, int maxLen) {
int end = offset;
while (end < offset + maxLen && end < data.length && data[end] != 0) {
end++;
}
try {
return utf8.decode(data.sublist(offset, end), allowMalformed: true);
} catch (e) {
// Fallback to Latin-1 if UTF-8 decoding fails
return String.fromCharCodes(data.sublist(offset, end));
}
// Helper to convert uint32 to hex string
String ackHashToHex(int ackHash) {
return ackHash.toRadixString(16).padLeft(8, '0');
}
// Helper to convert public key to hex string
@ -496,7 +501,7 @@ Uint8List buildSendTextMsgFrame(
final writer = BufferWriter();
writer.writeByte(cmdSendTxtMsg);
writer.writeByte(txtTypePlain);
writer.writeByte(attempt.clamp(0, 3));
writer.writeByte(attempt.clamp(0, 255));
writer.writeUInt32LE(timestamp);
writer.writeBytes(recipientPubKey.sublist(0, 6));
writer.writeString(text);
@ -556,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();
@ -676,14 +692,17 @@ Uint8List buildResetPathFrame(Uint8List pubKey) {
}
// Build CMD_ADD_UPDATE_CONTACT frame to set custom path
// Format: [cmd][pub_key x32][type][flags][path_len][path x64][name x32][timestamp x4]
// Format: [cmd][pub_key x32][type][flags][path_len][path x64][name x32][Lat? x4, Lon? x4][timestamp? x4]
Uint8List buildUpdateContactPathFrame(
Uint8List pubKey,
Uint8List customPath,
Uint8List path,
int pathLen, {
int type = 1, // ADV_TYPE_CHAT
int flags = 0,
String name = '',
double? lat,
double? lon,
DateTime? lastModified,
}) {
final writer = BufferWriter();
writer.writeByte(cmdAddUpdateContact);
@ -692,17 +711,7 @@ Uint8List buildUpdateContactPathFrame(
writer.writeByte(flags);
writer.writeByte(pathLen);
// Path data (64 bytes, zero-padded)
final pathPadded = Uint8List(maxPathSize);
if (customPath.isNotEmpty && pathLen > 0) {
final copyLen = customPath.length < maxPathSize
? customPath.length
: maxPathSize;
for (int i = 0; i < copyLen; i++) {
pathPadded[i] = customPath[i];
}
}
writer.writeBytes(pathPadded);
writer.writeBytesPadded(path, maxPathSize);
// Name (32 bytes, null-padded)
writer.writeCString(name, maxNameSize);
@ -711,6 +720,27 @@ Uint8List buildUpdateContactPathFrame(
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
writer.writeUInt32LE(timestamp);
if ((lat == null || lon == null) && lastModified != null) {
// If lat/lon not provided, write zeros
writer.writeInt32LE(0);
writer.writeInt32LE(0);
} else {
// Latitude and Longitude are expected in degrees, convert to int by multiplying by 1e6
// Latitude
final latitude = lat ?? 0.0;
writer.writeInt32LE((latitude * 1e6).round());
// Longitude
final longitude = lon ?? 0.0;
writer.writeInt32LE((longitude * 1e6).round());
}
if (lastModified != null) {
// Last modified
final lastModifiedTimestamp = lastModified.millisecondsSinceEpoch ~/ 1000;
writer.writeUInt32LE(lastModifiedTimestamp);
}
return writer.toBytes();
}
@ -811,7 +841,7 @@ Uint8List buildSendCliCommandFrame(
final writer = BufferWriter();
writer.writeByte(cmdSendTxtMsg);
writer.writeByte(txtTypeCliData);
writer.writeByte(attempt.clamp(0, 3));
writer.writeByte(attempt.clamp(0, 255));
writer.writeUInt32LE(timestamp);
writer.writeBytes(repeaterPubKey.sublist(0, 6));
writer.writeString(command);
@ -910,3 +940,18 @@ Uint8List buildSetAutoAddConfigFrame({
writer.writeByte(flags);
return writer.toBytes();
}
//Build CMD_SEND_TELEMETRY_REQ
// Format: [cmd][reserved x3][pub_key? x32]
Uint8List buildSendTelemetryReq(Uint8List? pubKey) {
final writer = BufferWriter();
writer.writeByte(cmdSendTelemetryReq);
if (pubKey != null && pubKey.length == pubKeySize) {
writer.writeBytes(Uint8List(3)); // reserved bytes
writer.writeBytes(pubKey);
} else {
writer.writeBytes(Uint8List(4)); // reserved bytes
}
return writer.toBytes();
}

View file

@ -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-",
];
}

View file

@ -1,8 +1,50 @@
import 'package:flutter/material.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:url_launcher/url_launcher.dart';
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,
required String text,
required TextStyle style,
TextStyle? linkStyle,
}) {
final effectiveLinkStyle = linkStyle ?? defaultLinkStyle(context, style);
const options = LinkifyOptions(humanize: false, defaultToHttps: false);
const linkifiers = [UrlLinkifier(), EmailLinkifier()];
void onOpen(LinkableElement link) => handleLinkTap(context, link.url);
if (PlatformInfo.isDesktop) {
return SelectableLinkify(
text: text,
style: style,
linkStyle: effectiveLinkStyle,
options: options,
linkifiers: linkifiers,
onOpen: onOpen,
);
}
return Linkify(
text: text,
style: style,
linkStyle: effectiveLinkStyle,
options: options,
linkifiers: linkifiers,
onOpen: onOpen,
);
}
static Future<void> handleLinkTap(BuildContext context, String url) async {
// Show confirmation dialog
final shouldOpen = await showDialog<bool>(

View file

@ -0,0 +1,31 @@
import '../models/contact.dart';
import '../connector/meshcore_protocol.dart';
class PathHelper {
static String formatPathHex(List<int> pathBytes) {
return pathBytes
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(',');
}
static String resolvePathNames(
List<int> pathBytes,
List<Contact> allContacts,
) {
return pathBytes
.map((b) {
final hex = b.toRadixString(16).padLeft(2, '0').toUpperCase();
final matches = allContacts
.where(
(c) =>
c.publicKey.first == b &&
(c.type == advTypeRepeater || c.type == advTypeRoom),
)
.toList();
if (matches.isEmpty) return hex;
if (matches.length == 1) return matches.first.name;
return matches.map((c) => c.name).join(' | ');
})
.join(' \u2192 ');
}
}

View file

@ -8,6 +8,50 @@ class ReactionInfo {
}
class ReactionHelper {
/// Apply a reaction to a list of messages by matching the reaction hash.
///
/// [messages] - the message list to search
/// [reactionInfo] - the parsed reaction
/// [getTimestampSecs] - extract timestamp seconds from a message
/// [getSenderName] - extract sender name for hash (null for 1:1 implicit)
/// [getMessageText] - extract message text
/// [getReactions] - extract current reactions map
/// [shouldSkip] - filter function to skip messages (e.g., skip outgoing for incoming reactions)
/// [updateMessage] - callback to update the message at index with new reactions
///
/// Returns whether a match was found.
static bool applyReaction<T>({
required List<T> messages,
required ReactionInfo reactionInfo,
required int Function(T) getTimestampSecs,
required String? Function(T) getSenderName,
required String Function(T) getMessageText,
required Map<String, int> Function(T) getReactions,
required bool Function(T) shouldSkip,
required void Function(int index, Map<String, int> newReactions)
updateMessage,
}) {
final targetHash = reactionInfo.targetHash;
for (int i = messages.length - 1; i >= 0; i--) {
final msg = messages[i];
if (shouldSkip(msg)) continue;
final msgHash = computeReactionHash(
getTimestampSecs(msg),
getSenderName(msg),
getMessageText(msg),
);
if (msgHash == targetHash) {
final currentReactions = Map<String, int>.from(getReactions(msg));
currentReactions[reactionInfo.emoji] =
(currentReactions[reactionInfo.emoji] ?? 0) + 1;
updateMessage(i, currentReactions);
return true;
}
}
return false;
}
static List<String>? _cachedEmojis;
/// Combined list of all reaction emojis in fixed order.

View file

@ -285,6 +285,7 @@
"contacts_newGroup": "Нова група",
"contacts_groupName": "Група",
"contacts_groupNameRequired": "Името на групата е задължително.",
"contacts_groupNameReserved": "Това име на група е запазено",
"contacts_groupAlreadyExists": "Групата \"{name}\" вече съществува.",
"@contacts_groupAlreadyExists": {
"placeholders": {
@ -1863,5 +1864,162 @@
"usbConnectionFailed": "Неуспешно свързване през USB: {error}",
"usbStatus_notConnected": "Изберете USB устройство",
"usbStatus_searching": "Търсене на USB устройства...",
"usbErrorConnectTimedOut": "Връзката прекъсна. Уверете се, че устройството има софтуер за USB връзка."
"usbErrorConnectTimedOut": "Връзката прекъсна. Уверете се, че устройството има софтуер за USB връзка.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpHostHint": "192.168.40.10",
"tcpScreenTitle": "Свържете се чрез TCP",
"connectionChoiceTcpLabel": "TCP",
"tcpHostLabel": "IP адрес",
"tcpPortLabel": "Пристанище",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Въведете крайната точка и свържете се.",
"tcpStatus_connectingTo": "Свързване към {endpoint}...",
"tcpErrorHostRequired": "Необходим е IP адрес.",
"tcpErrorPortInvalid": "Портът трябва да бъде между 1 и 65535.",
"tcpErrorUnsupported": "Транспортът чрез TCP не се поддържа на тази платформа.",
"tcpErrorTimedOut": "Връзката TCP изтекла.",
"tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}",
"map_showDiscoveryContacts": "Покажи контакти за откриване",
"map_setAsMyLocation": "Задайте като моя местоположение",
"@path_routeWeight": {
"placeholders": {
"weight": {
"type": "String"
},
"max": {
"type": "String"
}
}
},
"settings_denyAll": "Откажи всичко",
"settings_allowAll": "Позволи всичко",
"settings_allowByContact": "Позволи по флагове за контакт",
"settings_privacy": "Настройки на поверителността",
"settings_privacySettingsDescription": "Изберете каква информация устройството ви споделя с другите.",
"settings_privacySubtitle": "Контролирайте каква информация се споделя.",
"settings_telemetryBaseMode": "Базов режим на телеметрия",
"settings_telemetryLocationMode": "Режим на местоположение на телеметрията",
"settings_advertLocation": "Място на обявата",
"settings_advertLocationSubtitle": "Включи местоположение в обявата",
"contact_info": "Контактна информация",
"settings_telemetryEnvironmentMode": "Режим на средата на телеметрията",
"contact_telemetry": "Телеметрия",
"contact_lastSeen": "Последно видян",
"contact_clearChat": "Изчисти чата",
"contact_teleBase": "Базата данни за телеметрия",
"contact_settings": "Настройки за контакти",
"contact_teleBaseSubtitle": "Позволи споделяне на ниво на батерията и основна телеметрия",
"contact_teleEnv": "Среда на телеметрия",
"contact_teleLocSubtitle": "Позволи споделяне на данни за местоположение",
"contact_teleLoc": "Местоположение на телеметрията",
"contact_teleEnvSubtitle": "Позволи споделяне на данни от средносферните датчици",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"appSettings_initialRouteWeight": "Първоначална тежест на маршрута",
"appSettings_maxRouteWeight": "Максимално допустимо тегло на маршрута",
"appSettings_initialRouteWeightSubtitle": "Начално тегло за новооткрити маршрути",
"appSettings_maxRouteWeightSubtitle": "Максималното тегло, което един маршрут може да събере от успешни доставки.",
"appSettings_routeWeightSuccessIncrement": "Увеличение на теглото за успех",
"appSettings_routeWeightSuccessIncrementSubtitle": "Тегло, добавено към път след успешно доставяне.",
"appSettings_routeWeightFailureDecrement": "Намаляване на теглото, свързано с неуспех",
"appSettings_routeWeightFailureDecrementSubtitle": "Тегло, което е било премахнато от пътя след неуспешен опит за доставка.",
"appSettings_maxMessageRetries": "Максимален брой опити за изпращане на съобщение",
"appSettings_maxMessageRetriesSubtitle": "Брой опити за повторно изпращане, преди съобщението да бъде маркирано като неуспешно.",
"path_routeWeight": "{weight}/{max}",
"settings_multiAck": "Мулти-потвърди: {value}",
"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} (оставете празно, ако няма)."
}

View file

@ -285,6 +285,7 @@
"contacts_newGroup": "Neue Gruppe",
"contacts_groupName": "Gruppenname",
"contacts_groupNameRequired": "Der Gruppennamen ist erforderlich.",
"contacts_groupNameReserved": "Dieser Gruppenname ist reserviert",
"contacts_groupAlreadyExists": "Die Gruppe \"{name}\" existiert bereits.",
"@contacts_groupAlreadyExists": {
"placeholders": {
@ -1891,5 +1892,162 @@
"usbStatus_notConnected": "Wählen Sie ein USB-Gerät aus",
"usbStatus_connecting": "Verbindung zum USB-Gerät...",
"usbConnectionFailed": "Fehler beim USB-Verbindungsaufbau: {error}",
"usbErrorConnectTimedOut": "Verbindung konnte nicht hergestellt werden. Stellen Sie sicher, dass das Gerät die entsprechende USB-Firmware enthält."
"usbErrorConnectTimedOut": "Verbindung konnte nicht hergestellt werden. Stellen Sie sicher, dass das Gerät die entsprechende USB-Firmware enthält.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpHostLabel": "IP-Adresse",
"connectionChoiceTcpLabel": "TCP",
"tcpHostHint": "192.168.40.10",
"tcpScreenTitle": "Verbinden über TCP",
"tcpPortLabel": "Port",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Geben Sie den Endpunkt ein und verbinden Sie sich.",
"tcpStatus_connectingTo": "Verbindung zu {endpoint}...",
"tcpErrorHostRequired": "Eine IP-Adresse ist erforderlich.",
"tcpErrorPortInvalid": "Die Portnummer muss zwischen 1 und 65535 liegen.",
"tcpErrorUnsupported": "Die TCP-Übertragung wird auf dieser Plattform nicht unterstützt.",
"tcpErrorTimedOut": "Die TCP-Verbindung ist abgelaufen.",
"tcpConnectionFailed": "Fehler beim TCP-Verbindungsaufbau: {error}",
"map_showDiscoveryContacts": "Entdeckungs-Kontakte anzeigen",
"map_setAsMyLocation": "Als meine aktuelle Position festlegen",
"@path_routeWeight": {
"placeholders": {
"weight": {
"type": "String"
},
"max": {
"type": "String"
}
}
},
"settings_allowByContact": "Zulassen durch Kontaktflaggen",
"settings_privacy": "Datenschutzeinstellungen",
"settings_allowAll": "Alles zulassen",
"settings_privacySettingsDescription": "Wählen Sie die Informationen, die Ihr Gerät mit anderen teilt.",
"settings_denyAll": "Alle ablehnen",
"settings_privacySubtitle": "Steuern Sie die Informationen, die freigegeben werden.",
"settings_telemetryLocationMode": "Telemetrie-Ortsmodus",
"settings_telemetryEnvironmentMode": "Telemetrie-Umgebungsmodus",
"settings_advertLocation": "Anzeigenort",
"settings_advertLocationSubtitle": "Ort in der Anzeige einbeziehen",
"settings_telemetryBaseMode": "Telemetrie-Basismodus",
"contact_teleBase": "Telemetriebasis",
"contact_teleBaseSubtitle": "Erlauben des Freigebens des Batteriestands und der grundlegenden Telemetrie",
"contact_teleLoc": "Telemetrieort",
"contact_teleLocSubtitle": "Teilen von Standortdaten zulassen",
"contact_info": "Kontaktinformationen",
"contact_settings": "Kontakteinstellungen",
"contact_telemetry": "Telemetrie",
"contact_teleEnv": "Telemetrieumgebung",
"contact_lastSeen": "Zuletzt gesehen",
"contact_clearChat": "Chat löschen",
"contact_teleEnvSubtitle": "Teilen von Umgebungsensordaten zulassen",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"appSettings_initialRouteWeightSubtitle": "Ausgangsgewicht für neu entdeckte Pfade",
"appSettings_maxRouteWeightSubtitle": "Maximales Gewicht, das ein Weg durch erfolgreiche Lieferungen erreichen kann.",
"appSettings_maxRouteWeight": "Maximale Gesamtstreckenlänge",
"appSettings_initialRouteWeight": "Anfangs-Streckengewicht",
"appSettings_routeWeightSuccessIncrement": "Erhöhung des Erfolgsgewichts",
"appSettings_routeWeightSuccessIncrementSubtitle": "Gewicht, das einem Pfad nach erfolgreicher Lieferung hinzugefügt wird.",
"appSettings_routeWeightFailureDecrement": "Reduzierung des Gewichts bei Fehlern",
"appSettings_routeWeightFailureDecrementSubtitle": "Gewicht, das nach einem fehlgeschlagenen Versand von einem Weg entfernt wurde",
"appSettings_maxMessageRetries": "Maximale Anzahl an Wiederholungsversuchen",
"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}",
"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)."
}

View file

@ -49,6 +49,33 @@
"scanner_title": "MeshCore Open",
"connectionChoiceUsbLabel": "USB",
"connectionChoiceBluetoothLabel": "Bluetooth",
"connectionChoiceTcpLabel": "TCP",
"tcpScreenTitle": "Connect over TCP",
"tcpHostLabel": "IP Address",
"tcpHostHint": "192.168.40.10",
"tcpPortLabel": "Port",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Enter endpoint and connect",
"tcpStatus_connectingTo": "Connecting to {endpoint}...",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"tcpErrorHostRequired": "IP address is required.",
"tcpErrorPortInvalid": "Port must be between 1 and 65535.",
"tcpErrorUnsupported": "TCP transport is not supported on this platform.",
"tcpErrorTimedOut": "TCP connection timed out.",
"tcpConnectionFailed": "TCP connection failed: {error}",
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"usbScreenTitle": "Connect over USB",
"usbScreenSubtitle": "Choose a detected serial device and connect directly to your MeshCore node.",
"usbScreenStatus": "Select a USB device",
@ -100,6 +127,7 @@
}
}
},
"scanner_stop": "Stop",
"scanner_scan": "Scan",
"scanner_bluetoothOff": "Bluetooth is off",
@ -139,6 +167,26 @@
"settings_privacyModeToggle": "Toggle privacy mode to hide your name and location in advertisements.",
"settings_privacyModeEnabled": "Privacy mode enabled",
"settings_privacyModeDisabled": "Privacy mode disabled",
"settings_privacy": "Privacy Settings",
"settings_privacySubtitle": "Control what information is shared.",
"settings_privacySettingsDescription": "Choose what information your device shares with others.",
"settings_denyAll": "Deny all",
"settings_allowByContact": "Allow by contact flags",
"settings_allowAll": "Allow all",
"settings_telemetryBaseMode": "Telemetry Base Mode",
"settings_telemetryLocationMode": "Telemetry Location Mode",
"settings_telemetryEnvironmentMode": "Telemetry Environment Mode",
"settings_advertLocation": "Advert Location",
"settings_advertLocationSubtitle": "Include location in advert.",
"settings_multiAck": "Multi-ACKs: {value}",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"settings_telemetryModeUpdated": "Telemetry mode updated",
"settings_actions": "Actions",
"settings_sendAdvertisement": "Send Advertisement",
"settings_sendAdvertisementSubtitle": "Broadcast presence now",
@ -242,6 +290,23 @@
"appSettings_autoRouteRotationSubtitle": "Cycle between best paths and flood mode",
"appSettings_autoRouteRotationEnabled": "Auto route rotation enabled",
"appSettings_autoRouteRotationDisabled": "Auto route rotation disabled",
"appSettings_maxRouteWeight": "Max Route Weight",
"appSettings_maxRouteWeightSubtitle": "Maximum weight a path can accumulate from successful deliveries",
"appSettings_initialRouteWeight": "Initial Route Weight",
"appSettings_initialRouteWeightSubtitle": "Starting weight for newly discovered paths",
"appSettings_routeWeightSuccessIncrement": "Success Weight Increment",
"appSettings_routeWeightSuccessIncrementSubtitle": "Weight added to a path after successful delivery",
"appSettings_routeWeightFailureDecrement": "Failure Weight Decrement",
"appSettings_routeWeightFailureDecrementSubtitle": "Weight removed from a path after failed delivery",
"appSettings_maxMessageRetries": "Max Message Retries",
"appSettings_maxMessageRetriesSubtitle": "Number of retry attempts before marking a message as failed",
"path_routeWeight": "{weight}/{max}",
"@path_routeWeight": {
"placeholders": {
"weight": { "type": "String" },
"max": { "type": "String" }
}
},
"appSettings_battery": "Battery",
"appSettings_batteryChemistry": "Battery Chemistry",
"appSettings_batteryChemistryPerDevice": "Set per device ({deviceName})",
@ -389,6 +454,7 @@
"contacts_newGroup": "New Group",
"contacts_groupName": "Group name",
"contacts_groupNameRequired": "Group name is required",
"contacts_groupNameReserved": "This group name is reserved",
"contacts_groupAlreadyExists": "Group \"{name}\" already exists",
"@contacts_groupAlreadyExists": {
"placeholders": {
@ -427,6 +493,17 @@
}
}
},
"contact_info": "Contact Info",
"contact_settings": "Contact Settings",
"contact_telemetry": "Telemetry",
"contact_lastSeen": "Last seen",
"contact_clearChat": "Clear Chat",
"contact_teleBase": "Telemetry Base",
"contact_teleBaseSubtitle": "Allow sharing battery level and basic telemetry",
"contact_teleLoc": "Telemetry Location",
"contact_teleLocSubtitle": "Allow sharing location data",
"contact_teleEnv": "Telemetry Environment",
"contact_teleEnvSubtitle": "Allow sharing environment sensor data",
"channels_title": "Channels",
"channels_noChannelsConfigured": "No channels configured",
"channels_addPublicChannel": "Add Public Channel",
@ -783,6 +860,7 @@
"map_source": "Source",
"map_flags": "Flags",
"map_shareMarkerHere": "Share marker here",
"map_setAsMyLocation": "Set as my location",
"map_pinLabel": "Pin label",
"map_label": "Label",
"map_pointOfInterest": "Point of interest",
@ -804,12 +882,14 @@
"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",
"map_markers": "Markers",
"map_showSharedMarkers": "Show shared markers",
"map_showGuessedLocations": "Show guessed node locations",
"map_showDiscoveryContacts": "Show Discovery Contacts",
"map_guessedLocation": "Guessed location",
"map_lastSeenTime": "Last Seen Time",
"map_sharedPin": "Shared pin",
@ -817,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",
@ -1901,5 +1982,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"
}
}
}
}

View file

@ -285,6 +285,7 @@
"contacts_newGroup": "Nuevo Grupo",
"contacts_groupName": "Nombre del grupo",
"contacts_groupNameRequired": "El nombre del grupo es obligatorio",
"contacts_groupNameReserved": "Este nombre de grupo está reservado",
"contacts_groupAlreadyExists": "El grupo \"{name}\" ya existe",
"@contacts_groupAlreadyExists": {
"placeholders": {
@ -1891,5 +1892,162 @@
"usbStatus_searching": "Buscando dispositivos USB...",
"usbStatus_notConnected": "Seleccione un dispositivo USB",
"usbConnectionFailed": "Error al conectar mediante USB: {error}",
"usbErrorConnectTimedOut": "La conexión ha caducado. Asegúrese de que el dispositivo tenga el firmware USB Companion."
"usbErrorConnectTimedOut": "La conexión ha caducado. Asegúrese de que el dispositivo tenga el firmware USB Companion.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpScreenTitle": "Establecer conexión a través de TCP",
"connectionChoiceTcpLabel": "TCP",
"tcpHostHint": "192.168.40.10",
"tcpHostLabel": "Dirección IP",
"tcpPortLabel": "Puerto",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Ingrese la dirección final y conecte.",
"tcpStatus_connectingTo": "Conectándose a {endpoint}...",
"tcpErrorHostRequired": "Se requiere la dirección IP.",
"tcpErrorPortInvalid": "El puerto debe estar entre 1 y 65535.",
"tcpErrorUnsupported": "El protocolo de transporte TCP no está soportado en esta plataforma.",
"tcpErrorTimedOut": "La conexión TCP ha caducado.",
"tcpConnectionFailed": "Error en la conexión TCP: {error}",
"map_showDiscoveryContacts": "Mostrar Contactos de Descubrimiento",
"map_setAsMyLocation": "Establecer mi ubicación",
"@path_routeWeight": {
"placeholders": {
"weight": {
"type": "String"
},
"max": {
"type": "String"
}
}
},
"settings_privacySubtitle": "Controlar qué información se comparte.",
"settings_allowByContact": "Permitir por banderas de contacto",
"settings_denyAll": "Denegar todo",
"settings_telemetryBaseMode": "Modo base de telemetría",
"settings_telemetryEnvironmentMode": "Modo de entorno de telemetría",
"settings_advertLocationSubtitle": "Incluir ubicación en anuncio",
"contact_info": "Información de contacto",
"settings_privacySettingsDescription": "Elige qué información comparte tu dispositivo con otros.",
"settings_allowAll": "Permitir todo",
"settings_privacy": "Configuración de privacidad",
"contact_settings": "Configuración de contacto",
"settings_telemetryLocationMode": "Modo de ubicación de telemetría",
"contact_teleBase": "Base de Telemetría",
"contact_teleLoc": "Ubicación de telemetría",
"settings_advertLocation": "Ubicación de anuncio",
"contact_teleLocSubtitle": "Permitir el intercambio de datos de ubicación",
"contact_clearChat": "Borrar chat",
"contact_telemetry": "Telemetría",
"contact_lastSeen": "Visto por última vez",
"contact_teleBaseSubtitle": "Permitir el intercambio de nivel de batería y telemetría básica",
"contact_teleEnv": "Entorno de Telemetría",
"contact_teleEnvSubtitle": "Permitir el intercambio de datos de sensores de entorno",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"appSettings_initialRouteWeight": "Peso inicial de la ruta",
"appSettings_maxRouteWeight": "Peso máximo permitido para la ruta",
"appSettings_initialRouteWeightSubtitle": "Peso inicial para rutas recién descubiertas",
"appSettings_maxRouteWeightSubtitle": "Peso máximo que una ruta puede acumular gracias a entregas exitosas.",
"appSettings_routeWeightSuccessIncrement": "Incremento de peso para el éxito",
"appSettings_routeWeightSuccessIncrementSubtitle": "Peso añadido a una ruta después de una entrega exitosa.",
"appSettings_routeWeightFailureDecrement": "Reducción del peso asociado al fallo",
"appSettings_routeWeightFailureDecrementSubtitle": "Peso retirado de un camino después de un intento de entrega fallido.",
"appSettings_maxMessageRetries": "Número máximo de reintentos de envío de mensajes",
"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}",
"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)."
}

View file

@ -285,6 +285,7 @@
"contacts_newGroup": "Nouveau Groupe",
"contacts_groupName": "Nom du groupe",
"contacts_groupNameRequired": "Le nom du groupe est obligatoire.",
"contacts_groupNameReserved": "Ce nom de groupe est réservé",
"contacts_groupAlreadyExists": "Le groupe \"{name}\" existe déjà.",
"@contacts_groupAlreadyExists": {
"placeholders": {
@ -1863,5 +1864,162 @@
"usbConnectionFailed": "Échec de la connexion USB : {error}",
"usbStatus_connecting": "Connexion au périphérique USB...",
"usbStatus_searching": "Recherche de périphériques USB...",
"usbErrorConnectTimedOut": "La connexion a expiré. Assurez-vous que l'appareil dispose du firmware USB Companion."
"usbErrorConnectTimedOut": "La connexion a expiré. Assurez-vous que l'appareil dispose du firmware USB Companion.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpHostLabel": "Adresse IP",
"connectionChoiceTcpLabel": "TCP",
"tcpScreenTitle": "Établir une connexion via TCP",
"tcpHostHint": "192.168.40.10",
"tcpPortLabel": "Port",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Entrez l'adresse de destination et connectez-vous.",
"tcpStatus_connectingTo": "Connexion à {endpoint}...",
"tcpErrorHostRequired": "Une adresse IP est obligatoire.",
"tcpErrorPortInvalid": "La taille du port doit être comprise entre 1 et 65535.",
"tcpErrorUnsupported": "Le protocole TCP n'est pas pris en charge sur cette plateforme.",
"tcpErrorTimedOut": "La connexion TCP a expiré.",
"tcpConnectionFailed": "Échec de la connexion TCP : {error}",
"map_showDiscoveryContacts": "Afficher les contacts de découverte",
"map_setAsMyLocation": "Définir comme ma localisation",
"@path_routeWeight": {
"placeholders": {
"weight": {
"type": "String"
},
"max": {
"type": "String"
}
}
},
"settings_privacy": "Paramètres de confidentialité",
"settings_privacySubtitle": "Contrôlez les informations partagées",
"settings_telemetryLocationMode": "Mode d'emplacement de télémétrie",
"settings_telemetryEnvironmentMode": "Mode d'environnement de télémétrie",
"settings_advertLocation": "Emplacement de l'annonce",
"settings_advertLocationSubtitle": "Inclure l'emplacement dans l'annonce",
"settings_denyAll": "Refuser tout",
"settings_allowByContact": "Autoriser par drapeaux de contact",
"settings_privacySettingsDescription": "Choisissez les informations que votre appareil partage avec les autres.",
"settings_allowAll": "Autoriser tout",
"contact_info": "Informations de contact",
"settings_telemetryBaseMode": "Mode de base Télémétrie",
"contact_teleBase": "Base de télémétrie",
"contact_teleLoc": "Emplacement de télémétrie",
"contact_teleLocSubtitle": "Autoriser le partage des données de localisation",
"contact_teleEnv": "Environnement Télémétrie",
"contact_teleEnvSubtitle": "Autoriser le partage des données des capteurs d'environnement",
"contact_telemetry": "Télémétrie",
"contact_settings": "Paramètres de contact",
"contact_lastSeen": "Dernière fois vu",
"contact_clearChat": "Effacer la conversation",
"contact_teleBaseSubtitle": "Autoriser le partage du niveau de batterie et de la télémétrie de base",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"appSettings_maxRouteWeightSubtitle": "Poids maximal qu'un itinéraire peut accumuler grâce à des livraisons réussies.",
"appSettings_initialRouteWeight": "Poids initial de l'itinéraire",
"appSettings_maxRouteWeight": "Poids maximal autorisé pour le trajet",
"appSettings_initialRouteWeightSubtitle": "Poids de départ pour les nouveaux chemins découverts",
"appSettings_routeWeightSuccessIncrement": "Augmentation du poids de réussite",
"appSettings_routeWeightSuccessIncrementSubtitle": "Poids ajouté à un itinéraire après une livraison réussie.",
"appSettings_routeWeightFailureDecrement": "Réduction du poids de pénalité",
"appSettings_routeWeightFailureDecrementSubtitle": "Poids retiré d'un itinéraire après une tentative de livraison infructueuse.",
"appSettings_maxMessageRetries": "Nombre maximal de tentatives de récupération de messages",
"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",
"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 dappairage Bluetooth",
"scanner_linuxPairingPinPrompt": "Entrez le code PIN pour {deviceName} (laissez vide si aucun)."
}

2059
lib/l10n/app_hu.arb Normal file

File diff suppressed because it is too large Load diff

View file

@ -285,6 +285,7 @@
"contacts_newGroup": "Nuovo Gruppo",
"contacts_groupName": "Nome gruppo",
"contacts_groupNameRequired": "Il nome del gruppo è obbligatorio.",
"contacts_groupNameReserved": "Questo nome del gruppo è riservato",
"contacts_groupAlreadyExists": "Il gruppo \"{name}\" esiste già.",
"@contacts_groupAlreadyExists": {
"placeholders": {
@ -1863,5 +1864,162 @@
"usbConnectionFailed": "Errore nella connessione USB: {error}",
"usbStatus_notConnected": "Seleziona un dispositivo USB",
"usbStatus_connecting": "Connessione al dispositivo USB...",
"usbErrorConnectTimedOut": "La connessione è scaduta. Assicurarsi che il dispositivo abbia il firmware USB Companion."
"usbErrorConnectTimedOut": "La connessione è scaduta. Assicurarsi che il dispositivo abbia il firmware USB Companion.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpHostLabel": "Indirizzo IP",
"tcpHostHint": "192.168.40.10",
"connectionChoiceTcpLabel": "TCP",
"tcpScreenTitle": "Stabilire una connessione tramite TCP",
"tcpPortLabel": "Porta",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Inserisci l'endpoint e connettiti.",
"tcpStatus_connectingTo": "Connessione a {endpoint}...",
"tcpErrorHostRequired": "È necessario fornire un indirizzo IP.",
"tcpErrorPortInvalid": "La dimensione della porta deve essere compresa tra 1 e 65535.",
"tcpErrorUnsupported": "Il protocollo TCP non è supportato su questa piattaforma.",
"tcpErrorTimedOut": "La connessione TCP è scaduta.",
"tcpConnectionFailed": "Impossibile stabilire la connessione TCP: {error}",
"map_showDiscoveryContacts": "Mostra Contatti di Discovery",
"map_setAsMyLocation": "Imposta come la mia posizione",
"@path_routeWeight": {
"placeholders": {
"weight": {
"type": "String"
},
"max": {
"type": "String"
}
}
},
"settings_privacySettingsDescription": "Scegli le informazioni che il tuo dispositivo condivide con gli altri.",
"settings_allowByContact": "Consenti in base ai flag di contatto",
"settings_telemetryLocationMode": "Modalità di posizionamento telemetrico",
"settings_telemetryEnvironmentMode": "Modalità di ambiente di telemetria",
"settings_advertLocation": "Posizione dell'annuncio",
"settings_advertLocationSubtitle": "Includi la posizione nell'annuncio",
"settings_privacy": "Impostazioni sulla privacy",
"settings_denyAll": "Negare tutto",
"settings_privacySubtitle": "Controlla le informazioni che vengono condivise.",
"settings_allowAll": "Consenti tutto",
"contact_info": "Informazioni di Contatto",
"settings_telemetryBaseMode": "Modalità di base di telemetria",
"contact_teleBase": "Base di telemetria",
"contact_teleLoc": "Posizione telemetria",
"contact_teleLocSubtitle": "Consenti la condivisione dei dati di posizione",
"contact_clearChat": "Cancella chat",
"contact_telemetry": "Telemetria",
"contact_settings": "Impostazioni di contatto",
"contact_lastSeen": "Ultimo accesso",
"contact_teleBaseSubtitle": "Consenti la condivisione del livello della batteria e della telemetria di base",
"contact_teleEnvSubtitle": "Consenti la condivisione dei dati del sensore ambientale",
"contact_teleEnv": "Ambiente di telemetria",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"appSettings_initialRouteWeight": "Peso iniziale del percorso",
"appSettings_initialRouteWeightSubtitle": "Peso di partenza per nuovi percorsi",
"appSettings_maxRouteWeightSubtitle": "Il peso massimo che un percorso può accumulare grazie a consegne di successo.",
"appSettings_maxRouteWeight": "Massimo peso consentito per il percorso",
"appSettings_routeWeightSuccessIncrement": "Aumento del peso del successo",
"appSettings_routeWeightSuccessIncrementSubtitle": "Peso aggiunto a un percorso dopo una consegna riuscita.",
"appSettings_routeWeightFailureDecrement": "Riduzione del peso associato al fallimento",
"appSettings_routeWeightFailureDecrementSubtitle": "Peso rimosso da un percorso dopo un tentativo di consegna fallito.",
"appSettings_maxMessageRetries": "Numero massimo di tentativi di invio del messaggio",
"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}",
"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'è)."
}

2059
lib/l10n/app_ja.arb Normal file

File diff suppressed because it is too large Load diff

2059
lib/l10n/app_ko.arb Normal file

File diff suppressed because it is too large Load diff

View file

@ -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'),
@ -334,6 +340,84 @@ abstract class AppLocalizations {
/// **'Bluetooth'**
String get connectionChoiceBluetoothLabel;
/// No description provided for @connectionChoiceTcpLabel.
///
/// In en, this message translates to:
/// **'TCP'**
String get connectionChoiceTcpLabel;
/// No description provided for @tcpScreenTitle.
///
/// In en, this message translates to:
/// **'Connect over TCP'**
String get tcpScreenTitle;
/// No description provided for @tcpHostLabel.
///
/// In en, this message translates to:
/// **'IP Address'**
String get tcpHostLabel;
/// No description provided for @tcpHostHint.
///
/// In en, this message translates to:
/// **'192.168.40.10'**
String get tcpHostHint;
/// No description provided for @tcpPortLabel.
///
/// In en, this message translates to:
/// **'Port'**
String get tcpPortLabel;
/// No description provided for @tcpPortHint.
///
/// In en, this message translates to:
/// **'5000'**
String get tcpPortHint;
/// No description provided for @tcpStatus_notConnected.
///
/// In en, this message translates to:
/// **'Enter endpoint and connect'**
String get tcpStatus_notConnected;
/// No description provided for @tcpStatus_connectingTo.
///
/// In en, this message translates to:
/// **'Connecting to {endpoint}...'**
String tcpStatus_connectingTo(String endpoint);
/// No description provided for @tcpErrorHostRequired.
///
/// In en, this message translates to:
/// **'IP address is required.'**
String get tcpErrorHostRequired;
/// No description provided for @tcpErrorPortInvalid.
///
/// In en, this message translates to:
/// **'Port must be between 1 and 65535.'**
String get tcpErrorPortInvalid;
/// No description provided for @tcpErrorUnsupported.
///
/// In en, this message translates to:
/// **'TCP transport is not supported on this platform.'**
String get tcpErrorUnsupported;
/// No description provided for @tcpErrorTimedOut.
///
/// In en, this message translates to:
/// **'TCP connection timed out.'**
String get tcpErrorTimedOut;
/// No description provided for @tcpConnectionFailed.
///
/// In en, this message translates to:
/// **'TCP connection failed: {error}'**
String tcpConnectionFailed(String error);
/// No description provided for @usbScreenTitle.
///
/// In en, this message translates to:
@ -748,6 +832,84 @@ abstract class AppLocalizations {
/// **'Privacy mode disabled'**
String get settings_privacyModeDisabled;
/// No description provided for @settings_privacy.
///
/// In en, this message translates to:
/// **'Privacy Settings'**
String get settings_privacy;
/// No description provided for @settings_privacySubtitle.
///
/// In en, this message translates to:
/// **'Control what information is shared.'**
String get settings_privacySubtitle;
/// No description provided for @settings_privacySettingsDescription.
///
/// In en, this message translates to:
/// **'Choose what information your device shares with others.'**
String get settings_privacySettingsDescription;
/// No description provided for @settings_denyAll.
///
/// In en, this message translates to:
/// **'Deny all'**
String get settings_denyAll;
/// No description provided for @settings_allowByContact.
///
/// In en, this message translates to:
/// **'Allow by contact flags'**
String get settings_allowByContact;
/// No description provided for @settings_allowAll.
///
/// In en, this message translates to:
/// **'Allow all'**
String get settings_allowAll;
/// No description provided for @settings_telemetryBaseMode.
///
/// In en, this message translates to:
/// **'Telemetry Base Mode'**
String get settings_telemetryBaseMode;
/// No description provided for @settings_telemetryLocationMode.
///
/// In en, this message translates to:
/// **'Telemetry Location Mode'**
String get settings_telemetryLocationMode;
/// No description provided for @settings_telemetryEnvironmentMode.
///
/// In en, this message translates to:
/// **'Telemetry Environment Mode'**
String get settings_telemetryEnvironmentMode;
/// No description provided for @settings_advertLocation.
///
/// In en, this message translates to:
/// **'Advert Location'**
String get settings_advertLocation;
/// No description provided for @settings_advertLocationSubtitle.
///
/// In en, this message translates to:
/// **'Include location in advert.'**
String get settings_advertLocationSubtitle;
/// No description provided for @settings_multiAck.
///
/// In en, this message translates to:
/// **'Multi-ACKs: {value}'**
String settings_multiAck(String value);
/// No description provided for @settings_telemetryModeUpdated.
///
/// In en, this message translates to:
/// **'Telemetry mode updated'**
String get settings_telemetryModeUpdated;
/// No description provided for @settings_actions.
///
/// In en, this message translates to:
@ -1282,6 +1444,72 @@ abstract class AppLocalizations {
/// **'Auto route rotation disabled'**
String get appSettings_autoRouteRotationDisabled;
/// No description provided for @appSettings_maxRouteWeight.
///
/// In en, this message translates to:
/// **'Max Route Weight'**
String get appSettings_maxRouteWeight;
/// No description provided for @appSettings_maxRouteWeightSubtitle.
///
/// In en, this message translates to:
/// **'Maximum weight a path can accumulate from successful deliveries'**
String get appSettings_maxRouteWeightSubtitle;
/// No description provided for @appSettings_initialRouteWeight.
///
/// In en, this message translates to:
/// **'Initial Route Weight'**
String get appSettings_initialRouteWeight;
/// No description provided for @appSettings_initialRouteWeightSubtitle.
///
/// In en, this message translates to:
/// **'Starting weight for newly discovered paths'**
String get appSettings_initialRouteWeightSubtitle;
/// No description provided for @appSettings_routeWeightSuccessIncrement.
///
/// In en, this message translates to:
/// **'Success Weight Increment'**
String get appSettings_routeWeightSuccessIncrement;
/// No description provided for @appSettings_routeWeightSuccessIncrementSubtitle.
///
/// In en, this message translates to:
/// **'Weight added to a path after successful delivery'**
String get appSettings_routeWeightSuccessIncrementSubtitle;
/// No description provided for @appSettings_routeWeightFailureDecrement.
///
/// In en, this message translates to:
/// **'Failure Weight Decrement'**
String get appSettings_routeWeightFailureDecrement;
/// No description provided for @appSettings_routeWeightFailureDecrementSubtitle.
///
/// In en, this message translates to:
/// **'Weight removed from a path after failed delivery'**
String get appSettings_routeWeightFailureDecrementSubtitle;
/// No description provided for @appSettings_maxMessageRetries.
///
/// In en, this message translates to:
/// **'Max Message Retries'**
String get appSettings_maxMessageRetries;
/// No description provided for @appSettings_maxMessageRetriesSubtitle.
///
/// In en, this message translates to:
/// **'Number of retry attempts before marking a message as failed'**
String get appSettings_maxMessageRetriesSubtitle;
/// No description provided for @path_routeWeight.
///
/// In en, this message translates to:
/// **'{weight}/{max}'**
String path_routeWeight(String weight, String max);
/// No description provided for @appSettings_battery.
///
/// In en, this message translates to:
@ -1636,6 +1864,12 @@ abstract class AppLocalizations {
/// **'Group name is required'**
String get contacts_groupNameRequired;
/// No description provided for @contacts_groupNameReserved.
///
/// In en, this message translates to:
/// **'This group name is reserved'**
String get contacts_groupNameReserved;
/// No description provided for @contacts_groupAlreadyExists.
///
/// In en, this message translates to:
@ -1696,6 +1930,72 @@ abstract class AppLocalizations {
/// **'~ {days} days'**
String contacts_lastSeenDaysAgo(int days);
/// No description provided for @contact_info.
///
/// In en, this message translates to:
/// **'Contact Info'**
String get contact_info;
/// No description provided for @contact_settings.
///
/// In en, this message translates to:
/// **'Contact Settings'**
String get contact_settings;
/// No description provided for @contact_telemetry.
///
/// In en, this message translates to:
/// **'Telemetry'**
String get contact_telemetry;
/// No description provided for @contact_lastSeen.
///
/// In en, this message translates to:
/// **'Last seen'**
String get contact_lastSeen;
/// No description provided for @contact_clearChat.
///
/// In en, this message translates to:
/// **'Clear Chat'**
String get contact_clearChat;
/// No description provided for @contact_teleBase.
///
/// In en, this message translates to:
/// **'Telemetry Base'**
String get contact_teleBase;
/// No description provided for @contact_teleBaseSubtitle.
///
/// In en, this message translates to:
/// **'Allow sharing battery level and basic telemetry'**
String get contact_teleBaseSubtitle;
/// No description provided for @contact_teleLoc.
///
/// In en, this message translates to:
/// **'Telemetry Location'**
String get contact_teleLoc;
/// No description provided for @contact_teleLocSubtitle.
///
/// In en, this message translates to:
/// **'Allow sharing location data'**
String get contact_teleLocSubtitle;
/// No description provided for @contact_teleEnv.
///
/// In en, this message translates to:
/// **'Telemetry Environment'**
String get contact_teleEnv;
/// No description provided for @contact_teleEnvSubtitle.
///
/// In en, this message translates to:
/// **'Allow sharing environment sensor data'**
String get contact_teleEnvSubtitle;
/// No description provided for @channels_title.
///
/// In en, this message translates to:
@ -2686,6 +2986,12 @@ abstract class AppLocalizations {
/// **'Share marker here'**
String get map_shareMarkerHere;
/// No description provided for @map_setAsMyLocation.
///
/// In en, this message translates to:
/// **'Set as my location'**
String get map_setAsMyLocation;
/// No description provided for @map_pinLabel.
///
/// In en, this message translates to:
@ -2770,6 +3076,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:
@ -2806,6 +3118,12 @@ abstract class AppLocalizations {
/// **'Show guessed node locations'**
String get map_showGuessedLocations;
/// No description provided for @map_showDiscoveryContacts.
///
/// In en, this message translates to:
/// **'Show Discovery Contacts'**
String get map_showDiscoveryContacts;
/// No description provided for @map_guessedLocation.
///
/// In en, this message translates to:
@ -2851,9 +3169,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:
@ -5722,6 +6046,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
@ -5740,7 +6214,10 @@ class _AppLocalizationsDelegate
'en',
'es',
'fr',
'hu',
'it',
'ja',
'ko',
'nl',
'pl',
'pt',
@ -5769,8 +6246,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':

View file

@ -117,6 +117,50 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Свържете се чрез TCP';
@override
String get tcpHostLabel => 'IP адрес';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Пристанище';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Въведете крайната точка и свържете се.';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Свързване към $endpoint...';
}
@override
String get tcpErrorHostRequired => 'Необходим е IP адрес.';
@override
String get tcpErrorPortInvalid => 'Портът трябва да бъде между 1 и 65535.';
@override
String get tcpErrorUnsupported =>
'Транспортът чрез TCP не се поддържа на тази платформа.';
@override
String get tcpErrorTimedOut => 'Връзката TCP изтекла.';
@override
String tcpConnectionFailed(String error) {
return 'Неуспешно е установено TCP връзката: $error';
}
@override
String get usbScreenTitle => 'Свържете се чрез USB';
@ -354,6 +398,52 @@ class AppLocalizationsBg extends AppLocalizations {
String get settings_privacyModeDisabled =>
'Режим на поверителност е деактивиран';
@override
String get settings_privacy => 'Настройки на поверителността';
@override
String get settings_privacySubtitle =>
'Контролирайте каква информация се споделя.';
@override
String get settings_privacySettingsDescription =>
'Изберете каква информация устройството ви споделя с другите.';
@override
String get settings_denyAll => 'Откажи всичко';
@override
String get settings_allowByContact => 'Позволи по флагове за контакт';
@override
String get settings_allowAll => 'Позволи всичко';
@override
String get settings_telemetryBaseMode => 'Базов режим на телеметрия';
@override
String get settings_telemetryLocationMode =>
'Режим на местоположение на телеметрията';
@override
String get settings_telemetryEnvironmentMode =>
'Режим на средата на телеметрията';
@override
String get settings_advertLocation => 'Място на обявата';
@override
String get settings_advertLocationSubtitle =>
'Включи местоположение в обявата';
@override
String settings_multiAck(String value) {
return 'Мулти-потвърди: $value';
}
@override
String get settings_telemetryModeUpdated => 'Режим на телеметрията е обновен';
@override
String get settings_actions => 'Действия';
@ -651,6 +741,51 @@ class AppLocalizationsBg extends AppLocalizations {
String get appSettings_autoRouteRotationDisabled =>
'Автоматично маршрутизирането е деактивирано';
@override
String get appSettings_maxRouteWeight =>
'Максимално допустимо тегло на маршрута';
@override
String get appSettings_maxRouteWeightSubtitle =>
'Максималното тегло, което един маршрут може да събере от успешни доставки.';
@override
String get appSettings_initialRouteWeight =>
'Първоначална тежест на маршрута';
@override
String get appSettings_initialRouteWeightSubtitle =>
'Начално тегло за новооткрити маршрути';
@override
String get appSettings_routeWeightSuccessIncrement =>
'Увеличение на теглото за успех';
@override
String get appSettings_routeWeightSuccessIncrementSubtitle =>
'Тегло, добавено към път след успешно доставяне.';
@override
String get appSettings_routeWeightFailureDecrement =>
'Намаляване на теглото, свързано с неуспех';
@override
String get appSettings_routeWeightFailureDecrementSubtitle =>
'Тегло, което е било премахнато от пътя след неуспешен опит за доставка.';
@override
String get appSettings_maxMessageRetries =>
'Максимален брой опити за изпращане на съобщение';
@override
String get appSettings_maxMessageRetriesSubtitle =>
'Брой опити за повторно изпращане, преди съобщението да бъде маркирано като неуспешно.';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override
String get appSettings_battery => 'Батерия';
@ -858,6 +993,9 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get contacts_groupNameRequired => 'Името на групата е задължително.';
@override
String get contacts_groupNameReserved => 'Това име на група е запазено';
@override
String contacts_groupAlreadyExists(String name) {
return 'Групата \"$name\" вече съществува.';
@ -897,6 +1035,42 @@ class AppLocalizationsBg extends AppLocalizations {
return 'Последно видян $days дни преди.';
}
@override
String get contact_info => 'Контактна информация';
@override
String get contact_settings => 'Настройки за контакти';
@override
String get contact_telemetry => 'Телеметрия';
@override
String get contact_lastSeen => 'Последно видян';
@override
String get contact_clearChat => 'Изчисти чата';
@override
String get contact_teleBase => 'Базата данни за телеметрия';
@override
String get contact_teleBaseSubtitle =>
'Позволи споделяне на ниво на батерията и основна телеметрия';
@override
String get contact_teleLoc => 'Местоположение на телеметрията';
@override
String get contact_teleLocSubtitle =>
'Позволи споделяне на данни за местоположение';
@override
String get contact_teleEnv => 'Среда на телеметрия';
@override
String get contact_teleEnvSubtitle =>
'Позволи споделяне на данни от средносферните датчици';
@override
String get channels_title => 'Канали';
@ -1476,6 +1650,9 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get map_shareMarkerHere => 'Споделете маркер тук';
@override
String get map_setAsMyLocation => 'Задайте като моя местоположение';
@override
String get map_pinLabel => 'Етикетиране на пин';
@ -1521,6 +1698,9 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get map_otherNodes => 'Други възли';
@override
String get map_showOverlaps => 'Покриване на ключа на повтаряча';
@override
String get map_keyPrefix => 'Префикс на ключа';
@ -1540,6 +1720,9 @@ class AppLocalizationsBg extends AppLocalizations {
String get map_showGuessedLocations =>
'Покажете местоположенията на предположените възли.';
@override
String get map_showDiscoveryContacts => 'Покажи контакти за откриване';
@override
String get map_guessedLocation => 'Предполагано местоположение';
@ -1565,6 +1748,9 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get map_runTrace => 'Изпълни Път на Следване';
@override
String get map_runTraceWithReturnPath => 'Върни се по същия път.';
@override
String get map_removeLast => 'Премахни Последно';
@ -3310,4 +3496,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 (оставете празно, ако няма).';
}
}

View file

@ -117,6 +117,52 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Verbinden über TCP';
@override
String get tcpHostLabel => 'IP-Adresse';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Port';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected =>
'Geben Sie den Endpunkt ein und verbinden Sie sich.';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Verbindung zu $endpoint...';
}
@override
String get tcpErrorHostRequired => 'Eine IP-Adresse ist erforderlich.';
@override
String get tcpErrorPortInvalid =>
'Die Portnummer muss zwischen 1 und 65535 liegen.';
@override
String get tcpErrorUnsupported =>
'Die TCP-Übertragung wird auf dieser Plattform nicht unterstützt.';
@override
String get tcpErrorTimedOut => 'Die TCP-Verbindung ist abgelaufen.';
@override
String tcpConnectionFailed(String error) {
return 'Fehler beim TCP-Verbindungsaufbau: $error';
}
@override
String get usbScreenTitle => 'Verbinden über USB';
@ -352,6 +398,50 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get settings_privacyModeDisabled => 'Datenschutzmodus deaktiviert';
@override
String get settings_privacy => 'Datenschutzeinstellungen';
@override
String get settings_privacySubtitle =>
'Steuern Sie die Informationen, die freigegeben werden.';
@override
String get settings_privacySettingsDescription =>
'Wählen Sie die Informationen, die Ihr Gerät mit anderen teilt.';
@override
String get settings_denyAll => 'Alle ablehnen';
@override
String get settings_allowByContact => 'Zulassen durch Kontaktflaggen';
@override
String get settings_allowAll => 'Alles zulassen';
@override
String get settings_telemetryBaseMode => 'Telemetrie-Basismodus';
@override
String get settings_telemetryLocationMode => 'Telemetrie-Ortsmodus';
@override
String get settings_telemetryEnvironmentMode => 'Telemetrie-Umgebungsmodus';
@override
String get settings_advertLocation => 'Anzeigenort';
@override
String get settings_advertLocationSubtitle =>
'Ort in der Anzeige einbeziehen';
@override
String settings_multiAck(String value) {
return 'Mehrfach-Bestätigungen: $value';
}
@override
String get settings_telemetryModeUpdated => 'Telemetriemodus aktualisiert';
@override
String get settings_actions => 'Aktionen';
@ -649,6 +739,49 @@ class AppLocalizationsDe extends AppLocalizations {
String get appSettings_autoRouteRotationDisabled =>
'Automatische Routenrotation deaktiviert';
@override
String get appSettings_maxRouteWeight => 'Maximale Gesamtstreckenlänge';
@override
String get appSettings_maxRouteWeightSubtitle =>
'Maximales Gewicht, das ein Weg durch erfolgreiche Lieferungen erreichen kann.';
@override
String get appSettings_initialRouteWeight => 'Anfangs-Streckengewicht';
@override
String get appSettings_initialRouteWeightSubtitle =>
'Ausgangsgewicht für neu entdeckte Pfade';
@override
String get appSettings_routeWeightSuccessIncrement =>
'Erhöhung des Erfolgsgewichts';
@override
String get appSettings_routeWeightSuccessIncrementSubtitle =>
'Gewicht, das einem Pfad nach erfolgreicher Lieferung hinzugefügt wird.';
@override
String get appSettings_routeWeightFailureDecrement =>
'Reduzierung des Gewichts bei Fehlern';
@override
String get appSettings_routeWeightFailureDecrementSubtitle =>
'Gewicht, das nach einem fehlgeschlagenen Versand von einem Weg entfernt wurde';
@override
String get appSettings_maxMessageRetries =>
'Maximale Anzahl an Wiederholungsversuchen';
@override
String get appSettings_maxMessageRetriesSubtitle =>
'Anzahl der Versuche, eine Nachricht erneut zu senden, bevor sie als fehlgeschlagen markiert wird.';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override
String get appSettings_battery => 'Akku';
@ -856,6 +989,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get contacts_groupNameRequired => 'Der Gruppennamen ist erforderlich.';
@override
String get contacts_groupNameReserved => 'Dieser Gruppenname ist reserviert';
@override
String contacts_groupAlreadyExists(String name) {
return 'Die Gruppe \"$name\" existiert bereits.';
@ -895,6 +1031,41 @@ class AppLocalizationsDe extends AppLocalizations {
return '~ $days Tage';
}
@override
String get contact_info => 'Kontaktinformationen';
@override
String get contact_settings => 'Kontakteinstellungen';
@override
String get contact_telemetry => 'Telemetrie';
@override
String get contact_lastSeen => 'Zuletzt gesehen';
@override
String get contact_clearChat => 'Chat löschen';
@override
String get contact_teleBase => 'Telemetriebasis';
@override
String get contact_teleBaseSubtitle =>
'Erlauben des Freigebens des Batteriestands und der grundlegenden Telemetrie';
@override
String get contact_teleLoc => 'Telemetrieort';
@override
String get contact_teleLocSubtitle => 'Teilen von Standortdaten zulassen';
@override
String get contact_teleEnv => 'Telemetrieumgebung';
@override
String get contact_teleEnvSubtitle =>
'Teilen von Umgebungsensordaten zulassen';
@override
String get channels_title => 'Kanäle';
@ -1476,6 +1647,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get map_shareMarkerHere => 'Teilen Sie den Marker hier.';
@override
String get map_setAsMyLocation => 'Als meine aktuelle Position festlegen';
@override
String get map_pinLabel => 'Pin Name';
@ -1521,6 +1695,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';
@ -1540,6 +1717,9 @@ class AppLocalizationsDe extends AppLocalizations {
String get map_showGuessedLocations =>
'Zeige die vermuteten Knotenpositionen';
@override
String get map_showDiscoveryContacts => 'Entdeckungs-Kontakte anzeigen';
@override
String get map_guessedLocation => 'Geschätzter Ort';
@ -1565,6 +1745,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';
@ -3322,4 +3506,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).';
}
}

View file

@ -117,6 +117,50 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Connect over TCP';
@override
String get tcpHostLabel => 'IP Address';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Port';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Enter endpoint and connect';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Connecting to $endpoint...';
}
@override
String get tcpErrorHostRequired => 'IP address is required.';
@override
String get tcpErrorPortInvalid => 'Port must be between 1 and 65535.';
@override
String get tcpErrorUnsupported =>
'TCP transport is not supported on this platform.';
@override
String get tcpErrorTimedOut => 'TCP connection timed out.';
@override
String tcpConnectionFailed(String error) {
return 'TCP connection failed: $error';
}
@override
String get usbScreenTitle => 'Connect over USB';
@ -348,6 +392,48 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settings_privacyModeDisabled => 'Privacy mode disabled';
@override
String get settings_privacy => 'Privacy Settings';
@override
String get settings_privacySubtitle => 'Control what information is shared.';
@override
String get settings_privacySettingsDescription =>
'Choose what information your device shares with others.';
@override
String get settings_denyAll => 'Deny all';
@override
String get settings_allowByContact => 'Allow by contact flags';
@override
String get settings_allowAll => 'Allow all';
@override
String get settings_telemetryBaseMode => 'Telemetry Base Mode';
@override
String get settings_telemetryLocationMode => 'Telemetry Location Mode';
@override
String get settings_telemetryEnvironmentMode => 'Telemetry Environment Mode';
@override
String get settings_advertLocation => 'Advert Location';
@override
String get settings_advertLocationSubtitle => 'Include location in advert.';
@override
String settings_multiAck(String value) {
return 'Multi-ACKs: $value';
}
@override
String get settings_telemetryModeUpdated => 'Telemetry mode updated';
@override
String get settings_actions => 'Actions';
@ -640,6 +726,48 @@ class AppLocalizationsEn extends AppLocalizations {
String get appSettings_autoRouteRotationDisabled =>
'Auto route rotation disabled';
@override
String get appSettings_maxRouteWeight => 'Max Route Weight';
@override
String get appSettings_maxRouteWeightSubtitle =>
'Maximum weight a path can accumulate from successful deliveries';
@override
String get appSettings_initialRouteWeight => 'Initial Route Weight';
@override
String get appSettings_initialRouteWeightSubtitle =>
'Starting weight for newly discovered paths';
@override
String get appSettings_routeWeightSuccessIncrement =>
'Success Weight Increment';
@override
String get appSettings_routeWeightSuccessIncrementSubtitle =>
'Weight added to a path after successful delivery';
@override
String get appSettings_routeWeightFailureDecrement =>
'Failure Weight Decrement';
@override
String get appSettings_routeWeightFailureDecrementSubtitle =>
'Weight removed from a path after failed delivery';
@override
String get appSettings_maxMessageRetries => 'Max Message Retries';
@override
String get appSettings_maxMessageRetriesSubtitle =>
'Number of retry attempts before marking a message as failed';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override
String get appSettings_battery => 'Battery';
@ -845,6 +973,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get contacts_groupNameRequired => 'Group name is required';
@override
String get contacts_groupNameReserved => 'This group name is reserved';
@override
String contacts_groupAlreadyExists(String name) {
return 'Group \"$name\" already exists';
@ -883,6 +1014,40 @@ class AppLocalizationsEn extends AppLocalizations {
return '~ $days days';
}
@override
String get contact_info => 'Contact Info';
@override
String get contact_settings => 'Contact Settings';
@override
String get contact_telemetry => 'Telemetry';
@override
String get contact_lastSeen => 'Last seen';
@override
String get contact_clearChat => 'Clear Chat';
@override
String get contact_teleBase => 'Telemetry Base';
@override
String get contact_teleBaseSubtitle =>
'Allow sharing battery level and basic telemetry';
@override
String get contact_teleLoc => 'Telemetry Location';
@override
String get contact_teleLocSubtitle => 'Allow sharing location data';
@override
String get contact_teleEnv => 'Telemetry Environment';
@override
String get contact_teleEnvSubtitle => 'Allow sharing environment sensor data';
@override
String get channels_title => 'Channels';
@ -1452,6 +1617,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get map_shareMarkerHere => 'Share marker here';
@override
String get map_setAsMyLocation => 'Set as my location';
@override
String get map_pinLabel => 'Pin label';
@ -1497,6 +1665,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';
@ -1515,6 +1686,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get map_showGuessedLocations => 'Show guessed node locations';
@override
String get map_showDiscoveryContacts => 'Show Discovery Contacts';
@override
String get map_guessedLocation => 'Guessed location';
@ -1537,7 +1711,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';
@ -3256,4 +3433,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).';
}
}

View file

@ -117,6 +117,50 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Establecer conexión a través de TCP';
@override
String get tcpHostLabel => 'Dirección IP';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Puerto';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Ingrese la dirección final y conecte.';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Conectándose a $endpoint...';
}
@override
String get tcpErrorHostRequired => 'Se requiere la dirección IP.';
@override
String get tcpErrorPortInvalid => 'El puerto debe estar entre 1 y 65535.';
@override
String get tcpErrorUnsupported =>
'El protocolo de transporte TCP no está soportado en esta plataforma.';
@override
String get tcpErrorTimedOut => 'La conexión TCP ha caducado.';
@override
String tcpConnectionFailed(String error) {
return 'Error en la conexión TCP: $error';
}
@override
String get usbScreenTitle => 'Conecte mediante USB';
@ -352,6 +396,51 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get settings_privacyModeDisabled => 'Modo de privacidad desactivado';
@override
String get settings_privacy => 'Configuración de privacidad';
@override
String get settings_privacySubtitle =>
'Controlar qué información se comparte.';
@override
String get settings_privacySettingsDescription =>
'Elige qué información comparte tu dispositivo con otros.';
@override
String get settings_denyAll => 'Denegar todo';
@override
String get settings_allowByContact => 'Permitir por banderas de contacto';
@override
String get settings_allowAll => 'Permitir todo';
@override
String get settings_telemetryBaseMode => 'Modo base de telemetría';
@override
String get settings_telemetryLocationMode =>
'Modo de ubicación de telemetría';
@override
String get settings_telemetryEnvironmentMode =>
'Modo de entorno de telemetría';
@override
String get settings_advertLocation => 'Ubicación de anuncio';
@override
String get settings_advertLocationSubtitle => 'Incluir ubicación en anuncio';
@override
String settings_multiAck(String value) {
return 'Multi-ACKs: $value';
}
@override
String get settings_telemetryModeUpdated => 'Modo de telemetría actualizado';
@override
String get settings_actions => 'Acciones';
@ -650,6 +739,49 @@ class AppLocalizationsEs extends AppLocalizations {
String get appSettings_autoRouteRotationDisabled =>
'Rotación de ruta automática desactivada';
@override
String get appSettings_maxRouteWeight => 'Peso máximo permitido para la ruta';
@override
String get appSettings_maxRouteWeightSubtitle =>
'Peso máximo que una ruta puede acumular gracias a entregas exitosas.';
@override
String get appSettings_initialRouteWeight => 'Peso inicial de la ruta';
@override
String get appSettings_initialRouteWeightSubtitle =>
'Peso inicial para rutas recién descubiertas';
@override
String get appSettings_routeWeightSuccessIncrement =>
'Incremento de peso para el éxito';
@override
String get appSettings_routeWeightSuccessIncrementSubtitle =>
'Peso añadido a una ruta después de una entrega exitosa.';
@override
String get appSettings_routeWeightFailureDecrement =>
'Reducción del peso asociado al fallo';
@override
String get appSettings_routeWeightFailureDecrementSubtitle =>
'Peso retirado de un camino después de un intento de entrega fallido.';
@override
String get appSettings_maxMessageRetries =>
'Número máximo de reintentos de envío de mensajes';
@override
String get appSettings_maxMessageRetriesSubtitle =>
'Número de intentos de reintento antes de marcar un mensaje como fallido.';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override
String get appSettings_battery => 'Batería';
@ -857,6 +989,10 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get contacts_groupNameRequired => 'El nombre del grupo es obligatorio';
@override
String get contacts_groupNameReserved =>
'Este nombre de grupo está reservado';
@override
String contacts_groupAlreadyExists(String name) {
return 'El grupo \"$name\" ya existe';
@ -896,6 +1032,42 @@ class AppLocalizationsEs extends AppLocalizations {
return '~ $days días';
}
@override
String get contact_info => 'Información de contacto';
@override
String get contact_settings => 'Configuración de contacto';
@override
String get contact_telemetry => 'Telemetría';
@override
String get contact_lastSeen => 'Visto por última vez';
@override
String get contact_clearChat => 'Borrar chat';
@override
String get contact_teleBase => 'Base de Telemetría';
@override
String get contact_teleBaseSubtitle =>
'Permitir el intercambio de nivel de batería y telemetría básica';
@override
String get contact_teleLoc => 'Ubicación de telemetría';
@override
String get contact_teleLocSubtitle =>
'Permitir el intercambio de datos de ubicación';
@override
String get contact_teleEnv => 'Entorno de Telemetría';
@override
String get contact_teleEnvSubtitle =>
'Permitir el intercambio de datos de sensores de entorno';
@override
String get channels_title => 'Canales';
@ -1474,6 +1646,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get map_shareMarkerHere => 'Compartir marcador aquí';
@override
String get map_setAsMyLocation => 'Establecer mi ubicación';
@override
String get map_pinLabel => 'Etiqueta de marcador';
@ -1519,6 +1694,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';
@ -1538,6 +1716,9 @@ class AppLocalizationsEs extends AppLocalizations {
String get map_showGuessedLocations =>
'Mostrar las ubicaciones estimadas de los nodos.';
@override
String get map_showDiscoveryContacts => 'Mostrar Contactos de Descubrimiento';
@override
String get map_guessedLocation => 'Ubicación estimada';
@ -1562,6 +1743,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';
@ -3315,4 +3499,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).';
}
}

View file

@ -117,6 +117,52 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Établir une connexion via TCP';
@override
String get tcpHostLabel => 'Adresse IP';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Port';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected =>
'Entrez l\'adresse de destination et connectez-vous.';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Connexion à $endpoint...';
}
@override
String get tcpErrorHostRequired => 'Une adresse IP est obligatoire.';
@override
String get tcpErrorPortInvalid =>
'La taille du port doit être comprise entre 1 et 65535.';
@override
String get tcpErrorUnsupported =>
'Le protocole TCP n\'est pas pris en charge sur cette plateforme.';
@override
String get tcpErrorTimedOut => 'La connexion TCP a expiré.';
@override
String tcpConnectionFailed(String error) {
return 'Échec de la connexion TCP : $error';
}
@override
String get usbScreenTitle => 'Connectez via USB';
@ -354,6 +400,52 @@ class AppLocalizationsFr extends AppLocalizations {
String get settings_privacyModeDisabled =>
'Mode de confidentialité désactivé';
@override
String get settings_privacy => 'Paramètres de confidentialité';
@override
String get settings_privacySubtitle => 'Contrôlez les informations partagées';
@override
String get settings_privacySettingsDescription =>
'Choisissez les informations que votre appareil partage avec les autres.';
@override
String get settings_denyAll => 'Refuser tout';
@override
String get settings_allowByContact => 'Autoriser par drapeaux de contact';
@override
String get settings_allowAll => 'Autoriser tout';
@override
String get settings_telemetryBaseMode => 'Mode de base Télémétrie';
@override
String get settings_telemetryLocationMode =>
'Mode d\'emplacement de télémétrie';
@override
String get settings_telemetryEnvironmentMode =>
'Mode d\'environnement de télémétrie';
@override
String get settings_advertLocation => 'Emplacement de l\'annonce';
@override
String get settings_advertLocationSubtitle =>
'Inclure l\'emplacement dans l\'annonce';
@override
String settings_multiAck(String value) {
return 'Multi-ACKs : $value';
}
@override
String get settings_telemetryModeUpdated =>
'Le mode télémétrie a été mis à jour';
@override
String get settings_actions => 'Actions';
@ -652,6 +744,50 @@ class AppLocalizationsFr extends AppLocalizations {
String get appSettings_autoRouteRotationDisabled =>
'Rotation de l\'itinéraire automatique désactivée';
@override
String get appSettings_maxRouteWeight =>
'Poids maximal autorisé pour le trajet';
@override
String get appSettings_maxRouteWeightSubtitle =>
'Poids maximal qu\'un itinéraire peut accumuler grâce à des livraisons réussies.';
@override
String get appSettings_initialRouteWeight => 'Poids initial de l\'itinéraire';
@override
String get appSettings_initialRouteWeightSubtitle =>
'Poids de départ pour les nouveaux chemins découverts';
@override
String get appSettings_routeWeightSuccessIncrement =>
'Augmentation du poids de réussite';
@override
String get appSettings_routeWeightSuccessIncrementSubtitle =>
'Poids ajouté à un itinéraire après une livraison réussie.';
@override
String get appSettings_routeWeightFailureDecrement =>
'Réduction du poids de pénalité';
@override
String get appSettings_routeWeightFailureDecrementSubtitle =>
'Poids retiré d\'un itinéraire après une tentative de livraison infructueuse.';
@override
String get appSettings_maxMessageRetries =>
'Nombre maximal de tentatives de récupération de messages';
@override
String get appSettings_maxMessageRetriesSubtitle =>
'Nombre de tentatives de relance avant de marquer un message comme ayant échoué.';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override
String get appSettings_battery => 'Batterie';
@ -859,6 +995,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get contacts_groupNameRequired => 'Le nom du groupe est obligatoire.';
@override
String get contacts_groupNameReserved => 'Ce nom de groupe est réservé';
@override
String contacts_groupAlreadyExists(String name) {
return 'Le groupe \"$name\" existe déjà.';
@ -898,6 +1037,42 @@ class AppLocalizationsFr extends AppLocalizations {
return '~ $days jours';
}
@override
String get contact_info => 'Informations de contact';
@override
String get contact_settings => 'Paramètres de contact';
@override
String get contact_telemetry => 'Télémétrie';
@override
String get contact_lastSeen => 'Dernière fois vu';
@override
String get contact_clearChat => 'Effacer la conversation';
@override
String get contact_teleBase => 'Base de télémétrie';
@override
String get contact_teleBaseSubtitle =>
'Autoriser le partage du niveau de batterie et de la télémétrie de base';
@override
String get contact_teleLoc => 'Emplacement de télémétrie';
@override
String get contact_teleLocSubtitle =>
'Autoriser le partage des données de localisation';
@override
String get contact_teleEnv => 'Environnement Télémétrie';
@override
String get contact_teleEnvSubtitle =>
'Autoriser le partage des données des capteurs d\'environnement';
@override
String get channels_title => 'Canaux';
@ -1481,6 +1656,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get map_shareMarkerHere => 'Partager le marqueur ici';
@override
String get map_setAsMyLocation => 'Définir comme ma localisation';
@override
String get map_pinLabel => 'Étiquete de repin';
@ -1526,6 +1704,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é';
@ -1545,6 +1726,9 @@ class AppLocalizationsFr extends AppLocalizations {
String get map_showGuessedLocations =>
'Afficher les emplacements des nœuds estimés';
@override
String get map_showDiscoveryContacts => 'Afficher les contacts de découverte';
@override
String get map_guessedLocation => 'Lieu deviné';
@ -1570,6 +1754,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';
@ -3336,4 +3523,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 dappairage 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

View file

@ -117,6 +117,51 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Stabilire una connessione tramite TCP';
@override
String get tcpHostLabel => 'Indirizzo IP';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Porta';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Inserisci l\'endpoint e connettiti.';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Connessione a $endpoint...';
}
@override
String get tcpErrorHostRequired => 'È necessario fornire un indirizzo IP.';
@override
String get tcpErrorPortInvalid =>
'La dimensione della porta deve essere compresa tra 1 e 65535.';
@override
String get tcpErrorUnsupported =>
'Il protocollo TCP non è supportato su questa piattaforma.';
@override
String get tcpErrorTimedOut => 'La connessione TCP è scaduta.';
@override
String tcpConnectionFailed(String error) {
return 'Impossibile stabilire la connessione TCP: $error';
}
@override
String get usbScreenTitle => 'Connessione tramite USB';
@ -353,6 +398,52 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get settings_privacyModeDisabled => 'Modalità privacy disabilitata';
@override
String get settings_privacy => 'Impostazioni sulla privacy';
@override
String get settings_privacySubtitle =>
'Controlla le informazioni che vengono condivise.';
@override
String get settings_privacySettingsDescription =>
'Scegli le informazioni che il tuo dispositivo condivide con gli altri.';
@override
String get settings_denyAll => 'Negare tutto';
@override
String get settings_allowByContact => 'Consenti in base ai flag di contatto';
@override
String get settings_allowAll => 'Consenti tutto';
@override
String get settings_telemetryBaseMode => 'Modalità di base di telemetria';
@override
String get settings_telemetryLocationMode =>
'Modalità di posizionamento telemetrico';
@override
String get settings_telemetryEnvironmentMode =>
'Modalità di ambiente di telemetria';
@override
String get settings_advertLocation => 'Posizione dell\'annuncio';
@override
String get settings_advertLocationSubtitle =>
'Includi la posizione nell\'annuncio';
@override
String settings_multiAck(String value) {
return 'Multi-ACKs: $value';
}
@override
String get settings_telemetryModeUpdated => 'Modalità telemetria aggiornata';
@override
String get settings_actions => 'Azioni';
@ -650,6 +741,50 @@ class AppLocalizationsIt extends AppLocalizations {
String get appSettings_autoRouteRotationDisabled =>
'Rotazione del percorso automatico disabilitata';
@override
String get appSettings_maxRouteWeight =>
'Massimo peso consentito per il percorso';
@override
String get appSettings_maxRouteWeightSubtitle =>
'Il peso massimo che un percorso può accumulare grazie a consegne di successo.';
@override
String get appSettings_initialRouteWeight => 'Peso iniziale del percorso';
@override
String get appSettings_initialRouteWeightSubtitle =>
'Peso di partenza per nuovi percorsi';
@override
String get appSettings_routeWeightSuccessIncrement =>
'Aumento del peso del successo';
@override
String get appSettings_routeWeightSuccessIncrementSubtitle =>
'Peso aggiunto a un percorso dopo una consegna riuscita.';
@override
String get appSettings_routeWeightFailureDecrement =>
'Riduzione del peso associato al fallimento';
@override
String get appSettings_routeWeightFailureDecrementSubtitle =>
'Peso rimosso da un percorso dopo un tentativo di consegna fallito.';
@override
String get appSettings_maxMessageRetries =>
'Numero massimo di tentativi di invio del messaggio';
@override
String get appSettings_maxMessageRetriesSubtitle =>
'Numero di tentativi di riprova prima di considerare un messaggio come fallito.';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override
String get appSettings_battery => 'Batteria';
@ -856,6 +991,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get contacts_groupNameRequired => 'Il nome del gruppo è obbligatorio.';
@override
String get contacts_groupNameReserved => 'Questo nome del gruppo è riservato';
@override
String contacts_groupAlreadyExists(String name) {
return 'Il gruppo \"$name\" esiste già.';
@ -895,6 +1033,42 @@ class AppLocalizationsIt extends AppLocalizations {
return 'Ultimo visto $days giorni fa';
}
@override
String get contact_info => 'Informazioni di Contatto';
@override
String get contact_settings => 'Impostazioni di contatto';
@override
String get contact_telemetry => 'Telemetria';
@override
String get contact_lastSeen => 'Ultimo accesso';
@override
String get contact_clearChat => 'Cancella chat';
@override
String get contact_teleBase => 'Base di telemetria';
@override
String get contact_teleBaseSubtitle =>
'Consenti la condivisione del livello della batteria e della telemetria di base';
@override
String get contact_teleLoc => 'Posizione telemetria';
@override
String get contact_teleLocSubtitle =>
'Consenti la condivisione dei dati di posizione';
@override
String get contact_teleEnv => 'Ambiente di telemetria';
@override
String get contact_teleEnvSubtitle =>
'Consenti la condivisione dei dati del sensore ambientale';
@override
String get channels_title => 'Canali';
@ -1474,6 +1648,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get map_shareMarkerHere => 'Condividi marcatore qui';
@override
String get map_setAsMyLocation => 'Imposta come la mia posizione';
@override
String get map_pinLabel => 'Etichetta PIN';
@ -1519,6 +1696,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';
@ -1537,6 +1717,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get map_showGuessedLocations => 'Mostra le posizioni stimate dei nodi';
@override
String get map_showDiscoveryContacts => 'Mostra Contatti di Discovery';
@override
String get map_guessedLocation => 'Località indovinata';
@ -1561,6 +1744,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';
@ -3316,4 +3503,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

View file

@ -117,6 +117,51 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Verbind via TCP';
@override
String get tcpHostLabel => 'IP-adres';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Poort';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Voer het eindpunt in en verbind';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Verbinding maken met $endpoint...';
}
@override
String get tcpErrorHostRequired => 'Een IP-adres is vereist.';
@override
String get tcpErrorPortInvalid =>
'De poortwaarde moet tussen 1 en 65535 liggen.';
@override
String get tcpErrorUnsupported =>
'TCP-transport wordt niet ondersteund op deze platform.';
@override
String get tcpErrorTimedOut => 'De TCP-verbinding is verlopen.';
@override
String tcpConnectionFailed(String error) {
return 'Verbinding met TCP mislukt: $error';
}
@override
String get usbScreenTitle => 'Verbind via USB';
@ -350,6 +395,50 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get settings_privacyModeDisabled => 'Privacy modus is uitgeschakeld';
@override
String get settings_privacy => 'Privacyinstellingen';
@override
String get settings_privacySubtitle =>
'Beheer welke informatie wordt gedeeld';
@override
String get settings_privacySettingsDescription =>
'Kies welke informatie uw apparaat deelt met anderen';
@override
String get settings_denyAll => 'Weiger alles';
@override
String get settings_allowByContact => 'Toestaan op basis van contactvlaggen';
@override
String get settings_allowAll => 'Alles toestaan';
@override
String get settings_telemetryBaseMode => 'Telemetrie-basismodus';
@override
String get settings_telemetryLocationMode => 'Telemetrie-locatiemodus';
@override
String get settings_telemetryEnvironmentMode => 'Telemetrie-omgevingsmodus';
@override
String get settings_advertLocation => 'Advertentielocatie';
@override
String get settings_advertLocationSubtitle =>
'Locatie opnemen in advertentie';
@override
String settings_multiAck(String value) {
return 'Multi-ACKs: $value';
}
@override
String get settings_telemetryModeUpdated => 'Telemetrie-modus bijgewerkt';
@override
String get settings_actions => 'Acties';
@ -644,6 +733,49 @@ class AppLocalizationsNl extends AppLocalizations {
String get appSettings_autoRouteRotationDisabled =>
'Automatische route rotatie is uitgeschakeld';
@override
String get appSettings_maxRouteWeight => 'Maximale gewicht voor de route';
@override
String get appSettings_maxRouteWeightSubtitle =>
'Het maximale gewicht dat een route kan bereiken door succesvolle leveringen.';
@override
String get appSettings_initialRouteWeight => 'เริ่มต้น gewicht van de route';
@override
String get appSettings_initialRouteWeightSubtitle =>
'Startgewicht voor nieuwe, ontdekte routes';
@override
String get appSettings_routeWeightSuccessIncrement =>
'Toename in het gewicht van het succes';
@override
String get appSettings_routeWeightSuccessIncrementSubtitle =>
'Gewicht wordt toegevoegd aan een route na een succesvolle levering.';
@override
String get appSettings_routeWeightFailureDecrement =>
'Vermindering van het gewicht van fouten';
@override
String get appSettings_routeWeightFailureDecrementSubtitle =>
'Gewicht verwijderd van een pad na een mislukte levering';
@override
String get appSettings_maxMessageRetries =>
'Aantal pogingen om berichten te versturen';
@override
String get appSettings_maxMessageRetriesSubtitle =>
'Aantal pogingen om een bericht opnieuw te versturen voordat het als mislukt wordt gemarkeerd';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override
String get appSettings_battery => 'Batterij';
@ -850,6 +982,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get contacts_groupNameRequired => 'De groepnaam is verplicht.';
@override
String get contacts_groupNameReserved => 'Deze groepsnaam is gereserveerd';
@override
String contacts_groupAlreadyExists(String name) {
return 'De groep \"$name\" bestaat al.';
@ -889,6 +1024,40 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Laast gezien $days dagen geleden';
}
@override
String get contact_info => 'Contactinformatie';
@override
String get contact_settings => 'Contactinstellingen';
@override
String get contact_telemetry => 'Telemetrie';
@override
String get contact_lastSeen => 'Laatst gezien';
@override
String get contact_clearChat => 'Chat leegmaken';
@override
String get contact_teleBase => 'Telemetrie_basis';
@override
String get contact_teleBaseSubtitle =>
'Sta delen van batterij niveau en basis telemetrie toe';
@override
String get contact_teleLoc => 'Telemetrielocatie';
@override
String get contact_teleLocSubtitle => 'Locatiegegevens delen toestaan';
@override
String get contact_teleEnv => 'Telemetrieomgeving';
@override
String get contact_teleEnvSubtitle => 'Delen van omgevingsensordata toestaan';
@override
String get channels_title => 'Kanaal';
@ -1466,6 +1635,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get map_shareMarkerHere => 'Deel marker hier';
@override
String get map_setAsMyLocation => 'Stel dit in als mijn locatie';
@override
String get map_pinLabel => 'Label vastzetten';
@ -1511,6 +1683,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';
@ -1530,6 +1705,9 @@ class AppLocalizationsNl extends AppLocalizations {
String get map_showGuessedLocations =>
'Toon de voorspelde locaties van de knopen';
@override
String get map_showDiscoveryContacts => 'Ontdek contacten weergeven';
@override
String get map_guessedLocation => 'Geroerde locatie';
@ -1555,6 +1733,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';
@ -3300,4 +3481,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 => 'BluetoothkoppelingsPIN';
@override
String scanner_linuxPairingPinPrompt(String deviceName) {
return 'Voer PIN in voor $deviceName (laat leeg als er geen is).';
}
}

File diff suppressed because it is too large Load diff

View file

@ -117,6 +117,51 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Estabelecer conexão via TCP';
@override
String get tcpHostLabel => 'Endereço IP';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Porta';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Insira o endereço final e conecte-se.';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Conectando a $endpoint...';
}
@override
String get tcpErrorHostRequired => 'É necessário fornecer um endereço IP.';
@override
String get tcpErrorPortInvalid =>
'O valor do porto deve estar entre 1 e 65535.';
@override
String get tcpErrorUnsupported =>
'O protocolo TCP não é suportado nesta plataforma.';
@override
String get tcpErrorTimedOut => 'A conexão TCP expirou.';
@override
String tcpConnectionFailed(String error) {
return 'Falha na conexão TCP: $error';
}
@override
String get usbScreenTitle => 'Conecte via USB';
@ -353,6 +398,51 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get settings_privacyModeDisabled => 'Modo de privacidade desativado';
@override
String get settings_privacy => 'Configurações de Privacidade';
@override
String get settings_privacySubtitle => 'Controle o que é compartilhado.';
@override
String get settings_privacySettingsDescription =>
'Escolha quais informações o seu dispositivo compartilha com os outros.';
@override
String get settings_denyAll => 'Negar todos';
@override
String get settings_allowByContact => 'Permitir por bandeiras de contato';
@override
String get settings_allowAll => 'Permitir todos';
@override
String get settings_telemetryBaseMode => 'Modo Base de Telemetria';
@override
String get settings_telemetryLocationMode =>
'Modo de Localização de Telemetria';
@override
String get settings_telemetryEnvironmentMode =>
'Modo de Ambiente de Telemetria';
@override
String get settings_advertLocation => 'Localização do Anúncio';
@override
String get settings_advertLocationSubtitle =>
'Incluir localização no anúncio';
@override
String settings_multiAck(String value) {
return 'Multi-ACKs: $value';
}
@override
String get settings_telemetryModeUpdated => 'Modo de telemetria atualizado';
@override
String get settings_actions => 'Ações';
@ -651,6 +741,49 @@ class AppLocalizationsPt extends AppLocalizations {
String get appSettings_autoRouteRotationDisabled =>
'Rotação de roteamento automático desativada';
@override
String get appSettings_maxRouteWeight => 'Peso Máximo da Rota';
@override
String get appSettings_maxRouteWeightSubtitle =>
'Peso máximo que um determinado percurso pode acumular com entregas bem-sucedidas.';
@override
String get appSettings_initialRouteWeight => 'Peso Inicial da Rota';
@override
String get appSettings_initialRouteWeightSubtitle =>
'Peso inicial para novos caminhos descobertos';
@override
String get appSettings_routeWeightSuccessIncrement =>
'Aumento do peso para indicar sucesso';
@override
String get appSettings_routeWeightSuccessIncrementSubtitle =>
'Peso adicionado a um caminho após a entrega bem-sucedida.';
@override
String get appSettings_routeWeightFailureDecrement =>
'Redução do peso da falha';
@override
String get appSettings_routeWeightFailureDecrementSubtitle =>
'Peso removido de um caminho após uma tentativa de entrega malsucedida.';
@override
String get appSettings_maxMessageRetries =>
'Número máximo de tentativas de envio de mensagens';
@override
String get appSettings_maxMessageRetriesSubtitle =>
'Número de tentativas de reenvio antes de classificar uma mensagem como falha.';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override
String get appSettings_battery => 'Bateria';
@ -858,6 +991,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get contacts_groupNameRequired => 'O nome do grupo é obrigatório.';
@override
String get contacts_groupNameReserved => 'Este nome de grupo está reservado';
@override
String contacts_groupAlreadyExists(String name) {
return 'O grupo \"$name\" já existe';
@ -897,6 +1033,42 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Última vez visto $days dias atrás';
}
@override
String get contact_info => 'Informações de Contato';
@override
String get contact_settings => 'Configurações de Contato';
@override
String get contact_telemetry => 'Telemetria';
@override
String get contact_lastSeen => 'Visto pela última vez';
@override
String get contact_clearChat => 'Limpar Chat';
@override
String get contact_teleBase => 'Base de Telemetria';
@override
String get contact_teleBaseSubtitle =>
'Permitir compartilhamento do nível da bateria e telemetria básica';
@override
String get contact_teleLoc => 'Localização de Telemetria';
@override
String get contact_teleLocSubtitle =>
'Permitir compartilhamento de dados de localização';
@override
String get contact_teleEnv => 'Ambiente de Telemetria';
@override
String get contact_teleEnvSubtitle =>
'Permitir compartilhamento de dados do sensor de ambiente';
@override
String get channels_title => 'Canais';
@ -1475,6 +1647,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get map_shareMarkerHere => 'Compartilhar marcador aqui';
@override
String get map_setAsMyLocation => 'Defina minha localização';
@override
String get map_pinLabel => 'Rótulo de marcador';
@ -1520,6 +1695,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';
@ -1539,6 +1717,9 @@ class AppLocalizationsPt extends AppLocalizations {
String get map_showGuessedLocations =>
'Mostrar as localizações dos nós estimados';
@override
String get map_showDiscoveryContacts => 'Mostrar Contatos de Descoberta';
@override
String get map_guessedLocation => 'Localização estimada';
@ -1563,6 +1744,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';
@ -3312,4 +3496,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).';
}
}

View file

@ -117,6 +117,51 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Установить соединение по протоколу TCP';
@override
String get tcpHostLabel => 'IP-адрес';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Порт';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Введите адрес и подключитесь.';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Подключение к $endpoint...';
}
@override
String get tcpErrorHostRequired => 'Необходимо указать IP-адрес.';
@override
String get tcpErrorPortInvalid =>
'Порт должен находиться в диапазоне от 1 до 65535.';
@override
String get tcpErrorUnsupported =>
'Протокол TCP не поддерживается на этой платформе.';
@override
String get tcpErrorTimedOut => 'Соединение TCP не удалось установить.';
@override
String tcpConnectionFailed(String error) {
return 'Не удалось установить соединение TCP: $error';
}
@override
String get usbScreenTitle => 'Подключение через USB';
@ -353,6 +398,51 @@ class AppLocalizationsRu extends AppLocalizations {
String get settings_privacyModeDisabled =>
'Режим конфиденциальности выключен';
@override
String get settings_privacy => 'Настройки конфиденциальности';
@override
String get settings_privacySubtitle =>
'Контролируйте, какую информацию делиться.';
@override
String get settings_privacySettingsDescription =>
'Выберите, какую информацию ваше устройство будет делиться с другими.';
@override
String get settings_denyAll => 'Отклонить все';
@override
String get settings_allowByContact => 'Разрешить по флагам контактов';
@override
String get settings_allowAll => 'Разрешить все';
@override
String get settings_telemetryBaseMode => 'Базовый режим телеметрии';
@override
String get settings_telemetryLocationMode =>
'Режим местоположения телеметрии';
@override
String get settings_telemetryEnvironmentMode => 'Режим среды телеметрии';
@override
String get settings_advertLocation => 'Местоположение рекламы';
@override
String get settings_advertLocationSubtitle =>
'Включить местоположение в объявление';
@override
String settings_multiAck(String value) {
return 'Мульти-ACK: $value';
}
@override
String get settings_telemetryModeUpdated => 'Режим телеметрии обновлен';
@override
String get settings_actions => 'Действия';
@ -651,6 +741,50 @@ class AppLocalizationsRu extends AppLocalizations {
String get appSettings_autoRouteRotationDisabled =>
'Автоматическое переключение маршрутов отключено';
@override
String get appSettings_maxRouteWeight =>
'Максимальный допустимый вес маршрута';
@override
String get appSettings_maxRouteWeightSubtitle =>
'Максимальный вес, который может быть перевезён по определённому маршруту при успешных доставках.';
@override
String get appSettings_initialRouteWeight => 'Начальный вес маршрута';
@override
String get appSettings_initialRouteWeightSubtitle =>
'Начальный вес для новых, только что открытых маршрутов';
@override
String get appSettings_routeWeightSuccessIncrement =>
'Увеличение веса успеха';
@override
String get appSettings_routeWeightSuccessIncrementSubtitle =>
'Вес, добавленный к маршруту после успешной доставки.';
@override
String get appSettings_routeWeightFailureDecrement =>
'Уменьшение веса неудачи';
@override
String get appSettings_routeWeightFailureDecrementSubtitle =>
'Вес, который был удален с пути после неудачной доставки.';
@override
String get appSettings_maxMessageRetries =>
'Максимальное количество повторных попыток отправки сообщения';
@override
String get appSettings_maxMessageRetriesSubtitle =>
'Количество попыток повторной отправки сообщения перед тем, как пометить его как неудачное.';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override
String get appSettings_battery => 'Батарея';
@ -857,6 +991,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get contacts_groupNameRequired => 'Имя группы обязательно';
@override
String get contacts_groupNameReserved => 'Это имя группы зарезервировано';
@override
String contacts_groupAlreadyExists(String name) {
return 'Группа \"$name\" уже существует';
@ -896,6 +1033,42 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Видели $days дн. назад';
}
@override
String get contact_info => 'Контактная информация';
@override
String get contact_settings => 'Настройки контактов';
@override
String get contact_telemetry => 'Телеметрия';
@override
String get contact_lastSeen => 'Последний раз видели';
@override
String get contact_clearChat => 'Очистить чат';
@override
String get contact_teleBase => 'База телеметрии';
@override
String get contact_teleBaseSubtitle =>
'Разрешить обмен уровнем заряда батареи и базовой телеметрией';
@override
String get contact_teleLoc => 'Местоположение телеметрии';
@override
String get contact_teleLocSubtitle =>
'Разрешить обмен данными о местоположении';
@override
String get contact_teleEnv => 'Среда телеметрии';
@override
String get contact_teleEnvSubtitle =>
'Разрешить обмен данными датчиков окружающей среды';
@override
String get channels_title => 'Каналы';
@ -1477,6 +1650,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get map_shareMarkerHere => 'Поделиться меткой здесь';
@override
String get map_setAsMyLocation => 'Установить мое местоположение';
@override
String get map_pinLabel => 'Метка';
@ -1522,6 +1698,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get map_otherNodes => 'Другие ноды';
@override
String get map_showOverlaps => 'Перекрытия ключа повтора';
@override
String get map_keyPrefix => 'Префикс ключа';
@ -1541,6 +1720,9 @@ class AppLocalizationsRu extends AppLocalizations {
String get map_showGuessedLocations =>
'Отобразить предполагаемые места расположения узлов';
@override
String get map_showDiscoveryContacts => 'Показать контакты Discovery';
@override
String get map_guessedLocation => 'Угаданное место';
@ -1565,6 +1747,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get map_runTrace => 'Запустить трассировку пути';
@override
String get map_runTraceWithReturnPath => 'Вернуться обратно по тому же пути';
@override
String get map_removeLast => 'Удалить последний';
@ -3325,4 +3510,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 (оставьте пустым, если нет).';
}
}

View file

@ -117,6 +117,50 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Spojte sa pomocou protokolu TCP';
@override
String get tcpHostLabel => 'IP adresa';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Port';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Zadajte cieľovú adresu a pripojte sa.';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Pripojenie k $endpoint...';
}
@override
String get tcpErrorHostRequired => 'Je potrebné zadať IP adresu.';
@override
String get tcpErrorPortInvalid => 'Číslo portu musí byť medzi 1 a 65535.';
@override
String get tcpErrorUnsupported =>
'Prevoz prostredníctvom protokolu TCP nie je na tejto platforme podporovaný.';
@override
String get tcpErrorTimedOut => 'Pripojenie TCP vypršalo.';
@override
String tcpConnectionFailed(String error) {
return 'Neúspešné vytvorenie TCP spojenia: $error';
}
@override
String get usbScreenTitle => 'Pripojte cez USB';
@ -351,6 +395,49 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get settings_privacyModeDisabled => 'Ochranný režim je vypnutý';
@override
String get settings_privacy => 'Nastavenia súkromia';
@override
String get settings_privacySubtitle => 'Ovládni, aké informácie sa zdieľajú.';
@override
String get settings_privacySettingsDescription =>
'Vyberte, ktoré informácie váš zariadenie zdieľa s ostatnými.';
@override
String get settings_denyAll => 'Zamietnuť všetko';
@override
String get settings_allowByContact => 'Povoliť podľa kontaktových vlajok';
@override
String get settings_allowAll => 'Povoliť všetko';
@override
String get settings_telemetryBaseMode => 'Základný režim telemetrie';
@override
String get settings_telemetryLocationMode => 'Režim umiestnenia telemetrie';
@override
String get settings_telemetryEnvironmentMode => 'Režim prostredia telemetrie';
@override
String get settings_advertLocation => 'Umiestnenie inzerátu';
@override
String get settings_advertLocationSubtitle => 'Zahrnúť polohu do inzerátu';
@override
String settings_multiAck(String value) {
return 'Viaceré ACK: $value';
}
@override
String get settings_telemetryModeUpdated =>
'Režim telemetrie bol aktualizovaný';
@override
String get settings_actions => 'Možné akcie';
@ -643,6 +730,48 @@ class AppLocalizationsSk extends AppLocalizations {
String get appSettings_autoRouteRotationDisabled =>
'Automatické prekladanie trás pozastavené';
@override
String get appSettings_maxRouteWeight => 'Maximálna hmotnosť trasy';
@override
String get appSettings_maxRouteWeightSubtitle =>
'Maximálna hmotnosť, ktorú môže trás prenášať vďaka úspešným zásielkam.';
@override
String get appSettings_initialRouteWeight => 'Počiatočná váha trasy';
@override
String get appSettings_initialRouteWeightSubtitle =>
'Počiatočná váha pre nové, objavené cesty';
@override
String get appSettings_routeWeightSuccessIncrement => 'Zvyšenie váhy úspechu';
@override
String get appSettings_routeWeightSuccessIncrementSubtitle =>
'Hmotnosť pridaná k trase po úspešnej doručení';
@override
String get appSettings_routeWeightFailureDecrement =>
'Sníženie váhy, ktorá sa používa na odhad rizika.';
@override
String get appSettings_routeWeightFailureDecrementSubtitle =>
'Hmotnosť odstránená z cesty po neúspešnej doručenie';
@override
String get appSettings_maxMessageRetries =>
'Maximalný počet pokusov o doručenie správ';
@override
String get appSettings_maxMessageRetriesSubtitle =>
'Počet pokusov o odošleť pred označením správy ako neúspešnej';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override
String get appSettings_battery => 'Batéria';
@ -850,6 +979,9 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get contacts_groupNameRequired => 'Skupina musí mať názov.';
@override
String get contacts_groupNameReserved => 'Tento názov skupiny je rezervovaný';
@override
String contacts_groupAlreadyExists(String name) {
return 'Skupina \"$name\" už existuje';
@ -891,6 +1023,41 @@ class AppLocalizationsSk extends AppLocalizations {
return 'Posledné zobrazenie $days dní dozadu';
}
@override
String get contact_info => 'Kontaktné informácie';
@override
String get contact_settings => 'Nastavenia kontaktov';
@override
String get contact_telemetry => 'Telemetria';
@override
String get contact_lastSeen => 'Naposledy videný';
@override
String get contact_clearChat => 'Vymazať chat';
@override
String get contact_teleBase => 'Báza telemetrie';
@override
String get contact_teleBaseSubtitle =>
'Povoliť zdieľanie úrovne batérie a základnej telemetrie';
@override
String get contact_teleLoc => 'Lokácia telemetrie';
@override
String get contact_teleLocSubtitle => 'Povoliť zdieľanie údajov o lokalite';
@override
String get contact_teleEnv => 'Prostredie telemetrie';
@override
String get contact_teleEnvSubtitle =>
'Povoliť zdieľanie údajov senzorov prostredia';
@override
String get channels_title => 'Kanály';
@ -1469,6 +1636,9 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get map_shareMarkerHere => 'Zdieľte značku tu';
@override
String get map_setAsMyLocation => 'Nastavte ako moju polohu';
@override
String get map_pinLabel => 'Označka upozornenia';
@ -1514,6 +1684,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';
@ -1533,6 +1706,9 @@ class AppLocalizationsSk extends AppLocalizations {
String get map_showGuessedLocations =>
'Zobraziť umiestnenia odhadnutých uzlov';
@override
String get map_showDiscoveryContacts => 'Zobraziť kontakty objavov';
@override
String get map_guessedLocation => 'Odhadnutá lokalita';
@ -1557,6 +1733,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ý';
@ -3297,4 +3476,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).';
}
}

View file

@ -117,6 +117,50 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Komunicirajte preko protokola TCP';
@override
String get tcpHostLabel => 'IP naslov';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Vrata';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Vnesite končni naslov in se povežite';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Povezava z $endpoint...';
}
@override
String get tcpErrorHostRequired => 'Potrebna je IP-naslov.';
@override
String get tcpErrorPortInvalid => 'Port mora biti med 1 in 65535.';
@override
String get tcpErrorUnsupported =>
'Transport preko protokola TCP ni podprt na tej platformi.';
@override
String get tcpErrorTimedOut => 'Povezava TCP je presegla časovno obdobje.';
@override
String tcpConnectionFailed(String error) {
return 'Napaka pri povezavi TCP: $error';
}
@override
String get usbScreenTitle => 'Povežite preko USB';
@ -349,6 +393,50 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get settings_privacyModeDisabled => 'Privatni način je onemogočen.';
@override
String get settings_privacy => 'Nastavitve zasebnosti';
@override
String get settings_privacySubtitle =>
'Kontrolirajte, katere informacije so deljene.';
@override
String get settings_privacySettingsDescription =>
'Izberite, katere informacije vaš naprava deli z drugimi.';
@override
String get settings_denyAll => 'Zavrniti vse';
@override
String get settings_allowByContact => 'Dovoli po kontaktnih zastavah';
@override
String get settings_allowAll => 'Dovoli vse';
@override
String get settings_telemetryBaseMode => 'Osnovni način telemetrije';
@override
String get settings_telemetryLocationMode => 'Način delovanja telemetrije';
@override
String get settings_telemetryEnvironmentMode =>
'Način delovanja okolja telemetrije';
@override
String get settings_advertLocation => 'Lokacija oglasa';
@override
String get settings_advertLocationSubtitle => 'Vključi lokacijo v oglas.';
@override
String settings_multiAck(String value) {
return 'Večkratni potrditvi: $value';
}
@override
String get settings_telemetryModeUpdated => 'Način telemetrije posodobljen';
@override
String get settings_actions => 'Akcije';
@ -643,6 +731,49 @@ class AppLocalizationsSl extends AppLocalizations {
String get appSettings_autoRouteRotationDisabled =>
'Samodejno krmilno rotiranje je onemogočeno';
@override
String get appSettings_maxRouteWeight => 'Največja dovoljena teža poti';
@override
String get appSettings_maxRouteWeightSubtitle =>
'Največja teža, ki jo lahko pot doseže s uspešnimi dostavnami.';
@override
String get appSettings_initialRouteWeight => 'Izvirna teža poti';
@override
String get appSettings_initialRouteWeightSubtitle =>
'Izguba teže za nove, odkriti poti';
@override
String get appSettings_routeWeightSuccessIncrement =>
'Učinkovitost: povečanje';
@override
String get appSettings_routeWeightSuccessIncrementSubtitle =>
'Težava, dodana poti po uspešni dostavi';
@override
String get appSettings_routeWeightFailureDecrement =>
'Zmanjšanje teže, ki je povezana s pomanjkanjem';
@override
String get appSettings_routeWeightFailureDecrementSubtitle =>
'Težo, ki ni bila uspešno dostavljena, odstranili s poti.';
@override
String get appSettings_maxMessageRetries =>
'Najve število poskusov pošiljanja sporočil';
@override
String get appSettings_maxMessageRetriesSubtitle =>
'Število poskusov ponovnega poslanja, preden se sporočilo označuje kot neuspešno';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override
String get appSettings_battery => 'Baterija';
@ -848,6 +979,9 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get contacts_groupNameRequired => 'Ime skupine je obvezno.';
@override
String get contacts_groupNameReserved => 'To ime skupine je rezervirano';
@override
String contacts_groupAlreadyExists(String name) {
return 'Skupina \"$name\" že obstaja';
@ -887,6 +1021,41 @@ class AppLocalizationsSl extends AppLocalizations {
return 'Zadnjič viden pred $days dnem';
}
@override
String get contact_info => 'Kontaktni podatki';
@override
String get contact_settings => 'Nastavitve stika';
@override
String get contact_telemetry => 'Telemetrija';
@override
String get contact_lastSeen => 'Zadnjič videno';
@override
String get contact_clearChat => 'Počisti klepet';
@override
String get contact_teleBase => 'Baza telemetrije';
@override
String get contact_teleBaseSubtitle =>
'Dovoli deljenje stanja baterije in osnovne telemetrije';
@override
String get contact_teleLoc => 'Lokacija telemetrije';
@override
String get contact_teleLocSubtitle => 'Dovoli deljenje podatkov o lokaciji';
@override
String get contact_teleEnv => 'Okolje telemetrije';
@override
String get contact_teleEnvSubtitle =>
'Dovoli deljenje podatkov okoljskih senzorjev';
@override
String get channels_title => 'Kanali';
@ -1463,6 +1632,9 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get map_shareMarkerHere => 'Delite točke tukaj.';
@override
String get map_setAsMyLocation => 'Nastavite to kot mojo lokacijo';
@override
String get map_pinLabel => 'Oznaka za pritrditev';
@ -1508,6 +1680,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';
@ -1526,6 +1701,9 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get map_showGuessedLocations => 'Pokaži lokacije domnevnih not.';
@override
String get map_showDiscoveryContacts => 'Prikaži odkritja kontaktov';
@override
String get map_guessedLocation => 'Predpostavljena lokacija';
@ -1550,6 +1728,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';
@ -3298,4 +3479,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).';
}
}

View file

@ -117,6 +117,50 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'Anslut via TCP';
@override
String get tcpHostLabel => 'IP-adress';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Port';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Ange slutpunkt och anslut';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Anslutning till $endpoint...';
}
@override
String get tcpErrorHostRequired => 'IP-adress krävs.';
@override
String get tcpErrorPortInvalid => 'Porten måste vara mellan 1 och 65535.';
@override
String get tcpErrorUnsupported =>
'TCP-transport fungerar inte på denna plattform.';
@override
String get tcpErrorTimedOut => 'TCP-anslutningen har tidsut gått.';
@override
String tcpConnectionFailed(String error) {
return 'Fel vid TCP-anslutning: $error';
}
@override
String get usbScreenTitle => 'Anslut via USB';
@ -348,6 +392,49 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get settings_privacyModeDisabled => 'Privatläge är avstängt';
@override
String get settings_privacy => 'Inställningar för sekretess';
@override
String get settings_privacySubtitle =>
'Kontrollera vilken information som delas.';
@override
String get settings_privacySettingsDescription =>
'Välj vilken information din enhet delar med andra.';
@override
String get settings_denyAll => 'Neka alla';
@override
String get settings_allowByContact => 'Tillåt via kontaktflaggor';
@override
String get settings_allowAll => 'Tillåt alla';
@override
String get settings_telemetryBaseMode => 'Telemetribasläge';
@override
String get settings_telemetryLocationMode => 'Telemetritillstånd för plats';
@override
String get settings_telemetryEnvironmentMode => 'Telemetri miljöläge';
@override
String get settings_advertLocation => 'Annonsplacering';
@override
String get settings_advertLocationSubtitle => 'Inkludera plats i annonsen';
@override
String settings_multiAck(String value) {
return 'Multi-ACKs: $value';
}
@override
String get settings_telemetryModeUpdated => 'Telemetri-läge uppdaterat';
@override
String get settings_actions => 'Åtgärder';
@ -638,6 +725,48 @@ class AppLocalizationsSv extends AppLocalizations {
String get appSettings_autoRouteRotationDisabled =>
'Automatisk ruttrotation är avstängd';
@override
String get appSettings_maxRouteWeight => 'Maximalt tillåtet vikt för rutten';
@override
String get appSettings_maxRouteWeightSubtitle =>
'Maximal vikt som en leveransväg kan ackumulera från framgångsrika leveranser.';
@override
String get appSettings_initialRouteWeight => 'Initial vikt för rutt';
@override
String get appSettings_initialRouteWeightSubtitle =>
'Initial vikt för nyligen upptäckta vägar';
@override
String get appSettings_routeWeightSuccessIncrement =>
'Ökning av vikt för framgång';
@override
String get appSettings_routeWeightSuccessIncrementSubtitle =>
'Vikt läggs till en väg efter en lyckad leverans.';
@override
String get appSettings_routeWeightFailureDecrement =>
'Minskning av vikten för misslyckande';
@override
String get appSettings_routeWeightFailureDecrementSubtitle =>
'Vikt som tagits bort från en väg efter ett misslyckat leveransförsök';
@override
String get appSettings_maxMessageRetries => 'Maximalt antal försök';
@override
String get appSettings_maxMessageRetriesSubtitle =>
'Antal försök att skicka om ett meddelande innan det markeras som misslyckat.';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override
String get appSettings_battery => 'Batteri';
@ -844,6 +973,9 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get contacts_groupNameRequired => 'Gruppnamnet är obligatoriskt';
@override
String get contacts_groupNameReserved => 'Detta gruppnamn är reserverat';
@override
String contacts_groupAlreadyExists(String name) {
return 'Gruppen \"$name\" finns redan.';
@ -883,6 +1015,40 @@ class AppLocalizationsSv extends AppLocalizations {
return 'Senast synlig $days dagar sedan';
}
@override
String get contact_info => 'Kontaktinformation';
@override
String get contact_settings => 'Kontaktinställningar';
@override
String get contact_telemetry => 'Telemetri';
@override
String get contact_lastSeen => 'Senast sedd';
@override
String get contact_clearChat => 'Rensa Chatt';
@override
String get contact_teleBase => 'Telemetribas';
@override
String get contact_teleBaseSubtitle =>
'Tillåt delning av batterinivå och grundläggande telemetri';
@override
String get contact_teleLoc => 'Telemetridata plats';
@override
String get contact_teleLocSubtitle => 'Tillåt delning av platsdata';
@override
String get contact_teleEnv => 'Telemetri Miljö';
@override
String get contact_teleEnvSubtitle => 'Tillåt delning av miljösensordata';
@override
String get channels_title => 'Kanaler';
@ -1459,6 +1625,9 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get map_shareMarkerHere => 'Dela markeringen här';
@override
String get map_setAsMyLocation => 'Ange som min plats';
@override
String get map_pinLabel => 'Fästetikett';
@ -1504,6 +1673,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';
@ -1523,6 +1695,9 @@ class AppLocalizationsSv extends AppLocalizations {
String get map_showGuessedLocations =>
'Visa upp de antagna nodernas placeringar';
@override
String get map_showDiscoveryContacts => 'Visa Discovery-kontakter';
@override
String get map_guessedLocation => 'Gissad plats';
@ -1547,6 +1722,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';
@ -3278,4 +3456,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 => 'BluetoothparningsPIN';
@override
String scanner_linuxPairingPinPrompt(String deviceName) {
return 'Ange PIN för $deviceName (lämna tomt om ingen).';
}
}

View file

@ -117,6 +117,51 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => 'З\'єднатися через протокол TCP';
@override
String get tcpHostLabel => 'IP-адреса';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => 'Порт';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Введіть кінцеву точку та підключіться';
@override
String tcpStatus_connectingTo(String endpoint) {
return 'Підключення до $endpoint...';
}
@override
String get tcpErrorHostRequired => 'Необхідно вказати IP-адресу.';
@override
String get tcpErrorPortInvalid => 'Порт повинен бути в межах від 1 до 65535.';
@override
String get tcpErrorUnsupported =>
'Транспорт TCP не підтримується на цій платформі.';
@override
String get tcpErrorTimedOut =>
'З\'єднання TCP завершилося через закінчення часу очікування.';
@override
String tcpConnectionFailed(String error) {
return 'Не вдалося встановити з\'єднання TCP: $error';
}
@override
String get usbScreenTitle => 'Підключити через USB';
@ -350,6 +395,50 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get settings_privacyModeDisabled => 'Режим приватності вимкнено';
@override
String get settings_privacy => 'Налаштування приватності';
@override
String get settings_privacySubtitle =>
'Керуйте інформацією, яку буде спільно використовуватися';
@override
String get settings_privacySettingsDescription =>
'Виберіть, яку інформацію ваш пристрій буде передавати іншим.';
@override
String get settings_denyAll => 'Відхилити все';
@override
String get settings_allowByContact => 'Дозволити за контактними прапорцями';
@override
String get settings_allowAll => 'Дозволити все';
@override
String get settings_telemetryBaseMode => 'Режим базової телеметрії';
@override
String get settings_telemetryLocationMode => 'Режим місця телеметрії';
@override
String get settings_telemetryEnvironmentMode => 'Режим середовища телеметрії';
@override
String get settings_advertLocation => 'Розміщення реклами';
@override
String get settings_advertLocationSubtitle =>
'Включити місце розташування в оголошення';
@override
String settings_multiAck(String value) {
return 'Багатократне підтвердження: $value';
}
@override
String get settings_telemetryModeUpdated => 'Режим телеметрії оновлено';
@override
String get settings_actions => 'Дії';
@ -647,6 +736,49 @@ class AppLocalizationsUk extends AppLocalizations {
String get appSettings_autoRouteRotationDisabled =>
'Авторотація маршрутизації вимкнена';
@override
String get appSettings_maxRouteWeight => 'Максимальна вага маршруту';
@override
String get appSettings_maxRouteWeightSubtitle =>
'Максимальна вага, яку може накопичити маршрут завдяки успішним доставкам.';
@override
String get appSettings_initialRouteWeight => 'Початкова вартість маршруту';
@override
String get appSettings_initialRouteWeightSubtitle =>
'Початкова вага для нових відкритих шляхів';
@override
String get appSettings_routeWeightSuccessIncrement =>
'Збільшення ваги успіху';
@override
String get appSettings_routeWeightSuccessIncrementSubtitle =>
'Вага, додана до маршруту після успішної доставки';
@override
String get appSettings_routeWeightFailureDecrement =>
'Зменшення ваги помилки';
@override
String get appSettings_routeWeightFailureDecrementSubtitle =>
'Вага, яка була знята з маршруту після невдалої доставки';
@override
String get appSettings_maxMessageRetries =>
'Максимальна кількість повторних спроб надсилання повідомлення';
@override
String get appSettings_maxMessageRetriesSubtitle =>
'Кількість спроб повторного відправлення повідомлення перед тим, як позначити його як невдале';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override
String get appSettings_battery => 'Батарея';
@ -853,6 +985,9 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get contacts_groupNameRequired => 'Назва групи обов\'язкова.';
@override
String get contacts_groupNameReserved => 'Ця назва групи зарезервована';
@override
String contacts_groupAlreadyExists(String name) {
return 'Група «$name» вже існує.';
@ -892,6 +1027,42 @@ class AppLocalizationsUk extends AppLocalizations {
return 'В мережі $days дн. тому';
}
@override
String get contact_info => 'Контактна інформація';
@override
String get contact_settings => 'Налаштування контактів';
@override
String get contact_telemetry => 'Телеметрія';
@override
String get contact_lastSeen => 'Останній раз бачили';
@override
String get contact_clearChat => 'Очистити чат';
@override
String get contact_teleBase => 'Базовий телебачення';
@override
String get contact_teleBaseSubtitle =>
'Дозволити спільний доступ до рівня заряду батареї та базової телеметрії';
@override
String get contact_teleLoc => 'Розташування телеметрії';
@override
String get contact_teleLocSubtitle =>
'Дозволити спільне використання даних про місцеположення';
@override
String get contact_teleEnv => 'Середовище телеметрії';
@override
String get contact_teleEnvSubtitle =>
'Дозволити спільний доступ до даних датчиків середовища';
@override
String get channels_title => 'Канали';
@ -1474,6 +1645,9 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get map_shareMarkerHere => 'Поділитися маркером тут';
@override
String get map_setAsMyLocation => 'Встановити моє місцезнаходження';
@override
String get map_pinLabel => 'Мітка піна';
@ -1519,6 +1693,9 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get map_otherNodes => 'Інші вузли';
@override
String get map_showOverlaps => 'Перекриття ключа повторювача';
@override
String get map_keyPrefix => 'Префікс ключа';
@ -1538,6 +1715,9 @@ class AppLocalizationsUk extends AppLocalizations {
String get map_showGuessedLocations =>
'Показати місцезнаходження передбачених вузлів';
@override
String get map_showDiscoveryContacts => 'Показати контакти Відкриття';
@override
String get map_guessedLocation => 'Визначено місцезнаходження';
@ -1562,6 +1742,9 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get map_runTrace => 'Виконати трасування шляху';
@override
String get map_runTraceWithReturnPath => 'Повернутися назад тим же шляхом';
@override
String get map_removeLast => 'Видалити останній';
@ -3330,4 +3513,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 (залиште порожнім, якщо його немає).';
}
}

View file

@ -117,6 +117,49 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get connectionChoiceBluetoothLabel => '蓝牙';
@override
String get connectionChoiceTcpLabel => 'TCP';
@override
String get tcpScreenTitle => '通过 TCP 连接';
@override
String get tcpHostLabel => 'IP地址';
@override
String get tcpHostHint => '192.168.40.10';
@override
String get tcpPortLabel => '端口';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => '输入目标地址,然后连接';
@override
String tcpStatus_connectingTo(String endpoint) {
return '连接到 $endpoint...';
}
@override
String get tcpErrorHostRequired => '需要提供IP地址。';
@override
String get tcpErrorPortInvalid => '端口号必须在 1 到 65535 之间。';
@override
String get tcpErrorUnsupported => '此平台不支持 TCP 传输。';
@override
String get tcpErrorTimedOut => 'TCP 连接超时。';
@override
String tcpConnectionFailed(String error) {
return 'TCP 连接失败:$error';
}
@override
String get usbScreenTitle => '通过USB连接';
@ -331,6 +374,47 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get settings_privacyModeDisabled => '隐私模式已关闭';
@override
String get settings_privacy => '隐私设置';
@override
String get settings_privacySubtitle => '控制要共享的信息。';
@override
String get settings_privacySettingsDescription => '选择您的设备与他人共享的信息。';
@override
String get settings_denyAll => '拒绝所有';
@override
String get settings_allowByContact => '按联系人标志允许';
@override
String get settings_allowAll => '允许全部';
@override
String get settings_telemetryBaseMode => '遥测基础模式';
@override
String get settings_telemetryLocationMode => '遥测位置模式';
@override
String get settings_telemetryEnvironmentMode => '遥测环境模式';
@override
String get settings_advertLocation => '广告位置';
@override
String get settings_advertLocationSubtitle => '在广告中包含位置';
@override
String settings_multiAck(String value) {
return '多重ACK$value';
}
@override
String get settings_telemetryModeUpdated => '遥测模式已更新';
@override
String get settings_actions => '操作';
@ -605,6 +689,43 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get appSettings_autoRouteRotationDisabled => '自动路径轮换已禁用';
@override
String get appSettings_maxRouteWeight => '最大路径重量';
@override
String get appSettings_maxRouteWeightSubtitle => '一条路径可以累积的最大重量,取决于成功交付的数量。';
@override
String get appSettings_initialRouteWeight => '初始路线权重';
@override
String get appSettings_initialRouteWeightSubtitle => '新发现路径的初始重量';
@override
String get appSettings_routeWeightSuccessIncrement => '成功权重增加';
@override
String get appSettings_routeWeightSuccessIncrementSubtitle =>
'在成功交付后,将重量添加到路径中';
@override
String get appSettings_routeWeightFailureDecrement => '失败权重降低';
@override
String get appSettings_routeWeightFailureDecrementSubtitle =>
'从一条路径上移除的货物,由于无法成功交付而移除。';
@override
String get appSettings_maxMessageRetries => '最大消息重试次数';
@override
String get appSettings_maxMessageRetriesSubtitle => '在将消息标记为失败之前,允许尝试的次数';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override
String get appSettings_battery => '电池';
@ -802,6 +923,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get contacts_groupNameRequired => '请输入群聊名称';
@override
String get contacts_groupNameReserved => '该群组名称已被保留';
@override
String contacts_groupAlreadyExists(String name) {
return '名为 \"$name\" 的群聊已存在';
@ -840,6 +964,39 @@ class AppLocalizationsZh extends AppLocalizations {
return '最后在线 $days 天前';
}
@override
String get contact_info => '联系信息';
@override
String get contact_settings => '联系人设置';
@override
String get contact_telemetry => '遥测数据';
@override
String get contact_lastSeen => '最近出现';
@override
String get contact_clearChat => '清除聊天记录';
@override
String get contact_teleBase => '遥测基站';
@override
String get contact_teleBaseSubtitle => '允许共享电池电量和基本遥测数据';
@override
String get contact_teleLoc => '遥测位置';
@override
String get contact_teleLocSubtitle => '允许共享位置数据';
@override
String get contact_teleEnv => '遥测环境';
@override
String get contact_teleEnvSubtitle => '允许共享环境传感器数据';
@override
String get channels_title => '频道';
@ -1387,6 +1544,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get map_shareMarkerHere => '在此分享标记';
@override
String get map_setAsMyLocation => '设置为我的位置';
@override
String get map_pinLabel => '标签';
@ -1431,6 +1591,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get map_otherNodes => '其他节点';
@override
String get map_showOverlaps => '重复键重叠';
@override
String get map_keyPrefix => '关键字前缀';
@ -1449,6 +1612,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get map_showGuessedLocations => '显示猜测的节点位置';
@override
String get map_showDiscoveryContacts => '显示发现联系人';
@override
String get map_guessedLocation => '猜测的位置';
@ -1473,6 +1639,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get map_runTrace => '运行路径追踪';
@override
String get map_runTraceWithReturnPath => '沿着相同的路径返回';
@override
String get map_removeLast => '移除最后一个';
@ -3065,4 +3234,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如果没有请留空';
}
}

View file

@ -285,6 +285,7 @@
"contacts_newGroup": "Nieuwe Groep",
"contacts_groupName": "Groepnaam",
"contacts_groupNameRequired": "De groepnaam is verplicht.",
"contacts_groupNameReserved": "Deze groepsnaam is gereserveerd",
"contacts_groupAlreadyExists": "De groep \"{name}\" bestaat al.",
"@contacts_groupAlreadyExists": {
"placeholders": {
@ -1863,5 +1864,162 @@
"usbStatus_notConnected": "Selecteer een USB-apparaat",
"usbStatus_connecting": "Verbinding maken met USB-apparaat...",
"usbStatus_searching": "Zoeken naar USB-apparaten...",
"usbErrorConnectTimedOut": "Verbinding is verbroken. Zorg ervoor dat het apparaat de juiste USB-firmware heeft."
"usbErrorConnectTimedOut": "Verbinding is verbroken. Zorg ervoor dat het apparaat de juiste USB-firmware heeft.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpScreenTitle": "Verbind via TCP",
"tcpHostLabel": "IP-adres",
"tcpHostHint": "192.168.40.10",
"connectionChoiceTcpLabel": "TCP",
"tcpPortLabel": "Poort",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Voer het eindpunt in en verbind",
"tcpStatus_connectingTo": "Verbinding maken met {endpoint}...",
"tcpErrorHostRequired": "Een IP-adres is vereist.",
"tcpErrorPortInvalid": "De poortwaarde moet tussen 1 en 65535 liggen.",
"tcpErrorUnsupported": "TCP-transport wordt niet ondersteund op deze platform.",
"tcpErrorTimedOut": "De TCP-verbinding is verlopen.",
"tcpConnectionFailed": "Verbinding met TCP mislukt: {error}",
"map_showDiscoveryContacts": "Ontdek contacten weergeven",
"map_setAsMyLocation": "Stel dit in als mijn locatie",
"@path_routeWeight": {
"placeholders": {
"weight": {
"type": "String"
},
"max": {
"type": "String"
}
}
},
"settings_privacy": "Privacyinstellingen",
"settings_privacySubtitle": "Beheer welke informatie wordt gedeeld",
"settings_telemetryLocationMode": "Telemetrie-locatiemodus",
"settings_telemetryEnvironmentMode": "Telemetrie-omgevingsmodus",
"settings_advertLocation": "Advertentielocatie",
"settings_advertLocationSubtitle": "Locatie opnemen in advertentie",
"settings_privacySettingsDescription": "Kies welke informatie uw apparaat deelt met anderen",
"settings_allowByContact": "Toestaan op basis van contactvlaggen",
"settings_allowAll": "Alles toestaan",
"settings_denyAll": "Weiger alles",
"contact_info": "Contactinformatie",
"settings_telemetryBaseMode": "Telemetrie-basismodus",
"contact_teleBase": "Telemetrie_basis",
"contact_teleLoc": "Telemetrielocatie",
"contact_teleLocSubtitle": "Locatiegegevens delen toestaan",
"contact_teleEnv": "Telemetrieomgeving",
"contact_teleEnvSubtitle": "Delen van omgevingsensordata toestaan",
"contact_settings": "Contactinstellingen",
"contact_telemetry": "Telemetrie",
"contact_lastSeen": "Laatst gezien",
"contact_clearChat": "Chat leegmaken",
"contact_teleBaseSubtitle": "Sta delen van batterij niveau en basis telemetrie toe",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"appSettings_maxRouteWeightSubtitle": "Het maximale gewicht dat een route kan bereiken door succesvolle leveringen.",
"appSettings_initialRouteWeight": "เริ่มต้น gewicht van de route",
"appSettings_maxRouteWeight": "Maximale gewicht voor de route",
"appSettings_initialRouteWeightSubtitle": "Startgewicht voor nieuwe, ontdekte routes",
"appSettings_routeWeightSuccessIncrement": "Toename in het gewicht van het succes",
"appSettings_routeWeightSuccessIncrementSubtitle": "Gewicht wordt toegevoegd aan een route na een succesvolle levering.",
"appSettings_routeWeightFailureDecrement": "Vermindering van het gewicht van fouten",
"appSettings_routeWeightFailureDecrementSubtitle": "Gewicht verwijderd van een pad na een mislukte levering",
"appSettings_maxMessageRetries": "Aantal pogingen om berichten te versturen",
"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}",
"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": "BluetoothkoppelingsPIN"
}

File diff suppressed because it is too large Load diff

View file

@ -285,6 +285,7 @@
"contacts_newGroup": "Novo Grupo",
"contacts_groupName": "Nome do grupo",
"contacts_groupNameRequired": "O nome do grupo é obrigatório.",
"contacts_groupNameReserved": "Este nome de grupo está reservado",
"contacts_groupAlreadyExists": "O grupo \"{name}\" já existe",
"@contacts_groupAlreadyExists": {
"placeholders": {
@ -1863,5 +1864,162 @@
"usbStatus_notConnected": "Selecione um dispositivo USB",
"usbConnectionFailed": "Falha na conexão USB: {error}",
"usbStatus_connecting": "Conectando ao dispositivo USB...",
"usbErrorConnectTimedOut": "A conexão expirou. Verifique se o dispositivo possui o firmware USB Companion."
"usbErrorConnectTimedOut": "A conexão expirou. Verifique se o dispositivo possui o firmware USB Companion.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpHostLabel": "Endereço IP",
"connectionChoiceTcpLabel": "TCP",
"tcpScreenTitle": "Estabelecer conexão via TCP",
"tcpHostHint": "192.168.40.10",
"tcpPortLabel": "Porta",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Insira o endereço final e conecte-se.",
"tcpStatus_connectingTo": "Conectando a {endpoint}...",
"tcpErrorHostRequired": "É necessário fornecer um endereço IP.",
"tcpErrorPortInvalid": "O valor do porto deve estar entre 1 e 65535.",
"tcpErrorUnsupported": "O protocolo TCP não é suportado nesta plataforma.",
"tcpErrorTimedOut": "A conexão TCP expirou.",
"tcpConnectionFailed": "Falha na conexão TCP: {error}",
"map_showDiscoveryContacts": "Mostrar Contatos de Descoberta",
"map_setAsMyLocation": "Defina minha localização",
"@path_routeWeight": {
"placeholders": {
"weight": {
"type": "String"
},
"max": {
"type": "String"
}
}
},
"settings_privacySettingsDescription": "Escolha quais informações o seu dispositivo compartilha com os outros.",
"settings_allowByContact": "Permitir por bandeiras de contato",
"settings_telemetryLocationMode": "Modo de Localização de Telemetria",
"settings_telemetryEnvironmentMode": "Modo de Ambiente de Telemetria",
"settings_advertLocation": "Localização do Anúncio",
"settings_advertLocationSubtitle": "Incluir localização no anúncio",
"settings_privacySubtitle": "Controle o que é compartilhado.",
"settings_denyAll": "Negar todos",
"settings_allowAll": "Permitir todos",
"settings_privacy": "Configurações de Privacidade",
"contact_info": "Informações de Contato",
"settings_telemetryBaseMode": "Modo Base de Telemetria",
"contact_teleBase": "Base de Telemetria",
"contact_teleLoc": "Localização de Telemetria",
"contact_teleLocSubtitle": "Permitir compartilhamento de dados de localização",
"contact_teleEnv": "Ambiente de Telemetria",
"contact_teleEnvSubtitle": "Permitir compartilhamento de dados do sensor de ambiente",
"contact_lastSeen": "Visto pela última vez",
"contact_clearChat": "Limpar Chat",
"contact_telemetry": "Telemetria",
"contact_settings": "Configurações de Contato",
"contact_teleBaseSubtitle": "Permitir compartilhamento do nível da bateria e telemetria básica",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"appSettings_initialRouteWeight": "Peso Inicial da Rota",
"appSettings_maxRouteWeight": "Peso Máximo da Rota",
"appSettings_maxRouteWeightSubtitle": "Peso máximo que um determinado percurso pode acumular com entregas bem-sucedidas.",
"appSettings_initialRouteWeightSubtitle": "Peso inicial para novos caminhos descobertos",
"appSettings_routeWeightSuccessIncrement": "Aumento do peso para indicar sucesso",
"appSettings_routeWeightSuccessIncrementSubtitle": "Peso adicionado a um caminho após a entrega bem-sucedida.",
"appSettings_routeWeightFailureDecrement": "Redução do peso da falha",
"appSettings_routeWeightFailureDecrementSubtitle": "Peso removido de um caminho após uma tentativa de entrega malsucedida.",
"appSettings_maxMessageRetries": "Número máximo de tentativas de envio de mensagens",
"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}",
"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"
}

View file

@ -212,6 +212,7 @@
"contacts_newGroup": "Новая группа",
"contacts_groupName": "Имя группы",
"contacts_groupNameRequired": "Имя группы обязательно",
"contacts_groupNameReserved": "Это имя группы зарезервировано",
"contacts_groupAlreadyExists": "Группа \"{name}\" уже существует",
"contacts_filterContacts": "Фильтр контактов...",
"contacts_noContactsMatchFilter": "Нет контактов, соответствующих фильтру",
@ -1103,5 +1104,162 @@
"usbStatus_connecting": "Подключение к USB-устройству...",
"usbConnectionFailed": "Не удалось установить соединение через USB: {error}",
"usbStatus_notConnected": "Выберите USB-устройство",
"usbErrorConnectTimedOut": "Соединение не установлено. Убедитесь, что устройство имеет установленное программное обеспечение USB Companion."
"usbErrorConnectTimedOut": "Соединение не установлено. Убедитесь, что устройство имеет установленное программное обеспечение USB Companion.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpHostHint": "192.168.40.10",
"connectionChoiceTcpLabel": "TCP",
"tcpHostLabel": "IP-адрес",
"tcpScreenTitle": "Установить соединение по протоколу TCP",
"tcpPortLabel": "Порт",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Введите адрес и подключитесь.",
"tcpStatus_connectingTo": "Подключение к {endpoint}...",
"tcpErrorHostRequired": "Необходимо указать IP-адрес.",
"tcpErrorPortInvalid": "Порт должен находиться в диапазоне от 1 до 65535.",
"tcpErrorUnsupported": "Протокол TCP не поддерживается на этой платформе.",
"tcpErrorTimedOut": "Соединение TCP не удалось установить.",
"tcpConnectionFailed": "Не удалось установить соединение TCP: {error}",
"map_showDiscoveryContacts": "Показать контакты Discovery",
"map_setAsMyLocation": "Установить мое местоположение",
"@path_routeWeight": {
"placeholders": {
"weight": {
"type": "String"
},
"max": {
"type": "String"
}
}
},
"settings_privacy": "Настройки конфиденциальности",
"settings_privacySubtitle": "Контролируйте, какую информацию делиться.",
"settings_telemetryLocationMode": "Режим местоположения телеметрии",
"settings_telemetryEnvironmentMode": "Режим среды телеметрии",
"settings_advertLocation": "Местоположение рекламы",
"settings_advertLocationSubtitle": "Включить местоположение в объявление",
"settings_allowAll": "Разрешить все",
"settings_privacySettingsDescription": "Выберите, какую информацию ваше устройство будет делиться с другими.",
"settings_denyAll": "Отклонить все",
"settings_allowByContact": "Разрешить по флагам контактов",
"contact_info": "Контактная информация",
"settings_telemetryBaseMode": "Базовый режим телеметрии",
"contact_teleBase": "База телеметрии",
"contact_teleLoc": "Местоположение телеметрии",
"contact_teleLocSubtitle": "Разрешить обмен данными о местоположении",
"contact_teleEnv": "Среда телеметрии",
"contact_teleEnvSubtitle": "Разрешить обмен данными датчиков окружающей среды",
"contact_settings": "Настройки контактов",
"contact_telemetry": "Телеметрия",
"contact_clearChat": "Очистить чат",
"contact_lastSeen": "Последний раз видели",
"contact_teleBaseSubtitle": "Разрешить обмен уровнем заряда батареи и базовой телеметрией",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"appSettings_maxRouteWeight": "Максимальный допустимый вес маршрута",
"appSettings_maxRouteWeightSubtitle": "Максимальный вес, который может быть перевезён по определённому маршруту при успешных доставках.",
"appSettings_initialRouteWeightSubtitle": "Начальный вес для новых, только что открытых маршрутов",
"appSettings_initialRouteWeight": "Начальный вес маршрута",
"appSettings_routeWeightSuccessIncrement": "Увеличение веса успеха",
"appSettings_routeWeightSuccessIncrementSubtitle": "Вес, добавленный к маршруту после успешной доставки.",
"appSettings_routeWeightFailureDecrement": "Уменьшение веса неудачи",
"appSettings_routeWeightFailureDecrementSubtitle": "Вес, который был удален с пути после неудачной доставки.",
"appSettings_maxMessageRetries": "Максимальное количество повторных попыток отправки сообщения",
"appSettings_maxMessageRetriesSubtitle": "Количество попыток повторной отправки сообщения перед тем, как пометить его как неудачное.",
"path_routeWeight": "{weight}/{max}",
"settings_telemetryModeUpdated": "Режим телеметрии обновлен",
"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"
}

View file

@ -285,6 +285,7 @@
"contacts_newGroup": "Nová skupina",
"contacts_groupName": "Názov skupiny",
"contacts_groupNameRequired": "Skupina musí mať názov.",
"contacts_groupNameReserved": "Tento názov skupiny je rezervovaný",
"contacts_groupAlreadyExists": "Skupina \"{name}\" už existuje",
"@contacts_groupAlreadyExists": {
"placeholders": {
@ -1863,5 +1864,162 @@
"usbConnectionFailed": "Neúspešné pripojenie cez USB: {error}",
"usbStatus_notConnected": "Vyberte USB zariadenie",
"usbStatus_connecting": "Pripojenie k USB zariadeniu...",
"usbErrorConnectTimedOut": "Pripojenie nebolo úspešné. Uistite sa, že zariadenie má nainštalovaný firmware USB Companion."
"usbErrorConnectTimedOut": "Pripojenie nebolo úspešné. Uistite sa, že zariadenie má nainštalovaný firmware USB Companion.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpHostHint": "192.168.40.10",
"tcpHostLabel": "IP adresa",
"tcpScreenTitle": "Spojte sa pomocou protokolu TCP",
"connectionChoiceTcpLabel": "TCP",
"tcpPortLabel": "Port",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Zadajte cieľovú adresu a pripojte sa.",
"tcpStatus_connectingTo": "Pripojenie k {endpoint}...",
"tcpErrorHostRequired": "Je potrebné zadať IP adresu.",
"tcpErrorPortInvalid": "Číslo portu musí byť medzi 1 a 65535.",
"tcpErrorUnsupported": "Prevoz prostredníctvom protokolu TCP nie je na tejto platforme podporovaný.",
"tcpErrorTimedOut": "Pripojenie TCP vypršalo.",
"tcpConnectionFailed": "Neúspešné vytvorenie TCP spojenia: {error}",
"map_showDiscoveryContacts": "Zobraziť kontakty objavov",
"map_setAsMyLocation": "Nastavte ako moju polohu",
"@path_routeWeight": {
"placeholders": {
"weight": {
"type": "String"
},
"max": {
"type": "String"
}
}
},
"settings_privacy": "Nastavenia súkromia",
"settings_privacySubtitle": "Ovládni, aké informácie sa zdieľajú.",
"settings_telemetryLocationMode": "Režim umiestnenia telemetrie",
"settings_telemetryBaseMode": "Základný režim telemetrie",
"settings_advertLocation": "Umiestnenie inzerátu",
"settings_telemetryEnvironmentMode": "Režim prostredia telemetrie",
"settings_advertLocationSubtitle": "Zahrnúť polohu do inzerátu",
"settings_allowAll": "Povoliť všetko",
"settings_privacySettingsDescription": "Vyberte, ktoré informácie váš zariadenie zdieľa s ostatnými.",
"settings_denyAll": "Zamietnuť všetko",
"settings_allowByContact": "Povoliť podľa kontaktových vlajok",
"contact_info": "Kontaktné informácie",
"contact_settings": "Nastavenia kontaktov",
"contact_teleBaseSubtitle": "Povoliť zdieľanie úrovne batérie a základnej telemetrie",
"contact_teleLoc": "Lokácia telemetrie",
"contact_teleLocSubtitle": "Povoliť zdieľanie údajov o lokalite",
"contact_teleEnv": "Prostredie telemetrie",
"contact_telemetry": "Telemetria",
"contact_clearChat": "Vymazať chat",
"contact_lastSeen": "Naposledy videný",
"contact_teleBase": "Báza telemetrie",
"contact_teleEnvSubtitle": "Povoliť zdieľanie údajov senzorov prostredia",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"appSettings_maxRouteWeightSubtitle": "Maximálna hmotnosť, ktorú môže trás prenášať vďaka úspešným zásielkam.",
"appSettings_initialRouteWeightSubtitle": "Počiatočná váha pre nové, objavené cesty",
"appSettings_initialRouteWeight": "Počiatočná váha trasy",
"appSettings_maxRouteWeight": "Maximálna hmotnosť trasy",
"appSettings_routeWeightSuccessIncrement": "Zvyšenie váhy úspechu",
"appSettings_routeWeightSuccessIncrementSubtitle": "Hmotnosť pridaná k trase po úspešnej doručení",
"appSettings_routeWeightFailureDecrement": "Sníženie váhy, ktorá sa používa na odhad rizika.",
"appSettings_routeWeightFailureDecrementSubtitle": "Hmotnosť odstránená z cesty po neúspešnej doručenie",
"appSettings_maxMessageRetries": "Maximalný počet pokusov o doručenie správ",
"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}",
"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"
}

View file

@ -285,6 +285,7 @@
"contacts_newGroup": "Nova skupina",
"contacts_groupName": "Ime skupine",
"contacts_groupNameRequired": "Ime skupine je obvezno.",
"contacts_groupNameReserved": "To ime skupine je rezervirano",
"contacts_groupAlreadyExists": "Skupina \"{name}\" že obstaja",
"@contacts_groupAlreadyExists": {
"placeholders": {
@ -1863,5 +1864,162 @@
"usbStatus_connecting": "Povezava z USB napravo...",
"usbStatus_searching": "Iskanje USB naprav...",
"usbConnectionFailed": "Napaka pri povezavi preko USB: {error}",
"usbErrorConnectTimedOut": "Vzpostavitve ni bilo mogo. Prosimo, da se prepričate, da ima naprave trenutno nameštan firmware USB Companion."
"usbErrorConnectTimedOut": "Vzpostavitve ni bilo mogo. Prosimo, da se prepričate, da ima naprave trenutno nameštan firmware USB Companion.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"connectionChoiceTcpLabel": "TCP",
"tcpHostLabel": "IP naslov",
"tcpHostHint": "192.168.40.10",
"tcpScreenTitle": "Komunicirajte preko protokola TCP",
"tcpPortLabel": "Vrata",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Vnesite končni naslov in se povežite",
"tcpStatus_connectingTo": "Povezava z {endpoint}...",
"tcpErrorHostRequired": "Potrebna je IP-naslov.",
"tcpErrorPortInvalid": "Port mora biti med 1 in 65535.",
"tcpErrorUnsupported": "Transport preko protokola TCP ni podprt na tej platformi.",
"tcpErrorTimedOut": "Povezava TCP je presegla časovno obdobje.",
"tcpConnectionFailed": "Napaka pri povezavi TCP: {error}",
"map_showDiscoveryContacts": "Prikaži odkritja kontaktov",
"map_setAsMyLocation": "Nastavite to kot mojo lokacijo",
"@path_routeWeight": {
"placeholders": {
"weight": {
"type": "String"
},
"max": {
"type": "String"
}
}
},
"settings_privacy": "Nastavitve zasebnosti",
"settings_privacySettingsDescription": "Izberite, katere informacije vaš naprava deli z drugimi.",
"settings_telemetryBaseMode": "Osnovni način telemetrije",
"settings_telemetryLocationMode": "Način delovanja telemetrije",
"settings_telemetryEnvironmentMode": "Način delovanja okolja telemetrije",
"settings_advertLocation": "Lokacija oglasa",
"settings_allowByContact": "Dovoli po kontaktnih zastavah",
"settings_denyAll": "Zavrniti vse",
"settings_allowAll": "Dovoli vse",
"settings_privacySubtitle": "Kontrolirajte, katere informacije so deljene.",
"contact_info": "Kontaktni podatki",
"contact_teleBase": "Baza telemetrije",
"contact_teleBaseSubtitle": "Dovoli deljenje stanja baterije in osnovne telemetrije",
"contact_teleLoc": "Lokacija telemetrije",
"contact_lastSeen": "Zadnjič videno",
"contact_settings": "Nastavitve stika",
"settings_advertLocationSubtitle": "Vključi lokacijo v oglas.",
"contact_telemetry": "Telemetrija",
"contact_clearChat": "Počisti klepet",
"contact_teleEnv": "Okolje telemetrije",
"contact_teleEnvSubtitle": "Dovoli deljenje podatkov okoljskih senzorjev",
"contact_teleLocSubtitle": "Dovoli deljenje podatkov o lokaciji",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"appSettings_maxRouteWeightSubtitle": "Največja teža, ki jo lahko pot doseže s uspešnimi dostavnami.",
"appSettings_initialRouteWeight": "Izvirna teža poti",
"appSettings_initialRouteWeightSubtitle": "Izguba teže za nove, odkriti poti",
"appSettings_maxRouteWeight": "Največja dovoljena teža poti",
"appSettings_routeWeightSuccessIncrement": "Učinkovitost: povečanje",
"appSettings_routeWeightSuccessIncrementSubtitle": "Težava, dodana poti po uspešni dostavi",
"appSettings_routeWeightFailureDecrement": "Zmanjšanje teže, ki je povezana s pomanjkanjem",
"appSettings_routeWeightFailureDecrementSubtitle": "Težo, ki ni bila uspešno dostavljena, odstranili s poti.",
"appSettings_maxMessageRetries": "Najve število poskusov pošiljanja sporočil",
"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",
"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"
}

View file

@ -285,6 +285,7 @@
"contacts_newGroup": "Ny grupp",
"contacts_groupName": "Gruppnamn",
"contacts_groupNameRequired": "Gruppnamnet är obligatoriskt",
"contacts_groupNameReserved": "Detta gruppnamn är reserverat",
"contacts_groupAlreadyExists": "Gruppen \"{name}\" finns redan.",
"@contacts_groupAlreadyExists": {
"placeholders": {
@ -1863,5 +1864,162 @@
"usbStatus_notConnected": "Välj en USB-enhet",
"usbConnectionFailed": "Fel vid USB-anslutning: {error}",
"usbStatus_searching": "Söker efter USB-enheter...",
"usbErrorConnectTimedOut": "Anslutningen har tidsutgått. Se till att enheten har rätt USB-firmware."
"usbErrorConnectTimedOut": "Anslutningen har tidsutgått. Se till att enheten har rätt USB-firmware.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpHostHint": "192.168.40.10",
"tcpHostLabel": "IP-adress",
"tcpScreenTitle": "Anslut via TCP",
"connectionChoiceTcpLabel": "TCP",
"tcpPortLabel": "Port",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Ange slutpunkt och anslut",
"tcpStatus_connectingTo": "Anslutning till {endpoint}...",
"tcpErrorHostRequired": "IP-adress krävs.",
"tcpErrorPortInvalid": "Porten måste vara mellan 1 och 65535.",
"tcpErrorUnsupported": "TCP-transport fungerar inte på denna plattform.",
"tcpErrorTimedOut": "TCP-anslutningen har tidsut gått.",
"tcpConnectionFailed": "Fel vid TCP-anslutning: {error}",
"map_showDiscoveryContacts": "Visa Discovery-kontakter",
"map_setAsMyLocation": "Ange som min plats",
"@path_routeWeight": {
"placeholders": {
"weight": {
"type": "String"
},
"max": {
"type": "String"
}
}
},
"settings_privacy": "Inställningar för sekretess",
"settings_allowAll": "Tillåt alla",
"settings_privacySubtitle": "Kontrollera vilken information som delas.",
"settings_telemetryEnvironmentMode": "Telemetri miljöläge",
"settings_telemetryBaseMode": "Telemetribasläge",
"settings_telemetryLocationMode": "Telemetritillstånd för plats",
"settings_advertLocation": "Annonsplacering",
"contact_info": "Kontaktinformation",
"contact_settings": "Kontaktinställningar",
"contact_telemetry": "Telemetri",
"settings_denyAll": "Neka alla",
"settings_allowByContact": "Tillåt via kontaktflaggor",
"settings_privacySettingsDescription": "Välj vilken information din enhet delar med andra.",
"contact_lastSeen": "Senast sedd",
"contact_clearChat": "Rensa Chatt",
"contact_teleEnv": "Telemetri Miljö",
"settings_advertLocationSubtitle": "Inkludera plats i annonsen",
"contact_teleEnvSubtitle": "Tillåt delning av miljösensordata",
"contact_teleBase": "Telemetribas",
"contact_teleBaseSubtitle": "Tillåt delning av batterinivå och grundläggande telemetri",
"contact_teleLoc": "Telemetridata plats",
"contact_teleLocSubtitle": "Tillåt delning av platsdata",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"appSettings_initialRouteWeightSubtitle": "Initial vikt för nyligen upptäckta vägar",
"appSettings_maxRouteWeight": "Maximalt tillåtet vikt för rutten",
"appSettings_maxRouteWeightSubtitle": "Maximal vikt som en leveransväg kan ackumulera från framgångsrika leveranser.",
"appSettings_initialRouteWeight": "Initial vikt för rutt",
"appSettings_routeWeightSuccessIncrement": "Ökning av vikt för framgång",
"appSettings_routeWeightSuccessIncrementSubtitle": "Vikt läggs till en väg efter en lyckad leverans.",
"appSettings_routeWeightFailureDecrement": "Minskning av vikten för misslyckande",
"appSettings_routeWeightFailureDecrementSubtitle": "Vikt som tagits bort från en väg efter ett misslyckat leveransförsök",
"appSettings_maxMessageRetries": "Maximalt antal försök",
"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}",
"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": "BluetoothparningsPIN",
"scanner_linuxPairingPinPrompt": "Ange PIN för {deviceName} (lämna tomt om ingen).",
"scanner_linuxPairingHidePin": "Dölj PIN"
}

View file

@ -286,6 +286,7 @@
"contacts_newGroup": "Нова група",
"contacts_groupName": "Назва групи",
"contacts_groupNameRequired": "Назва групи обов'язкова.",
"contacts_groupNameReserved": "Ця назва групи зарезервована",
"contacts_groupAlreadyExists": "Група «{name}» вже існує.",
"@contacts_groupAlreadyExists": {
"placeholders": {
@ -1863,5 +1864,162 @@
"usbStatus_notConnected": "Виберіть пристрій USB",
"usbConnectionFailed": "Не вдалося встановити з'єднання через USB: {error}",
"usbStatus_connecting": "Підключення до USB-пристрою...",
"usbErrorConnectTimedOut": "З'єднання не вдалося встановити. Переконайтеся, що пристрій має встановлене програмне забезпечення USB Companion."
"usbErrorConnectTimedOut": "З'єднання не вдалося встановити. Переконайтеся, що пристрій має встановлене програмне забезпечення USB Companion.",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"connectionChoiceTcpLabel": "TCP",
"tcpHostHint": "192.168.40.10",
"tcpHostLabel": "IP-адреса",
"tcpScreenTitle": "З'єднатися через протокол TCP",
"tcpPortLabel": "Порт",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Введіть кінцеву точку та підключіться",
"tcpStatus_connectingTo": "Підключення до {endpoint}...",
"tcpErrorHostRequired": "Необхідно вказати IP-адресу.",
"tcpErrorPortInvalid": "Порт повинен бути в межах від 1 до 65535.",
"tcpErrorUnsupported": "Транспорт TCP не підтримується на цій платформі.",
"tcpErrorTimedOut": "З'єднання TCP завершилося через закінчення часу очікування.",
"tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}",
"map_showDiscoveryContacts": "Показати контакти Відкриття",
"map_setAsMyLocation": "Встановити моє місцезнаходження",
"@path_routeWeight": {
"placeholders": {
"weight": {
"type": "String"
},
"max": {
"type": "String"
}
}
},
"settings_privacySubtitle": "Керуйте інформацією, яку буде спільно використовуватися",
"settings_privacy": "Налаштування приватності",
"settings_telemetryBaseMode": "Режим базової телеметрії",
"settings_telemetryLocationMode": "Режим місця телеметрії",
"settings_advertLocation": "Розміщення реклами",
"settings_advertLocationSubtitle": "Включити місце розташування в оголошення",
"settings_privacySettingsDescription": "Виберіть, яку інформацію ваш пристрій буде передавати іншим.",
"settings_allowAll": "Дозволити все",
"settings_denyAll": "Відхилити все",
"settings_allowByContact": "Дозволити за контактними прапорцями",
"settings_telemetryEnvironmentMode": "Режим середовища телеметрії",
"contact_info": "Контактна інформація",
"contact_teleBaseSubtitle": "Дозволити спільний доступ до рівня заряду батареї та базової телеметрії",
"contact_teleLoc": "Розташування телеметрії",
"contact_teleBase": "Базовий телебачення",
"contact_teleLocSubtitle": "Дозволити спільне використання даних про місцеположення",
"contact_settings": "Налаштування контактів",
"contact_telemetry": "Телеметрія",
"contact_clearChat": "Очистити чат",
"contact_lastSeen": "Останній раз бачили",
"contact_teleEnv": "Середовище телеметрії",
"contact_teleEnvSubtitle": "Дозволити спільний доступ до даних датчиків середовища",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"appSettings_initialRouteWeight": "Початкова вартість маршруту",
"appSettings_initialRouteWeightSubtitle": "Початкова вага для нових відкритих шляхів",
"appSettings_maxRouteWeight": "Максимальна вага маршруту",
"appSettings_maxRouteWeightSubtitle": "Максимальна вага, яку може накопичити маршрут завдяки успішним доставкам.",
"appSettings_routeWeightSuccessIncrement": "Збільшення ваги успіху",
"appSettings_routeWeightSuccessIncrementSubtitle": "Вага, додана до маршруту після успішної доставки",
"appSettings_routeWeightFailureDecrement": "Зменшення ваги помилки",
"appSettings_routeWeightFailureDecrementSubtitle": "Вага, яка була знята з маршруту після невдалої доставки",
"appSettings_maxMessageRetries": "Максимальна кількість повторних спроб надсилання повідомлення",
"appSettings_maxMessageRetriesSubtitle": "Кількість спроб повторного відправлення повідомлення перед тим, як позначити його як невдале",
"path_routeWeight": "{weight}/{max}",
"settings_telemetryModeUpdated": "Режим телеметрії оновлено",
"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"
}

View file

@ -300,6 +300,7 @@
"contacts_newGroup": "新建群聊",
"contacts_groupName": "群聊名称",
"contacts_groupNameRequired": "请输入群聊名称",
"contacts_groupNameReserved": "该群组名称已被保留",
"contacts_groupAlreadyExists": "名为 \"{name}\" 的群聊已存在",
"@contacts_groupAlreadyExists": {
"placeholders": {
@ -1868,5 +1869,162 @@
"usbStatus_connecting": "连接USB设备...",
"usbStatus_notConnected": "选择一个 USB 设备",
"usbConnectionFailed": "USB 连接失败:{error}",
"usbErrorConnectTimedOut": "连接超时。请确保设备已安装 USB 伴侣固件。"
"usbErrorConnectTimedOut": "连接超时。请确保设备已安装 USB 伴侣固件。",
"@tcpStatus_connectingTo": {
"placeholders": {
"endpoint": {
"type": "String"
}
}
},
"@tcpConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"tcpHostLabel": "IP地址",
"tcpHostHint": "192.168.40.10",
"tcpScreenTitle": "通过 TCP 连接",
"connectionChoiceTcpLabel": "TCP",
"tcpPortLabel": "端口",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "输入目标地址,然后连接",
"tcpStatus_connectingTo": "连接到 {endpoint}...",
"tcpErrorHostRequired": "需要提供IP地址。",
"tcpErrorPortInvalid": "端口号必须在 1 到 65535 之间。",
"tcpErrorUnsupported": "此平台不支持 TCP 传输。",
"tcpErrorTimedOut": "TCP 连接超时。",
"tcpConnectionFailed": "TCP 连接失败:{error}",
"map_showDiscoveryContacts": "显示发现联系人",
"map_setAsMyLocation": "设置为我的位置",
"@path_routeWeight": {
"placeholders": {
"weight": {
"type": "String"
},
"max": {
"type": "String"
}
}
},
"settings_privacySubtitle": "控制要共享的信息。",
"settings_privacySettingsDescription": "选择您的设备与他人共享的信息。",
"settings_telemetryBaseMode": "遥测基础模式",
"settings_telemetryLocationMode": "遥测位置模式",
"settings_advertLocation": "广告位置",
"settings_advertLocationSubtitle": "在广告中包含位置",
"settings_allowByContact": "按联系人标志允许",
"settings_denyAll": "拒绝所有",
"settings_privacy": "隐私设置",
"settings_allowAll": "允许全部",
"contact_info": "联系信息",
"contact_teleBase": "遥测基站",
"contact_teleBaseSubtitle": "允许共享电池电量和基本遥测数据",
"settings_telemetryEnvironmentMode": "遥测环境模式",
"contact_teleLoc": "遥测位置",
"contact_teleEnv": "遥测环境",
"contact_teleEnvSubtitle": "允许共享环境传感器数据",
"contact_clearChat": "清除聊天记录",
"contact_lastSeen": "最近出现",
"contact_settings": "联系人设置",
"contact_teleLocSubtitle": "允许共享位置数据",
"contact_telemetry": "遥测数据",
"@settings_multiAck": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"appSettings_maxRouteWeight": "最大路径重量",
"appSettings_initialRouteWeightSubtitle": "新发现路径的初始重量",
"appSettings_initialRouteWeight": "初始路线权重",
"appSettings_maxRouteWeightSubtitle": "一条路径可以累积的最大重量,取决于成功交付的数量。",
"appSettings_routeWeightSuccessIncrement": "成功权重增加",
"appSettings_routeWeightSuccessIncrementSubtitle": "在成功交付后,将重量添加到路径中",
"appSettings_routeWeightFailureDecrement": "失败权重降低",
"appSettings_routeWeightFailureDecrementSubtitle": "从一条路径上移除的货物,由于无法成功交付而移除。",
"appSettings_maxMessageRetries": "最大消息重试次数",
"appSettings_maxMessageRetriesSubtitle": "在将消息标记为失败之前,允许尝试的次数",
"path_routeWeight": "{weight}/{max}",
"settings_multiAck": "多重ACK{value}",
"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"
}

View file

@ -19,6 +19,8 @@ import 'services/app_debug_log_service.dart';
import 'services/background_service.dart';
import 'services/map_tile_cache_service.dart';
import 'services/chat_text_scale_service.dart';
import 'services/ui_view_state_service.dart';
import 'services/timeout_prediction_service.dart';
import 'storage/prefs_manager.dart';
import 'utils/app_logger.dart';
@ -39,6 +41,8 @@ void main() async {
final backgroundService = BackgroundService();
final mapTileCacheService = MapTileCacheService();
final chatTextScaleService = ChatTextScaleService();
final uiViewStateService = UiViewStateService();
final timeoutPredictionService = TimeoutPredictionService(storage);
// Load settings
await appSettingsService.loadSettings();
@ -56,6 +60,8 @@ void main() async {
_registerThirdPartyLicenses();
await chatTextScaleService.initialize();
await uiViewStateService.initialize();
await timeoutPredictionService.initialize();
// Wire up connector with services
connector.initialize(
@ -65,6 +71,7 @@ void main() async {
bleDebugLogService: bleDebugLogService,
appDebugLogService: appDebugLogService,
backgroundService: backgroundService,
timeoutPredictionService: timeoutPredictionService,
);
await connector.loadContactCache();
@ -86,6 +93,8 @@ void main() async {
appDebugLogService: appDebugLogService,
mapTileCacheService: mapTileCacheService,
chatTextScaleService: chatTextScaleService,
uiViewStateService: uiViewStateService,
timeoutPredictionService: timeoutPredictionService,
),
);
}
@ -121,6 +130,8 @@ class MeshCoreApp extends StatelessWidget {
final AppDebugLogService appDebugLogService;
final MapTileCacheService mapTileCacheService;
final ChatTextScaleService chatTextScaleService;
final UiViewStateService uiViewStateService;
final TimeoutPredictionService timeoutPredictionService;
const MeshCoreApp({
super.key,
@ -133,6 +144,8 @@ class MeshCoreApp extends StatelessWidget {
required this.appDebugLogService,
required this.mapTileCacheService,
required this.chatTextScaleService,
required this.uiViewStateService,
required this.timeoutPredictionService,
});
@override
@ -146,8 +159,10 @@ class MeshCoreApp extends StatelessWidget {
ChangeNotifierProvider.value(value: bleDebugLogService),
ChangeNotifierProvider.value(value: appDebugLogService),
ChangeNotifierProvider.value(value: chatTextScaleService),
ChangeNotifierProvider.value(value: uiViewStateService),
Provider.value(value: storage),
Provider.value(value: mapTileCacheService),
ChangeNotifierProvider.value(value: timeoutPredictionService),
],
child: Consumer<AppSettingsService>(
builder: (context, settingsService, child) {

View file

@ -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;
@ -32,6 +33,11 @@ class AppSettings {
final bool notifyOnNewChannelMessage;
final bool notifyOnNewAdvert;
final bool autoRouteRotationEnabled;
final double maxRouteWeight;
final double initialRouteWeight;
final double routeWeightSuccessIncrement;
final double routeWeightFailureDecrement;
final int maxMessageRetries;
final String themeMode;
final String? languageOverride; // null = system default
final bool appDebugLogEnabled;
@ -39,12 +45,17 @@ class AppSettings {
final Map<String, String> batteryChemistryByRepeaterId;
final UnitSystem unitSystem;
final Set<String> mutedChannels;
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 = '',
@ -59,6 +70,11 @@ class AppSettings {
this.notifyOnNewChannelMessage = true,
this.notifyOnNewAdvert = true,
this.autoRouteRotationEnabled = false,
this.maxRouteWeight = 5.0,
this.initialRouteWeight = 3.0,
this.routeWeightSuccessIncrement = 0.5,
this.routeWeightFailureDecrement = 0.2,
this.maxMessageRetries = 5,
this.themeMode = 'system',
this.languageOverride,
this.appDebugLogEnabled = false,
@ -66,6 +82,10 @@ class AppSettings {
Map<String, String>? batteryChemistryByRepeaterId,
this.unitSystem = UnitSystem.metric,
Set<String>? mutedChannels,
this.mapShowDiscoveryContacts = true,
this.tcpServerAddress = '',
this.tcpServerPort = 0,
this.jumpToOldestUnread = false,
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {},
batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {},
mutedChannels = mutedChannels ?? {};
@ -76,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,
@ -90,6 +111,11 @@ class AppSettings {
'notify_on_new_channel_message': notifyOnNewChannelMessage,
'notify_on_new_advert': notifyOnNewAdvert,
'auto_route_rotation_enabled': autoRouteRotationEnabled,
'max_route_weight': maxRouteWeight,
'initial_route_weight': initialRouteWeight,
'route_weight_success_increment': routeWeightSuccessIncrement,
'route_weight_failure_decrement': routeWeightFailureDecrement,
'max_message_retries': maxMessageRetries,
'theme_mode': themeMode,
'language_override': languageOverride,
'app_debug_log_enabled': appDebugLogEnabled,
@ -97,6 +123,10 @@ class AppSettings {
'battery_chemistry_by_repeater_id': batteryChemistryByRepeaterId,
'unit_system': unitSystem.value,
'muted_channels': mutedChannels.toList(),
'map_show_discovery_contacts': mapShowDiscoveryContacts,
'tcp_server_address': tcpServerAddress,
'tcp_server_port': tcpServerPort,
'jump_to_oldest_unread': jumpToOldestUnread,
};
}
@ -113,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,
@ -133,6 +164,14 @@ class AppSettings {
notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true,
autoRouteRotationEnabled:
json['auto_route_rotation_enabled'] as bool? ?? false,
maxRouteWeight: (json['max_route_weight'] as num?)?.toDouble() ?? 5.0,
initialRouteWeight:
(json['initial_route_weight'] as num?)?.toDouble() ?? 3.0,
routeWeightSuccessIncrement:
(json['route_weight_success_increment'] as num?)?.toDouble() ?? 0.5,
routeWeightFailureDecrement:
(json['route_weight_failure_decrement'] as num?)?.toDouble() ?? 0.2,
maxMessageRetries: json['max_message_retries'] as int? ?? 5,
themeMode: json['theme_mode'] as String? ?? 'system',
languageOverride: json['language_override'] as String?,
appDebugLogEnabled: json['app_debug_log_enabled'] as bool? ?? false,
@ -152,6 +191,11 @@ class AppSettings {
?.map((e) => e.toString())
.toSet()) ??
{},
mapShowDiscoveryContacts:
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,
);
}
@ -160,6 +204,7 @@ class AppSettings {
bool? mapShowRepeaters,
bool? mapShowChatNodes,
bool? mapShowOtherNodes,
bool? mapShowOverlaps,
double? mapTimeFilterHours,
bool? mapKeyPrefixEnabled,
String? mapKeyPrefix,
@ -174,6 +219,11 @@ class AppSettings {
bool? notifyOnNewChannelMessage,
bool? notifyOnNewAdvert,
bool? autoRouteRotationEnabled,
double? maxRouteWeight,
double? initialRouteWeight,
double? routeWeightSuccessIncrement,
double? routeWeightFailureDecrement,
int? maxMessageRetries,
String? themeMode,
Object? languageOverride = _unset,
bool? appDebugLogEnabled,
@ -181,12 +231,17 @@ class AppSettings {
Map<String, String>? batteryChemistryByRepeaterId,
UnitSystem? unitSystem,
Set<String>? mutedChannels,
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,
@ -206,6 +261,13 @@ class AppSettings {
notifyOnNewAdvert: notifyOnNewAdvert ?? this.notifyOnNewAdvert,
autoRouteRotationEnabled:
autoRouteRotationEnabled ?? this.autoRouteRotationEnabled,
maxRouteWeight: maxRouteWeight ?? this.maxRouteWeight,
initialRouteWeight: initialRouteWeight ?? this.initialRouteWeight,
routeWeightSuccessIncrement:
routeWeightSuccessIncrement ?? this.routeWeightSuccessIncrement,
routeWeightFailureDecrement:
routeWeightFailureDecrement ?? this.routeWeightFailureDecrement,
maxMessageRetries: maxMessageRetries ?? this.maxMessageRetries,
themeMode: themeMode ?? this.themeMode,
languageOverride: languageOverride == _unset
? this.languageOverride
@ -217,6 +279,11 @@ class AppSettings {
batteryChemistryByRepeaterId ?? this.batteryChemistryByRepeaterId,
unitSystem: unitSystem ?? this.unitSystem,
mutedChannels: mutedChannels ?? this.mutedChannels,
mapShowDiscoveryContacts:
mapShowDiscoveryContacts ?? this.mapShowDiscoveryContacts,
tcpServerAddress: tcpServerAddress ?? this.tcpServerAddress,
tcpServerPort: tcpServerPort ?? this.tcpServerPort,
jumpToOldestUnread: jumpToOldestUnread ?? this.jumpToOldestUnread,
);
}
}

View file

@ -24,20 +24,23 @@ class Channel {
bool get isPublicChannel => pskHex == publicChannelPsk;
static Channel? fromFrame(Uint8List data) {
static Channel? fromFrame(Uint8List frame) {
// CHANNEL_INFO format:
// [0] = RESP_CODE_CHANNEL_INFO (18)
// [1] = channel_idx
// [2-33] = name (32 bytes, null-terminated)
// [34-49] = psk (16 bytes)
if (data.length < 50) return null;
if (data[0] != respCodeChannelInfo) return null;
final index = data[1];
final name = readCString(data, 2, 32);
final psk = Uint8List.fromList(data.sublist(34, 50));
return Channel(index: index, name: name, psk: psk);
if (frame.length < 50) return null;
final reader = BufferReader(frame);
try {
if (reader.readByte() != respCodeChannelInfo) return null;
final index = reader.readByte();
final name = reader.readCStringGreedy(32);
final psk = reader.readBytes(16);
return Channel(index: index, name: name, psk: psk);
} catch (e) {
return null;
}
}
static Channel empty(int index) {

View file

@ -2,6 +2,7 @@ import 'dart:typed_data';
import '../connector/meshcore_protocol.dart';
import '../helpers/reaction_helper.dart';
import '../helpers/smaz.dart';
import '../utils/app_logger.dart';
enum ChannelMessageStatus { pending, sent, failed }
@ -36,6 +37,7 @@ class ChannelMessage {
final List<Uint8List> pathVariants;
final int? channelIndex;
final String messageId;
final String? packetHash;
final String? replyToMessageId;
final String? replyToSenderName;
final String? replyToText;
@ -55,6 +57,7 @@ class ChannelMessage {
List<Uint8List>? pathVariants,
this.channelIndex,
String? messageId,
this.packetHash,
this.replyToMessageId,
this.replyToSenderName,
this.replyToText,
@ -79,6 +82,7 @@ class ChannelMessage {
int? pathLength,
Uint8List? pathBytes,
List<Uint8List>? pathVariants,
String? packetHash,
String? replyToMessageId,
String? replyToSenderName,
String? replyToText,
@ -98,6 +102,7 @@ class ChannelMessage {
pathVariants: pathVariants ?? this.pathVariants,
channelIndex: channelIndex,
messageId: messageId,
packetHash: packetHash ?? this.packetHash,
replyToMessageId: replyToMessageId ?? this.replyToMessageId,
replyToSenderName: replyToSenderName ?? this.replyToSenderName,
replyToText: replyToText ?? this.replyToText,
@ -105,89 +110,82 @@ class ChannelMessage {
);
}
static ChannelMessage? fromFrame(Uint8List data) {
static ChannelMessage? fromFrame(Uint8List frame) {
// CHANNEL_MSG_RECV format varies by version:
// V3: [0]=code [1]=SNR [2]=rsv1 [3]=rsv2 [4]=channel_idx [5]=path_len [path... optional] [txt_type] [timestamp x4] [text...]
// Non-V3: [0]=code [1]=channel_idx [2]=path_len [3]=txt_type [4-7]=timestamp [8+]=text
if (data.length < 8) return null;
if (frame.length < 8) return null;
try {
final reader = BufferReader(frame);
final code = reader.readByte();
if (code != respCodeChannelMsgRecv && code != respCodeChannelMsgRecvV3) {
return null;
}
final code = data[0];
if (code != respCodeChannelMsgRecv && code != respCodeChannelMsgRecvV3) {
int pathLen;
int txtType;
Uint8List pathBytes = Uint8List(0);
int channelIdx;
if (code == respCodeChannelMsgRecvV3) {
reader.skipBytes(1); // Skip SNR
final flags = reader.readByte();
final hasPath = (flags & 0x01) != 0;
reader.skipBytes(1); // Skip reserved byte
channelIdx = reader.readByte();
pathLen = reader.readInt8();
txtType = reader.readByte();
if (hasPath && pathLen > 0) {
reader.rewind(); // Rewind to read path length again for pathBytes
pathBytes = reader.readBytes(pathLen);
}
} else {
channelIdx = reader.readByte();
pathLen = reader.readInt8();
txtType = reader.readByte();
}
final timestampRaw = reader.readUInt32LE();
if (txtType != txtTypePlain) {
return null;
}
final text = reader.readCString();
// Extract sender name and actual message from "name: msg" format
String senderName = 'Unknown';
String actualText = text;
final colonIndex = text.indexOf(':');
if (colonIndex > 0 && colonIndex < text.length - 1 && colonIndex < 50) {
final potentialSender = text.substring(0, colonIndex);
if (!RegExp(r'[:\[\]]').hasMatch(potentialSender)) {
senderName = potentialSender;
final offset =
(colonIndex + 1 < text.length && text[colonIndex + 1] == ' ')
? colonIndex + 2
: colonIndex + 1;
actualText = text.substring(offset);
}
}
final decodedText = Smaz.tryDecodePrefixed(actualText) ?? actualText;
return ChannelMessage(
senderKey: null,
senderName: senderName,
text: decodedText,
timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000),
isOutgoing: false,
status: ChannelMessageStatus.sent,
pathLength: pathLen,
pathBytes: pathBytes,
channelIndex: channelIdx,
);
} catch (e) {
appLogger.error('Error parsing channel message frame: $e');
// If parsing fails, return null to avoid crashes
return null;
}
int timestampOffset, textOffset, pathLenOffset, txtTypeOffset;
Uint8List pathBytes = Uint8List(0);
int channelIdx;
if (code == respCodeChannelMsgRecvV3) {
channelIdx = data[4];
pathLenOffset = 5;
final pathLen = data[pathLenOffset].toSigned(8);
var cursor = 6;
final hasPathBytesFlag = (data[2] & 0x01) != 0;
final canFitPath = pathLen > 0 && data.length >= cursor + pathLen + 5;
final hasValidTxtType =
cursor < data.length &&
(data[cursor] == txtTypePlain || data[cursor] == txtTypeCliData);
if ((hasPathBytesFlag || (canFitPath && !hasValidTxtType)) &&
canFitPath) {
pathBytes = Uint8List.fromList(data.sublist(cursor, cursor + pathLen));
cursor += pathLen;
}
txtTypeOffset = cursor;
cursor += 1; // txt_type
timestampOffset = cursor;
textOffset = cursor + 4;
} else {
channelIdx = data[1];
pathLenOffset = 2;
txtTypeOffset = 3;
timestampOffset = 4;
textOffset = 8;
}
if (data.length < textOffset + 1) return null;
final txtType = data[txtTypeOffset];
if (txtType != txtTypePlain) {
return null;
}
final pathLen = data[pathLenOffset].toSigned(8);
final timestampRaw = readUint32LE(data, timestampOffset);
final text = readCString(data, textOffset, data.length - textOffset);
// Extract sender name and actual message from "name: msg" format
String senderName = 'Unknown';
String actualText = text;
final colonIndex = text.indexOf(':');
if (colonIndex > 0 && colonIndex < text.length - 1 && colonIndex < 50) {
final potentialSender = text.substring(0, colonIndex);
if (!RegExp(r'[:\[\]]').hasMatch(potentialSender)) {
senderName = potentialSender;
final offset =
(colonIndex + 1 < text.length && text[colonIndex + 1] == ' ')
? colonIndex + 2
: colonIndex + 1;
actualText = text.substring(offset);
}
}
final decodedText = Smaz.tryDecodePrefixed(actualText) ?? actualText;
return ChannelMessage(
senderKey: null,
senderName: senderName,
text: decodedText,
timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000),
isOutgoing: false,
status: ChannelMessageStatus.sent,
pathLength: pathLen,
pathBytes: pathBytes,
channelIndex: channelIdx,
);
}
static ChannelMessage outgoing(

View file

@ -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;
}
}
}

View file

@ -17,6 +17,9 @@ class Contact {
final double? longitude;
final DateTime lastSeen;
final DateTime lastMessageAt;
final bool isActive;
final bool wasPulled;
final Uint8List? rawPacket;
Contact({
required this.publicKey,
@ -31,6 +34,9 @@ class Contact {
this.longitude,
required this.lastSeen,
DateTime? lastMessageAt,
this.isActive = true,
this.wasPulled = false,
this.rawPacket,
}) : lastMessageAt = lastMessageAt ?? lastSeen;
String get publicKeyHex => pubKeyToHex(publicKey);
@ -61,7 +67,17 @@ class Contact {
return '$pathLength hops';
}
bool get hasLocation => latitude != null && longitude != null;
bool get hasLocation {
const double epsilon = 1e-6;
final lat = latitude ?? 0.0;
final lon = longitude ?? 0.0;
return (lat.abs() > epsilon || lon.abs() > epsilon) &&
lat >= -90.0 &&
lat <= 90.0 &&
lon >= -180.0 &&
lon <= 180.0;
}
bool get isFavorite => (flags & contactFlagFavorite) != 0;
Contact copyWith({
@ -78,6 +94,8 @@ class Contact {
double? longitude,
DateTime? lastSeen,
DateTime? lastMessageAt,
bool? isActive,
Uint8List? rawPacket,
}) {
return Contact(
publicKey: publicKey ?? this.publicKey,
@ -96,18 +114,19 @@ class Contact {
longitude: longitude ?? this.longitude,
lastSeen: lastSeen ?? this.lastSeen,
lastMessageAt: lastMessageAt ?? this.lastMessageAt,
isActive: isActive ?? this.isActive,
rawPacket: rawPacket ?? this.rawPacket,
);
}
String get pathIdList {
final pathBytes = _pathBytesForDisplay;
/// 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
@ -118,47 +137,14 @@ 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)}>";
}
Uint8List? get traceRouteBytes {
final pathBytes = _pathBytesForDisplay;
Uint8List? traceBytes;
if (pathBytes.isEmpty) {
traceBytes = Uint8List(1);
traceBytes[0] = publicKey[0];
return traceBytes;
}
if (type == advTypeRepeater || type == advTypeRoom) {
final len = (pathBytes.length + pathBytes.length + 1);
traceBytes = Uint8List(len);
traceBytes[pathBytes.length] = publicKey[0];
for (int i = 0; i < pathBytes.length; i++) {
traceBytes[i] = pathBytes[i];
if (i < pathBytes.length) {
traceBytes[len - 1 - i] = pathBytes[i];
}
}
} else {
if (pathBytes.length < 2) {
return pathBytes[0] == 0 ? null : pathBytes;
}
final len = (pathBytes.length + pathBytes.length - 1);
traceBytes = Uint8List(len);
for (int i = 0; i < pathBytes.length; i++) {
traceBytes[i] = pathBytes[i];
if (i < pathBytes.length - 1) {
traceBytes[len - 1 - i] = pathBytes[i];
}
}
}
return traceBytes;
}
Uint8List get _pathBytesForDisplay {
Uint8List get pathBytesForDisplay {
if (pathOverride != null) {
if (pathOverride! < 0) return Uint8List(0);
return pathOverrideBytes ?? Uint8List(0);
@ -175,6 +161,12 @@ class Contact {
return null;
}
final pubKey = reader.readBytes(pubKeySize);
// Guard: reject contacts with zeroed or mostly-zeroed public keys
// (indicates corrupt flash storage on the firmware side)
final zeroCount = pubKey.where((b) => b == 0).length;
if (zeroCount > pubKeySize ~/ 2) return null;
final type = reader.readByte();
final flags = reader.readByte();
final pathLen = reader.readByte();
@ -184,14 +176,22 @@ class Contact {
final pathBytes = reader.readBytes(maxPathSize).sublist(0, safePathLen);
final name = reader.readCStringGreedy(maxNameSize);
// Guard: reject contacts with non-printable names (corrupt flash data)
if (name.isNotEmpty &&
name.codeUnits.every((c) => c < 0x20 || c == 0xFFFD)) {
return null;
}
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(
@ -199,11 +199,13 @@ class Contact {
name: name.isEmpty ? 'Unknown' : name,
type: type,
flags: flags,
pathLength: pathLen > 0 ? (pathLen > maxPathSize ? -1 : pathLen) : -1,
pathLength: (pathLen == 0xFF || pathLen > maxPathSize) ? -1 : pathLen,
path: pathBytes,
latitude: lat,
longitude: lon,
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastMod * 1000),
isActive: true,
rawPacket: null,
);
} catch (e) {
appLogger.error('Failed to parse contact frame: $e');
@ -217,4 +219,7 @@ class Contact {
@override
int get hashCode => publicKeyHex.hashCode;
bool get teleBaseEnabled => (flags & contactFlagTeleBase) != 0;
bool get teleLocEnabled => (flags & contactFlagTeleLoc) != 0;
bool get teleEnvEnabled => (flags & contactFlagTeleEnv) != 0;
}

View file

@ -0,0 +1,43 @@
class DeliveryObservation {
final String contactKey;
final int pathLength;
final int messageBytes;
final int secondsSinceLastRx;
final bool isFlood;
final int deliveryMs;
final DateTime timestamp;
DeliveryObservation({
required this.contactKey,
required this.pathLength,
required this.messageBytes,
required this.secondsSinceLastRx,
required this.isFlood,
required this.deliveryMs,
required this.timestamp,
});
Map<String, dynamic> toJson() {
return {
'contact_key': contactKey,
'path_length': pathLength,
'message_bytes': messageBytes,
'seconds_since_last_rx': secondsSinceLastRx,
'is_flood': isFlood,
'delivery_ms': deliveryMs,
'timestamp': timestamp.toIso8601String(),
};
}
factory DeliveryObservation.fromJson(Map<String, dynamic> json) {
return DeliveryObservation(
contactKey: json['contact_key'] as String,
pathLength: json['path_length'] as int,
messageBytes: json['message_bytes'] as int,
secondsSinceLastRx: json['seconds_since_last_rx'] as int? ?? 0,
isFlood: json['is_flood'] as bool,
deliveryMs: json['delivery_ms'] as int,
timestamp: DateTime.parse(json['timestamp'] as String),
);
}
}

View file

@ -1,105 +0,0 @@
import 'dart:typed_data';
import '../connector/meshcore_protocol.dart';
class DiscoveryContact {
final Uint8List rawPacket;
final Uint8List publicKey;
final String name;
final int type;
final int pathLength; // -1 = flood, 0+ = direct hops (from device)
final Uint8List path; // Path bytes from device
final double? latitude;
final double? longitude;
final DateTime lastSeen;
DiscoveryContact({
required this.rawPacket,
required this.publicKey,
required this.name,
required this.type,
required this.pathLength,
required this.path,
this.latitude,
this.longitude,
required this.lastSeen,
});
String get publicKeyHex => pubKeyToHex(publicKey);
String get typeLabel {
switch (type) {
case advTypeChat:
return 'Chat';
case advTypeRepeater:
return 'Repeater';
case advTypeRoom:
return 'Room';
case advTypeSensor:
return 'Sensor';
default:
return 'Unknown';
}
}
String get pathLabel {
if (pathLength < 0) return 'Flood';
if (pathLength == 0) return 'Direct';
return '$pathLength hops';
}
bool get hasLocation => latitude != null && longitude != null;
DiscoveryContact copyWith({
Uint8List? rawPacket,
Uint8List? publicKey,
String? name,
int? type,
int? pathLength,
Uint8List? path,
double? latitude,
double? longitude,
DateTime? lastSeen,
}) {
return DiscoveryContact(
rawPacket: rawPacket ?? this.rawPacket,
publicKey: publicKey ?? this.publicKey,
name: name ?? this.name,
type: type ?? this.type,
pathLength: pathLength ?? this.pathLength,
path: path ?? this.path,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
lastSeen: lastSeen ?? this.lastSeen,
);
}
String get pathIdList {
final pathBytes = path;
if (pathBytes.isEmpty) return '';
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;
final chunk = pathBytes.sublist(i, end);
parts.add(
chunk
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(),
);
}
return parts.join(',');
}
String get shortPubKeyHex {
return "<${publicKeyHex.substring(0, 8)}...${publicKeyHex.substring(publicKeyHex.length - 8)}>";
}
@override
bool operator ==(Object other) =>
other is DiscoveryContact && publicKeyHex == other.publicKeyHex;
@override
int get hashCode => publicKeyHex.hashCode;
}

View file

@ -16,13 +16,14 @@ class Message {
final String? messageId;
final int retryCount;
final int? estimatedTimeoutMs;
final Uint8List? expectedAckHash;
final int? expectedAckHash;
final DateTime? sentAt;
final DateTime? deliveredAt;
final int? tripTimeMs;
final int? pathLength;
final Uint8List pathBytes;
final Map<String, int> reactions;
final Map<String, MessageStatus> reactionStatuses;
final Uint8List fourByteRoomContactKey;
Message({
@ -43,9 +44,11 @@ class Message {
Uint8List? pathBytes,
Uint8List? fourByteRoomContactKey,
Map<String, int>? reactions,
Map<String, MessageStatus>? reactionStatuses,
}) : pathBytes = pathBytes ?? Uint8List(0),
fourByteRoomContactKey = fourByteRoomContactKey ?? Uint8List(0),
reactions = reactions ?? {};
reactions = reactions ?? {},
reactionStatuses = reactionStatuses ?? {};
String get senderKeyHex => pubKeyToHex(senderKey);
@ -53,7 +56,7 @@ class Message {
MessageStatus? status,
int? retryCount,
int? estimatedTimeoutMs,
Uint8List? expectedAckHash,
int? expectedAckHash,
DateTime? sentAt,
DateTime? deliveredAt,
int? tripTimeMs,
@ -61,6 +64,7 @@ class Message {
Uint8List? pathBytes,
bool? isCli,
Map<String, int>? reactions,
Map<String, MessageStatus>? reactionStatuses,
Uint8List? fourByteRoomContactKey,
}) {
return Message(
@ -80,38 +84,41 @@ class Message {
pathLength: pathLength ?? this.pathLength,
pathBytes: pathBytes ?? this.pathBytes,
reactions: reactions ?? this.reactions,
reactionStatuses: reactionStatuses ?? this.reactionStatuses,
fourByteRoomContactKey:
fourByteRoomContactKey ?? this.fourByteRoomContactKey,
);
}
static Message? fromFrame(Uint8List data, Uint8List selfPubKey) {
if (data.length < msgTextOffset + 1) return null;
static Message? fromFrame(Uint8List frame, Uint8List selfPubKey) {
if (frame.length < msgTextOffset + 1) return null;
final reader = BufferReader(frame);
try {
final code = reader.readByte();
if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
return null;
}
final code = data[0];
if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
final senderKey = reader.readBytes(pubKeySize);
final timestampRaw = reader.readInt32LE();
final flags = reader.readByte();
if ((flags >> 2) != txtTypePlain) {
return null;
}
final text = reader.readCString();
return Message(
senderKey: senderKey,
text: text,
timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000),
isOutgoing: false,
isCli: false,
status: MessageStatus.delivered,
pathBytes: Uint8List(0),
);
} catch (e) {
return null;
}
final senderKey = Uint8List.fromList(
data.sublist(msgPubKeyOffset, msgPubKeyOffset + pubKeySize),
);
final timestampRaw = readUint32LE(data, msgTimestampOffset);
final flags = data[msgFlagsOffset];
if ((flags >> 2) != txtTypePlain) {
return null;
}
final text = readCString(data, msgTextOffset, data.length - msgTextOffset);
return Message(
senderKey: senderKey,
text: text,
timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000),
isOutgoing: false,
isCli: false,
status: MessageStatus.delivered,
pathBytes: Uint8List(0),
);
}
static Message outgoing(

View file

@ -1,11 +1,12 @@
class PathRecord {
final int hopCount;
final int tripTimeMs;
final DateTime timestamp;
final DateTime? timestamp;
final bool wasFloodDiscovery;
final List<int> pathBytes;
final int successCount;
final int failureCount;
final double routeWeight;
PathRecord({
required this.hopCount,
@ -15,6 +16,7 @@ class PathRecord {
required this.pathBytes,
required this.successCount,
required this.failureCount,
this.routeWeight = 1.0,
});
String get displayText =>
@ -24,11 +26,12 @@ class PathRecord {
return {
'hop_count': hopCount,
'trip_time_ms': tripTimeMs,
'timestamp': timestamp.toIso8601String(),
'timestamp': timestamp?.toIso8601String(),
'was_flood': wasFloodDiscovery,
'path_bytes': pathBytes,
'success_count': successCount,
'failure_count': failureCount,
'route_weight': routeWeight,
};
}
@ -36,12 +39,15 @@ class PathRecord {
return PathRecord(
hopCount: json['hop_count'] as int,
tripTimeMs: json['trip_time_ms'] as int,
timestamp: DateTime.parse(json['timestamp'] as String),
timestamp: json['timestamp'] != null
? DateTime.parse(json['timestamp'] as String)
: null,
wasFloodDiscovery: json['was_flood'] as bool,
pathBytes:
(json['path_bytes'] as List?)?.map((b) => b as int).toList() ?? [],
successCount: json['success_count'] as int? ?? 0,
failureCount: json['failure_count'] as int? ?? 0,
routeWeight: (json['route_weight'] as num?)?.toDouble() ?? 1.0,
);
}
}

View file

@ -1,3 +1,9 @@
import 'dart:typed_data';
import 'contact.dart';
const int recentAttemptDiversityWindow = 2;
class PathSelection {
final List<int> pathBytes;
final int hopCount;
@ -9,3 +15,38 @@ class PathSelection {
required this.useFlood,
});
}
PathSelection resolvePathSelection(
Contact contact, {
PathSelection? selection,
bool forceFlood = false,
}) {
if (contact.pathOverride != null) {
if (contact.pathOverride! < 0) {
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
}
return PathSelection(
pathBytes: contact.pathOverrideBytes ?? Uint8List(0),
hopCount: contact.pathOverride!,
useFlood: false,
);
}
if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) {
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
}
if (selection != null && selection.pathBytes.isNotEmpty) {
return PathSelection(
pathBytes: selection.pathBytes,
hopCount: selection.hopCount,
useFlood: false,
);
}
return PathSelection(
pathBytes: contact.path,
hopCount: contact.pathLength,
useFlood: false,
);
}

View file

@ -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),
@ -310,6 +318,118 @@ class AppSettingsScreen extends StatelessWidget {
);
},
),
if (settingsService.settings.autoRouteRotationEnabled) ...[
const Divider(height: 1),
ListTile(
title: Text(context.l10n.appSettings_maxRouteWeight),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.l10n.appSettings_maxRouteWeightSubtitle),
Slider(
value: settingsService.settings.maxRouteWeight,
min: 1,
max: 10,
divisions: 9,
label: settingsService.settings.maxRouteWeight
.round()
.toString(),
onChanged: (value) =>
settingsService.setMaxRouteWeight(value),
),
],
),
),
const Divider(height: 1),
ListTile(
title: Text(context.l10n.appSettings_initialRouteWeight),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.l10n.appSettings_initialRouteWeightSubtitle),
Slider(
value: settingsService.settings.initialRouteWeight,
min: 0.5,
max: 5.0,
divisions: 9,
label: settingsService.settings.initialRouteWeight
.toStringAsFixed(1),
onChanged: (value) =>
settingsService.setInitialRouteWeight(value),
),
],
),
),
const Divider(height: 1),
ListTile(
title: Text(context.l10n.appSettings_routeWeightSuccessIncrement),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context
.l10n
.appSettings_routeWeightSuccessIncrementSubtitle,
),
Slider(
value: settingsService.settings.routeWeightSuccessIncrement,
min: 0.1,
max: 2.0,
divisions: 19,
label: settingsService.settings.routeWeightSuccessIncrement
.toStringAsFixed(1),
onChanged: (value) =>
settingsService.setRouteWeightSuccessIncrement(value),
),
],
),
),
const Divider(height: 1),
ListTile(
title: Text(context.l10n.appSettings_routeWeightFailureDecrement),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context
.l10n
.appSettings_routeWeightFailureDecrementSubtitle,
),
Slider(
value: settingsService.settings.routeWeightFailureDecrement,
min: 0.1,
max: 2.0,
divisions: 19,
label: settingsService.settings.routeWeightFailureDecrement
.toStringAsFixed(1),
onChanged: (value) =>
settingsService.setRouteWeightFailureDecrement(value),
),
],
),
),
const Divider(height: 1),
ListTile(
title: Text(context.l10n.appSettings_maxMessageRetries),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.l10n.appSettings_maxMessageRetriesSubtitle),
Slider(
value: settingsService.settings.maxMessageRetries
.toDouble(),
min: 2,
max: 10,
divisions: 8,
label: settingsService.settings.maxMessageRetries
.toString(),
onChanged: (value) =>
settingsService.setMaxMessageRetries(value.toInt()),
),
],
),
),
],
],
),
);
@ -577,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;
}
@ -664,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',
),
],
),
),

View file

@ -118,6 +118,19 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
: Icons.download,
size: 18,
),
onLongPress: () async {
await Clipboard.setData(
ClipboardData(
text: entry.payload
.map(
(b) => b
.toRadixString(16)
.padLeft(2, '0'),
)
.join(''),
),
);
},
);
}
@ -270,66 +283,66 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
if (payload.length < 101) {
return 'ADVERT (short)';
}
var offset = 0;
final pubKey = _bytesToHex(
payload.sublist(offset, offset + 32),
spaced: false,
);
offset += 32;
final timestamp = readUint32LE(payload, offset);
offset += 4;
offset += 64; // signature
final flags = payload[offset++];
final role = _deviceRoleLabel(flags & 0x0F);
final hasLocation = (flags & 0x10) != 0;
final hasFeature1 = (flags & 0x20) != 0;
final hasFeature2 = (flags & 0x40) != 0;
final hasName = (flags & 0x80) != 0;
String? name;
double? lat;
double? lon;
if (hasLocation && payload.length >= offset + 8) {
lat = readInt32LE(payload, offset) / 1000000.0;
lon = readInt32LE(payload, offset + 4) / 1000000.0;
offset += 8;
final reader = BufferReader(payload);
try {
final pubKey = _bytesToHex(reader.readBytes(pubKeySize), spaced: false);
final timestamp = reader.readUInt32LE();
reader.skipBytes(signatureSize);
final flags = reader.readByte();
final role = _deviceRoleLabel(flags & 0x0F);
final hasLocation = (flags & 0x10) != 0;
final hasFeature1 = (flags & 0x20) != 0;
final hasFeature2 = (flags & 0x40) != 0;
final hasName = (flags & 0x80) != 0;
String? name;
double? lat;
double? lon;
if (hasLocation) {
lat = reader.readInt32LE() / 1000000.0;
lon = reader.readInt32LE() / 1000000.0;
}
if (hasFeature1) reader.skipBytes(2);
if (hasFeature2) reader.skipBytes(2);
if (hasName) {
name = reader.readCStringGreedy(maxNameSize);
}
final namePart = (name != null && name.isNotEmpty) ? ' name="$name"' : '';
final locPart = (lat != null && lon != null)
? ' loc=${lat.toStringAsFixed(6)},${lon.toStringAsFixed(6)}'
: '';
return 'ADVERT role=$role ts=$timestamp$namePart$locPart key=${pubKey.substring(0, 12)}';
} catch (e) {
return 'ADVERT (invalid)';
}
if (hasFeature1) offset += 2;
if (hasFeature2) offset += 2;
if (hasName && payload.length > offset) {
final rawName = String.fromCharCodes(payload.sublist(offset));
final nul = rawName.indexOf('\u0000');
name = nul >= 0 ? rawName.substring(0, nul) : rawName;
name = name.trim();
}
final namePart = (name != null && name.isNotEmpty) ? ' name="$name"' : '';
final locPart = (lat != null && lon != null)
? ' loc=${lat.toStringAsFixed(6)},${lon.toStringAsFixed(6)}'
: '';
return 'ADVERT role=$role ts=$timestamp$namePart$locPart key=${pubKey.substring(0, 12)}';
}
String _decodeControlSummary(Uint8List payload) {
if (payload.isEmpty) return 'CONTROL (empty)';
final flags = payload[0];
final subType = flags & 0xF0;
if (subType == 0x80) {
if (payload.length < 6) return 'CONTROL DISCOVER_REQ (short)';
final typeFilter = payload[1];
final tag = readUint32LE(payload, 2);
final since = payload.length >= 10 ? readUint32LE(payload, 6) : 0;
return 'CONTROL DISCOVER_REQ filter=0x${typeFilter.toRadixString(16).padLeft(2, '0')} tag=$tag since=$since';
final reader = BufferReader(payload);
try {
final flags = reader.readByte();
final subType = flags & 0xF0;
if (subType == 0x80) {
if (payload.length < 6) return 'CONTROL DISCOVER_REQ (short)';
final typeFilter = reader.readByte();
final tag = reader.readInt32LE();
final since = payload.length >= 10 ? reader.readInt32LE() : 0;
return 'CONTROL DISCOVER_REQ filter=0x${typeFilter.toRadixString(16).padLeft(2, '0')} tag=$tag since=$since';
}
if (subType == 0x90) {
if (payload.length < 14) return 'CONTROL DISCOVER_RESP (short)';
final nodeType = flags & 0x0F;
final snrRaw = payload[1];
final snrSigned = snrRaw > 127 ? snrRaw - 256 : snrRaw;
final snr = snrSigned / 4.0;
final tag = reader.readInt32LE();
final keyLen = payload.length - 6;
return 'CONTROL DISCOVER_RESP node=${_deviceRoleLabel(nodeType)} snr=${snr.toStringAsFixed(2)} tag=$tag key=$keyLen';
}
return 'CONTROL subtype=0x${subType.toRadixString(16).padLeft(2, '0')}';
} catch (e) {
return 'CONTROL (invalid)';
}
if (subType == 0x90) {
if (payload.length < 14) return 'CONTROL DISCOVER_RESP (short)';
final nodeType = flags & 0x0F;
final snrRaw = payload[1];
final snrSigned = snrRaw > 127 ? snrRaw - 256 : snrRaw;
final snr = snrSigned / 4.0;
final tag = readUint32LE(payload, 2);
final keyLen = payload.length - 6;
return 'CONTROL DISCOVER_RESP node=${_deviceRoleLabel(nodeType)} snr=${snr.toStringAsFixed(2)} tag=$tag key=$keyLen';
}
return 'CONTROL subtype=0x${subType.toRadixString(16).padLeft(2, '0')}';
}
String _payloadTypeLabel(int payloadType) {

View file

@ -4,11 +4,11 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../utils/platform_info.dart';
import '../helpers/chat_scroll_controller.dart';
import '../connector/meshcore_protocol.dart';
import '../helpers/link_handler.dart';
@ -27,6 +27,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';
@ -50,6 +51,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
bool _isLoadingOlder = false;
MeshCoreConnector? _connector;
DateTime? _lastChannelSendAt;
bool _channelSkipNextBottomSnap = false;
@override
void initState() {
@ -58,11 +61,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();
@ -169,6 +206,34 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
],
),
centerTitle: false,
actions: [
const RadioStatsIconButton(),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) {
if (value == 'clearChat') {
context.read<MeshCoreConnector>().clearMessagesForChannel(
widget.channel.index,
);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'clearChat',
child: Row(
children: [
const Icon(Icons.delete, size: 20, color: Colors.red),
const SizedBox(width: 12),
Text(
context.l10n.contact_clearChat,
style: const TextStyle(color: Colors.red),
),
],
),
),
],
),
],
),
body: SafeArea(
top: false,
@ -219,6 +284,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();
});
@ -314,8 +383,13 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
],
Flexible(
child: GestureDetector(
onTap: () => _showMessagePathInfo(message),
onTap: PlatformInfo.isDesktop
? null
: () => _showMessagePathInfo(message),
onLongPress: () => _showMessageActions(message),
onSecondaryTapUp: PlatformInfo.isDesktop
? (_) => _showMessageActions(message)
: null,
child: Container(
padding: gifId != null
? const EdgeInsets.all(4)
@ -433,25 +507,12 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Flexible(
child: Linkify(
child: LinkHandler.buildLinkifyText(
context: context,
text: message.text,
style: TextStyle(
fontSize: bodyFontSize * textScale,
),
linkStyle: TextStyle(
fontSize: bodyFontSize * textScale,
color: Colors.green,
decoration: TextDecoration.underline,
),
options: const LinkifyOptions(
humanize: false,
defaultToHttps: false,
),
linkifiers: const [UrlLinkifier()],
onOpen: (link) => LinkHandler.handleLinkTap(
context,
link.url,
),
),
),
if (!enableTracing && isOutgoing) ...[
@ -560,7 +621,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
],
);
if (!isOutgoing) {
if (!isOutgoing && !PlatformInfo.isDesktop) {
return _SwipeReplyBubble(
maxSwipeOffset: maxSwipeOffset,
replySwipeThreshold: replySwipeThreshold,
@ -1237,6 +1298,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;
@ -1298,6 +1369,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
_setReplyingTo(message);
},
),
if (PlatformInfo.isDesktop)
ListTile(
leading: const Icon(Icons.route),
title: Text(context.l10n.chat_path),
onTap: () {
Navigator.pop(sheetContext);
_showMessagePathInfo(message);
},
),
// Can't react to your own messages
if (!message.isOutgoing)
ListTile(

View file

@ -40,8 +40,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
final primaryPath = !channelMessage && !message.isOutgoing
? Uint8List.fromList(primaryPathTmp.reversed.toList())
: primaryPathTmp;
final hops = _buildPathHops(primaryPath, connector.contacts, l10n);
final hops = _buildPathHops(primaryPath, connector, l10n);
final hasHopDetails = primaryPath.isNotEmpty;
final observedLabel = _formatObservedHops(
primaryPath.length,
@ -62,8 +61,12 @@ class ChannelMessagePathScreen extends StatelessWidget {
builder: (context) => PathTraceMapScreen(
title: context.l10n.contacts_repeaterPathTrace,
path: primaryPath,
flipPathRound: true,
reversePathRound: !message.isOutgoing && !channelMessage,
flipPathAround: true,
reversePathAround:
!(!channelMessage && !message.isOutgoing),
pathHashByteWidth: context
.read<MeshCoreConnector>()
.pathHashByteWidth,
),
),
),
@ -302,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() {
@ -336,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>(
@ -364,11 +385,7 @@ class _ChannelMessagePathMapScreenState
: selectedPathTmp;
final selectedIndex = _indexForPath(selectedPath, observedPaths);
final hops = _buildPathHops(
selectedPath,
connector.contacts,
context.l10n,
);
final hops = _buildPathHops(selectedPath, connector, context.l10n);
final points = <LatLng>[];
@ -423,6 +440,7 @@ class _ChannelMessagePathMapScreenState
children: [
FlutterMap(
key: mapKey,
mapController: _mapController,
options: MapOptions(
initialCenter: initialCenter,
initialZoom: initialZoom,
@ -474,6 +492,7 @@ class _ChannelMessagePathMapScreenState
) {
setState(() {
_selectedPath = observedPaths[index].pathBytes;
_focusedHopIndex = null;
});
}),
if (points.isEmpty)
@ -729,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(
@ -789,19 +817,71 @@ class _ObservedPath {
List<_PathHop> _buildPathHops(
Uint8List pathBytes,
List<Contact> contacts,
MeshCoreConnector connector,
AppLocalizations l10n,
) {
if (pathBytes.isEmpty) return const [];
final candidatesByPrefix = <int, List<Contact>>{};
for (final contact in connector.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();
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) {
var 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;
}
hops.add(
_PathHop(
index: i + 1,
prefix: prefix,
prefix: pathBytes[i],
contact: contact,
position: _resolvePosition(contact),
position: resolvedPosition,
l10n: l10n,
),
);
@ -809,42 +889,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) {

View file

@ -4,6 +4,7 @@ import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:meshcore_open/storage/channel_message_store.dart';
import 'package:meshcore_open/utils/platform_info.dart';
import 'package:meshcore_open/widgets/app_bar.dart';
import 'package:provider/provider.dart';
import 'package:uuid/uuid.dart';
@ -11,6 +12,7 @@ import 'package:uuid/uuid.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../services/app_settings_service.dart';
import '../services/ui_view_state_service.dart';
import '../models/channel.dart';
import '../models/community.dart';
import '../storage/community_store.dart';
@ -28,8 +30,6 @@ import 'contacts_screen.dart';
import 'map_screen.dart';
import 'settings_screen.dart';
enum ChannelSortOption { manual, name, latestMessages, unread }
class ChannelsScreen extends StatefulWidget {
final bool hideBackButton;
@ -43,17 +43,20 @@ class _ChannelsScreenState extends State<ChannelsScreen>
with DisconnectNavigationMixin {
final TextEditingController _searchController = TextEditingController();
final CommunityStore _communityStore = CommunityStore();
String _searchQuery = '';
Timer? _searchDebounce;
ChannelSortOption _sortOption = ChannelSortOption.manual;
List<Community> _communities = [];
// Cache of PSK hex -> Community for quick lookup
final Map<String, Community> _pskToCommunity = {};
ChannelMessageStore get _channelMessageStore => ChannelMessageStore();
@override
void initState() {
super.initState();
_searchController.text = context
.read<UiViewStateService>()
.channelsSearchText;
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<MeshCoreConnector>().getChannels();
_loadCommunities();
@ -61,6 +64,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
}
Future<void> _loadCommunities() async {
final connector = context.read<MeshCoreConnector>();
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
final communities = await _communityStore.loadCommunities();
if (mounted) {
setState(() {
@ -106,7 +111,10 @@ class _ChannelsScreenState extends State<ChannelsScreen>
@override
Widget build(BuildContext context) {
final connector = context.watch<MeshCoreConnector>();
final viewState = context.watch<UiViewStateService>();
final channelMessageStore = ChannelMessageStore();
channelMessageStore.setPublicKeyHex = connector.selfPublicKeyHex;
// Auto-navigate back to scanner if disconnected
if (!checkConnectionAndNavigate(connector)) {
@ -119,7 +127,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
canPop: allowBack,
child: Scaffold(
appBar: AppBar(
title: AppBarTitle(context.l10n.channels_title),
title: AppBarTitle(context.l10n.channels_title, indicators: false),
centerTitle: true,
automaticallyImplyLeading: false,
actions: [
@ -199,6 +207,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
final filteredChannels = _filterAndSortChannels(
channels,
connector,
viewState,
);
return Column(
@ -213,17 +222,19 @@ class _ChannelsScreenState extends State<ChannelsScreen>
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_searchQuery.isNotEmpty)
if (viewState.channelsSearchText.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchDebounce?.cancel();
_searchDebounce = null;
_searchController.clear();
setState(() {
_searchQuery = '';
});
context
.read<UiViewStateService>()
.setChannelsSearchText('');
},
),
_buildFilterButton(),
_buildFilterButton(viewState),
],
),
border: OutlineInputBorder(
@ -240,9 +251,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
const Duration(milliseconds: 300),
() {
if (!mounted) return;
setState(() {
_searchQuery = value.toLowerCase();
});
context
.read<UiViewStateService>()
.setChannelsSearchText(value);
},
);
},
@ -277,8 +288,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
),
],
)
: (_sortOption == ChannelSortOption.manual &&
_searchQuery.isEmpty)
: (viewState.channelsSortOption ==
ChannelSortOption.manual &&
viewState.channelsSearchText.isEmpty)
? ReorderableListView.builder(
padding: const EdgeInsets.only(
left: 16,
@ -406,78 +418,96 @@ class _ChannelsScreenState extends State<ChannelsScreen>
return Card(
key: ValueKey('channel_${channel.index}'),
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
dense: true,
minVerticalPadding: 0,
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
visualDensity: const VisualDensity(vertical: -2),
leading: Stack(
children: [
CircleAvatar(
backgroundColor: bgColor,
child: Icon(icon, color: iconColor),
),
if (isCommunityChannel)
Positioned(
right: 0,
bottom: 0,
child: Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: Colors.purple,
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).cardColor,
width: 2,
child: GestureDetector(
onSecondaryTapUp: PlatformInfo.isDesktop
? (_) => _showChannelActions(
context,
connector,
channelMessageStore,
channel,
)
: null,
child: ListTile(
dense: true,
minVerticalPadding: 0,
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
visualDensity: const VisualDensity(vertical: -2),
leading: Stack(
children: [
CircleAvatar(
backgroundColor: bgColor,
child: Icon(icon, color: iconColor),
),
if (isCommunityChannel)
Positioned(
right: 0,
bottom: 0,
child: Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: Colors.purple,
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).cardColor,
width: 2,
),
),
child: const Icon(
Icons.people,
size: 8,
color: Colors.white,
),
),
child: const Icon(Icons.people, size: 8, color: Colors.white),
),
),
],
),
title: Text(
channel.name.isEmpty
? context.l10n.channels_channelIndex(channel.index)
: channel.name,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text(subtitle, maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (unreadCount > 0) ...[
UnreadBadge(count: unreadCount),
const SizedBox(width: 4),
],
if (showDragHandle && dragIndex != null)
ReorderableDelayedDragStartListener(
index: dragIndex,
child: Icon(
Icons.drag_handle,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
title: Text(
channel.name.isEmpty
? context.l10n.channels_channelIndex(channel.index)
: channel.name,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (unreadCount > 0) ...[
UnreadBadge(count: unreadCount),
const SizedBox(width: 4),
],
if (showDragHandle && dragIndex != null)
ReorderableDelayedDragStartListener(
index: dragIndex,
child: Icon(
Icons.drag_handle,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
],
),
onTap: () async {
connector.markChannelRead(channel.index);
await Future.delayed(const Duration(milliseconds: 50));
if (context.mounted) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChannelChatScreen(channel: channel),
),
);
}
},
onLongPress: () => _showChannelActions(
context,
connector,
channelMessageStore,
channel,
],
),
onTap: () async {
connector.markChannelRead(channel.index);
await Future.delayed(const Duration(milliseconds: 50));
if (context.mounted) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChannelChatScreen(channel: channel),
),
);
}
},
onLongPress: () => _showChannelActions(
context,
connector,
channelMessageStore,
channel,
),
),
),
);
@ -578,59 +608,40 @@ class _ChannelsScreenState extends State<ChannelsScreen>
await showDisconnectDialog(context, connector);
}
Widget _buildFilterButton() {
const actionSortManual = 0;
const actionSortName = 1;
const actionSortLatest = 2;
const actionSortUnread = 3;
return SortFilterMenu(
Widget _buildFilterButton(UiViewStateService viewState) {
return SortFilterMenu<ChannelSortOption>(
tooltip: context.l10n.listFilter_tooltip,
sections: [
SortFilterMenuSection(
SortFilterMenuSection<ChannelSortOption>(
title: context.l10n.channels_sortBy,
options: [
SortFilterMenuOption(
value: actionSortManual,
SortFilterMenuOption<ChannelSortOption>(
value: ChannelSortOption.manual,
label: context.l10n.channels_sortManual,
checked: _sortOption == ChannelSortOption.manual,
checked: viewState.channelsSortOption == ChannelSortOption.manual,
),
SortFilterMenuOption(
value: actionSortName,
SortFilterMenuOption<ChannelSortOption>(
value: ChannelSortOption.name,
label: context.l10n.channels_sortAZ,
checked: _sortOption == ChannelSortOption.name,
checked: viewState.channelsSortOption == ChannelSortOption.name,
),
SortFilterMenuOption(
value: actionSortLatest,
SortFilterMenuOption<ChannelSortOption>(
value: ChannelSortOption.latestMessages,
label: context.l10n.channels_sortLatestMessages,
checked: _sortOption == ChannelSortOption.latestMessages,
checked:
viewState.channelsSortOption ==
ChannelSortOption.latestMessages,
),
SortFilterMenuOption(
value: actionSortUnread,
SortFilterMenuOption<ChannelSortOption>(
value: ChannelSortOption.unread,
label: context.l10n.channels_sortUnread,
checked: _sortOption == ChannelSortOption.unread,
checked: viewState.channelsSortOption == ChannelSortOption.unread,
),
],
),
],
onSelected: (action) {
setState(() {
switch (action) {
case actionSortManual:
_sortOption = ChannelSortOption.manual;
break;
case actionSortLatest:
_sortOption = ChannelSortOption.latestMessages;
break;
case actionSortUnread:
_sortOption = ChannelSortOption.unread;
break;
case actionSortName:
default:
_sortOption = ChannelSortOption.name;
break;
}
});
onSelected: (sortOption) {
viewState.setChannelsSortOption(sortOption);
},
);
}
@ -638,11 +649,14 @@ class _ChannelsScreenState extends State<ChannelsScreen>
List<Channel> _filterAndSortChannels(
List<Channel> channels,
MeshCoreConnector connector,
UiViewStateService viewState,
) {
var filtered = channels.where((channel) {
if (_searchQuery.isEmpty) return true;
if (viewState.channelsSearchText.isEmpty) return true;
final label = _normalizeChannelName(channel);
return label.toLowerCase().contains(_searchQuery);
return label.toLowerCase().contains(
viewState.channelsSearchText.toLowerCase(),
);
}).toList();
int compareByName(Channel a, Channel b) {
@ -651,7 +665,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
return nameA.toLowerCase().compareTo(nameB.toLowerCase());
}
switch (_sortOption) {
switch (viewState.channelsSortOption) {
case ChannelSortOption.manual:
break;
case ChannelSortOption.latestMessages:
@ -712,6 +726,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
bool isRegularHashtag = true;
Community? selectedCommunity;
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
showDialog(
context: context,
builder: (dialogContext) => StatefulBuilder(
@ -763,7 +779,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
);
}
Widget? buildExpandedContent() {
Widget? buildExpandedContent(
ChannelMessageStore channelMessageStore,
) {
switch (selectedOption) {
case 0: // Create Private Channel
return Column(
@ -788,7 +806,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
children: [
Expanded(
child: FilledButton(
onPressed: () {
onPressed: () async {
final name = nameController.text.trim();
if (name.isEmpty) {
ScaffoldMessenger.of(
@ -810,7 +828,14 @@ class _ChannelsScreenState extends State<ChannelsScreen>
psk[i] = random.nextInt(256);
}
Navigator.pop(dialogContext);
connector.setChannel(nextIndex, name, psk);
await connector.setChannel(
nextIndex,
name,
psk,
);
await channelMessageStore.clearChannelMessages(
nextIndex,
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@ -1329,7 +1354,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
subtitle:
dialogContext.l10n.channels_createPrivateChannelDesc,
),
if (selectedOption == 0) buildExpandedContent()!,
if (selectedOption == 0)
buildExpandedContent(_channelMessageStore)!,
const Divider(height: 1),
buildOptionTile(
optionIndex: 1,
@ -1338,7 +1364,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
subtitle:
dialogContext.l10n.channels_joinPrivateChannelDesc,
),
if (selectedOption == 1) buildExpandedContent()!,
if (selectedOption == 1)
buildExpandedContent(_channelMessageStore)!,
if (!hasPublicChannel) ...[
const Divider(height: 1),
buildOptionTile(
@ -1348,7 +1375,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
subtitle:
dialogContext.l10n.channels_joinPublicChannelDesc,
),
if (selectedOption == 2) buildExpandedContent()!,
if (selectedOption == 2)
buildExpandedContent(_channelMessageStore)!,
],
const Divider(height: 1),
buildOptionTile(
@ -1358,7 +1386,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
subtitle:
dialogContext.l10n.channels_joinHashtagChannelDesc,
),
if (selectedOption == 3) buildExpandedContent()!,
if (selectedOption == 3)
buildExpandedContent(_channelMessageStore)!,
const Divider(height: 1),
buildOptionTile(
optionIndex: 4,
@ -1366,7 +1395,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
title: dialogContext.l10n.community_scanQr,
subtitle: dialogContext.l10n.community_join,
),
if (selectedOption == 4) buildExpandedContent()!,
if (selectedOption == 4)
buildExpandedContent(_channelMessageStore)!,
const Divider(height: 1),
buildOptionTile(
optionIndex: 5,
@ -1374,7 +1404,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
title: dialogContext.l10n.community_create,
subtitle: dialogContext.l10n.community_createDesc,
),
if (selectedOption == 5) buildExpandedContent()!,
if (selectedOption == 5)
buildExpandedContent(_channelMessageStore)!,
],
),
),
@ -1524,7 +1555,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
try {
await connector.deleteChannel(channel.index);
channelMessageStore.clearChannelMessages(channel.index);
await channelMessageStore.clearChannelMessages(channel.index);
if (!context.mounted) return;
@ -1749,6 +1780,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
}
final channelCount = communityChannels.length;
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
showDialog(
context: context,

View file

@ -5,9 +5,10 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:meshcore_open/screens/path_trace_map.dart';
import 'package:provider/provider.dart';
import '../utils/platform_info.dart';
import 'package:latlong2/latlong.dart';
import '../connector/meshcore_connector.dart';
@ -16,6 +17,7 @@ import '../helpers/reaction_helper.dart';
import '../widgets/message_status_icon.dart';
import '../helpers/chat_scroll_controller.dart';
import '../helpers/link_handler.dart';
import '../helpers/path_helper.dart';
import '../helpers/utf8_length_limiter.dart';
import '../helpers/smaz.dart';
import '../models/channel_message.dart';
@ -35,8 +37,10 @@ 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';
enum _ChatInputAction { sendGif, insertEmoji, shareLocation }
@ -53,8 +57,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 +70,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();
@ -109,10 +155,9 @@ class _ChatScreenState extends State<ChatScreen> {
final unreadLabel = context.l10n.chat_unread(unreadCount);
final pathLabel = _currentPathLabel(contact);
// Show path details if we have path data (from device or override)
final hasPathData =
contact.path.isNotEmpty || contact.pathOverrideBytes != null;
// Show path details if we have non-empty path data (from device or override)
final effectivePath = contact.pathOverrideBytes ?? contact.path;
final hasPathData = effectivePath.isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -146,12 +191,25 @@ class _ChatScreenState extends State<ChatScreen> {
final contact = _resolveContact(connector);
final isFloodMode = contact.pathOverride == -1;
final isDirectMode = contact.pathOverride == 0;
final activeMode = isFloodMode
? 'flood'
: isDirectMode
? 'direct'
: 'auto';
return PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: context.l10n.chat_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(contact, pathLen: -1);
} else if (mode == 'direct') {
await connector.setPathOverride(
contact,
pathLen: 0,
pathBytes: Uint8List(0),
);
} else {
await connector.setPathOverride(contact, pathLen: null);
}
@ -164,7 +222,7 @@ class _ChatScreenState extends State<ChatScreen> {
Icon(
Icons.auto_mode,
size: 20,
color: !isFloodMode
color: activeMode == 'auto'
? Theme.of(context).primaryColor
: null,
),
@ -172,7 +230,30 @@ class _ChatScreenState extends State<ChatScreen> {
Text(
context.l10n.chat_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode
fontWeight: activeMode == 'auto'
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: 'direct',
child: Row(
children: [
Icon(
Icons.near_me,
size: 20,
color: activeMode == 'direct'
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
context.l10n.chat_direct,
style: TextStyle(
fontWeight: activeMode == 'direct'
? FontWeight.bold
: FontWeight.normal,
),
@ -187,7 +268,7 @@ class _ChatScreenState extends State<ChatScreen> {
Icon(
Icons.waves,
size: 20,
color: isFloodMode
color: activeMode == 'flood'
? Theme.of(context).primaryColor
: null,
),
@ -195,7 +276,7 @@ class _ChatScreenState extends State<ChatScreen> {
Text(
context.l10n.chat_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode
fontWeight: activeMode == 'flood'
? FontWeight.bold
: FontWeight.normal,
),
@ -212,10 +293,79 @@ class _ChatScreenState extends State<ChatScreen> {
tooltip: context.l10n.chat_pathManagement,
onPressed: () => _showPathHistory(context),
),
IconButton(
icon: const Icon(Icons.info_outline),
onPressed: () => _showContactInfo(context),
Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
return PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) {
if (value == 'info') {
_showContactInfo(context);
}
if (value == 'settings') {
_showContactSettings(context);
}
if (value == 'telemetry') {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
TelemetryScreen(contact: widget.contact),
),
);
}
if (value == 'clearChat') {
connector.clearMessagesForContact(widget.contact);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'info',
child: Row(
children: [
const Icon(Icons.info_outline, size: 20),
const SizedBox(width: 12),
Text(context.l10n.contact_info),
],
),
),
PopupMenuItem(
value: 'telemetry',
child: Row(
children: [
const Icon(Icons.bar_chart, size: 20),
const SizedBox(width: 12),
Text(context.l10n.contact_telemetry),
],
),
),
PopupMenuItem(
value: 'settings',
child: Row(
children: [
const Icon(Icons.settings, size: 20),
const SizedBox(width: 12),
Text(context.l10n.contact_settings),
],
),
),
PopupMenuItem(
value: 'clearChat',
child: Row(
children: [
const Icon(Icons.delete, size: 20, color: Colors.red),
const SizedBox(width: 12),
Text(
context.l10n.contact_clearChat,
style: const TextStyle(color: Colors.red),
),
],
),
),
],
);
},
),
const RadioStatsIconButton(),
],
),
body: Consumer<MeshCoreConnector>(
@ -254,7 +404,9 @@ class _ChatScreenState extends State<ChatScreen> {
),
const SizedBox(height: 8),
Text(
context.l10n.chat_sendMessageTo(widget.contact.name),
context.l10n.chat_sendMessageTo(
_resolveContact(context.read<MeshCoreConnector>()).name,
),
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
),
],
@ -272,6 +424,8 @@ 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();
});
@ -296,10 +450,10 @@ class _ChatScreenState extends State<ChatScreen> {
);
}
final messageIndex = index;
Contact contact = widget.contact;
Contact contact = _resolveContact(connector);
final message = reversedMessages[messageIndex];
String fourByteHex = '';
if (widget.contact.type == advTypeRoom) {
if (contact.type == advTypeRoom) {
contact = _resolveContactFrom4Bytes(
connector,
message.fourByteRoomContactKey.isEmpty
@ -317,16 +471,23 @@ class _ChatScreenState extends State<ChatScreen> {
final textScale = context.select<ChatTextScaleService, double>(
(service) => service.scale,
);
return _MessageBubble(
final resolvedContact = _resolveContact(connector);
final bubble = _MessageBubble(
message: message,
senderName: widget.contact.type == advTypeRoom
senderName: resolvedContact.type == advTypeRoom
? "${contact.name} [$fourByteHex]"
: contact.name,
isRoomServer: widget.contact.type == advTypeRoom,
isRoomServer: resolvedContact.type == advTypeRoom,
textScale: textScale,
onTap: () => _openMessagePath(message, contact),
onLongPress: () => _showMessageActions(message, contact),
onRetryReaction: (msg, emoji) =>
_sendReaction(msg, contact, emoji),
);
if (identical(message, _pendingUnreadScrollTarget)) {
return KeyedSubtree(key: _unreadScrollKey, child: bubble);
}
return bubble;
},
);
},
@ -598,6 +759,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();
final outboundText = connector.prepareContactOutboundText(
widget.contact,
@ -610,7 +781,7 @@ class _ChatScreenState extends State<ChatScreen> {
return;
}
connector.sendMessage(widget.contact, text);
connector.sendMessage(_resolveContact(connector), text);
_textController.clear();
_textFieldFocusNode.requestFocus();
}
@ -807,7 +978,7 @@ class _ChatScreenState extends State<ChatScreen> {
// Set the path override to persist user's choice
await connector.setPathOverride(
widget.contact,
_resolveContact(connector),
pathLen: pathLength,
pathBytes: pathBytes,
);
@ -816,7 +987,7 @@ class _ChatScreenState extends State<ChatScreen> {
Navigator.pop(context);
await _notifyPathSet(
connector,
widget.contact,
_resolveContact(connector),
pathBytes,
path.hopCount,
);
@ -875,7 +1046,9 @@ class _ChatScreenState extends State<ChatScreen> {
style: const TextStyle(fontSize: 11),
),
onTap: () async {
await connector.clearContactPath(widget.contact);
await connector.clearContactPath(
_resolveContact(connector),
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@ -903,7 +1076,7 @@ class _ChatScreenState extends State<ChatScreen> {
),
onTap: () async {
await connector.setPathOverride(
widget.contact,
_resolveContact(connector),
pathLen: -1,
);
if (!context.mounted) return;
@ -932,7 +1105,8 @@ class _ChatScreenState extends State<ChatScreen> {
);
}
String _formatRelativeTime(DateTime time) {
String _formatRelativeTime(DateTime? time) {
if (time == null) return '';
final diff = DateTime.now().difference(time);
if (diff.inSeconds < 60) return context.l10n.time_justNow;
if (diff.inMinutes < 60) {
@ -953,15 +1127,31 @@ class _ChatScreenState extends State<ChatScreen> {
return;
}
final formattedPath = pathBytes
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(',');
final connector = context.read<MeshCoreConnector>();
final allContacts = connector.allContacts;
final formattedPath = PathHelper.formatPathHex(pathBytes);
final resolvedNames = PathHelper.resolvePathNames(pathBytes, allContacts);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.chat_fullPath),
content: SelectableText(formattedPath),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(formattedPath),
const SizedBox(height: 8),
SelectableText(
resolvedNames,
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.push(
@ -970,8 +1160,9 @@ class _ChatScreenState extends State<ChatScreen> {
builder: (context) => PathTraceMapScreen(
title: context.l10n.contacts_repeaterPathTrace,
path: Uint8List.fromList(pathBytes),
flipPathRound: true,
flipPathAround: true,
targetContact: widget.contact,
pathHashByteWidth: connector.pathHashByteWidth,
),
),
),
@ -986,11 +1177,22 @@ class _ChatScreenState extends State<ChatScreen> {
);
}
int _resolveContactIndex = -1;
Contact _resolveContact(MeshCoreConnector connector) {
return connector.contacts.firstWhere(
if (_resolveContactIndex >= 0 &&
_resolveContactIndex < connector.contacts.length &&
connector.contacts[_resolveContactIndex].publicKeyHex ==
widget.contact.publicKeyHex) {
return connector.contacts[_resolveContactIndex];
}
_resolveContactIndex = connector.contacts.indexWhere(
(c) => c.publicKeyHex == widget.contact.publicKeyHex,
orElse: () => widget.contact,
);
if (_resolveContactIndex == -1) {
return widget.contact;
}
return connector.contacts[_resolveContactIndex];
}
Contact _resolveContactFrom4Bytes(
@ -1043,59 +1245,127 @@ class _ChatScreenState extends State<ChatScreen> {
void _showContactInfo(BuildContext context) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
connector.ensureContactSmazSettingLoaded(widget.contact.publicKeyHex);
final contact = _resolveContact(connector);
showDialog(
context: context,
builder: (context) => Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
final contact = _resolveContact(connector);
final smazEnabled = connector.isContactSmazEnabled(
contact.publicKeyHex,
);
return AlertDialog(
title: Text(contact.name),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoRow(context.l10n.chat_type, contact.typeLabel),
_buildInfoRow(context.l10n.chat_path, contact.pathLabel),
if (contact.hasLocation)
_buildInfoRow(
context.l10n.chat_location,
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
),
_buildInfoRow(
context.l10n.chat_publicKey,
'${contact.publicKeyHex.substring(0, 16)}...',
),
const Divider(),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(context.l10n.channels_smazCompression),
subtitle: Text(context.l10n.chat_compressOutgoingMessages),
value: smazEnabled,
onChanged: (value) {
connector.setContactSmazEnabled(
contact.publicKeyHex,
value,
);
},
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.common_close),
builder: (context) => AlertDialog(
title: SelectableText(contact.name),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoRow(context.l10n.chat_type, contact.typeLabel),
_buildInfoRow(context.l10n.chat_path, contact.pathLabel),
_buildInfoRow(
context.l10n.contact_lastSeen,
_formatContactLastMessage(contact.lastMessageAt),
),
if (contact.hasLocation)
_buildInfoRow(
context.l10n.chat_location,
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
),
_buildInfoRow(context.l10n.chat_publicKey, contact.publicKeyHex),
],
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.common_close),
),
],
),
);
}
void _showContactSettings(BuildContext context) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
connector.ensureContactSmazSettingLoaded(widget.contact.publicKeyHex);
final contact = widget.contact;
bool smazEnabled = connector.isContactSmazEnabled(contact.publicKeyHex);
bool teleBaseEnabled = contact.teleBaseEnabled;
bool teleLocEnabled = contact.teleLocEnabled;
bool teleEnvEnabled = contact.teleEnvEnabled;
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: Text(context.l10n.contact_settings),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (contact.hasLocation) ...[
_buildInfoRow(
context.l10n.chat_location,
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
),
const Divider(height: 8),
],
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(context.l10n.channels_smazCompression),
subtitle: Text(context.l10n.chat_compressOutgoingMessages),
value: smazEnabled,
onChanged: (value) {
connector.setContactSmazEnabled(
contact.publicKeyHex,
value,
);
setDialogState(() => smazEnabled = value);
},
),
const Divider(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(context.l10n.contact_teleBase),
subtitle: Text(context.l10n.contact_teleBaseSubtitle),
value: teleBaseEnabled,
onChanged: (value) {
setDialogState(() => teleBaseEnabled = value);
},
),
const Divider(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(context.l10n.contact_teleLoc),
subtitle: Text(context.l10n.contact_teleLocSubtitle),
value: teleLocEnabled,
onChanged: (value) {
setDialogState(() => teleLocEnabled = value);
},
),
const Divider(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(context.l10n.contact_teleEnv),
subtitle: Text(context.l10n.contact_teleEnvSubtitle),
value: teleEnvEnabled,
onChanged: (value) {
setDialogState(() => teleEnvEnabled = value);
},
),
],
),
),
actions: [
TextButton(
onPressed: () {
connector.setContactFlags(
contact,
teleBase: teleBaseEnabled,
teleLoc: teleLocEnabled,
teleEnv: teleEnvEnabled,
);
Navigator.pop(context);
},
child: Text(context.l10n.common_close),
),
],
),
),
);
}
@ -1110,12 +1380,32 @@ class _ChatScreenState extends State<ChatScreen> {
width: 80,
child: Text(label, style: TextStyle(color: Colors.grey[600])),
),
Expanded(child: Text(value)),
Expanded(child: SelectableText(value)),
],
),
);
}
String _formatContactLastMessage(DateTime timestamp) {
final diff = DateTime.now().difference(timestamp);
if (diff.isNegative || diff.inMinutes < 5) {
return context.l10n.contacts_lastSeenNow;
}
if (diff.inMinutes < 60) {
return context.l10n.contacts_lastSeenMinsAgo(diff.inMinutes);
}
if (diff.inHours < 24) {
final hours = diff.inHours;
return hours == 1
? context.l10n.contacts_lastSeenHourAgo
: context.l10n.contacts_lastSeenHoursAgo(hours);
}
final days = diff.inDays;
return days == 1
? context.l10n.contacts_lastSeenDayAgo
: context.l10n.contacts_lastSeenDaysAgo(days);
}
void _openChat(BuildContext context, Contact contact) {
// Check if this is a repeater
context.read<MeshCoreConnector>().markContactRead(contact.publicKeyHex);
@ -1135,11 +1425,13 @@ 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
final availableContacts = connector.contacts
final availableContacts = connector.allContacts
.where((c) => c != widget.contact)
.toList();
@ -1158,11 +1450,7 @@ class _ChatScreenState extends State<ChatScreen> {
);
if (result == null) {
appLogger.info(
'PathSelectionDialog was cancelled or returned null',
tag: 'ChatScreen',
);
return;
return; // Cancelled keep existing path
}
if (!mounted) {
@ -1178,14 +1466,19 @@ class _ChatScreenState extends State<ChatScreen> {
tag: 'ChatScreen',
);
await connector.setPathOverride(
widget.contact,
_resolveContact(connector),
pathLen: result.length,
pathBytes: result,
);
appLogger.info('setPathOverride completed', tag: 'ChatScreen');
if (!mounted) return;
await _notifyPathSet(connector, widget.contact, result, result.length);
await _notifyPathSet(
connector,
_resolveContact(connector),
result,
result.length,
);
}
void _openMessagePath(Message message, Contact contact) {
@ -1197,10 +1490,10 @@ class _ChatScreenState extends State<ChatScreen> {
final String senderName;
if (message.isOutgoing) {
senderName = connector.selfName ?? context.l10n.chat_me;
} else if (widget.contact.type == advTypeRoom) {
} else if (_resolveContact(connector).type == advTypeRoom) {
senderName = "${contact.name} [$fourByteHex]";
} else {
senderName = widget.contact.name;
senderName = _resolveContact(connector).name;
}
final pathMessage = ChannelMessage(
senderKey: null,
@ -1238,6 +1531,15 @@ class _ChatScreenState extends State<ChatScreen> {
_showReactionEmojiPicker(message, contact);
},
),
if (PlatformInfo.isDesktop)
ListTile(
leading: const Icon(Icons.route),
title: Text(context.l10n.chat_path),
onTap: () {
Navigator.pop(sheetContext);
_openMessagePath(message, contact);
},
),
ListTile(
leading: const Icon(Icons.copy),
title: Text(context.l10n.common_copy),
@ -1263,7 +1565,8 @@ class _ChatScreenState extends State<ChatScreen> {
_retryMessage(message);
},
),
if (widget.contact.type == advTypeRoom)
if (_resolveContact(context.read<MeshCoreConnector>()).type ==
advTypeRoom)
ListTile(
leading: const Icon(Icons.chat),
title: Text(context.l10n.contacts_openChat),
@ -1301,7 +1604,7 @@ class _ChatScreenState extends State<ChatScreen> {
void _retryMessage(Message message) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
// Retry using the contact's current path override setting
connector.sendMessage(widget.contact, message.text);
connector.sendMessage(_resolveContact(connector), message.text);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.chat_retryingMessage)));
@ -1328,7 +1631,8 @@ class _ChatScreenState extends State<ChatScreen> {
// For room servers, include sender name (like channels) since multiple users
// For 1:1 chats, sender is implicit (null)
final senderName = widget.contact.type == advTypeRoom
final liveContact = _resolveContact(connector);
final senderName = liveContact.type == advTypeRoom
? senderContact.name
: null;
final hash = ReactionHelper.computeReactionHash(
@ -1337,7 +1641,7 @@ class _ChatScreenState extends State<ChatScreen> {
message.text,
);
final reactionText = 'r:$hash:$emojiIndex';
connector.sendMessage(widget.contact, reactionText);
connector.sendMessage(_resolveContact(connector), reactionText);
}
}
@ -1347,6 +1651,7 @@ class _MessageBubble extends StatelessWidget {
final bool isRoomServer;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final void Function(Message message, String emoji)? onRetryReaction;
final double textScale;
const _MessageBubble({
@ -1356,6 +1661,7 @@ class _MessageBubble extends StatelessWidget {
required this.textScale,
this.onTap,
this.onLongPress,
this.onRetryReaction,
});
@override
@ -1389,8 +1695,11 @@ class _MessageBubble extends StatelessWidget {
: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: onTap,
onTap: PlatformInfo.isDesktop ? null : onTap,
onLongPress: onLongPress,
onSecondaryTapUp: PlatformInfo.isDesktop
? (_) => onLongPress?.call()
: null,
child: Row(
mainAxisAlignment: isOutgoing
? MainAxisAlignment.end
@ -1507,26 +1816,13 @@ class _MessageBubble extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Flexible(
child: Linkify(
child: LinkHandler.buildLinkifyText(
context: context,
text: messageText,
style: TextStyle(
color: textColor,
fontSize: bodyFontSize * textScale,
),
linkStyle: TextStyle(
color: Colors.green,
decoration: TextDecoration.underline,
fontSize: bodyFontSize * textScale,
),
options: const LinkifyOptions(
humanize: false,
defaultToHttps: false,
),
linkifiers: const [UrlLinkifier()],
onOpen: (link) => LinkHandler.handleLinkTap(
context,
link.url,
),
),
),
if (!enableTracing && isOutgoing) ...[
@ -1555,7 +1851,10 @@ class _MessageBubble extends StatelessWidget {
child: Text(
context.l10n.chat_retryCount(
message.retryCount,
4,
context
.read<AppSettingsService>()
.settings
.maxMessageRetries,
),
style: TextStyle(
fontSize: 10,
@ -1716,33 +2015,64 @@ class _MessageBubble extends StatelessWidget {
children: message.reactions.entries.map((entry) {
final emoji = entry.key;
final count = entry.value;
final status = message.reactionStatuses[emoji];
final isPending =
status == MessageStatus.pending || status == MessageStatus.sent;
final isFailed = status == MessageStatus.failed;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: colorScheme.outline.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(emoji, style: const TextStyle(fontSize: 16)),
if (count > 1) ...[
const SizedBox(width: 4),
Text(
'$count',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: colorScheme.onSecondaryContainer,
),
return GestureDetector(
onTap: isFailed && onRetryReaction != null
? () => onRetryReaction!(message, emoji)
: null,
child: Opacity(
opacity: isPending ? 0.5 : 1.0,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isFailed
? colorScheme.errorContainer
: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isFailed
? colorScheme.error
: colorScheme.outline.withValues(alpha: 0.3),
width: 1,
),
],
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(emoji, style: const TextStyle(fontSize: 16)),
if (count > 1) ...[
const SizedBox(width: 4),
Text(
'$count',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: colorScheme.onSecondaryContainer,
),
),
],
if (isPending) ...[
const SizedBox(width: 2),
SizedBox(
width: 8,
height: 8,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: colorScheme.onSecondaryContainer,
),
),
],
if (isFailed) ...[
const SizedBox(width: 2),
Icon(Icons.replay, size: 10, color: colorScheme.error),
],
],
),
),
),
);
}).toList(),

View file

@ -51,6 +51,9 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
_isProcessing = true;
});
final connector = context.read<MeshCoreConnector>();
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
try {
// Parse the community data
final community = Community.fromQrData(const Uuid().v4(), data);
@ -209,6 +212,8 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
bool addPublicChannel,
) async {
// Save community to local storage
final connector = context.read<MeshCoreConnector>();
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
await _communityStore.addCommunity(community);
// Optionally add the community public channel to the device

View file

@ -0,0 +1,250 @@
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.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();
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;
}
}

File diff suppressed because it is too large Load diff

View file

@ -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);
}
}

View file

@ -7,8 +7,9 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../l10n/l10n.dart';
import '../models/discovery_contact.dart';
import '../models/contact.dart';
import '../utils/contact_search.dart';
import '../utils/platform_info.dart';
import '../widgets/app_bar.dart';
import '../widgets/list_filter_widget.dart';
@ -88,7 +89,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
itemCount: filteredAndSorted.length,
itemBuilder: (context, index) {
final contact = filteredAndSorted[index];
return ListTile(
final tile = ListTile(
leading: CircleAvatar(
backgroundColor: _getTypeColor(contact.type),
child: Icon(
@ -120,6 +121,14 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
onLongPress: () =>
_showContactContextMenu(contact, connector),
);
if (PlatformInfo.isDesktop) {
return GestureDetector(
onSecondaryTapUp: (_) =>
_showContactContextMenu(contact, connector),
child: tile,
);
}
return tile;
},
),
),
@ -129,7 +138,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
}
Future<void> _showContactContextMenu(
DiscoveryContact contact,
Contact contact,
MeshCoreConnector connector,
) async {
final action = await showModalBottomSheet<String>(
@ -169,7 +178,8 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
connector.importDiscoveredContact(contact);
break;
case 'copy_contact':
final hexString = pubKeyToHex(contact.rawPacket);
if (contact.rawPacket == null) return;
final hexString = pubKeyToHex(contact.rawPacket!);
Clipboard.setData(ClipboardData(text: "meshcore://$hexString"));
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
@ -207,7 +217,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
}
Widget _buildFilters(
List<DiscoveryContact> filteredAndSorted,
List<Contact> filteredAndSorted,
MeshCoreConnector connector,
) {
String hintText = "";
@ -309,8 +319,8 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
);
}
List<DiscoveryContact> _filterAndSortContacts(
List<DiscoveryContact> contacts,
List<Contact> _filterAndSortContacts(
List<Contact> contacts,
MeshCoreConnector connector,
) {
var filtered = contacts.where((contact) {
@ -350,7 +360,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
return filtered;
}
bool _matchesTypeFilter(DiscoveryContact contact) {
bool _matchesTypeFilter(Contact contact) {
switch (typeFilter) {
case ContactTypeFilter.all:
return true;

View file

@ -1,6 +1,8 @@
import 'dart:collection';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
@ -50,7 +52,8 @@ class MapScreen extends StatefulWidget {
}
class _MapScreenState extends State<MapScreen> {
static const double _labelZoomThreshold = 8.5;
// Zoom level at which node labels start to appear
static const double _labelZoomThreshold = 14.0;
final MapController _mapController = MapController();
final MapMarkerService _markerService = MapMarkerService();
@ -91,6 +94,15 @@ class _MapScreenState extends State<MapScreen> {
});
}
bool _checkLocationPlausibility(double lat, double lon) {
const double epsilon = 1e-6;
return (lat.abs() > epsilon || lon.abs() > epsilon) &&
lat >= -90.0 &&
lat <= 90.0 &&
lon >= -180.0 &&
lon <= 180.0;
}
double _standardDeviation(List<double> values) {
if (values.length <= 1) {
return 0.0;
@ -126,7 +138,12 @@ class _MapScreenState extends State<MapScreen> {
builder: (context, connector, settingsService, pathHistory, child) {
final tileCache = context.read<MapTileCacheService>();
final settings = settingsService.settings;
final contacts = connector.contacts;
final allContacts = connector.allContacts;
final contacts = settings.mapShowDiscoveryContacts
? allContacts
: allContacts.where((c) => c.isActive).toList();
final highlightPosition = widget.highlightPosition;
final sharedMarkers = settings.mapShowMarkers
? _collectSharedMarkers(connector)
@ -159,13 +176,13 @@ class _MapScreenState extends State<MapScreen> {
: filteredByTime;
// Filter by location
final contactsWithLocation = filteredByKeyPrefix
.where((c) => c.hasLocation)
.toList();
final contactsWithLocation = filteredByKeyPrefix.where((c) {
return c.hasLocation;
}).toList();
// All contacts with a known location used as anchors regardless of
// time/key-prefix filters so that repeaters are always available.
final allContactsWithLocation = contacts
final allContactsWithLocation = allContacts
.where((c) => c.hasLocation)
.toList();
@ -313,7 +330,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)
@ -461,14 +480,19 @@ 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)
...guessedLocations.map(_buildGuessedMarker),
..._buildGuessedMarker(
guessedLocations,
showLabels: _showNodeLabels,
),
..._buildMarkers(
contactsWithLocation,
settings,
@ -484,28 +508,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,
),
),
),
),
@ -525,6 +554,7 @@ class _MapScreenState extends State<MapScreen> {
),
if (!_isBuildingPathTrace)
_buildLegend(
contacts,
contactsWithLocation,
settings,
sharedMarkers.length,
@ -561,6 +591,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])) {
@ -576,6 +607,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>{};
@ -598,19 +634,6 @@ class _MapScreenState extends State<MapScreen> {
if (r != null) anchorSet.add(LatLng(r.latitude!, r.longitude!));
}
// Fallback: for any last-hop byte with no GPS repeater, average the
// positions of contacts with known GPS that share the same last hop.
// Those contacts are all adjacent to the same unknown repeater, so their
// centroid is a reasonable proxy for its location.
for (final byte in lastHopBytes) {
if (repeaterByHash.containsKey(byte)) continue;
for (final c in withLocation) {
if (c.path.isNotEmpty && c.path.last == byte) {
anchorSet.add(LatLng(c.latitude!, c.longitude!));
}
}
}
// Filter anchors that are geometrically inconsistent with radio range.
// Two anchors more than 2 * maxRange apart cannot both be in direct radio
// range of the same node, so isolated outliers are removed.
@ -622,21 +645,44 @@ class _MapScreenState extends State<MapScreen> {
final LatLng position;
if (anchors.length == 1) {
// Offset single-anchor guesses so they don't overlap the repeater marker.
// Use the contact's public key byte as a deterministic angle seed.
const offsetDeg = 0.003; // ~330 m at the equator
final angle = (contact.publicKey[1] / 255.0) * 2 * pi;
position = LatLng(
anchors[0].latitude + offsetDeg * cos(angle),
anchors[0].longitude + offsetDeg * sin(angle),
// Spread single-anchor guesses around the anchor so they remain visible.
position = _offsetGuessedPosition(
anchors[0],
contact,
radiusMeters: 330,
);
} else {
double lat = 0, lon = 0;
for (final a in anchors) {
lat += a.latitude;
lon += a.longitude;
if (!_checkLocationPlausibility(
position.latitude,
position.longitude,
)) {
continue; // discard implausible guesses near (0, 0)
}
} else {
double lat = 0, lon = 0, weight = 1.0;
int counted = 0;
for (final a in anchors) {
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),
contact,
radiusMeters: anchors.length >= 3 ? 80 : 120,
);
if (!_checkLocationPlausibility(
position.latitude,
position.longitude,
)) {
continue; // discard implausible guesses near (0, 0
}
position = LatLng(lat / anchors.length, lon / anchors.length);
}
result.add(
_GuessedLocation(
@ -650,6 +696,31 @@ class _MapScreenState extends State<MapScreen> {
return result;
}
LatLng _offsetGuessedPosition(
LatLng anchor,
Contact contact, {
required double radiusMeters,
}) {
final seed = _guessSeed(contact.publicKey);
final angle = ((seed & 0xFFFF) / 0x10000) * 2 * pi;
final latOffsetDeg = (radiusMeters / 111320.0) * cos(angle);
final lonScale = max(cos(anchor.latitude * pi / 180.0).abs(), 0.2);
final lonOffsetDeg = (radiusMeters / (111320.0 * lonScale)) * sin(angle);
return LatLng(
anchor.latitude + latOffsetDeg,
anchor.longitude + lonOffsetDeg,
);
}
int _guessSeed(Uint8List publicKey) {
var seed = 0x811C9DC5;
for (final byte in publicKey) {
seed ^= byte;
seed = (seed * 0x01000193) & 0x7FFFFFFF;
}
return seed;
}
/// Estimates the free-space maximum LoRa range in km from the connected
/// device's current radio parameters. Returns null if parameters are unknown.
double? _estimateLoRaRangeKm(MeshCoreConnector connector) {
@ -710,40 +781,117 @@ class _MapScreenState extends State<MapScreen> {
.toList();
}
Marker _buildGuessedMarker(_GuessedLocation guess) {
final color = _getNodeColor(guess.contact.type);
return Marker(
point: guess.position,
width: 35,
height: 35,
child: GestureDetector(
onTap: () => _showNodeInfo(
context,
guess.contact,
guessedPosition: guess.position,
),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: color.withValues(alpha: guess.highConfidence ? 0.55 : 0.30),
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),
List<Marker> _buildGuessedMarker(
List<_GuessedLocation> guessed, {
required bool showLabels,
}) {
final markers = <Marker>[];
for (final guess in guessed) {
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,
),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: color.withValues(
alpha: guess.highConfidence ? 0.55 : 0.30,
),
],
),
child: const Icon(
Icons.not_listed_location,
color: Colors.white,
size: 20,
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: const Icon(
Icons.not_listed_location,
color: Colors.white,
size: 20,
),
),
),
),
);
);
markers.add(marker);
if (showLabels) {
markers.add(
_buildNodeLabelMarker(
point: guess.position,
label: guess.contact.name,
),
);
}
}
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;
}
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(
@ -752,25 +900,8 @@ class _MapScreenState extends State<MapScreen> {
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,
@ -786,7 +917,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: [
@ -813,7 +946,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,
),
);
}
@ -888,25 +1023,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,
@ -942,6 +1077,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(
@ -1205,6 +1388,7 @@ class _MapScreenState extends State<MapScreen> {
Contact contact, {
LatLng? guessedPosition,
}) {
final connector = context.read<MeshCoreConnector>();
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
@ -1250,6 +1434,9 @@ class _MapScreenState extends State<MapScreen> {
advTypeChat) // Only show chat button for chat nodes
TextButton(
onPressed: () {
if (!contact.isActive) {
connector.importDiscoveredContact(contact);
}
Navigator.pop(dialogContext);
Navigator.push(
context,
@ -1263,6 +1450,9 @@ class _MapScreenState extends State<MapScreen> {
if (contact.type == advTypeRepeater)
TextButton(
onPressed: () {
if (!contact.isActive) {
connector.importDiscoveredContact(contact);
}
Navigator.pop(dialogContext);
_showRepeaterLogin(context, contact);
},
@ -1271,6 +1461,9 @@ class _MapScreenState extends State<MapScreen> {
if (contact.type == advTypeRoom)
TextButton(
onPressed: () {
if (!contact.isActive) {
connector.importDiscoveredContact(contact);
}
Navigator.pop(dialogContext);
_showRoomLogin(context, contact);
},
@ -1442,6 +1635,23 @@ class _MapScreenState extends State<MapScreen> {
);
},
),
ListTile(
leading: const Icon(Icons.my_location),
title: Text(context.l10n.map_setAsMyLocation),
onTap: () async {
final messenger = ScaffoldMessenger.of(context);
final successMsg = context.l10n.settings_locationUpdated;
Navigator.pop(sheetContext);
if (!connector.isConnected) return;
await connector.setNodeLocation(
lat: position.latitude,
lon: position.longitude,
);
await connector.refreshDeviceInfo();
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text(successMsg)));
},
),
ListTile(
leading: const Icon(Icons.close),
title: Text(context.l10n.common_cancel),
@ -1755,6 +1965,23 @@ class _MapScreenState extends State<MapScreen> {
},
contentPadding: EdgeInsets.zero,
),
CheckboxListTile(
title: Text(context.l10n.map_showDiscoveryContacts),
value: settings.mapShowDiscoveryContacts,
onChanged: (value) {
service.setMapShowDiscoveryContacts(value ?? true);
},
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,
@ -1913,12 +2140,13 @@ class _MapScreenState extends State<MapScreen> {
});
}
void _startPath() {
void _startPath(LatLng position) {
setState(() {
_isBuildingPathTrace = true;
_pathTrace.clear();
_points.clear();
_polylines.clear();
_points.add(position);
});
}
@ -1964,21 +2192,25 @@ 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,
),
),
);
@ -1986,15 +2218,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;
@ -2006,7 +2260,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),
),
],
),

View file

@ -44,6 +44,24 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
PathSelection? _pendingStatusSelection;
List<Map<String, dynamic>>? _parsedNeighbors;
int _resolveRepeaterIndex = -1;
Contact _resolveRepeater(MeshCoreConnector connector) {
if (_resolveRepeaterIndex >= 0 &&
_resolveRepeaterIndex < connector.contacts.length &&
connector.contacts[_resolveRepeaterIndex].publicKeyHex ==
widget.repeater.publicKeyHex) {
return connector.contacts[_resolveRepeaterIndex];
}
_resolveRepeaterIndex = connector.contacts.indexWhere(
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
);
if (_resolveRepeaterIndex == -1) {
return widget.repeater;
}
return connector.contacts[_resolveRepeaterIndex];
}
@override
void initState() {
super.initState();
@ -124,12 +142,11 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
void _handleNeighborsResponse(MeshCoreConnector connector, Uint8List frame) {
final buffer = BufferReader(frame);
final contacts = connector.allContacts;
try {
final neighborCount = buffer.readUInt16LE();
final parsedNeighbors = parseNeighborsData(buffer, buffer.readUInt16LE());
connector.contacts.where((c) => c.type == advTypeRepeater).forEach((
repeater,
) {
contacts.where((c) => c.type == advTypeRepeater).forEach((repeater) {
for (var neighborData in parsedNeighbors) {
final publicKey = neighborData['publicKey'];
if (listEquals(
@ -164,13 +181,6 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
}
}
Contact _resolveRepeater(MeshCoreConnector connector) {
return connector.contacts.firstWhere(
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
orElse: () => widget.repeater,
);
}
Future<void> _loadNeighbors() async {
if (_commandService == null) return;

View file

@ -52,18 +52,20 @@ class PathTraceMapScreen extends StatefulWidget {
final String title;
final Uint8List path;
final int? repeaterId;
final bool flipPathRound;
final bool reversePathRound;
final bool flipPathAround;
final bool reversePathAround;
final Contact? targetContact;
final int pathHashByteWidth;
const PathTraceMapScreen({
super.key,
required this.title,
required this.path,
this.repeaterId,
this.flipPathRound = false,
this.reversePathRound = false,
this.flipPathAround = false,
this.reversePathAround = false,
this.targetContact,
this.pathHashByteWidth = pathHashSize,
});
@override
@ -93,6 +95,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
ValueKey<String> _mapKey = const ValueKey('initial');
double _pathDistanceMeters = 0.0;
bool _showNodeLabels = true;
Contact? _targetContact;
String _formatPathPrefixes(Uint8List pathBytes) {
return pathBytes
@ -114,14 +117,42 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
super.dispose();
}
Uint8List addReturnPath(Uint8List pathBytes) {
Uint8List? traceBytes;
final len = (pathBytes.length + pathBytes.length - 1);
traceBytes = Uint8List(len);
for (int i = 0; i < pathBytes.length; i++) {
traceBytes[i] = pathBytes[i];
if (i < pathBytes.length - 1) {
traceBytes[len - 1 - i] = pathBytes[i];
Uint8List buildPath(Uint8List pathBytes) {
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] = pk?[0] ?? 0;
return traceBytes;
}
if (widget.targetContact?.type == advTypeRepeater ||
widget.targetContact?.type == advTypeRoom) {
final len = (pathBytes.length + pathBytes.length + 1);
traceBytes = Uint8List(len);
traceBytes[pathBytes.length] = widget.targetContact?.publicKey[0] ?? 0;
for (int i = 0; i < pathBytes.length; i++) {
traceBytes[i] = pathBytes[i];
if (i < pathBytes.length) {
traceBytes[len - 1 - i] = pathBytes[i];
}
}
} else {
if (pathBytes.length < 2) {
return pathBytes[0] == 0 ? Uint8List(0) : pathBytes;
}
final len = (pathBytes.length + pathBytes.length - 1);
traceBytes = Uint8List(len);
for (int i = 0; i < pathBytes.length; i++) {
traceBytes[i] = pathBytes[i];
if (i < pathBytes.length - 1) {
traceBytes[len - 1 - i] = pathBytes[i];
}
}
}
return traceBytes;
@ -135,17 +166,17 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
});
}
final Uint8List path;
Uint8List pathTmp = widget.reversePathRound
final pathTmp = widget.reversePathAround
? Uint8List.fromList(widget.path.reversed.toList())
: widget.path;
if (widget.flipPathRound) {
path = addReturnPath(pathTmp);
} else {
path = pathTmp;
}
final path = widget.flipPathAround ? buildPath(pathTmp) : pathTmp;
appLogger.info(
'Initiating path trace with path: ${_formatPathPrefixes(path)}',
tag: 'PathTraceMapScreen',
noNotify: !mounted,
);
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final frame = buildTraceReq(
@ -235,10 +266,8 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
.toList();
Map<int, Contact> pathContacts = {};
connector.contacts.where((c) => c.type != advTypeChat).forEach((
repeater,
) {
final contacts = connector.allContacts;
contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
for (var repeaterData in pathData) {
if (listEquals(
repeater.publicKey.sublist(0, 1),
@ -283,18 +312,21 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
// Compute endpoint position for the target contact.
LatLng? targetPos;
bool targetGuessed = false;
final target = widget.targetContact;
if (target != null) {
if (target.hasLocation) {
targetPos = LatLng(target.latitude!, target.longitude!);
} else if (pathData.isNotEmpty) {
_targetContact = widget.targetContact;
if (_targetContact != null) {
final tc = _targetContact!;
if (tc.hasLocation) {
targetPos = LatLng(tc.latitude!, tc.longitude!);
} else if (widget.path.length > 1) {
// Infer from the last hop: average GPS contacts sharing that hop.
// For a round-trip path (flipPathRound), the target-side hop sits
// in the middle of the symmetric sequence; .last is the local side.
final lastHop = (widget.flipPathRound && pathData.length > 1)
? pathData[(pathData.length - 1) ~/ 2]
: pathData.last;
final peers = connector.contacts
// For a round-trip path (flipPathAround/reversePathAround), the target-side hop
// sits in the middle of the symmetric sequence; .last is the local side.
final lastHop = widget.reversePathAround
? widget.path.first
: widget.path.last;
final peers = connector.allContacts
.where(
(c) =>
c.hasLocation &&
@ -310,12 +342,34 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
peers.map((c) => c.longitude!).reduce((a, b) => a + b) /
peers.length;
const offsetDeg = 0.003;
final angle = (target.publicKey[1] / 255.0) * 2 * pi;
final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
targetPos = LatLng(
lat + offsetDeg * cos(angle),
lon + offsetDeg * sin(angle),
);
targetGuessed = true;
} else if (inferredPositions.containsKey(lastHop)) {
final lat = inferredPositions[lastHop]!.latitude;
final lon = inferredPositions[lastHop]!.longitude;
const offsetDeg = 0.003;
final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
targetPos = LatLng(
lat + offsetDeg * cos(angle),
lon + offsetDeg * sin(angle),
);
targetGuessed = true;
} else {
// As a last resort, just place it at the same position as the last hop.
final contact = pathContacts[lastHop];
if (contact != null && contact.hasLocation) {
const offsetDeg = 0.003;
final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
targetPos = LatLng(
contact.latitude! + offsetDeg * cos(angle),
contact.longitude! + offsetDeg * sin(angle),
);
targetGuessed = true;
}
}
}
}
@ -324,7 +378,12 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
_points = <LatLng>[];
_points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!));
int hopLast = 0;
int hopLastLast = 0;
for (final hop in _traceData!.pathData) {
if (hop == hopLastLast && widget.flipPathAround) {
break; //skip duplicate hops in round-trip paths
}
final contact = _traceData!.pathContacts[hop];
if (contact != null && contact.hasLocation) {
_points.add(LatLng(contact.latitude!, contact.longitude!));
@ -332,8 +391,14 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
final inferred = inferredPositions[hop];
if (inferred != null) _points.add(inferred);
}
hopLastLast = hopLast;
hopLast = hop;
}
if (targetPos != null) {
if (_targetContact != null && _targetContact!.type == advTypeChat) {
_points.add(targetPos);
}
}
if (targetPos != null) _points.add(targetPos);
_polylines = _points.length > 1
? [
Polyline(
@ -422,7 +487,8 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
],
),
),
if (_hasData) _buildMapPathTrace(context, tileCache),
if (_hasData)
_buildMapPathTrace(context, tileCache, _targetContact),
if (_points.isEmpty &&
!_hasData &&
!_isLoading &&
@ -451,17 +517,28 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
List<Marker> _buildHopMarkers(
List<int> pathData, {
required bool showLabels,
required Contact? target,
}) {
final markers = <Marker>[];
int hopLast = 0;
int hopLastLast = 0;
for (final hop in pathData) {
final contact = _traceData!.pathContacts[hop];
final inferred = _inferredHopPositions[hop];
final hasGps = contact != null && contact.hasLocation;
if (!hasGps && inferred == null) continue;
if (hop == hopLastLast && widget.flipPathAround) {
continue; //skip duplicate hops in round-trip paths
}
if (!hasGps && inferred == null) {
hopLastLast = hopLast;
hopLast = hop;
continue; //skip hops with no GPS and no inferred position
}
final point = hasGps
? LatLng(contact.latitude!, contact.longitude!)
: inferred!;
final label = hop.toRadixString(16).padLeft(2, '0').toUpperCase();
markers.add(
Marker(
point: point,
@ -503,6 +580,8 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
),
);
}
hopLastLast = hopLast;
hopLast = hop;
}
final selfLat = context.read<MeshCoreConnector>().selfLatitude;
@ -552,9 +631,9 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
// Add target contact endpoint marker.
final targetPos = _targetContactPosition;
if (targetPos != null) {
if (targetPos != null && target != null && target.type == advTypeChat) {
final isGuessed = _targetContactIsGuessed;
final targetName = widget.targetContact?.name ?? '?';
final targetName = target.name;
markers.add(
Marker(
point: targetPos,
@ -690,6 +769,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
Widget _buildMapPathTrace(
BuildContext context,
MapTileCacheService tileCache,
Contact? target,
) {
return FlutterMap(
key: _mapKey,
@ -728,6 +808,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
markers: _buildHopMarkers(
_traceData!.pathData,
showLabels: _showNodeLabels,
target: target,
),
),
],

View file

@ -77,11 +77,22 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
});
}
int _resolveRepeaterIndex = -1;
Contact _resolveRepeater(MeshCoreConnector connector) {
return connector.contacts.firstWhere(
if (_resolveRepeaterIndex >= 0 &&
_resolveRepeaterIndex < connector.contacts.length &&
connector.contacts[_resolveRepeaterIndex].publicKeyHex ==
widget.repeater.publicKeyHex) {
return connector.contacts[_resolveRepeaterIndex];
}
_resolveRepeaterIndex = connector.contacts.indexWhere(
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
orElse: () => widget.repeater,
);
if (_resolveRepeaterIndex == -1) {
return widget.repeater;
}
return connector.contacts[_resolveRepeaterIndex];
}
void _handleTextMessageResponse(Uint8List frame) {

View file

@ -205,8 +205,7 @@ class RepeaterHubScreen extends StatelessWidget {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
TelemetryScreen(repeater: repeater, password: password),
builder: (context) => TelemetryScreen(contact: repeater),
),
);
},

View file

@ -129,11 +129,22 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
_commandService?.handleResponse(widget.repeater, parsed.text);
}
int _resolveRepeaterIndex = -1;
Contact _resolveRepeater(MeshCoreConnector connector) {
return connector.contacts.firstWhere(
if (_resolveRepeaterIndex >= 0 &&
_resolveRepeaterIndex < connector.contacts.length &&
connector.contacts[_resolveRepeaterIndex].publicKeyHex ==
widget.repeater.publicKeyHex) {
return connector.contacts[_resolveRepeaterIndex];
}
_resolveRepeaterIndex = connector.contacts.indexWhere(
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
orElse: () => widget.repeater,
);
if (_resolveRepeaterIndex == -1) {
return widget.repeater;
}
return connector.contacts[_resolveRepeaterIndex];
}
bool _matchesRepeaterPrefix(Uint8List prefix) {

View file

@ -91,11 +91,22 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
});
}
int _resolveRepeaterIndex = -1;
Contact _resolveRepeater(MeshCoreConnector connector) {
return connector.contacts.firstWhere(
if (_resolveRepeaterIndex >= 0 &&
_resolveRepeaterIndex < connector.contacts.length &&
connector.contacts[_resolveRepeaterIndex].publicKeyHex ==
widget.repeater.publicKeyHex) {
return connector.contacts[_resolveRepeaterIndex];
}
_resolveRepeaterIndex = connector.contacts.indexWhere(
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
orElse: () => widget.repeater,
);
if (_resolveRepeaterIndex == -1) {
return widget.repeater;
}
return connector.contacts[_resolveRepeaterIndex];
}
void _handleTextMessageResponse(Uint8List frame) {

View file

@ -6,10 +6,12 @@ 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';
import 'contacts_screen.dart';
import 'tcp_screen.dart';
import 'usb_screen.dart';
/// Screen for scanning and connecting to MeshCore devices
@ -125,61 +127,78 @@ class _ScannerScreenState extends State<ScannerScreen> {
connector.state == MeshCoreConnectionState.scanning;
final isBluetoothOff = _bluetoothState == BluetoothAdapterState.off;
final usbSupported = PlatformInfo.supportsUsbSerial;
final tcpSupported = !PlatformInfo.isWeb;
return SafeArea(
top: false,
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (usbSupported)
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (usbSupported)
FloatingActionButton.extended(
onPressed: () {
appLogger.info(
'USB selected, opening UsbScreen',
tag: 'ScannerScreen',
);
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const UsbScreen()),
);
},
heroTag: 'scanner_usb_action',
icon: const Icon(Icons.usb),
label: Text(context.l10n.connectionChoiceUsbLabel),
),
if (usbSupported) const SizedBox(width: 12),
if (tcpSupported)
FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const TcpScreen()),
);
},
heroTag: 'scanner_tcp_action',
icon: const Icon(Icons.lan),
label: Text(context.l10n.connectionChoiceTcpLabel),
),
if (tcpSupported) const SizedBox(width: 12),
FloatingActionButton.extended(
onPressed: () {
appLogger.info(
'USB selected, opening UsbScreen',
tag: 'ScannerScreen',
);
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const UsbScreen()),
);
},
heroTag: 'scanner_usb_action',
icon: const Icon(Icons.usb),
label: Text(context.l10n.connectionChoiceUsbLabel),
heroTag: 'scanner_ble_action',
onPressed: isBluetoothOff
? null
: () {
if (isScanning) {
connector.stopScan();
} else {
unawaited(
connector.startScan().catchError((e) {
appLogger.warn(
'startScan error: $e',
tag: 'ScannerScreen',
);
}),
);
}
},
icon: isScanning
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.bluetooth_searching),
label: Text(
isScanning
? context.l10n.scanner_stop
: context.l10n.scanner_scan,
),
),
if (usbSupported) const SizedBox(width: 12),
FloatingActionButton.extended(
heroTag: 'scanner_ble_action',
onPressed: isBluetoothOff
? null
: () {
if (isScanning) {
connector.stopScan();
} else {
unawaited(
connector.startScan().catchError((e) {
appLogger.warn(
'startScan error: $e',
tag: 'ScannerScreen',
);
}),
);
}
},
icon: isScanning
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.bluetooth_searching),
label: Text(
isScanning
? context.l10n.scanner_stop
: context.l10n.scanner_scan,
),
),
],
],
),
),
);
},
@ -270,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(
@ -287,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(

View file

@ -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),
@ -287,10 +298,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.visibility_off_outlined),
title: Text(l10n.settings_privacyMode),
subtitle: Text(l10n.settings_privacyModeSubtitle),
title: Text(l10n.settings_privacy),
subtitle: Text(l10n.settings_privacySubtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => _togglePrivacy(context, connector),
onTap: () => _privacySettings(context, connector),
),
],
),
@ -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,55 +671,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
void _togglePrivacy(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.settings_privacyMode),
content: Text(l10n.settings_privacyModeToggle),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.common_cancel),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
await connector.setPrivacyMode(true);
await connector.refreshDeviceInfo();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.settings_privacyModeEnabled)),
);
},
child: Text(l10n.common_enable),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
await connector.setPrivacyMode(false);
await connector.refreshDeviceInfo();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.settings_privacyModeDisabled)),
);
},
child: Text(l10n.common_disable),
),
],
),
);
}
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();
@ -977,6 +942,136 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
void _privacySettings(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
int telemetryMode = connector.telemetryModeBase;
int telemetryLocMode = connector.telemetryModeLoc;
int telemetryEnvMode = connector.telemetryModeEnv;
bool advertLocPolicy = connector.advertLocationPolicy == 0 ? false : true;
int multiAcks = connector.multiAcks;
final telemModeBase = [
DropdownMenuItem(value: teleModeDeny, child: Text(l10n.settings_denyAll)),
DropdownMenuItem(
value: teleModeAllowFlags,
child: Text(l10n.settings_allowByContact),
),
DropdownMenuItem(
value: teleModeAllowAll,
child: Text(l10n.settings_allowAll),
),
];
showDialog(
context: context,
builder: (dialogContext) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: Text(l10n.settings_privacy),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.settings_privacySettingsDescription),
const SizedBox(height: 16),
FeatureToggleRow(
title: l10n.settings_advertLocation,
subtitle: l10n.settings_advertLocationSubtitle,
value: advertLocPolicy,
onChanged: (value) {
setDialogState(() => advertLocPolicy = value);
},
),
const SizedBox(height: 8),
DropdownButtonFormField<int>(
initialValue: telemetryMode,
decoration: InputDecoration(
labelText: l10n.settings_telemetryBaseMode,
border: const OutlineInputBorder(),
),
items: telemModeBase,
onChanged: (value) {
if (value != null) {
setDialogState(() => telemetryMode = value);
}
},
),
const SizedBox(height: 16),
DropdownButtonFormField<int>(
initialValue: telemetryLocMode,
decoration: InputDecoration(
labelText: l10n.settings_telemetryLocationMode,
border: const OutlineInputBorder(),
),
items: telemModeBase,
onChanged: (value) {
if (value != null) {
setDialogState(() => telemetryLocMode = value);
}
},
),
const SizedBox(height: 16),
DropdownButtonFormField<int>(
initialValue: telemetryEnvMode,
decoration: InputDecoration(
labelText: l10n.settings_telemetryEnvironmentMode,
border: const OutlineInputBorder(),
),
items: telemModeBase,
onChanged: (value) {
if (value != null) {
setDialogState(() => telemetryEnvMode = value);
}
},
),
const SizedBox(height: 16),
Text(
l10n.settings_multiAck(multiAcks.toString()),
style: Theme.of(context).textTheme.bodyMedium,
),
Slider(
value: multiAcks.toDouble(),
min: 0,
max: 2,
divisions: 2,
label: multiAcks.toString(),
onChanged: (value) {
setDialogState(() => multiAcks = value.round());
},
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.common_cancel),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
await connector.setTelemetryModeBase(
telemetryMode,
telemetryLocMode,
telemetryEnvMode,
advertLocPolicy ? 1 : 0,
multiAcks,
);
await connector.refreshDeviceInfo();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.settings_telemetryModeUpdated)),
);
},
child: Text(l10n.common_save),
),
],
),
),
);
}
class _RadioSettingsDialog extends StatefulWidget {
final MeshCoreConnector connector;

295
lib/screens/tcp_screen.dart Normal file
View file

@ -0,0 +1,295 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../services/app_settings_service.dart';
import '../utils/platform_info.dart';
import '../widgets/adaptive_app_bar_title.dart';
import 'contacts_screen.dart';
import 'usb_screen.dart';
class TcpScreen extends StatefulWidget {
const TcpScreen({super.key});
@override
State<TcpScreen> createState() => _TcpScreenState();
}
class _TcpScreenState extends State<TcpScreen> {
late final TextEditingController _hostController;
late final TextEditingController _portController;
late final MeshCoreConnector _connector;
late final VoidCallback _connectionListener;
bool _navigatedToContacts = false;
@override
void initState() {
super.initState();
_hostController = TextEditingController(
text: context.read<AppSettingsService>().settings.tcpServerAddress,
);
_portController = TextEditingController(
text: context.read<AppSettingsService>().settings.tcpServerPort > 0
? context.read<AppSettingsService>().settings.tcpServerPort.toString()
: '',
);
_connector = context.read<MeshCoreConnector>();
_connectionListener = () {
if (!mounted) return;
if (_connector.state == MeshCoreConnectionState.disconnected) {
_navigatedToContacts = false;
}
if (_connector.state == MeshCoreConnectionState.connected &&
_connector.isTcpTransportConnected &&
!_navigatedToContacts) {
context.read<AppSettingsService>().setTcpServerAddress(
_hostController.text,
);
context.read<AppSettingsService>().setTcpServerPort(
int.tryParse(_portController.text) ?? 0,
);
_navigatedToContacts = true;
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const ContactsScreen()),
);
}
};
_connector.addListener(_connectionListener);
}
@override
void dispose() {
_hostController.dispose();
_portController.dispose();
_connector.removeListener(_connectionListener);
if (!_navigatedToContacts &&
_connector.activeTransport == MeshCoreTransportType.tcp &&
_connector.state != MeshCoreConnectionState.disconnected) {
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_connector.disconnect(manual: true));
});
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).maybePop(),
),
title: AdaptiveAppBarTitle(context.l10n.tcpScreenTitle),
centerTitle: true,
),
body: SafeArea(
top: false,
child: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
final isConnecting =
connector.state == MeshCoreConnectionState.connecting &&
connector.activeTransport == MeshCoreTransportType.tcp;
final isButtonDisabled =
isConnecting ||
connector.state == MeshCoreConnectionState.scanning;
return Column(
children: [
_buildStatusBar(context, connector),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _hostController,
decoration: InputDecoration(
labelText: context.l10n.tcpHostLabel,
hintText: context.l10n.tcpHostHint,
border: const OutlineInputBorder(),
),
enabled: !isConnecting,
keyboardType: TextInputType.url,
),
const SizedBox(height: 12),
TextField(
controller: _portController,
decoration: InputDecoration(
labelText: context.l10n.tcpPortLabel,
hintText: context.l10n.tcpPortHint,
border: const OutlineInputBorder(),
),
enabled: !isConnecting,
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
FilledButton.icon(
key: const Key('tcp_connect_button'),
onPressed: isButtonDisabled ? null : _connectTcp,
icon: isConnecting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.lan),
label: Text(
isConnecting
? context.l10n.scanner_connecting
: context.l10n.common_connect,
),
),
],
),
),
],
);
},
),
),
bottomNavigationBar: SafeArea(
top: false,
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (PlatformInfo.supportsUsbSerial)
FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const UsbScreen()),
);
},
heroTag: 'tcp_usb_action',
extendedPadding: const EdgeInsets.symmetric(horizontal: 12),
icon: const Icon(Icons.usb),
label: Text(context.l10n.connectionChoiceUsbLabel),
),
if (PlatformInfo.supportsUsbSerial) const SizedBox(width: 12),
FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).maybePop();
},
heroTag: 'tcp_ble_action',
extendedPadding: const EdgeInsets.symmetric(horizontal: 12),
icon: const Icon(Icons.bluetooth),
label: Text(context.l10n.connectionChoiceBluetoothLabel),
),
],
),
),
),
);
}
Widget _buildStatusBar(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
String statusText;
Color statusColor;
if (connector.isTcpTransportConnected) {
statusText = l10n.scanner_connectedTo(
connector.activeTcpEndpoint ?? 'TCP',
);
statusColor = Colors.green;
} else if (connector.state == MeshCoreConnectionState.connecting &&
connector.activeTransport == MeshCoreTransportType.tcp) {
statusText = l10n.tcpStatus_connectingTo(
'${_hostController.text}:${_portController.text}',
);
statusColor = Colors.orange;
} else if (connector.state == MeshCoreConnectionState.disconnecting &&
connector.activeTransport == MeshCoreTransportType.tcp) {
statusText = l10n.scanner_disconnecting;
statusColor = Colors.orange;
} else {
statusText = l10n.tcpStatus_notConnected;
statusColor = Colors.grey;
}
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
color: statusColor.withValues(alpha: 0.1),
child: Row(
children: [
Icon(Icons.circle, size: 12, color: statusColor),
const SizedBox(width: 8),
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
statusText,
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
);
}
Future<void> _connectTcp() async {
if (_connector.state == MeshCoreConnectionState.connecting ||
_connector.state == MeshCoreConnectionState.connected ||
_connector.state == MeshCoreConnectionState.disconnecting) {
return;
}
final host = _hostController.text.trim();
final parsedPort = int.tryParse(_portController.text.trim());
if (host.isEmpty) {
_showError(context.l10n.tcpErrorHostRequired);
return;
}
if (parsedPort == null || parsedPort < 1 || parsedPort > 65535) {
_showError(context.l10n.tcpErrorPortInvalid);
return;
}
try {
await _connector.connectTcp(host: host, port: parsedPort);
} catch (error) {
if (!mounted) return;
_showError(_friendlyErrorMessage(error));
}
}
void _showError(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: Colors.red),
);
}
String _friendlyErrorMessage(Object error) {
if (error is UnsupportedError) {
return context.l10n.tcpErrorUnsupported;
}
if (error is TimeoutException) {
return context.l10n.tcpErrorTimedOut;
}
if (error is StateError) {
return context.l10n.tcpConnectionFailed(error.message);
}
if (error is ArgumentError) {
return context.l10n.tcpConnectionFailed(
error.message?.toString() ?? error.toString(),
);
}
return context.l10n.tcpConnectionFailed(error.toString());
}
}

View file

@ -10,30 +10,22 @@ import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../services/app_settings_service.dart';
import '../services/repeater_command_service.dart';
import '../utils/app_logger.dart';
import '../widgets/path_management_dialog.dart';
import '../helpers/cayenne_lpp.dart';
import '../utils/battery_utils.dart';
class TelemetryScreen extends StatefulWidget {
final Contact repeater;
final String password;
final Contact contact;
const TelemetryScreen({
super.key,
required this.repeater,
required this.password,
});
const TelemetryScreen({super.key, required this.contact});
@override
State<TelemetryScreen> createState() => _TelemetryScreenState();
}
class _TelemetryScreenState extends State<TelemetryScreen> {
static const int _statusPayloadOffset = 8;
static const int _statusStatsSize = 52;
static const int _statusResponseBytes =
_statusPayloadOffset + _statusStatsSize;
Uint8List _tagData = Uint8List(4);
int _tagData = 0;
bool _isLoading = false;
bool _isLoaded = false;
@ -44,6 +36,26 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
PathSelection? _pendingStatusSelection;
List<Map<String, dynamic>>? _parsedTelemetry;
int _tripTime = 0;
int _resolveContactIndex = -1;
Contact _resolveContact(MeshCoreConnector connector) {
if (_resolveContactIndex >= 0 &&
_resolveContactIndex < connector.contacts.length &&
connector.contacts[_resolveContactIndex].publicKeyHex ==
widget.contact.publicKeyHex) {
return connector.contacts[_resolveContactIndex];
}
_resolveContactIndex = connector.contacts.indexWhere(
(c) => c.publicKeyHex == widget.contact.publicKeyHex,
);
if (_resolveContactIndex == -1) {
return widget.contact;
}
return connector.contacts[_resolveContactIndex];
}
@override
void initState() {
super.initState();
@ -60,27 +72,62 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
// Listen for incoming text messages from the repeater
_frameSubscription = connector.receivedFrames.listen((frame) {
if (frame.isEmpty) return;
final reader = BufferReader(frame);
try {
final cmd = reader.readByte();
if (cmd == respCodeSent) {
reader.skipBytes(1); // Skip the reserved byte
_tagData = reader.readUInt32LE();
_tripTime = reader.readUInt32LE();
_statusTimeout?.cancel();
_statusTimeout = Timer(Duration(milliseconds: _tripTime), () {
if (!mounted) return;
setState(() {
_isLoading = false;
_isLoaded = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.telemetry_requestTimeout),
backgroundColor: Colors.red,
),
);
_recordTelemetryResult(false);
});
}
if (frame[0] == respCodeSent) {
_tagData = frame.sublist(2, 6);
}
// Check if it's a binary response
if (cmd == pushCodeBinaryResponse) {
if (!mounted) return;
reader.skipBytes(1); // Skip the reserved byte
if (reader.readUInt32LE() != _tagData) return;
_handleTelemetryResponse(reader.readRemainingBytes());
}
// Check if it's a binary response
if (frame[0] == pushCodeBinaryResponse &&
listEquals(frame.sublist(2, 6), _tagData)) {
if (!mounted) return;
_handleStatusResponse(frame.sublist(6));
// Check if it's a telemetry response (for chat contacts)
if (cmd == pushCodeTelemetryResponse) {
reader.skipBytes(1); // Skip the reserved byte
final pubkey = reader.readBytes(6);
if (!mounted) return;
if (!listEquals(widget.contact.publicKey.sublist(0, 6), pubkey)) {
return;
}
_handleTelemetryResponse(reader.readRemainingBytes());
}
} catch (e) {
appLogger.error('Error parsing incoming frame: $e');
// If parsing fails, ignore the frame
}
});
}
void _handleStatusResponse(Uint8List frame) {
void _handleTelemetryResponse(Uint8List frame) {
final parsedTelemetry = CayenneLpp.parseByChannel(frame);
final batteryMv = _extractTelemetryBatteryMillivolts(parsedTelemetry);
if (batteryMv != null) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
connector.updateRepeaterBatterySnapshot(
widget.repeater.publicKeyHex,
widget.contact.publicKeyHex,
batteryMv,
source: 'telemetry',
);
@ -105,13 +152,6 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
});
}
Contact _resolveRepeater(MeshCoreConnector connector) {
return connector.contacts.firstWhere(
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
orElse: () => widget.repeater,
);
}
Future<void> _loadTelemetry() async {
if (_commandService == null) return;
@ -121,41 +161,20 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
});
try {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final repeater = _resolveRepeater(connector);
final selection = await connector.preparePathForContactSend(repeater);
final selection = await connector.preparePathForContactSend(
_resolveContact(connector),
);
_pendingStatusSelection = selection;
final frame = buildSendBinaryReq(
repeater.publicKey,
payload: Uint8List.fromList([reqTypeGetTelemetry]),
);
await connector.sendFrame(frame);
final pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
var messageBytes = frame.length >= _statusResponseBytes
? frame.length
: _statusResponseBytes;
if (messageBytes < maxFrameSize) {
messageBytes = maxFrameSize;
}
final timeoutMs = connector.calculateTimeout(
pathLength: pathLengthValue,
messageBytes: messageBytes,
);
_statusTimeout?.cancel();
_statusTimeout = Timer(Duration(milliseconds: timeoutMs), () {
if (!mounted) return;
setState(() {
_isLoading = false;
_isLoaded = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.telemetry_requestTimeout),
backgroundColor: Colors.red,
),
Uint8List frame;
if (widget.contact.type != advTypeChat) {
frame = buildSendBinaryReq(
widget.contact.publicKey,
payload: Uint8List.fromList([reqTypeGetTelemetry]),
);
_recordStatusResult(false);
});
} else {
frame = buildSendTelemetryReq(widget.contact.publicKey);
}
await connector.sendFrame(frame);
} catch (e) {
if (mounted) {
setState(() {
@ -173,12 +192,16 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
}
}
void _recordStatusResult(bool success) {
void _recordTelemetryResult(bool success) {
final selection = _pendingStatusSelection;
if (selection == null) return;
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final repeater = _resolveRepeater(connector);
connector.recordRepeaterPathResult(repeater, selection, success, null);
connector.recordRepeaterPathResult(
widget.contact,
selection,
success,
null,
);
_pendingStatusSelection = null;
}
@ -196,8 +219,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
final connector = context.watch<MeshCoreConnector>();
final settings = context.watch<AppSettingsService>().settings;
final isImperialUnits = settings.unitSystem == UnitSystem.imperial;
final repeater = _resolveRepeater(connector);
final isFloodMode = repeater.pathOverride == -1;
final isFloodMode = widget.contact.pathOverride == -1;
return Scaffold(
appBar: AppBar(
@ -210,7 +232,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
Text(
repeater.name,
widget.contact.name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
@ -225,9 +247,9 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
tooltip: l10n.repeater_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1);
await connector.setPathOverride(widget.contact, pathLen: -1);
} else {
await connector.setPathOverride(repeater, pathLen: null);
await connector.setPathOverride(widget.contact, pathLen: null);
}
},
itemBuilder: (context) => [
@ -283,7 +305,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
icon: const Icon(Icons.timeline),
tooltip: l10n.repeater_pathManagement,
onPressed: () =>
PathManagementDialog.show(context, contact: repeater),
PathManagementDialog.show(context, contact: widget.contact),
),
IconButton(
icon: _isLoading
@ -437,7 +459,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
final l10n = context.l10n;
final connector = context.watch<MeshCoreConnector>();
final batteryMv =
connector.getRepeaterBatteryMillivolts(widget.repeater.publicKeyHex) ??
connector.getRepeaterBatteryMillivolts(widget.contact.publicKeyHex) ??
(telemetryVolts == null ? null : (telemetryVolts * 1000).round());
if (batteryMv == null) return l10n.common_notAvailable;
final chemistry = _batteryChemistry();
@ -449,7 +471,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
String _batteryChemistry() {
final settingsService = context.read<AppSettingsService>();
return settingsService.batteryChemistryForRepeater(
widget.repeater.publicKeyHex,
widget.contact.publicKeyHex,
);
}

View file

@ -12,6 +12,7 @@ import '../utils/usb_port_labels.dart';
import '../widgets/adaptive_app_bar_title.dart';
import 'contacts_screen.dart';
import 'scanner_screen.dart';
import 'tcp_screen.dart';
class UsbScreen extends StatefulWidget {
const UsbScreen({super.key});
@ -107,45 +108,69 @@ class _UsbScreenState extends State<UsbScreen> {
bottomNavigationBar: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
final isLoading = _isLoadingPorts;
final showBle =
PlatformInfo.isWeb ||
PlatformInfo.isAndroid ||
PlatformInfo.isIOS;
final showBle = true;
final showTcp = !PlatformInfo.isWeb;
return SafeArea(
top: false,
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (showBle)
FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => const ScannerScreen(),
),
);
},
heroTag: 'usb_ble_action',
icon: const Icon(Icons.bluetooth),
label: Text(context.l10n.connectionChoiceBluetoothLabel),
),
if (showBle) const SizedBox(width: 12),
if (!_supportsHotPlug)
FloatingActionButton.extended(
onPressed: isLoading ? null : _loadPorts,
heroTag: 'usb_refresh_action',
icon: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
label: Text(context.l10n.repeater_refresh),
),
],
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (showTcp)
FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const TcpScreen()),
);
},
heroTag: 'usb_tcp_action',
extendedPadding: const EdgeInsets.symmetric(
horizontal: 12,
),
icon: const Icon(Icons.lan),
label: Text(context.l10n.connectionChoiceTcpLabel),
),
if (showTcp && showBle) const SizedBox(width: 12),
if (showBle)
FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => const ScannerScreen(),
),
);
},
heroTag: 'usb_ble_action',
extendedPadding: const EdgeInsets.symmetric(
horizontal: 12,
),
icon: const Icon(Icons.bluetooth),
label: Text(context.l10n.connectionChoiceBluetoothLabel),
),
if ((showTcp || showBle) && !_supportsHotPlug)
const SizedBox(width: 12),
if (!_supportsHotPlug)
FloatingActionButton.extended(
onPressed: isLoading ? null : _loadPorts,
heroTag: 'usb_refresh_action',
extendedPadding: const EdgeInsets.symmetric(
horizontal: 12,
),
icon: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.usb),
label: Text(context.l10n.scanner_scan),
),
],
),
),
);
},
@ -192,9 +217,18 @@ class _UsbScreenState extends State<UsbScreen> {
children: [
Icon(Icons.circle, size: 12, color: statusColor),
const SizedBox(width: 8),
Text(
statusText,
style: TextStyle(color: statusColor, fontWeight: FontWeight.w500),
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
statusText,
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.w500,
),
),
),
),
],
),

View file

@ -51,6 +51,7 @@ class AppDebugLogService extends ChangeNotifier {
String message, {
String tag = 'App',
AppDebugLogLevel level = AppDebugLogLevel.info,
bool noNotify = false,
}) {
if (!_enabled && !kDebugMode) return;
if (!_enabled) {
@ -72,22 +73,24 @@ class AppDebugLogService extends ChangeNotifier {
_entries.removeRange(0, _entries.length - maxEntries);
}
notifyListeners();
if (!noNotify) {
notifyListeners();
}
// Also print to console for development
debugPrint('[$tag] $message');
}
void info(String message, {String tag = 'App'}) {
log(message, tag: tag, level: AppDebugLogLevel.info);
void info(String message, {String tag = 'App', bool noNotify = false}) {
log(message, tag: tag, level: AppDebugLogLevel.info, noNotify: noNotify);
}
void warn(String message, {String tag = 'App'}) {
log(message, tag: tag, level: AppDebugLogLevel.warning);
void warn(String message, {String tag = 'App', bool noNotify = false}) {
log(message, tag: tag, level: AppDebugLogLevel.warning, noNotify: noNotify);
}
void error(String message, {String tag = 'App'}) {
log(message, tag: tag, level: AppDebugLogLevel.error);
void error(String message, {String tag = 'App', bool noNotify = false}) {
log(message, tag: tag, level: AppDebugLogLevel.error, noNotify: noNotify);
}
void clear() {

View file

@ -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));
}
@ -120,6 +124,30 @@ class AppSettingsService extends ChangeNotifier {
await updateSettings(_settings.copyWith(autoRouteRotationEnabled: value));
}
Future<void> setMaxRouteWeight(double value) async {
await updateSettings(_settings.copyWith(maxRouteWeight: value));
}
Future<void> setInitialRouteWeight(double value) async {
await updateSettings(_settings.copyWith(initialRouteWeight: value));
}
Future<void> setRouteWeightSuccessIncrement(double value) async {
await updateSettings(
_settings.copyWith(routeWeightSuccessIncrement: value),
);
}
Future<void> setRouteWeightFailureDecrement(double value) async {
await updateSettings(
_settings.copyWith(routeWeightFailureDecrement: value),
);
}
Future<void> setMaxMessageRetries(int value) async {
await updateSettings(_settings.copyWith(maxMessageRetries: value));
}
Future<void> setThemeMode(String value) async {
await updateSettings(_settings.copyWith(themeMode: value));
}
@ -134,6 +162,10 @@ class AppSettingsService extends ChangeNotifier {
appLogger.setEnabled(value);
}
Future<void> setMapShowDiscoveryContacts(bool value) async {
await updateSettings(_settings.copyWith(mapShowDiscoveryContacts: value));
}
Future<void> setBatteryChemistryForDevice(
String deviceId,
String chemistry,
@ -178,4 +210,16 @@ class AppSettingsService extends ChangeNotifier {
..remove(channelName);
await updateSettings(_settings.copyWith(mutedChannels: updated));
}
Future<void> setTcpServerAddress(String value) async {
await updateSettings(_settings.copyWith(tcpServerAddress: value));
}
Future<void> setTcpServerPort(int value) async {
await updateSettings(_settings.copyWith(tcpServerPort: value));
}
Future<void> setJumpToOldestUnread(bool value) async {
await updateSettings(_settings.copyWith(jumpToOldestUnread: value));
}
}

View file

@ -65,7 +65,7 @@ class ChatTextScaleService extends ChangeNotifier {
void _commitScale() {
_saveTimer?.cancel();
PrefsManager.instance.setDouble(_prefKey, _scale);
unawaited(PrefsManager.instance.setDouble(_prefKey, _scale));
}
double _clamp(double value) => value.clamp(_minScale, _maxScale).toDouble();

View file

@ -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'));
}

View file

@ -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,
});
}

View file

@ -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;
}

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,7 @@ import 'dart:ui';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter/foundation.dart';
import '../helpers/reaction_helper.dart';
import '../l10n/app_localizations.dart';
import '../utils/platform_info.dart';
@ -145,6 +146,19 @@ class NotificationService {
return true;
}
/// Format special message types for human-readable notifications.
static String formatNotificationText(String text) {
final trimmed = text.trim();
final reaction = ReactionHelper.parseReaction(trimmed);
if (reaction != null) {
return 'Reacted ${reaction.emoji}';
}
if (RegExp(r'^g:[A-Za-z0-9_-]+$').hasMatch(trimmed)) {
return 'Sent a GIF';
}
return text;
}
Future<void> _showMessageNotificationImpl({
required String contactName,
required String message,
@ -187,7 +201,7 @@ class NotificationService {
await _notifications.show(
id: contactId?.hashCode ?? 0,
title: contactName,
body: message,
body: formatNotificationText(message),
notificationDetails: notificationDetails,
payload: 'message:$contactId',
);
@ -232,7 +246,9 @@ class NotificationService {
try {
await _notifications.show(
id: contactId?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
id: contactId != null
? 'advert:$contactId'.hashCode
: DateTime.now().millisecondsSinceEpoch,
title: _l10n.notification_newTypeDiscovered(contactType),
body: contactName,
notificationDetails: notificationDetails,
@ -281,7 +297,7 @@ class NotificationService {
macOS: macDetails,
);
final preview = message.trim();
final preview = formatNotificationText(message.trim());
final body = preview.isEmpty
? _l10n.notification_receivedNewMessage
: preview;
@ -331,6 +347,61 @@ class NotificationService {
await _notifications.cancel(id: id);
}
/// Cancel the notification for a specific contact and update the app badge.
Future<void> clearContactNotification(
String contactId,
int totalUnreadCount,
) async {
if (!await _ensureInitialized()) return;
await _notifications.cancel(id: contactId.hashCode);
await _updateBadge(totalUnreadCount);
}
/// Cancel the notification for a specific channel and update the app badge.
Future<void> clearChannelNotification(
int channelIndex,
int totalUnreadCount,
) async {
if (!await _ensureInitialized()) return;
await _notifications.cancel(id: channelIndex.hashCode);
await _updateBadge(totalUnreadCount);
}
/// Cancel advert notifications for the given contact public key hexes.
Future<void> clearAdvertNotifications(List<String> contactIds) async {
if (!await _ensureInitialized()) return;
for (final id in contactIds) {
await _notifications.cancel(id: 'advert:$id'.hashCode);
}
}
Future<void> _updateBadge(int count) async {
if (PlatformInfo.isIOS || PlatformInfo.isMacOS) {
// On Apple platforms, set the badge number directly via a silent update.
final darwinDetails = DarwinNotificationDetails(
presentAlert: false,
presentSound: false,
presentBadge: true,
badgeNumber: count,
);
final details = NotificationDetails(
iOS: darwinDetails,
macOS: darwinDetails,
);
// Use a fixed ID so each update replaces the previous one.
await _notifications.show(
id: 'badge_update'.hashCode,
title: null,
body: null,
notificationDetails: details,
);
// Immediately cancel the silent notification so it doesn't appear in tray.
await _notifications.cancel(id: 'badge_update'.hashCode);
}
// On Android, badge count is derived from active notifications,
// so cancelling the specific notification above is sufficient.
}
//
// Public notification methods (rate limiting is enforced automatically)
//
@ -373,6 +444,7 @@ class NotificationService {
Future<void> showChannelMessageNotification({
required String channelName,
required String senderName,
required String message,
int? channelIndex,
int? badgeCount,
@ -383,7 +455,7 @@ class NotificationService {
_PendingNotification(
type: _NotificationType.channelMessage,
title: channelName,
body: message,
body: '$senderName: $message',
id: channelIndex?.toString(),
badgeCount: badgeCount,
),

View file

@ -9,6 +9,8 @@ class PathHistoryService extends ChangeNotifier {
final Map<String, ContactPathHistory> _cache = {};
final Map<String, int> _autoRotationIndex = {};
final Map<String, _FloodStats> _floodStats = {};
final Set<String> _pendingLoads = {};
final Map<String, List<_DeferredPathRecord>> _deferredRecords = {};
// LRU cache eviction tracking
static const int _maxCachedContacts = 50;
@ -18,7 +20,6 @@ class PathHistoryService extends ChangeNotifier {
int _version = 0;
int get version => _version;
static const int _autoRotationTopCount = 3;
PathHistoryService(this._storage);
@ -26,17 +27,21 @@ class PathHistoryService extends ChangeNotifier {
// Load cached path histories on startup if needed
}
void handlePathUpdated(Contact contact) {
if (contact.pathLength < 0) return;
void handlePathUpdated(Contact contact, {double initialWeight = 1.0}) {
if (contact.pathLength < 0 && contact.path.isEmpty) return;
final hopCount = contact.pathLength < 0
? contact.path.length
: contact.pathLength;
_addPathRecord(
contactPubKeyHex: contact.publicKeyHex,
hopCount: contact.pathLength,
hopCount: hopCount,
tripTimeMs: 0,
wasFloodDiscovery: true,
pathBytes: contact.path,
successCount: 0,
failureCount: 0,
routeWeight: initialWeight,
timestamp: null,
);
}
@ -54,6 +59,44 @@ class PathHistoryService extends ChangeNotifier {
pathBytes: selection.pathBytes,
successCount: 0,
failureCount: 0,
timestamp: null,
);
}
/// When a flood message is delivered, credit the contact's current device
/// path so that the route the ACK traveled back through gets a weight boost.
void recordFloodPathAttribution({
required String contactPubKeyHex,
required List<int> pathBytes,
required int hopCount,
int? tripTimeMs,
double successIncrement = 0.5,
double maxWeight = 5.0,
}) {
if (pathBytes.isEmpty || hopCount < 0) return;
final existing = _findPathRecord(contactPubKeyHex, pathBytes);
final successCount = (existing?.successCount ?? 0) + 1;
final failureCount = existing?.failureCount ?? 0;
final currentWeight = existing?.routeWeight ?? 1.0;
final newWeight = (currentWeight + successIncrement).clamp(0.0, maxWeight);
debugPrint(
'Flood path attribution: crediting path [${pathBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(',')}] '
'for $contactPubKeyHex (weight $currentWeight$newWeight)',
);
_addPathRecord(
contactPubKeyHex: contactPubKeyHex,
hopCount: hopCount,
tripTimeMs: tripTimeMs ?? existing?.tripTimeMs ?? 0,
wasFloodDiscovery: true,
pathBytes: pathBytes,
successCount: successCount,
failureCount: failureCount,
routeWeight: newWeight,
timestamp: DateTime.now(),
);
}
@ -62,6 +105,9 @@ class PathHistoryService extends ChangeNotifier {
PathSelection selection, {
required bool success,
int? tripTimeMs,
double successIncrement = 0.5,
double failureDecrement = 0.5,
double maxWeight = 5.0,
}) {
if (selection.useFlood) {
final stats = _floodStats.putIfAbsent(
@ -82,6 +128,18 @@ class PathHistoryService extends ChangeNotifier {
final successCount = (existing?.successCount ?? 0) + (success ? 1 : 0);
final failureCount = (existing?.failureCount ?? 0) + (success ? 0 : 1);
final currentWeight = existing?.routeWeight ?? 1.0;
double newWeight;
if (success) {
newWeight = (currentWeight + successIncrement).clamp(0.0, maxWeight);
} else {
newWeight = currentWeight - failureDecrement;
if (newWeight <= 0) {
removePathRecord(contactPubKeyHex, selection.pathBytes);
return;
}
}
_addPathRecord(
contactPubKeyHex: contactPubKeyHex,
hopCount: selection.hopCount,
@ -90,37 +148,68 @@ class PathHistoryService extends ChangeNotifier {
pathBytes: selection.pathBytes,
successCount: successCount,
failureCount: failureCount,
routeWeight: newWeight,
timestamp: success ? DateTime.now() : existing?.timestamp,
);
}
PathSelection getNextAutoPathSelection(String contactPubKeyHex) {
final ranked = _getRankedPaths(
contactPubKeyHex,
).take(_autoRotationTopCount).toList();
PathSelection selectPathForAttempt(
String contactPubKeyHex, {
required int attemptIndex,
required int maxRetries,
List<PathSelection> recentSelections = const [],
}) {
if (maxRetries <= 0 || attemptIndex >= maxRetries - 1) {
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
}
final ranked = _getRankedPaths(contactPubKeyHex);
if (ranked.isEmpty) {
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
}
_trackAccess(contactPubKeyHex);
final selections =
ranked
.map(
(path) => PathSelection(
pathBytes: path.pathBytes,
hopCount: path.hopCount,
useFlood: false,
),
)
.toList()
..add(
const PathSelection(pathBytes: [], hopCount: -1, useFlood: true),
);
final recentPaths = recentSelections
.where((selection) => !selection.useFlood)
.map((selection) => selection.pathBytes)
.toList();
final candidates = recentPaths.isEmpty
? ranked
: ranked
.where(
(path) => !recentPaths.any(
(recentPath) => _pathsEqual(path.pathBytes, recentPath),
),
)
.toList();
final selected = candidates.isNotEmpty
? (recentPaths.isEmpty
? _selectRotatedCandidate(contactPubKeyHex, candidates)
: candidates.first)
: ranked.first;
return PathSelection(
pathBytes: selected.pathBytes,
hopCount: selected.hopCount,
useFlood: false,
);
}
PathRecord _selectRotatedCandidate(
String contactPubKeyHex,
List<PathRecord> candidates,
) {
if (candidates.length <= 1) {
_autoRotationIndex[contactPubKeyHex] = 0;
return candidates.first;
}
final currentIndex = _autoRotationIndex[contactPubKeyHex] ?? 0;
final selection = selections[currentIndex % selections.length];
_autoRotationIndex[contactPubKeyHex] = currentIndex + 1;
return selection;
final selectedIndex = currentIndex % candidates.length;
_autoRotationIndex[contactPubKeyHex] =
(selectedIndex + 1) % candidates.length;
return candidates[selectedIndex];
}
void _addPathRecord({
@ -131,37 +220,68 @@ class PathHistoryService extends ChangeNotifier {
required List<int> pathBytes,
required int successCount,
required int failureCount,
double routeWeight = 1.0,
DateTime? timestamp,
}) {
var history = _cache[contactPubKeyHex];
if (history == null) {
// If a load is already in progress, defer this record
if (_pendingLoads.contains(contactPubKeyHex)) {
_deferredRecords.putIfAbsent(contactPubKeyHex, () => []);
_deferredRecords[contactPubKeyHex]!.add(
_DeferredPathRecord(
hopCount: hopCount,
tripTimeMs: tripTimeMs,
wasFloodDiscovery: wasFloodDiscovery,
pathBytes: pathBytes,
successCount: successCount,
failureCount: failureCount,
routeWeight: routeWeight,
timestamp: timestamp,
),
);
return;
}
_pendingLoads.add(contactPubKeyHex);
_loadHistoryFromStorage(contactPubKeyHex).then((loaded) {
if (loaded != null) {
_cache[contactPubKeyHex] = loaded;
_addPathRecordInternal(
contactPubKeyHex,
hopCount,
tripTimeMs,
wasFloodDiscovery,
pathBytes,
successCount,
failureCount,
);
} else {
_cache[contactPubKeyHex] = ContactPathHistory(
contactPubKeyHex: contactPubKeyHex,
recentPaths: [],
);
_addPathRecordInternal(
contactPubKeyHex,
hopCount,
tripTimeMs,
wasFloodDiscovery,
pathBytes,
successCount,
failureCount,
);
_cache[contactPubKeyHex] =
loaded ??
ContactPathHistory(
contactPubKeyHex: contactPubKeyHex,
recentPaths: [],
);
_addPathRecordInternal(
contactPubKeyHex,
hopCount,
tripTimeMs,
wasFloodDiscovery,
pathBytes,
successCount,
failureCount,
routeWeight,
timestamp,
);
// Apply any deferred records
final deferred = _deferredRecords.remove(contactPubKeyHex);
if (deferred != null) {
for (final record in deferred) {
_addPathRecordInternal(
contactPubKeyHex,
record.hopCount,
record.tripTimeMs,
record.wasFloodDiscovery,
record.pathBytes,
record.successCount,
record.failureCount,
record.routeWeight,
record.timestamp,
);
}
}
_pendingLoads.remove(contactPubKeyHex);
});
return;
}
@ -174,6 +294,8 @@ class PathHistoryService extends ChangeNotifier {
pathBytes,
successCount,
failureCount,
routeWeight,
timestamp,
);
}
@ -185,6 +307,8 @@ class PathHistoryService extends ChangeNotifier {
List<int> pathBytes,
int successCount,
int failureCount,
double routeWeight,
DateTime? timestamp,
) {
var history = _cache[contactPubKeyHex];
if (history == null) return;
@ -198,16 +322,18 @@ class PathHistoryService extends ChangeNotifier {
tripTimeMs = existing.tripTimeMs;
}
wasFloodDiscovery = existing.wasFloodDiscovery || wasFloodDiscovery;
timestamp ??= existing.timestamp;
}
final newRecord = PathRecord(
hopCount: hopCount,
tripTimeMs: tripTimeMs,
timestamp: DateTime.now(),
timestamp: timestamp,
wasFloodDiscovery: wasFloodDiscovery,
pathBytes: pathBytes,
successCount: successCount,
failureCount: failureCount,
routeWeight: routeWeight,
);
final updatedPaths = List<PathRecord>.from(history.recentPaths);
@ -275,6 +401,23 @@ class PathHistoryService extends ChangeNotifier {
return history?.mostRecent;
}
({
int successCount,
int failureCount,
int lastTripTimeMs,
DateTime? lastUsed,
})?
getFloodStats(String contactPubKeyHex) {
final stats = _floodStats[contactPubKeyHex];
if (stats == null) return null;
return (
successCount: stats.successCount,
failureCount: stats.failureCount,
lastTripTimeMs: stats.lastTripTimeMs,
lastUsed: stats.lastUsed,
);
}
Future<void> clearPathHistory(String contactPubKeyHex) async {
_cache.remove(contactPubKeyHex);
_cacheAccessOrder.remove(contactPubKeyHex);
@ -322,26 +465,81 @@ class PathHistoryService extends ChangeNotifier {
final ranked = List<PathRecord>.from(history.recentPaths)
..removeWhere((p) => p.pathBytes.isEmpty);
final fastestTripMs = _getFastestKnownTripMs(ranked);
final highestRouteWeight = _getHighestKnownRouteWeight(ranked);
ranked.sort((a, b) {
final aRate =
(a.successCount + 1) / (a.successCount + a.failureCount + 2);
final bRate =
(b.successCount + 1) / (b.successCount + b.failureCount + 2);
if (aRate != bRate) return bRate.compareTo(aRate);
if (a.successCount != b.successCount) {
return b.successCount.compareTo(a.successCount);
final scoreCompare =
_scorePathRecord(
b,
fastestTripMs: fastestTripMs,
highestRouteWeight: highestRouteWeight,
).compareTo(
_scorePathRecord(
a,
fastestTripMs: fastestTripMs,
highestRouteWeight: highestRouteWeight,
),
);
if (scoreCompare != 0) {
return scoreCompare;
}
if (a.routeWeight != b.routeWeight) {
return b.routeWeight.compareTo(a.routeWeight);
}
final aTrip = a.tripTimeMs == 0 ? 999999 : a.tripTimeMs;
final bTrip = b.tripTimeMs == 0 ? 999999 : b.tripTimeMs;
if (aTrip != bTrip) return aTrip.compareTo(bTrip);
return b.timestamp.compareTo(a.timestamp);
final aTime = a.timestamp ?? DateTime.fromMillisecondsSinceEpoch(0);
final bTime = b.timestamp ?? DateTime.fromMillisecondsSinceEpoch(0);
return bTime.compareTo(aTime);
});
return ranked;
}
int? _getFastestKnownTripMs(List<PathRecord> paths) {
final knownTrips = paths
.where((path) => path.tripTimeMs > 0)
.map((path) => path.tripTimeMs)
.toList();
if (knownTrips.isEmpty) return null;
return knownTrips.reduce((a, b) => a < b ? a : b);
}
double _getHighestKnownRouteWeight(List<PathRecord> paths) {
if (paths.isEmpty) return 1.0;
final highestWeight = paths
.map((path) => path.routeWeight)
.reduce((a, b) => a > b ? a : b);
return highestWeight <= 0 ? 1.0 : highestWeight;
}
double _scorePathRecord(
PathRecord path, {
required int? fastestTripMs,
required double highestRouteWeight,
}) {
final totalAttempts = path.successCount + path.failureCount;
final reliability = (path.successCount + 1) / (totalAttempts + 2);
final latency = fastestTripMs == null || path.tripTimeMs <= 0
? 0.6
: (fastestTripMs / path.tripTimeMs).clamp(0.0, 1.0);
final freshness = path.timestamp == null
? 0.0
: 1.0 /
(1.0 +
(DateTime.now().difference(path.timestamp!).inMinutes /
60.0 /
24.0));
final routeWeight = (path.routeWeight / highestRouteWeight).clamp(0.0, 1.0);
return (reliability * 0.45) +
(latency * 0.25) +
(freshness * 0.1) +
(routeWeight * 0.2);
}
bool _pathsEqual(List<int> a, List<int> b) {
return listEquals(a, b);
}
@ -367,6 +565,38 @@ class PathHistoryService extends ChangeNotifier {
_floodStats.remove(oldest);
}
}
void clearAllHistories() {
_cache.clear();
_cacheAccessOrder.clear();
_autoRotationIndex.clear();
_floodStats.clear();
_storage.clearAllPathHistories();
_version = 0;
notifyListeners();
}
}
class _DeferredPathRecord {
final int hopCount;
final int tripTimeMs;
final bool wasFloodDiscovery;
final List<int> pathBytes;
final int successCount;
final int failureCount;
final double routeWeight;
final DateTime? timestamp;
_DeferredPathRecord({
required this.hopCount,
required this.tripTimeMs,
required this.wasFloodDiscovery,
required this.pathBytes,
required this.successCount,
required this.failureCount,
this.routeWeight = 1.0,
this.timestamp,
});
}
class _FloodStats {

View file

@ -1,4 +1,5 @@
import 'dart:convert';
import '../models/delivery_observation.dart';
import '../models/path_history.dart';
import '../storage/prefs_manager.dart';
@ -6,6 +7,7 @@ class StorageService {
static const String _pathHistoryPrefix = 'path_history_';
static const String _pendingMessagesKey = 'pending_messages';
static const String _repeaterPasswordsKey = 'repeater_passwords';
static const String _deliveryObservationsKey = 'delivery_observations';
Future<void> savePathHistory(
String contactPubKeyHex,
@ -122,4 +124,33 @@ class StorageService {
final prefs = PrefsManager.instance;
await prefs.remove(_repeaterPasswordsKey);
}
Future<void> saveDeliveryObservations(
List<DeliveryObservation> observations,
) async {
final prefs = PrefsManager.instance;
final jsonStr = jsonEncode(observations.map((o) => o.toJson()).toList());
await prefs.setString(_deliveryObservationsKey, jsonStr);
}
Future<List<DeliveryObservation>> loadDeliveryObservations() async {
final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_deliveryObservationsKey);
if (jsonStr == null) return [];
try {
final list = jsonDecode(jsonStr) as List;
return list
.map((e) => DeliveryObservation.fromJson(e as Map<String, dynamic>))
.toList();
} catch (e) {
return [];
}
}
Future<void> clearDeliveryObservations() async {
final prefs = PrefsManager.instance;
await prefs.remove(_deliveryObservationsKey);
}
}

View file

@ -0,0 +1,2 @@
export 'tcp_transport_service_native.dart'
if (dart.library.js_interop) 'tcp_transport_service_web.dart';

View file

@ -0,0 +1,210 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'app_debug_log_service.dart';
import 'usb_serial_frame_codec.dart';
class TcpTransportService {
final StreamController<Uint8List> _frameController =
StreamController<Uint8List>.broadcast();
final UsbSerialFrameDecoder _frameDecoder = UsbSerialFrameDecoder();
StreamSubscription<Uint8List>? _socketSubscription;
Socket? _socket;
AppDebugLogService? _debugLogService;
TcpTransportStatus _status = TcpTransportStatus.disconnected;
String? _activeHost;
int? _activePort;
Future<void> _pendingWrite = Future<void>.value();
int _connectGeneration = 0;
TcpTransportStatus get status => _status;
Stream<Uint8List> get frameStream => _frameController.stream;
bool get isConnected => _status == TcpTransportStatus.connected;
String? get activeEndpoint => _activeHost == null || _activePort == null
? null
: '$_activeHost:$_activePort';
void setDebugLogService(AppDebugLogService? service) {
_debugLogService = service;
}
Future<void> connect({
required String host,
required int port,
Duration timeout = const Duration(seconds: 10),
}) async {
if (_status == TcpTransportStatus.connected ||
_status == TcpTransportStatus.connecting) {
throw StateError('TCP transport is already active');
}
final trimmedHost = host.trim();
if (trimmedHost.isEmpty) {
throw ArgumentError.value(host, 'host', 'Host cannot be empty');
}
if (port < 1 || port > 65535) {
throw ArgumentError.value(port, 'port', 'Port must be in 1..65535');
}
_status = TcpTransportStatus.connecting;
final generation = ++_connectGeneration;
_frameDecoder.reset();
try {
final socket = await Socket.connect(trimmedHost, port, timeout: timeout);
if (generation != _connectGeneration ||
_status != TcpTransportStatus.connecting) {
try {
await socket.close();
} catch (_) {}
try {
socket.destroy();
} catch (_) {}
return;
}
socket.setOption(SocketOption.tcpNoDelay, true);
_socket = socket;
_activeHost = trimmedHost;
_activePort = port;
_socketSubscription = socket.listen(
_handleSocketData,
onError: _handleSocketError,
onDone: _handleSocketDone,
);
_status = TcpTransportStatus.connected;
_debugLogService?.info(
'TCP transport opened endpoint=$activeEndpoint',
tag: 'TCP',
);
} catch (error) {
await _cleanupFailedConnect();
_status = TcpTransportStatus.disconnected;
rethrow;
}
}
Future<void> write(Uint8List data) async {
if (!isConnected || _socket == null) {
throw StateError('TCP transport is not connected');
}
final packet = wrapUsbSerialTxFrame(data);
_logFrameSummary('TCP TX frame', data);
final writeTask = _pendingWrite.then((_) async {
final socket = _socket;
if (!isConnected || socket == null) {
throw StateError('TCP transport is not connected');
}
socket.add(packet);
await socket.flush();
});
_pendingWrite = writeTask.catchError((_) {});
await writeTask;
}
Future<void> disconnect() async {
_connectGeneration += 1;
if (_status == TcpTransportStatus.disconnected) return;
final endpoint = activeEndpoint;
_status = TcpTransportStatus.disconnecting;
_frameDecoder.reset();
_activeHost = null;
_activePort = null;
final subscription = _socketSubscription;
_socketSubscription = null;
await subscription?.cancel();
final socket = _socket;
_socket = null;
try {
await socket?.close();
} catch (_) {}
try {
socket?.destroy();
} catch (_) {}
_status = TcpTransportStatus.disconnected;
_debugLogService?.info(
'TCP transport closed endpoint=${endpoint ?? 'unknown'}',
tag: 'TCP',
);
}
void dispose() {
unawaited(disconnect().whenComplete(_closeFrameController));
}
Future<void> _cleanupFailedConnect() async {
final subscription = _socketSubscription;
_socketSubscription = null;
await subscription?.cancel();
final socket = _socket;
_socket = null;
try {
await socket?.close();
} catch (_) {}
try {
socket?.destroy();
} catch (_) {}
_activeHost = null;
_activePort = null;
_frameDecoder.reset();
}
void _handleSocketData(Uint8List bytes) {
for (final packet in _frameDecoder.ingest(bytes)) {
if (!packet.isRxFrame) {
_debugLogService?.info(
'TCP ignored packet start=0x${packet.frameStart.toRadixString(16).padLeft(2, '0')} len=${packet.payload.length}',
tag: 'TCP',
);
continue;
}
_addFrame(packet.payload);
}
}
void _handleSocketError(Object error, [StackTrace? stackTrace]) {
_addFrameError(error, stackTrace);
unawaited(disconnect());
}
void _handleSocketDone() {
if (_status == TcpTransportStatus.disconnecting ||
_status == TcpTransportStatus.disconnected) {
return;
}
_addFrameError(StateError('TCP socket closed by remote endpoint'));
unawaited(disconnect());
}
void _addFrame(Uint8List payload) {
if (_frameController.isClosed) return;
_frameController.add(payload);
}
void _addFrameError(Object error, [StackTrace? stackTrace]) {
if (_frameController.isClosed) return;
_frameController.addError(error, stackTrace);
}
void _logFrameSummary(String prefix, Uint8List payload) {
final code = payload.isNotEmpty ? payload.first : -1;
_debugLogService?.info(
'$prefix code=$code len=${payload.length}',
tag: 'TCP',
);
}
Future<void> _closeFrameController() async {
if (_frameController.isClosed) return;
await _frameController.close();
}
}
enum TcpTransportStatus { disconnected, connecting, connected, disconnecting }

View file

@ -0,0 +1,35 @@
import 'dart:typed_data';
import 'app_debug_log_service.dart';
class TcpTransportService {
AppDebugLogService? _debugLogService;
Stream<Uint8List> get frameStream => const Stream<Uint8List>.empty();
bool get isConnected => false;
String? get activeEndpoint => null;
void setDebugLogService(AppDebugLogService? service) {
_debugLogService = service;
}
Future<void> connect({
required String host,
required int port,
Duration timeout = const Duration(seconds: 10),
}) async {
_debugLogService?.warn(
'TCP transport requested on web for $host:$port',
tag: 'TCP',
);
throw UnsupportedError('TCP transport is not supported on web.');
}
Future<void> write(Uint8List data) async {
throw UnsupportedError('TCP transport is not supported on web.');
}
Future<void> disconnect() async {}
void dispose() {}
}

View file

@ -0,0 +1,229 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:ml_algo/ml_algo.dart';
import 'package:ml_dataframe/ml_dataframe.dart';
import '../models/delivery_observation.dart';
import 'storage_service.dart';
class _ContactStats {
int count = 0;
double _sum = 0;
void add(double ms) {
count++;
_sum += ms;
}
double get mean => _sum / count;
}
class TimeoutPredictionService extends ChangeNotifier {
final StorageService? _storage;
static const int minObservations = 10;
static const int maxObservations = 100;
static const int _retrainInterval = 5;
// 1.5x multiplier on raw prediction to account for variance in delivery
// times tight enough to improve on worst-case physics, loose enough
// to avoid premature timeouts from model noise.
static const double _safetyMargin = 1.5;
static const int _minContactObservations = 10;
List<DeliveryObservation> _observations = [];
LinearRegressor? _model;
List<String> _activeFeatures = [];
int _observationsSinceLastTrain = 0;
final Map<String, _ContactStats> _contactStats = {};
Timer? _persistTimer;
TimeoutPredictionService(StorageService storage) : _storage = storage;
TimeoutPredictionService.noStorage() : _storage = null;
int get observationCount => _observations.length;
bool get hasModel => _model != null;
Future<void> initialize() async {
_observations = await _storage?.loadDeliveryObservations() ?? [];
_rebuildContactStats();
if (_observations.length >= minObservations) {
_trainModel();
}
debugPrint(
'TimeoutPrediction: initialized with ${_observations.length} observations, '
'model=${_model != null ? "ready" : "waiting for data"}',
);
}
void recordObservation({
required String contactKey,
required int pathLength,
required int messageBytes,
required int tripTimeMs,
int secondsSinceLastRx = 0,
}) {
final observation = DeliveryObservation(
contactKey: contactKey,
pathLength: pathLength,
messageBytes: messageBytes,
secondsSinceLastRx: secondsSinceLastRx,
isFlood: pathLength < 0,
deliveryMs: tripTimeMs,
timestamp: DateTime.now(),
);
_observations.add(observation);
if (_observations.length > maxObservations) {
_observations.removeAt(0);
}
_contactStats.putIfAbsent(contactKey, () => _ContactStats());
_contactStats[contactKey]!.add(tripTimeMs.toDouble());
_observationsSinceLastTrain++;
if (_observationsSinceLastTrain >= _retrainInterval &&
_observations.length >= minObservations) {
_trainModel();
}
_persistTimer?.cancel();
_persistTimer = Timer(const Duration(seconds: 2), () {
_storage?.saveDeliveryObservations(_observations);
});
debugPrint(
'TimeoutPrediction: recorded ${tripTimeMs}ms for $pathLength hops '
'(${_observations.length} total)',
);
}
int? predictTimeout({
String? contactKey,
required int pathLength,
required int messageBytes,
int secondsSinceLastRx = 0,
}) {
if (_model == null) return null;
try {
if (_activeFeatures.isEmpty) return null;
final allFeatures = {
'pathLength': pathLength.toDouble(),
'messageBytes': messageBytes.toDouble(),
'secSinceRx': secondsSinceLastRx.toDouble(),
'isFlood': pathLength < 0 ? 1.0 : 0.0,
};
final row = _activeFeatures.map((f) => allFeatures[f]!).toList();
final features = DataFrame(
[row],
headerExists: false,
header: _activeFeatures,
);
final prediction = _model!.predict(features);
final rawValue = prediction.rows.first.first;
var predictedMs = (rawValue is double)
? rawValue
: (rawValue as num).toDouble();
debugPrint(
'TimeoutPrediction: raw prediction=$predictedMs for '
'pathLength=$pathLength, messageBytes=$messageBytes, '
'features=$_activeFeatures',
);
// Sanity check: if prediction is negative or zero, fall back
if (predictedMs <= 0) return null;
// Blend with per-contact mean if enough data
if (contactKey != null) {
final stats = _contactStats[contactKey];
if (stats != null && stats.count >= _minContactObservations) {
predictedMs = 0.5 * predictedMs + 0.5 * stats.mean;
}
}
// Connector clamps this between physics min/max bounds
final timeout = (predictedMs * _safetyMargin).ceil();
debugPrint(
'TimeoutPrediction: ML timeout ${timeout}ms '
'(raw: ${predictedMs.round()}ms, contact: $contactKey)',
);
return timeout;
} catch (e) {
debugPrint('TimeoutPrediction: prediction failed: $e');
return null;
}
}
void _trainModel() {
try {
// Build feature columns, then exclude any with zero variance
// (ml_algo's OLS produces all-zero coefficients for singular matrices)
final allNames = ['pathLength', 'messageBytes', 'secSinceRx', 'isFlood'];
final allExtractors = <double Function(DeliveryObservation)>[
(o) => o.pathLength.toDouble(),
(o) => o.messageBytes.toDouble(),
(o) => o.secondsSinceLastRx.toDouble(),
(o) => o.isFlood ? 1.0 : 0.0,
];
_activeFeatures = [];
for (var i = 0; i < allNames.length; i++) {
final values = _observations.map(allExtractors[i]).toSet();
if (values.length > 1) _activeFeatures.add(allNames[i]);
}
if (_activeFeatures.isEmpty) {
debugPrint(
'TimeoutPrediction: no features with variance, skipping training',
);
return;
}
final header = [..._activeFeatures, 'deliveryMs'];
final rows = _observations.map((o) {
final row = <double>[];
for (var i = 0; i < allNames.length; i++) {
if (_activeFeatures.contains(allNames[i])) {
row.add(allExtractors[i](o));
}
}
row.add(o.deliveryMs.toDouble());
return row;
});
final data = DataFrame([header, ...rows], headerExists: true);
_model = LinearRegressor(data, 'deliveryMs');
_observationsSinceLastTrain = 0;
// Log training summary with sample predictions
final avgMs =
_observations.map((o) => o.deliveryMs).reduce((a, b) => a + b) /
_observations.length;
debugPrint(
'TimeoutPrediction: trained on ${_observations.length} observations '
'(avg: ${avgMs.round()}ms, features: $_activeFeatures)',
);
} catch (e) {
debugPrint('TimeoutPrediction: training failed: $e');
}
}
@override
void dispose() {
_persistTimer?.cancel();
super.dispose();
}
void _rebuildContactStats() {
_contactStats.clear();
for (final obs in _observations) {
_contactStats.putIfAbsent(obs.contactKey, () => _ContactStats());
_contactStats[obs.contactKey]!.add(obs.deliveryMs.toDouble());
}
}
}

View file

@ -0,0 +1,154 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../storage/prefs_manager.dart';
import '../utils/contact_search.dart';
const String contactsAllGroupsValue = '__all__';
enum ChannelSortOption { manual, name, latestMessages, unread }
class UiViewStateService extends ChangeNotifier {
static const _keyContactsSelectedGroupName = 'ui_contacts_selected_group';
static const _keyContactsSortOption = 'ui_contacts_sort_option';
static const _keyContactsShowUnreadOnly = 'ui_contacts_show_unread_only';
static const _keyContactsTypeFilter = 'ui_contacts_type_filter';
static const _keyChannelsSortOption = 'ui_channels_sort_option';
static const _keyChannelsSortIndexLegacy = 'ui_channels_sort_index';
String _contactsSelectedGroupName = contactsAllGroupsValue;
String _contactsSearchText = '';
bool _contactsSearchExpanded = false;
ContactSortOption _contactsSortOption = ContactSortOption.lastSeen;
bool _contactsShowUnreadOnly = false;
ContactTypeFilter _contactsTypeFilter = ContactTypeFilter.all;
String _channelsSearchText = '';
ChannelSortOption _channelsSortOption = ChannelSortOption.manual;
String get contactsSelectedGroupName => _contactsSelectedGroupName;
String get contactsSearchText => _contactsSearchText;
bool get contactsSearchExpanded => _contactsSearchExpanded;
ContactSortOption get contactsSortOption => _contactsSortOption;
bool get contactsShowUnreadOnly => _contactsShowUnreadOnly;
ContactTypeFilter get contactsTypeFilter => _contactsTypeFilter;
String get channelsSearchText => _channelsSearchText;
ChannelSortOption get channelsSortOption => _channelsSortOption;
Future<void> initialize() async {
final prefs = PrefsManager.instance;
final selectedGroupName = prefs.getString(_keyContactsSelectedGroupName);
if (selectedGroupName != null && selectedGroupName.isNotEmpty) {
_contactsSelectedGroupName = selectedGroupName;
}
final sortStr = prefs.getString(_keyContactsSortOption);
if (sortStr != null) {
_contactsSortOption = ContactSortOption.values.firstWhere(
(e) => e.name == sortStr,
orElse: () => ContactSortOption.lastSeen,
);
}
_contactsShowUnreadOnly =
prefs.getBool(_keyContactsShowUnreadOnly) ?? false;
final typeStr = prefs.getString(_keyContactsTypeFilter);
if (typeStr != null) {
_contactsTypeFilter = ContactTypeFilter.values.firstWhere(
(e) => e.name == typeStr,
orElse: () => ContactTypeFilter.all,
);
}
final channelSortStr = prefs.getString(_keyChannelsSortOption);
if (channelSortStr != null) {
_channelsSortOption = ChannelSortOption.values.firstWhere(
(e) => e.name == channelSortStr,
orElse: () => ChannelSortOption.manual,
);
return;
}
// Backward compatibility for old persisted index format.
switch (prefs.getInt(_keyChannelsSortIndexLegacy) ?? 0) {
case 0:
_channelsSortOption = ChannelSortOption.manual;
break;
case 1:
_channelsSortOption = ChannelSortOption.name;
break;
case 2:
_channelsSortOption = ChannelSortOption.latestMessages;
break;
case 3:
_channelsSortOption = ChannelSortOption.unread;
break;
default:
_channelsSortOption = ChannelSortOption.manual;
}
}
void setContactsSelectedGroupName(String value) {
if (_contactsSelectedGroupName == value) return;
_contactsSelectedGroupName = value;
notifyListeners();
unawaited(
PrefsManager.instance.setString(_keyContactsSelectedGroupName, value),
);
}
void setContactsSearchText(String value) {
if (_contactsSearchText == value) return;
_contactsSearchText = value;
notifyListeners();
}
void setContactsSearchExpanded(bool value) {
if (_contactsSearchExpanded == value) return;
_contactsSearchExpanded = value;
notifyListeners();
}
void setContactsSortOption(ContactSortOption value) {
if (_contactsSortOption == value) return;
_contactsSortOption = value;
notifyListeners();
unawaited(
PrefsManager.instance.setString(_keyContactsSortOption, value.name),
);
}
void setContactsShowUnreadOnly(bool value) {
if (_contactsShowUnreadOnly == value) return;
_contactsShowUnreadOnly = value;
notifyListeners();
unawaited(PrefsManager.instance.setBool(_keyContactsShowUnreadOnly, value));
}
void setContactsTypeFilter(ContactTypeFilter value) {
if (_contactsTypeFilter == value) return;
_contactsTypeFilter = value;
notifyListeners();
unawaited(
PrefsManager.instance.setString(_keyContactsTypeFilter, value.name),
);
}
void setChannelsSearchText(String value) {
if (_channelsSearchText == value) return;
_channelsSearchText = value;
notifyListeners();
}
void setChannelsSortOption(ChannelSortOption value) {
if (_channelsSortOption == value) return;
_channelsSortOption = value;
notifyListeners();
unawaited(
PrefsManager.instance.setString(_keyChannelsSortOption, value.name),
);
}
}

View file

@ -189,6 +189,10 @@ class UsbSerialService {
serial.setStopBits1();
serial.setFlowControlNone();
serial.setRTS(false);
// Toggle DTR lowhigh so the device sees a fresh connection even
// if the previous disconnect didn't cleanly signal DTR drop.
serial.setDTR(false);
await Future<void>.delayed(const Duration(milliseconds: 50));
serial.setDTR(true);
_serial = serial;
// Update the normalized port name to whichever candidate succeeded.
@ -249,12 +253,27 @@ class UsbSerialService {
_status = UsbSerialStatus.connected;
}
Future<void> writeRaw(Uint8List data) async {
if (!isConnected) {
throw StateError('USB serial port is not open');
}
if (_useAndroidUsbHost) {
try {
await _androidMethodChannel.invokeMethod<void>('write', {'data': data});
} on PlatformException catch (error) {
throw StateError(error.message ?? error.code);
}
} else {
_serial!.write(data);
}
}
Future<void> write(Uint8List data) async {
if (!isConnected) {
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', {
@ -300,6 +319,7 @@ class UsbSerialService {
_serial = null;
try {
if (serial?.isOpen() == FlOpenStatus.open) {
serial?.setDTR(false);
serial?.closePort();
}
} catch (_) {
@ -350,6 +370,7 @@ class UsbSerialService {
final serial = _serial;
try {
if (serial?.isOpen() == FlOpenStatus.open) {
serial?.setDTR(false);
serial?.closePort(); // synchronous C call kills the SerialThread
}
} catch (_) {}
@ -426,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].
///

View file

@ -127,6 +127,17 @@ class UsbSerialService {
}
}
Future<void> writeRaw(Uint8List data) async {
if (!isConnected || _writer == null) {
throw StateError('USB serial port is not open');
}
final promise = _writer!.callMethod<JSPromise<JSAny?>>(
'write'.toJS,
data.toJS,
);
await promise.toDart;
}
Future<void> write(Uint8List data) async {
if (!isConnected || _writer == null) {
throw StateError('USB serial port is not open');

View file

@ -1,5 +1,7 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:meshcore_open/utils/app_logger.dart';
import '../models/channel_message.dart';
import '../helpers/smaz.dart';
import 'prefs_manager.dart';
@ -7,13 +9,25 @@ import 'prefs_manager.dart';
class ChannelMessageStore {
static const String _keyPrefix = 'channel_messages_';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
/// Save messages for a specific channel
Future<void> saveChannelMessages(
int channelIndex,
List<ChannelMessage> messages,
) async {
if (publicKeyHex.isEmpty) {
appLogger.warn(
'Public key hex is not set. Cannot save channel messages.',
);
return;
}
final prefs = PrefsManager.instance;
final key = '$_keyPrefix$channelIndex';
final key = '$keyFor$channelIndex';
// Convert messages to JSON
final jsonList = messages.map((msg) => _messageToJson(msg)).toList();
@ -24,12 +38,35 @@ class ChannelMessageStore {
/// Load messages for a specific channel
Future<List<ChannelMessage>> loadChannelMessages(int channelIndex) async {
if (publicKeyHex.isEmpty) {
appLogger.warn(
'Public key hex is not set. Cannot load channel messages.',
);
return [];
}
final prefs = PrefsManager.instance;
final key = '$_keyPrefix$channelIndex';
final jsonString = prefs.getString(key);
if (jsonString == null) return [];
final key = '$keyFor$channelIndex';
final oldKey = '$_keyPrefix$channelIndex';
String? jsonString = prefs.getString(key);
if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(oldKey);
prefs.remove(oldKey);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info(
'Migrating channel messages from legacy key $oldKey to scoped key $key',
);
await prefs.setString(key, legacyJsonString);
jsonString = legacyJsonString;
}
}
if (jsonString == null || jsonString.isEmpty) {
jsonString = prefs.getString(keyFor);
}
if (jsonString == null || jsonString.isEmpty) {
return [];
}
try {
final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList.map((json) => _messageFromJson(json)).toList();
@ -42,14 +79,14 @@ class ChannelMessageStore {
/// Clear messages for a specific channel
Future<void> clearChannelMessages(int channelIndex) async {
final prefs = PrefsManager.instance;
final key = '$_keyPrefix$channelIndex';
final key = '$keyFor$channelIndex';
await prefs.remove(key);
}
/// Clear all channel messages
Future<void> clearAllChannelMessages() async {
final prefs = PrefsManager.instance;
final keys = prefs.getKeys().where((k) => k.startsWith(_keyPrefix));
final keys = prefs.getKeys().where((k) => k.startsWith(keyFor));
for (var key in keys) {
await prefs.remove(key);
}
@ -71,6 +108,7 @@ class ChannelMessageStore {
'pathVariants': msg.pathVariants.map(base64Encode).toList(),
'repeats': msg.repeats.map(_repeatToJson).toList(),
'messageId': msg.messageId,
'packetHash': msg.packetHash,
'replyToMessageId': msg.replyToMessageId,
'replyToSenderName': msg.replyToSenderName,
'replyToText': msg.replyToText,
@ -106,6 +144,7 @@ class ChannelMessageStore {
const [],
channelIndex: json['channelIndex'] as int?,
messageId: json['messageId'] as String?,
packetHash: json['packetHash'] as String?,
replyToMessageId: json['replyToMessageId'] as String?,
replyToSenderName: json['replyToSenderName'] as String?,
replyToText: json['replyToText'] as String?,

View file

@ -1,20 +1,49 @@
import 'dart:convert';
import '../utils/app_logger.dart';
import 'prefs_manager.dart';
class ChannelOrderStore {
static const String _key = 'channel_order';
static const String _keyPrefix = 'channel_order_';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
Future<void> saveChannelOrder(List<int> order) async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot save channel order.');
return;
}
final prefs = PrefsManager.instance;
await prefs.setString(_key, jsonEncode(order));
await prefs.setString(keyFor, jsonEncode(order));
}
Future<List<int>> loadChannelOrder() async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot load channel order.');
return [];
}
final prefs = PrefsManager.instance;
final raw = prefs.getString(_key);
if (raw == null || raw.isEmpty) return [];
String? jsonString = prefs.getString(keyFor);
if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(_keyPrefix);
prefs.remove(_keyPrefix);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info(
'Migrating channel order from legacy key $_keyPrefix to scoped key $keyFor',
);
await prefs.setString(keyFor, legacyJsonString);
jsonString = legacyJsonString;
}
}
if (jsonString == null || jsonString.isEmpty) {
return [];
}
try {
final decoded = jsonDecode(raw);
final decoded = jsonDecode(jsonString);
if (decoded is List) {
return decoded
.map((value) => value is int ? value : int.tryParse('$value'))
@ -24,7 +53,7 @@ class ChannelOrderStore {
} catch (_) {
// fall through to legacy parse
}
return raw
return jsonString
.split(',')
.map((value) => int.tryParse(value))
.whereType<int>()

View file

@ -1,17 +1,49 @@
import '../utils/app_logger.dart';
import 'prefs_manager.dart';
class ChannelSettingsStore {
static const String _smazKeyPrefix = 'channel_smaz_';
static const String _keyPrefix = 'channel_smaz_';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
Future<bool> loadSmazEnabled(int channelIndex) async {
if (publicKeyHex.isEmpty) {
appLogger.warn(
'Public key hex is not set. Cannot load channel settings.',
);
return false;
}
final prefs = PrefsManager.instance;
final key = '$_smazKeyPrefix$channelIndex';
return prefs.getBool(key) ?? false;
final key = '$keyFor$channelIndex';
final oldKey = '$_keyPrefix$channelIndex';
bool? enabled = prefs.getBool(oldKey);
if (enabled == null) {
// Attempt migration from legacy unscoped key on first load
enabled = prefs.getBool(oldKey);
prefs.remove(oldKey);
if (enabled != null) {
appLogger.info(
'Migrating channel settings from legacy key $oldKey to scoped key $key',
);
await prefs.setBool(key, enabled);
}
}
return enabled ?? false;
}
Future<void> saveSmazEnabled(int channelIndex, bool enabled) async {
if (publicKeyHex.isEmpty) {
appLogger.warn(
'Public key hex is not set. Cannot save channel settings.',
);
return;
}
final prefs = PrefsManager.instance;
final key = '$_smazKeyPrefix$channelIndex';
final key = '$keyFor$channelIndex';
await prefs.setBool(key, enabled);
}
}

View file

@ -2,18 +2,46 @@ import 'dart:convert';
import 'dart:typed_data';
import '../models/channel.dart';
import '../utils/app_logger.dart';
import 'prefs_manager.dart';
class ChannelStore {
static const String _key = 'channels';
static const String _keyPrefix = 'channels';
String publicKeyHex = '';
set setPublicKeyHex(String value) =>
publicKeyHex = value.length >= 10 ? value.substring(0, 10) : '';
String get keyFor => '$_keyPrefix$publicKeyHex';
Future<List<Channel>> loadChannels() async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot load channels.');
return [];
}
final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_key);
if (jsonStr == null) return [];
String? jsonString = prefs.getString(keyFor);
if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(_keyPrefix);
prefs.remove(_keyPrefix);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info(
'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor',
);
await prefs.setString(keyFor, legacyJsonString);
jsonString = legacyJsonString;
}
}
if (jsonString == null || jsonString.isEmpty) {
jsonString = prefs.getString(keyFor);
}
if (jsonString == null || jsonString.isEmpty) {
return [];
}
try {
final jsonList = jsonDecode(jsonStr) as List<dynamic>;
final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList
.map((entry) => _fromJson(entry as Map<String, dynamic>))
.toList();
@ -23,9 +51,13 @@ class ChannelStore {
}
Future<void> saveChannels(List<Channel> channels) async {
if (publicKeyHex.isEmpty) {
appLogger.warn('Public key hex is not set. Cannot save channels.');
return;
}
final prefs = PrefsManager.instance;
final jsonList = channels.map(_toJson).toList();
await prefs.setString(_key, jsonEncode(jsonList));
await prefs.setString(keyFor, jsonEncode(jsonList));
}
Map<String, dynamic> _toJson(Channel channel) {

Some files were not shown because too many files have changed in this diff Show more