mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
merge main
This commit is contained in:
commit
2e6f4ebad8
150 changed files with 37506 additions and 3365 deletions
File diff suppressed because it is too large
Load diff
70
lib/connector/meshcore_connector_tcp.dart
Normal file
70
lib/connector/meshcore_connector_tcp.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
12
lib/connector/meshcore_uuids.dart
Normal file
12
lib/connector/meshcore_uuids.dart
Normal 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-",
|
||||
];
|
||||
}
|
||||
|
|
@ -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>(
|
||||
|
|
|
|||
31
lib/helpers/path_helper.dart
Normal file
31
lib/helpers/path_helper.dart
Normal 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 ');
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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} (оставете празно, ако няма)."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 d’appairage Bluetooth",
|
||||
"scanner_linuxPairingPinPrompt": "Entrez le code PIN pour {deviceName} (laissez vide si aucun)."
|
||||
}
|
||||
|
|
|
|||
2059
lib/l10n/app_hu.arb
Normal file
2059
lib/l10n/app_hu.arb
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
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
2059
lib/l10n/app_ko.arb
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -10,7 +10,10 @@ import 'app_localizations_de.dart';
|
|||
import 'app_localizations_en.dart';
|
||||
import 'app_localizations_es.dart';
|
||||
import 'app_localizations_fr.dart';
|
||||
import 'app_localizations_hu.dart';
|
||||
import 'app_localizations_it.dart';
|
||||
import 'app_localizations_ja.dart';
|
||||
import 'app_localizations_ko.dart';
|
||||
import 'app_localizations_nl.dart';
|
||||
import 'app_localizations_pl.dart';
|
||||
import 'app_localizations_pt.dart';
|
||||
|
|
@ -112,7 +115,10 @@ abstract class AppLocalizations {
|
|||
Locale('en'),
|
||||
Locale('es'),
|
||||
Locale('fr'),
|
||||
Locale('hu'),
|
||||
Locale('it'),
|
||||
Locale('ja'),
|
||||
Locale('ko'),
|
||||
Locale('nl'),
|
||||
Locale('pl'),
|
||||
Locale('pt'),
|
||||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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 (оставете празно, ако няма).';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 d’appairage Bluetooth';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Entrez le code PIN pour $deviceName (laissez vide si aucun).';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3603
lib/l10n/app_localizations_hu.dart
Normal file
3603
lib/l10n/app_localizations_hu.dart
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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\'è).';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3418
lib/l10n/app_localizations_ja.dart
Normal file
3418
lib/l10n/app_localizations_ja.dart
Normal file
File diff suppressed because it is too large
Load diff
3417
lib/l10n/app_localizations_ko.dart
Normal file
3417
lib/l10n/app_localizations_ko.dart
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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 => 'Bluetooth‑koppelings‑PIN';
|
||||
|
||||
@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
|
|
@ -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).';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (оставьте пустым, если нет).';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 => 'Bluetooth‑parnings‑PIN';
|
||||
|
||||
@override
|
||||
String scanner_linuxPairingPinPrompt(String deviceName) {
|
||||
return 'Ange PIN för $deviceName (lämna tomt om ingen).';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (залиште порожнім, якщо його немає).';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(如果没有,请留空)。';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "Bluetooth‑koppelings‑PIN"
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "Bluetooth‑parnings‑PIN",
|
||||
"scanner_linuxPairingPinPrompt": "Ange PIN för {deviceName} (lämna tomt om ingen).",
|
||||
"scanner_linuxPairingHidePin": "Dölj PIN"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
48
lib/models/companion_radio_stats.dart
Normal file
48
lib/models/companion_radio_stats.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
43
lib/models/delivery_observation.dart
Normal file
43
lib/models/delivery_observation.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
250
lib/screens/companion_radio_stats_screen.dart
Normal file
250
lib/screens/companion_radio_stats_screen.dart
Normal 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
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -205,8 +205,7 @@ class RepeaterHubScreen extends StatelessWidget {
|
|||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
TelemetryScreen(repeater: repeater, password: password),
|
||||
builder: (context) => TelemetryScreen(contact: repeater),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
295
lib/screens/tcp_screen.dart
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
37
lib/services/linux_ble_error_classifier.dart
Normal file
37
lib/services/linux_ble_error_classifier.dart
Normal 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'));
|
||||
}
|
||||
423
lib/services/linux_ble_pairing_service.dart
Normal file
423
lib/services/linux_ble_pairing_service.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
28
lib/services/linux_ble_pairing_service_stub.dart
Normal file
28
lib/services/linux_ble_pairing_service_stub.dart
Normal 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
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2
lib/services/tcp_transport_service.dart
Normal file
2
lib/services/tcp_transport_service.dart
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export 'tcp_transport_service_native.dart'
|
||||
if (dart.library.js_interop) 'tcp_transport_service_web.dart';
|
||||
210
lib/services/tcp_transport_service_native.dart
Normal file
210
lib/services/tcp_transport_service_native.dart
Normal 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 }
|
||||
35
lib/services/tcp_transport_service_web.dart
Normal file
35
lib/services/tcp_transport_service_web.dart
Normal 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() {}
|
||||
}
|
||||
229
lib/services/timeout_prediction_service.dart
Normal file
229
lib/services/timeout_prediction_service.dart
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
154
lib/services/ui_view_state_service.dart
Normal file
154
lib/services/ui_view_state_service.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -189,6 +189,10 @@ class UsbSerialService {
|
|||
serial.setStopBits1();
|
||||
serial.setFlowControlNone();
|
||||
serial.setRTS(false);
|
||||
// Toggle DTR low→high 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].
|
||||
///
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue