Merge branch 'main' into fix-channel-del

This commit is contained in:
zjs81
2026-02-22 13:01:36 -07:00
committed by GitHub
71 changed files with 9045 additions and 1360 deletions
+1
View File
@@ -0,0 +1 @@
4.0.0
+1
View File
@@ -78,6 +78,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
-**Android**: Full support (API 21+) -**Android**: Full support (API 21+)
-**iOS**: Full support (iOS 12+) -**iOS**: Full support (iOS 12+)
- 🚧 **Desktop**: Limited support (macOS/Linux/Windows) - 🚧 **Desktop**: Limited support (macOS/Linux/Windows)
- 🚧 **Web**: Under construction (Chrome)
### Dependencies ### Dependencies
+506 -31
View File
@@ -29,6 +29,7 @@ import '../storage/contact_store.dart';
import '../storage/message_store.dart'; import '../storage/message_store.dart';
import '../storage/unread_store.dart'; import '../storage/unread_store.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/battery_utils.dart';
import 'meshcore_protocol.dart'; import 'meshcore_protocol.dart';
class MeshCoreUuids { class MeshCoreUuids {
@@ -37,6 +38,42 @@ class MeshCoreUuids {
static const String txCharacteristic = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"; static const String txCharacteristic = "6e400003-b5a3-f393-e0a9-e50e24dcca9e";
} }
class DirectRepeater {
static const int maxAgeMinutes = 30; // Max age for direct repeater info
final int pubkeyFirstByte;
double snr;
DateTime lastUpdated;
DirectRepeater({
required this.pubkeyFirstByte,
required this.snr,
DateTime? lastUpdated,
}) : lastUpdated = lastUpdated ?? DateTime.now();
void update(double newSNR) {
snr = newSNR;
lastUpdated = DateTime.now();
}
int get ranking {
if (isStale()) {
return -1; // Stale repeaters get lowest rank
}
// Higher SNR gets higher rank and recency within maxAgeMinutes breaks ties.
final ageMs =
DateTime.now().millisecondsSinceEpoch -
lastUpdated.millisecondsSinceEpoch;
final maxAgeMs = maxAgeMinutes * 60 * 1000;
final recencyScore = (maxAgeMs - ageMs).clamp(0, maxAgeMs);
return ((snr - 31.75) * 1000).round() + recencyScore;
}
bool isStale() {
return DateTime.now().difference(lastUpdated) >
const Duration(minutes: maxAgeMinutes);
}
}
enum MeshCoreConnectionState { enum MeshCoreConnectionState {
disconnected, disconnected,
scanning, scanning,
@@ -45,6 +82,18 @@ enum MeshCoreConnectionState {
disconnecting, disconnecting,
} }
class RepeaterBatterySnapshot {
final int millivolts;
final DateTime updatedAt;
final String source;
const RepeaterBatterySnapshot({
required this.millivolts,
required this.updatedAt,
required this.source,
});
}
class MeshCoreConnector extends ChangeNotifier { class MeshCoreConnector extends ChangeNotifier {
// Message windowing to limit memory usage // Message windowing to limit memory usage
static const int _messageWindowSize = 200; static const int _messageWindowSize = 200;
@@ -65,6 +114,10 @@ class MeshCoreConnector extends ChangeNotifier {
final List<Channel> _channels = []; final List<Channel> _channels = [];
final Map<String, List<Message>> _conversations = {}; final Map<String, List<Message>> _conversations = {};
final Map<int, List<ChannelMessage>> _channelMessages = {}; final Map<int, List<ChannelMessage>> _channelMessages = {};
final List<String> _pendingChannelSentQueue = [];
final List<_PendingCommandAck> _pendingGenericAckQueue = [];
static const String _reactionSendQueuePrefix = '__reaction_send__';
int _reactionSendQueueSequence = 0;
final Set<String> _loadedConversationKeys = {}; final Set<String> _loadedConversationKeys = {};
final Map<int, Set<String>> _processedChannelReactions = final Map<int, Set<String>> _processedChannelReactions =
{}; // channelIndex -> Set of "targetHash_emoji" {}; // channelIndex -> Set of "targetHash_emoji"
@@ -95,6 +148,7 @@ class MeshCoreConnector extends ChangeNotifier {
int? _batteryMillivolts; int? _batteryMillivolts;
double? _selfLatitude; double? _selfLatitude;
double? _selfLongitude; double? _selfLongitude;
final List<DirectRepeater> _directRepeaters = List.empty(growable: true);
bool _isLoadingContacts = false; bool _isLoadingContacts = false;
bool _isLoadingChannels = false; bool _isLoadingChannels = false;
bool _hasLoadedChannels = false; bool _hasLoadedChannels = false;
@@ -150,6 +204,7 @@ class MeshCoreConnector extends ChangeNotifier {
final Map<String, bool> _contactSmazEnabled = {}; final Map<String, bool> _contactSmazEnabled = {};
final Set<String> _knownContactKeys = {}; final Set<String> _knownContactKeys = {};
final Map<String, int> _contactUnreadCount = {}; final Map<String, int> _contactUnreadCount = {};
final Map<String, RepeaterBatterySnapshot> _repeaterBatterySnapshots = {};
bool _unreadStateLoaded = false; bool _unreadStateLoaded = false;
final Map<String, _RepeaterAckContext> _pendingRepeaterAcks = {}; final Map<String, _RepeaterAckContext> _pendingRepeaterAcks = {};
String? _activeContactKey; String? _activeContactKey;
@@ -196,6 +251,7 @@ class MeshCoreConnector extends ChangeNotifier {
String? get selfName => _selfName; String? get selfName => _selfName;
double? get selfLatitude => _selfLatitude; double? get selfLatitude => _selfLatitude;
double? get selfLongitude => _selfLongitude; double? get selfLongitude => _selfLongitude;
List<DirectRepeater> get directRepeaters => _directRepeaters;
int? get currentTxPower => _currentTxPower; int? get currentTxPower => _currentTxPower;
int? get maxTxPower => _maxTxPower; int? get maxTxPower => _maxTxPower;
int? get currentFreqHz => _currentFreqHz; int? get currentFreqHz => _currentFreqHz;
@@ -216,10 +272,32 @@ class MeshCoreConnector extends ChangeNotifier {
: 0; : 0;
int? get batteryPercent => _batteryMillivolts == null int? get batteryPercent => _batteryMillivolts == null
? null ? null
: _estimateBatteryPercent( : estimateBatteryPercentFromMillivolts(
_batteryMillivolts!, _batteryMillivolts!,
_batteryChemistryForDevice(), _batteryChemistryForDevice(),
); );
RepeaterBatterySnapshot? getRepeaterBatterySnapshot(String contactKeyHex) =>
_repeaterBatterySnapshots[contactKeyHex];
int? getRepeaterBatteryMillivolts(String contactKeyHex) =>
_repeaterBatterySnapshots[contactKeyHex]?.millivolts;
void updateRepeaterBatterySnapshot(
String contactKeyHex,
int millivolts, {
String source = 'unknown',
}) {
if (contactKeyHex.isEmpty || millivolts <= 0) return;
final previous = _repeaterBatterySnapshots[contactKeyHex];
final snapshot = RepeaterBatterySnapshot(
millivolts: millivolts,
updatedAt: DateTime.now(),
source: source,
);
_repeaterBatterySnapshots[contactKeyHex] = snapshot;
if (previous?.millivolts != millivolts) {
notifyListeners();
}
}
String _batteryChemistryForDevice() { String _batteryChemistryForDevice() {
final deviceId = _device?.remoteId.toString(); final deviceId = _device?.remoteId.toString();
@@ -227,27 +305,6 @@ class MeshCoreConnector extends ChangeNotifier {
return _appSettingsService!.batteryChemistryForDevice(deviceId); return _appSettingsService!.batteryChemistryForDevice(deviceId);
} }
int _estimateBatteryPercent(int millivolts, String chemistry) {
final range = _batteryVoltageRange(chemistry);
final minMv = range.$1;
final maxMv = range.$2;
if (millivolts <= minMv) return 0;
if (millivolts >= maxMv) return 100;
return (((millivolts - minMv) * 100) / (maxMv - minMv)).round();
}
(int, int) _batteryVoltageRange(String chemistry) {
switch (chemistry) {
case 'lifepo4':
return (2600, 3650);
case 'lipo':
return (3000, 4200);
case 'nmc':
default:
return (3000, 4200);
}
}
List<Message> getMessages(Contact contact) { List<Message> getMessages(Contact contact) {
return _conversations[contact.publicKeyHex] ?? []; return _conversations[contact.publicKeyHex] ?? [];
} }
@@ -923,6 +980,7 @@ class MeshCoreConnector extends ChangeNotifier {
_clientRepeat = null; _clientRepeat = null;
_firmwareVerCode = null; _firmwareVerCode = null;
_batteryMillivolts = null; _batteryMillivolts = null;
_repeaterBatterySnapshots.clear();
_batteryRequested = false; _batteryRequested = false;
_awaitingSelfInfo = false; _awaitingSelfInfo = false;
_maxContacts = _defaultMaxContacts; _maxContacts = _defaultMaxContacts;
@@ -934,6 +992,9 @@ class MeshCoreConnector extends ChangeNotifier {
_isSyncingChannels = false; _isSyncingChannels = false;
_channelSyncInFlight = false; _channelSyncInFlight = false;
_hasLoadedChannels = false; _hasLoadedChannels = false;
_pendingChannelSentQueue.clear();
_pendingGenericAckQueue.clear();
_reactionSendQueueSequence = 0;
_setState(MeshCoreConnectionState.disconnected); _setState(MeshCoreConnectionState.disconnected);
if (!manual) { if (!manual) {
@@ -941,7 +1002,11 @@ class MeshCoreConnector extends ChangeNotifier {
} }
} }
Future<void> sendFrame(Uint8List data) async { Future<void> sendFrame(
Uint8List data, {
String? channelSendQueueId,
bool expectsGenericAck = false,
}) async {
if (!isConnected || _rxCharacteristic == null) { if (!isConnected || _rxCharacteristic == null) {
throw Exception("Not connected to a MeshCore device"); throw Exception("Not connected to a MeshCore device");
} }
@@ -960,6 +1025,11 @@ class MeshCoreConnector extends ChangeNotifier {
data.toList(), data.toList(),
withoutResponse: canWriteWithoutResponse, withoutResponse: canWriteWithoutResponse,
); );
_trackPendingGenericAck(
data,
channelSendQueueId: channelSendQueueId,
expectsGenericAck: expectsGenericAck,
);
} }
Future<void> requestBatteryStatus({bool force = false}) async { Future<void> requestBatteryStatus({bool force = false}) async {
@@ -1315,7 +1385,13 @@ class MeshCoreConnector extends ChangeNotifier {
notifyListeners(); notifyListeners();
// Send the reaction to the device (don't add as a visible message) // Send the reaction to the device (don't add as a visible message)
await sendFrame(buildSendChannelTextMsgFrame(channel.index, text)); final reactionQueueId = _nextReactionSendQueueId();
_pendingChannelSentQueue.add(reactionQueueId);
await sendFrame(
buildSendChannelTextMsgFrame(channel.index, text),
channelSendQueueId: reactionQueueId,
expectsGenericAck: true,
);
return; return;
} }
@@ -1325,6 +1401,7 @@ class MeshCoreConnector extends ChangeNotifier {
channel.index, channel.index,
); );
_addChannelMessage(channel.index, message); _addChannelMessage(channel.index, message);
_pendingChannelSentQueue.add(message.messageId);
notifyListeners(); notifyListeners();
final trimmed = text.trim(); final trimmed = text.trim();
@@ -1334,7 +1411,11 @@ class MeshCoreConnector extends ChangeNotifier {
(isChannelSmazEnabled(channel.index) && !isStructuredPayload) (isChannelSmazEnabled(channel.index) && !isStructuredPayload)
? Smaz.encodeIfSmaller(text) ? Smaz.encodeIfSmaller(text)
: text; : text;
await sendFrame(buildSendChannelTextMsgFrame(channel.index, outboundText)); await sendFrame(
buildSendChannelTextMsgFrame(channel.index, outboundText),
channelSendQueueId: message.messageId,
expectsGenericAck: true,
);
} }
Future<void> removeContact(Contact contact) async { Future<void> removeContact(Contact contact) async {
@@ -1681,6 +1762,9 @@ class MeshCoreConnector extends ChangeNotifier {
debugPrint('RX frame: code=$code len=${frame.length}'); debugPrint('RX frame: code=$code len=${frame.length}');
switch (code) { switch (code) {
case respCodeOk:
_handleOk();
break;
case respCodeDeviceInfo: case respCodeDeviceInfo:
_handleDeviceInfo(frame); _handleDeviceInfo(frame);
break; break;
@@ -1696,6 +1780,11 @@ class MeshCoreConnector extends ChangeNotifier {
_isLoadingContacts = true; _isLoadingContacts = true;
notifyListeners(); notifyListeners();
break; break;
case pushCodeNewAdvert:
debugPrint('Got New CONTACT');
// It's the same format as respCodeContact, so we can reuse the handler
_handleContact(frame);
break;
case respCodeContact: case respCodeContact:
debugPrint('Got CONTACT'); debugPrint('Got CONTACT');
_handleContact(frame); _handleContact(frame);
@@ -1740,6 +1829,7 @@ class MeshCoreConnector extends ChangeNotifier {
case pushCodeStatusResponse: case pushCodeStatusResponse:
break; break;
case pushCodeLogRxData: case pushCodeLogRxData:
_handleRxData(frame);
_handleLogRxData(frame); _handleLogRxData(frame);
break; break;
case respCodeChannelInfo: case respCodeChannelInfo:
@@ -1769,6 +1859,17 @@ class MeshCoreConnector extends ChangeNotifier {
'Firmware responded with error code: $errCode', 'Firmware responded with error code: $errCode',
tag: 'Protocol', tag: 'Protocol',
); );
if (_pendingGenericAckQueue.isEmpty) {
return;
}
final failedAck = _pendingGenericAckQueue.removeAt(0);
if (failedAck.commandCode != cmdSendChannelTxtMsg ||
failedAck.channelSendQueueId == null) {
return;
}
_pendingChannelSentQueue.remove(failedAck.channelSendQueueId);
} }
void _handlePathUpdated(Uint8List frame) { void _handlePathUpdated(Uint8List frame) {
@@ -2028,6 +2129,80 @@ class MeshCoreConnector extends ChangeNotifier {
} }
} }
void _handleContactAdvert(Contact contact) {
if (listEquals(contact.publicKey, _selfPublicKey)) {
return;
}
if (contact.type == advTypeRepeater) {
_contactUnreadCount.remove(contact.publicKeyHex);
_unreadStore.saveContactUnreadCount(
Map<String, int>.from(_contactUnreadCount),
);
}
// Check if this is a new contact
final isNewContact = !_knownContactKeys.contains(contact.publicKeyHex);
final existingIndex = _contacts.indexWhere(
(c) => c.publicKeyHex == contact.publicKeyHex,
);
if (existingIndex >= 0) {
final existing = _contacts[existingIndex];
final mergedLastMessageAt =
existing.lastMessageAt.isAfter(contact.lastMessageAt)
? existing.lastMessageAt
: contact.lastMessageAt;
appLogger.info(
'Refreshing contact ${contact.name}: devicePath=${contact.pathLength}, existingOverride=${existing.pathOverride}',
tag: 'Connector',
);
// CRITICAL: Preserve user's path override when contact is refreshed from device
_contacts[existingIndex] = contact.copyWith(
lastMessageAt: mergedLastMessageAt,
pathOverride: existing.pathOverride, // Preserve user's path choice
pathOverrideBytes: existing.pathOverrideBytes,
);
appLogger.info(
'After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_contacts[existingIndex].pathLength}',
tag: 'Connector',
);
} else {
_contacts.add(contact);
appLogger.info(
'Added new contact ${contact.name}: pathLen=${contact.pathLength}',
tag: 'Connector',
);
}
_knownContactKeys.add(contact.publicKeyHex);
_loadMessagesForContact(contact.publicKeyHex);
// Add path to history if we have a valid path
if (_pathHistoryService != null && contact.pathLength >= 0) {
_pathHistoryService!.handlePathUpdated(contact);
}
notifyListeners();
// Show notification for new contact (advertisement)
if (isNewContact && _appSettingsService != null) {
final settings = _appSettingsService!.settings;
if (settings.notificationsEnabled && settings.notifyOnNewAdvert) {
_notificationService.showAdvertNotification(
contactName: contact.name,
contactType: contact.typeLabel,
contactId: contact.publicKeyHex,
);
}
}
if (!_isLoadingContacts) {
unawaited(_persistContacts());
}
}
Future<void> _persistContacts() async { Future<void> _persistContacts() async {
await _contactStore.saveContacts(_contacts); await _contactStore.saveContacts(_contacts);
} }
@@ -2354,6 +2529,8 @@ class MeshCoreConnector extends ChangeNotifier {
} }
final label = channelName ?? _channelDisplayName(channelIndex); final label = channelName ?? _channelDisplayName(channelIndex);
if (_appSettingsService!.isChannelMuted(label)) return;
_notificationService.showChannelMessageNotification( _notificationService.showChannelMessageNotification(
channelName: label, channelName: label,
message: message.text, message: message.text,
@@ -2477,8 +2654,22 @@ class MeshCoreConnector extends ChangeNotifier {
return; return;
} }
if (_retryService != null) { final retryService = _retryService;
_retryService!.updateMessageFromSent(ackHash, timeoutMs); if (retryService != null &&
retryService.updateMessageFromSent(
ackHash,
timeoutMs,
allowQueueFallback: false,
)) {
return;
}
if (_markNextPendingChannelMessageSent()) {
return;
}
if (retryService != null) {
retryService.updateMessageFromSent(ackHash, timeoutMs);
} }
} else { } else {
// Fallback to old behavior // Fallback to old behavior
@@ -2495,6 +2686,64 @@ class MeshCoreConnector extends ChangeNotifier {
} }
} }
bool _markNextPendingChannelMessageSent() {
while (_pendingChannelSentQueue.isNotEmpty) {
final queuedMessageId = _pendingChannelSentQueue.removeAt(0);
if (_isReactionSendQueueId(queuedMessageId)) {
return true;
}
if (_markPendingChannelMessageSentById(queuedMessageId)) {
return true;
}
}
return false;
}
bool _markPendingChannelMessageSentById(String messageId) {
for (final entry in _channelMessages.entries) {
final channelMessages = entry.value;
for (int i = channelMessages.length - 1; i >= 0; i--) {
final message = channelMessages[i];
if (message.messageId != messageId) {
continue;
}
if (!message.isOutgoing ||
message.status != ChannelMessageStatus.pending) {
return false;
}
channelMessages[i] = message.copyWith(
status: ChannelMessageStatus.sent,
);
_pendingChannelSentQueue.remove(messageId);
unawaited(
_channelMessageStore.saveChannelMessages(entry.key, channelMessages),
);
notifyListeners();
return true;
}
}
return false;
}
void _handleOk() {
if (_pendingGenericAckQueue.isEmpty) {
return;
}
final pendingAck = _pendingGenericAckQueue.removeAt(0);
if (pendingAck.commandCode != cmdSendChannelTxtMsg ||
pendingAck.channelSendQueueId == null) {
return;
}
final queueId = pendingAck.channelSendQueueId!;
_pendingChannelSentQueue.remove(queueId);
if (_isReactionSendQueueId(queueId)) {
return;
}
_markPendingChannelMessageSentById(queueId);
}
void _handleSendConfirmed(Uint8List frame) { void _handleSendConfirmed(Uint8List frame) {
// Frame format from C++: // Frame format from C++:
// [0] = PUSH_CODE_SEND_CONFIRMED // [0] = PUSH_CODE_SEND_CONFIRMED
@@ -3073,18 +3322,22 @@ class MeshCoreConnector extends ChangeNotifier {
mergedPathBytes.length, mergedPathBytes.length,
); );
final newRepeatCount = existing.repeatCount + 1; final newRepeatCount = existing.repeatCount + 1;
final promotedFromPending =
newRepeatCount == 1 &&
existing.status == ChannelMessageStatus.pending;
messages[existingIndex] = existing.copyWith( messages[existingIndex] = existing.copyWith(
repeatCount: newRepeatCount, repeatCount: newRepeatCount,
pathLength: mergedPathLength, pathLength: mergedPathLength,
pathBytes: mergedPathBytes, pathBytes: mergedPathBytes,
pathVariants: mergedPathVariants, pathVariants: mergedPathVariants,
// Mark as sent when first repeat is heard // Mark as sent when first repeat is heard
status: status: promotedFromPending
newRepeatCount == 1 &&
existing.status == ChannelMessageStatus.pending
? ChannelMessageStatus.sent ? ChannelMessageStatus.sent
: existing.status, : existing.status,
); );
if (promotedFromPending) {
_pendingChannelSentQueue.remove(existing.messageId);
}
} else { } else {
messages.add(processedMessage); messages.add(processedMessage);
} }
@@ -3257,11 +3510,37 @@ class MeshCoreConnector extends ChangeNotifier {
_queuedMessageSyncInFlight = false; _queuedMessageSyncInFlight = false;
_isSyncingChannels = false; _isSyncingChannels = false;
_channelSyncInFlight = false; _channelSyncInFlight = false;
_pendingChannelSentQueue.clear();
_pendingGenericAckQueue.clear();
_reactionSendQueueSequence = 0;
_setState(MeshCoreConnectionState.disconnected); _setState(MeshCoreConnectionState.disconnected);
_scheduleReconnect(); _scheduleReconnect();
} }
void _trackPendingGenericAck(
Uint8List data, {
String? channelSendQueueId,
required bool expectsGenericAck,
}) {
if (!expectsGenericAck || data.isEmpty) return;
_pendingGenericAckQueue.add(
_PendingCommandAck(
commandCode: data[0],
channelSendQueueId: channelSendQueueId,
),
);
}
String _nextReactionSendQueueId() {
_reactionSendQueueSequence++;
return '$_reactionSendQueuePrefix$_reactionSendQueueSequence';
}
bool _isReactionSendQueueId(String queueId) {
return queueId.startsWith(_reactionSendQueuePrefix);
}
Map<String, String> _parseKeyValueString(String input) { Map<String, String> _parseKeyValueString(String input) {
final result = <String, String>{}; final result = <String, String>{};
@@ -3287,7 +3566,11 @@ class MeshCoreConnector extends ChangeNotifier {
void _handleCustomVars(Uint8List frame) { void _handleCustomVars(Uint8List frame) {
final buf = BufferReader(frame.sublist(1)); final buf = BufferReader(frame.sublist(1));
_currentCustomVars = _parseKeyValueString(buf.readString()); try {
_currentCustomVars = _parseKeyValueString(buf.readString());
} catch (e) {
appLogger.warn('Malformed custom vars frame: $e', tag: 'Connector');
}
} }
void _setState(MeshCoreConnectionState newState) { void _setState(MeshCoreConnectionState newState) {
@@ -3311,6 +3594,191 @@ class MeshCoreConnector extends ChangeNotifier {
super.dispose(); super.dispose();
} }
void _handleRxData(Uint8List frame) {
final packet = BufferReader(frame);
double snr = 0.0;
int routeType = 0;
int payloadType = 0;
Uint8List pathBytes = Uint8List(0);
Uint8List payload = Uint8List(0);
try {
packet.skipBytes(1); // Skip frame type byte
snr = packet.readInt8() / 4.0;
packet.skipBytes(1); // Skip RSSI byte
//final rssi = packet.readByte();
final header = packet.readByte();
routeType = header & 0x03;
payloadType = (header >> 2) & 0x0F;
//final payloadVer = (header >> 6) & 0x03;
final pathLen = packet.readByte();
pathBytes = packet.readBytes(pathLen);
payload = packet.readBytes(packet.remaining);
} catch (e) {
appLogger.warn('Malformed RX frame: $e', tag: 'Connector');
return;
}
switch (payloadType) {
case payloadTypeADVERT:
_handlePayloadAdvertReceived(payload, pathBytes, routeType, snr);
break;
default:
}
}
void _handlePayloadAdvertReceived(
Uint8List frame,
Uint8List path,
int routeType,
double snr,
) {
final advert = BufferReader(frame);
double latitude = 0.0;
double longitude = 0.0;
String name = '';
String contactKeyHex = '';
Uint8List publicKey = Uint8List(0);
int type = 0;
int timestamp = 0;
bool hasLocation = false;
bool hasName = false;
try {
publicKey = advert.readBytes(32);
contactKeyHex = publicKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
timestamp = advert.readInt32LE();
//TODO add signature verification
advert.skipBytes(64); // Skip signature for now
final flags = advert.readByte();
type = flags & 0x0F;
hasLocation = (flags & 0x10) != 0;
// For future use:
//final hasFeature1 = (flags & 0x20) != 0;
//final hasFeature2 = (flags & 0x40) != 0;
hasName = (flags & 0x80) != 0;
if (hasLocation && advert.remaining >= 8) {
latitude = advert.readInt32LE() / 1e6;
longitude = advert.readInt32LE() / 1e6;
}
if (hasName && advert.remaining > 0) {
name = advert.readString();
}
} catch (e) {
appLogger.warn('Malformed advert frame: $e', tag: 'Connector');
return;
}
if (listEquals(publicKey, _selfPublicKey)) {
return;
}
// Check if this is a new contact
final isNewContact = !_knownContactKeys.contains(contactKeyHex);
if (isNewContact) {
final newContact = Contact(
publicKey: publicKey,
name: name,
type: type,
pathLength: path.length,
path: Uint8List.fromList(
path.reversed.toList(),
), // Store path in reverse for easier use in outgoing messages
latitude: latitude,
longitude: longitude,
lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000),
);
_handleContactAdvert(newContact);
_updateDirectRepeater(newContact, snr, path);
return;
}
final existingIndex = _contacts.indexWhere(
(c) => c.publicKeyHex == contactKeyHex,
);
if (existingIndex >= 0) {
final existing = _contacts[existingIndex];
final mergedLastMessageAt = existing.lastMessageAt.isAfter(DateTime.now())
? DateTime.now()
: existing.lastMessageAt;
appLogger.info(
'Refreshing contact ${existing.name}: devicePath=${existing.pathLength}, existingOverride=${existing.pathOverride}',
tag: 'Connector',
);
// CRITICAL: Preserve user's path override when contact is refreshed from device
_contacts[existingIndex] = existing.copyWith(
latitude: hasLocation ? latitude : existing.latitude,
longitude: hasLocation ? longitude : existing.longitude,
name: hasName ? name : existing.name,
path: Uint8List.fromList(path.reversed.toList()),
pathLength: path.length,
lastMessageAt: mergedLastMessageAt,
lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000),
pathOverride: existing.pathOverride, // Preserve user's path choice
pathOverrideBytes: existing.pathOverrideBytes,
);
// Add path to history if we have a valid path
if (_pathHistoryService != null &&
_contacts[existingIndex].pathLength >= 0) {
_pathHistoryService!.handlePathUpdated(_contacts[existingIndex]);
}
_updateDirectRepeater(_contacts[existingIndex], snr, path);
appLogger.info(
'After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_contacts[existingIndex].pathLength}',
tag: 'Connector',
);
}
}
void _updateDirectRepeater(Contact contact, double snr, Uint8List path) {
final pubkeyFirstByte = path.isNotEmpty
? path.last
: contact.publicKey.first;
_directRepeaters.removeWhere((r) => r.isStale());
//We can use adverts from chat and sensor nodes, but only if the advert has a path to get the last hop.
if ((contact.type == advTypeChat || contact.type == advTypeSensor) &&
path.isEmpty) {
notifyListeners();
return;
}
final isTracked = _directRepeaters.where(
(r) => r.pubkeyFirstByte == pubkeyFirstByte,
);
final sortedRepeaters = List<DirectRepeater>.from(_directRepeaters)
..sort((a, b) => b.snr.compareTo(a.snr));
final weakestRepeater = sortedRepeaters.isNotEmpty
? sortedRepeaters.last
: null;
if (_directRepeaters.length >= 5 &&
weakestRepeater != null &&
isTracked.isEmpty) {
_directRepeaters.remove(weakestRepeater);
}
if (isTracked.isNotEmpty) {
final repeater = isTracked.first;
repeater.update(snr);
} else if (_directRepeaters.length < 5) {
_directRepeaters.add(
DirectRepeater(pubkeyFirstByte: pubkeyFirstByte, snr: snr),
);
}
notifyListeners();
}
} }
const int _phRouteMask = 0x03; const int _phRouteMask = 0x03;
@@ -3368,3 +3836,10 @@ class _RepeaterAckContext {
required this.messageBytes, required this.messageBytes,
}); });
} }
class _PendingCommandAck {
final int commandCode;
final String? channelSendQueueId;
_PendingCommandAck({required this.commandCode, this.channelSendQueueId});
}
+55 -1
View File
@@ -13,12 +13,22 @@ class BufferReader {
int readByte() => readBytes(1)[0]; int readByte() => readBytes(1)[0];
Uint8List readBytes(int count) { Uint8List readBytes(int count) {
if (_pointer + count > _buffer.length) {
throw RangeError(
'Attempted to read $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}',
);
}
final data = _buffer.sublist(_pointer, _pointer + count); final data = _buffer.sublist(_pointer, _pointer + count);
_pointer += count; _pointer += count;
return data; return data;
} }
void skipBytes(int count) { void skipBytes(int count) {
if (_pointer + count > _buffer.length) {
throw RangeError(
'Attempted to skip $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}',
);
}
_pointer += count; _pointer += count;
} }
@@ -151,6 +161,7 @@ const int cmdGetContactByKey = 30;
const int cmdGetChannel = 31; const int cmdGetChannel = 31;
const int cmdSetChannel = 32; const int cmdSetChannel = 32;
const int cmdSendTracePath = 36; const int cmdSendTracePath = 36;
const int cmdSetOtherParams = 38;
const int cmdGetRadioSettings = 57; const int cmdGetRadioSettings = 57;
const int cmdGetTelemetryReq = 39; const int cmdGetTelemetryReq = 39;
const int cmdGetCustomVar = 40; const int cmdGetCustomVar = 40;
@@ -166,7 +177,7 @@ const int reqTypeGetStatus = 0x01;
const int reqTypeKeepAlive = 0x02; const int reqTypeKeepAlive = 0x02;
const int reqTypeGetTelemetry = 0x03; const int reqTypeGetTelemetry = 0x03;
const int reqTypeGetAccessList = 0x05; const int reqTypeGetAccessList = 0x05;
const int reqTypeGetNeighbours = 0x06; const int reqTypeGetNeighbors = 0x06;
// Repeater response codes // Repeater response codes
const int respServerLoginOk = 0; const int respServerLoginOk = 0;
@@ -212,6 +223,30 @@ const int advTypeRepeater = 2;
const int advTypeRoom = 3; const int advTypeRoom = 3;
const int advTypeSensor = 4; const int advTypeSensor = 4;
// Payload Types
const int payloadTypeREQ =
0x00; // request (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)
const int payloadTypeRESPONSE =
0x01; // response to REQ or ANON_REQ (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)
const int payloadTypeTXTMSG =
0x02; // a plain text message (prefixed with dest/src hashes, MAC) (enc data: timestamp, text)
const int payloadTypeACK = 0x03; // a simple ack
const int payloadTypeADVERT = 0x04; // a node advertising its Identity
const int payloadTypeGRPTXT =
0x05; // an (unverified) group text message (prefixed with channel hash, MAC) (enc data: timestamp, "name: msg")
const int payloadTypeGRPDATA =
0x06; // an (unverified) group datagram (prefixed with channel hash, MAC) (enc data: timestamp, blob)
const int payloadTypeANONREQ =
0x07; // generic request (prefixed with dest_hash, ephemeral pub_key, MAC) (enc data: ...)
const int payloadTypePATH =
0x08; // returned path (prefixed with dest/src hashes, MAC) (enc data: path, extra)
const int payloadTypeTRACE = 0x09; // trace a path, collecting SNI for each hop
const int payloadTypeMULTIPART = 0x0A; // packet is one of a set of packets
const int payloadTypeCONTROL = 0x0B; // a control/discovery packet
//...
const int payloadTypeRawCustom =
0x0F; // custom packet as raw bytes, for applications with custom encryption, payloads, etc
// Sizes // Sizes
const int pubKeySize = 32; const int pubKeySize = 32;
const int maxPathSize = 64; const int maxPathSize = 64;
@@ -788,3 +823,22 @@ Uint8List buildZeroHopContact(Uint8List pubKey) {
writer.writeBytes(pubKey); writer.writeBytes(pubKey);
return writer.toBytes(); return writer.toBytes();
} }
// Build CMD_SET_OTHER_PARAMS frame
// Format: [cmd][allowAutoAddContacts][allowTelemetryFlags][advertLocationPolicy][multiAcks]
Uint8List buildSetOtherParamsFrame(
bool allowAutoAddContacts,
int allowTelemetryFlags,
int advertLocationPolicy,
int multiAcks,
) {
final writer = BufferWriter();
writer.writeByte(cmdSetOtherParams);
writer.writeByte(
allowAutoAddContacts ? 0x00 : 0x01,
); // Allow Auto Add Contacts
writer.writeByte(allowTelemetryFlags); // Allow Telemetry Flags
writer.writeByte(advertLocationPolicy); // Advertisement Location Policy
writer.writeByte(multiAcks); // Multi Acknowledgements
return writer.toBytes();
}
+175 -161
View File
@@ -1,4 +1,6 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:meshcore_open/utils/app_logger.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
class CayenneLpp { class CayenneLpp {
@@ -84,180 +86,192 @@ class CayenneLpp {
static List<Map<String, dynamic>> parse(Uint8List bytes) { static List<Map<String, dynamic>> parse(Uint8List bytes) {
final buffer = BufferReader(bytes); final buffer = BufferReader(bytes);
final telemetry = <Map<String, dynamic>>[]; final telemetry = <Map<String, dynamic>>[];
try {
while (buffer.remaining >= 2) {
final channel = buffer.readUInt8();
final type = buffer.readUInt8();
while (buffer.remaining >= 2) { if (channel == 0 && type == 0) {
final channel = buffer.readUInt8(); break;
final type = buffer.readUInt8(); }
if (channel == 0 && type == 0) { switch (type) {
break; case lppGenericSensor:
} telemetry.add({
'channel': channel,
switch (type) { 'type': type,
case lppGenericSensor: 'value': buffer.readUInt32BE(),
telemetry.add({ });
'channel': channel, break;
'type': type, case lppLuminosity:
'value': buffer.readUInt32BE(), telemetry.add({
}); 'channel': channel,
break; 'type': type,
case lppLuminosity: 'value': buffer.readUInt16BE(),
telemetry.add({ });
'channel': channel, break;
'type': type, case lppPresence:
'value': buffer.readUInt16BE(), telemetry.add({
}); 'channel': channel,
break; 'type': type,
case lppPresence: 'value': buffer.readUInt8(),
telemetry.add({ });
'channel': channel, break;
'type': type, case lppTemperature:
'value': buffer.readUInt8(), telemetry.add({
}); 'channel': channel,
break; 'type': type,
case lppTemperature: 'value': buffer.readInt16BE() / 10,
telemetry.add({ });
'channel': channel, break;
'type': type, case lppRelativeHumidity:
'value': buffer.readInt16BE() / 10, telemetry.add({
}); 'channel': channel,
break; 'type': type,
case lppRelativeHumidity: 'value': buffer.readUInt8() / 2,
telemetry.add({ });
'channel': channel, break;
'type': type, case lppBarometricPressure:
'value': buffer.readUInt8() / 2, telemetry.add({
}); 'channel': channel,
break; 'type': type,
case lppBarometricPressure: 'value': buffer.readUInt16BE() / 10,
telemetry.add({ });
'channel': channel, break;
'type': type, case lppVoltage:
'value': buffer.readUInt16BE() / 10, telemetry.add({
}); 'channel': channel,
break; 'type': type,
case lppVoltage: 'value': buffer.readInt16BE() / 100,
telemetry.add({ });
'channel': channel, break;
'type': type, case lppCurrent:
'value': buffer.readInt16BE() / 100, telemetry.add({
}); 'channel': channel,
break; 'type': type,
case lppCurrent: 'value': buffer.readInt16BE() / 1000,
telemetry.add({ });
'channel': channel, break;
'type': type, case lppPercentage:
'value': buffer.readInt16BE() / 1000, telemetry.add({
}); 'channel': channel,
break; 'type': type,
case lppPercentage: 'value': buffer.readUInt8(),
telemetry.add({ });
'channel': channel, break;
'type': type, case lppConcentration:
'value': buffer.readUInt8(), telemetry.add({
}); 'channel': channel,
break; 'type': type,
case lppConcentration: 'value': buffer.readUInt16BE(),
telemetry.add({ });
'channel': channel, break;
'type': type, case lppPower:
'value': buffer.readUInt16BE(), telemetry.add({
}); 'channel': channel,
break; 'type': type,
case lppPower: 'value': buffer.readUInt16BE(),
telemetry.add({ });
'channel': channel, break;
'type': type, case lppGps:
'value': buffer.readUInt16BE(), telemetry.add({
}); 'channel': channel,
break; 'type': type,
case lppGps: 'value': {
telemetry.add({ 'latitude': buffer.readInt24BE() / 10000,
'channel': channel, 'longitude': buffer.readInt24BE() / 10000,
'type': type, 'altitude': buffer.readInt24BE() / 100,
'value': { },
'latitude': buffer.readInt24BE() / 10000, });
'longitude': buffer.readInt24BE() / 10000, break;
'altitude': buffer.readInt24BE() / 100, default:
}, return telemetry;
}); }
break;
default:
return telemetry;
} }
return telemetry;
} catch (e) {
// Handle parsing errors, possibly due to malformed data
appLogger.error('Error parsing Cayenne LPP data: $e');
// Return any telemetry parsed so far to preserve partial data
return telemetry;
} }
return telemetry;
} }
static List<Map<String, dynamic>> parseByChannel(Uint8List bytes) { static List<Map<String, dynamic>> parseByChannel(Uint8List bytes) {
final buffer = BufferReader(bytes); final buffer = BufferReader(bytes);
final Map<int, Map<String, dynamic>> channels = {}; final Map<int, Map<String, dynamic>> channels = {};
try {
while (buffer.remaining >= 2) {
final channel = buffer.readUInt8();
final type = buffer.readUInt8();
while (buffer.remaining >= 2) { // Optional: stop on padding (00 00)
final channel = buffer.readUInt8(); if (channel == 0 && type == 0) {
final type = buffer.readUInt8(); break;
}
// Optional: stop on padding (00 00) final channelData = channels.putIfAbsent(
if (channel == 0 && type == 0) { channel,
break; () => {'channel': channel, 'values': <String, dynamic>{}},
);
switch (type) {
case lppGenericSensor:
channelData['values']['generic'] = buffer.readUInt32BE();
break;
case lppLuminosity:
channelData['values']['luminosity'] = buffer.readUInt16BE();
break;
case lppPresence:
channelData['values']['presence'] = buffer.readUInt8() != 0;
break;
case lppTemperature:
channelData['values']['temperature'] = buffer.readInt16BE() / 10.0;
break;
case lppRelativeHumidity:
channelData['values']['humidity'] = buffer.readUInt8() / 2.0;
break;
case lppBarometricPressure:
channelData['values']['pressure'] = buffer.readUInt16BE() / 10.0;
break;
case lppVoltage:
channelData['values']['voltage'] = buffer.readInt16BE() / 100.0;
break;
case lppCurrent:
channelData['values']['current'] = buffer.readInt16BE() / 1000.0;
break;
case lppPercentage:
channelData['values']['percentage'] = buffer.readUInt8();
break;
case lppConcentration:
channelData['values']['concentration'] = buffer.readUInt16BE();
break;
case lppPower:
channelData['values']['power'] = buffer.readUInt16BE();
break;
case lppGps:
channelData['values']['gps'] = {
'latitude': buffer.readInt24BE() / 10000.0,
'longitude': buffer.readInt24BE() / 10000.0,
'altitude': buffer.readInt24BE() / 100.0,
};
break;
// Add more types as needed...
default:
//Stopped parsing to avoid misalignment
return channels.values.toList();
}
} }
final channelData = channels.putIfAbsent( final List<Map<String, dynamic>> channelsOut = channels.values.toList();
channel, channelsOut.sort((a, b) => a['channel'].compareTo(b['channel']));
() => {'channel': channel, 'values': <String, dynamic>{}}, return channelsOut;
); } catch (e) {
// Handle parsing errors, possibly due to malformed data
switch (type) { appLogger.error('Error parsing Cayenne LPP data: $e');
case lppGenericSensor: return <
channelData['values']['generic'] = buffer.readUInt32BE(); Map<String, dynamic>
break; >[]; // Return an empty list on error to avoid crashing the app
case lppLuminosity:
channelData['values']['luminosity'] = buffer.readUInt16BE();
break;
case lppPresence:
channelData['values']['presence'] = buffer.readUInt8() != 0;
break;
case lppTemperature:
channelData['values']['temperature'] = buffer.readInt16BE() / 10.0;
break;
case lppRelativeHumidity:
channelData['values']['humidity'] = buffer.readUInt8() / 2.0;
break;
case lppBarometricPressure:
channelData['values']['pressure'] = buffer.readUInt16BE() / 10.0;
break;
case lppVoltage:
channelData['values']['voltage'] = buffer.readInt16BE() / 100.0;
break;
case lppCurrent:
channelData['values']['current'] = buffer.readInt16BE() / 1000.0;
break;
case lppPercentage:
channelData['values']['percentage'] = buffer.readUInt8();
break;
case lppConcentration:
channelData['values']['concentration'] = buffer.readUInt16BE();
break;
case lppPower:
channelData['values']['power'] = buffer.readUInt16BE();
break;
case lppGps:
channelData['values']['gps'] = {
'latitude': buffer.readInt24BE() / 10000.0,
'longitude': buffer.readInt24BE() / 10000.0,
'altitude': buffer.readInt24BE() / 100.0,
};
break;
// Add more types as needed...
default:
// Unknown type: skip or handle error?
continue;
}
} }
final List<Map<String, dynamic>> channelsOut = channels.values.toList();
channelsOut.sort((a, b) => a['channel'].compareTo(b['channel']));
return channelsOut;
} }
} }
+124 -4
View File
@@ -334,6 +334,8 @@
"channels_publicChannel": "Публичен канал", "channels_publicChannel": "Публичен канал",
"channels_privateChannel": "Частен канал", "channels_privateChannel": "Частен канал",
"channels_editChannel": "Редактирай канал", "channels_editChannel": "Редактирай канал",
"channels_muteChannel": "Заглуши канала",
"channels_unmuteChannel": "Включи известията на канала",
"channels_deleteChannel": "Изтрий канала", "channels_deleteChannel": "Изтрий канала",
"channels_deleteChannelConfirm": "Изтрий \"{name}\"? Това не може да бъде отменено.", "channels_deleteChannelConfirm": "Изтрий \"{name}\"? Това не може да бъде отменено.",
"@channels_deleteChannelConfirm": { "@channels_deleteChannelConfirm": {
@@ -1351,12 +1353,12 @@
} }
} }
}, },
"repeater_neighboursSubtitle": "Преглед на съседни възли с нулев скок.", "repeater_neighborsSubtitle": "Преглед на съседни възли с нулев скок.",
"repeater_neighbours": "Съседи", "repeater_neighbors": "Съседи",
"neighbors_receivedData": "Получени данни за съседи", "neighbors_receivedData": "Получени данни за съседи",
"neighbors_requestTimedOut": "Съседите поискат изтичане на време.", "neighbors_requestTimedOut": "Съседите поискат изтичане на време.",
"neighbors_errorLoading": "Грешка при зареждане на съседи: {error}", "neighbors_errorLoading": "Грешка при зареждане на съседи: {error}",
"neighbors_repeatersNeighbours": "Повторители Съседи", "neighbors_repeatersNeighbors": "Повторители Съседи",
"neighbors_noData": "Няма налични данни за съседи.", "neighbors_noData": "Няма налични данни за съседи.",
"channels_createPrivateChannel": "Създай Частен Канал", "channels_createPrivateChannel": "Създай Частен Канал",
"channels_joinPrivateChannel": "Присъедини се към Частен Канал", "channels_joinPrivateChannel": "Присъедини се към Частен Канал",
@@ -1594,7 +1596,125 @@
"scanner_bluetoothOff": "Bluetooth е изключен.", "scanner_bluetoothOff": "Bluetooth е изключен.",
"scanner_enableBluetooth": "Активирайте Bluetooth", "scanner_enableBluetooth": "Активирайте Bluetooth",
"scanner_bluetoothOffMessage": "Моля, активирайте Bluetooth, за да сканирате за устройства.", "scanner_bluetoothOffMessage": "Моля, активирайте Bluetooth, за да сканирате за устройства.",
"snrIndicator_lastSeen": "Последно видян",
"snrIndicator_nearByRepeaters": "Близки повтарящи се устройства",
"chat_ShowAllPaths": "Покажи всички пътища",
"settings_clientRepeatSubtitle": "Позволете на това устройство да предава пакети към мрежата за други устройства.", "settings_clientRepeatSubtitle": "Позволете на това устройство да предава пакети към мрежата за други устройства.",
"settings_clientRepeatFreqWarning": "За повторение извън мрежата са необходими честоти от 433, 869 или 918 MHz.", "settings_clientRepeatFreqWarning": "За повторение извън мрежата са необходими честоти от 433, 869 или 918 MHz.",
"settings_clientRepeat": "Без електричество – повторение" "settings_clientRepeat": "Без електричество – повторение",
"settings_aboutOpenMeteoAttribution": "Данни за надморска височина на LOS: Open-Meteo (CC BY 4.0)",
"appSettings_unitsTitle": "единици",
"appSettings_unitsMetric": "Метрика (m / km)",
"appSettings_unitsImperial": "Имперска (ft / mi)",
"map_lineOfSight": "Линия на видимост",
"map_losScreenTitle": "Линия на видимост",
"losSelectStartEnd": "Изберете начални и крайни възли за LOS.",
"losRunFailed": "Проверката на пряката видимост е неуспешна: {error}",
"@losRunFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"losClearAllPoints": "Изчистете всички точки",
"losRunToViewElevationProfile": "Стартирайте LOS, за да видите профила на надморската височина",
"losMenuTitle": "LOS меню",
"losMenuSubtitle": "Докоснете възли или натиснете продължително карта за персонализирани точки",
"losShowDisplayNodes": "Показване на възли на дисплея",
"losCustomPoints": "Персонализирани точки",
"losCustomPointLabel": "Персонализирано {index}",
"@losCustomPointLabel": {
"placeholders": {
"index": {
"type": "int"
}
}
},
"losPointA": "Точка А",
"losPointB": "Точка Б",
"losAntennaA": "Антена A: {value} {unit}",
"@losAntennaA": {
"placeholders": {
"value": {
"type": "String"
},
"unit": {
"type": "String"
}
}
},
"losAntennaB": "Антена B: {value} {unit}",
"@losAntennaB": {
"placeholders": {
"value": {
"type": "String"
},
"unit": {
"type": "String"
}
}
},
"losRun": "Стартирайте LOS",
"losNoElevationData": "Няма данни за надморска височина",
"losProfileClear": "{distance} {distanceUnit}, чист LOS, минимално разстояние {clearance} {heightUnit}",
"@losProfileClear": {
"placeholders": {
"distance": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"clearance": {
"type": "String"
},
"heightUnit": {
"type": "String"
}
}
},
"losProfileBlocked": "{distance} {distanceUnit}, блокиран от {obstruction} {heightUnit}",
"@losProfileBlocked": {
"placeholders": {
"distance": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"obstruction": {
"type": "String"
},
"heightUnit": {
"type": "String"
}
}
},
"losStatusChecking": "LOS: проверка...",
"losStatusNoData": "LOS: няма данни",
"losStatusSummary": "LOS: {clear}/{total} ясно, {blocked} блокирано, {unknown} неизвестно",
"@losStatusSummary": {
"placeholders": {
"clear": {
"type": "int"
},
"total": {
"type": "int"
},
"blocked": {
"type": "int"
},
"unknown": {
"type": "int"
}
}
},
"losErrorElevationUnavailable": "Няма налични данни за надморска височина за една или повече проби.",
"losErrorInvalidInput": "Невалидни данни за точки/надморска височина за изчисляване на LOS.",
"losRenameCustomPoint": "Преименувайте персонализирана точка",
"losPointName": "Име на точката",
"losShowPanelTooltip": "Показване на LOS панел",
"losHidePanelTooltip": "Скриване на LOS панела",
"losElevationAttribution": "Данни за надморска височина: Open-Meteo (CC BY 4.0)"
} }
+124 -4
View File
@@ -334,6 +334,8 @@
"channels_publicChannel": "Öffentlicher Kanal", "channels_publicChannel": "Öffentlicher Kanal",
"channels_privateChannel": "Privater Kanal", "channels_privateChannel": "Privater Kanal",
"channels_editChannel": "Kanal bearbeiten", "channels_editChannel": "Kanal bearbeiten",
"channels_muteChannel": "Kanal stummschalten",
"channels_unmuteChannel": "Kanal Stummschaltung aufheben",
"channels_deleteChannel": "Lösche den Kanal", "channels_deleteChannel": "Lösche den Kanal",
"channels_deleteChannelConfirm": "Löschen von \"{name}\"? Dies kann nicht rückgängig gemacht werden.", "channels_deleteChannelConfirm": "Löschen von \"{name}\"? Dies kann nicht rückgängig gemacht werden.",
"@channels_deleteChannelConfirm": { "@channels_deleteChannelConfirm": {
@@ -1351,12 +1353,12 @@
} }
} }
}, },
"repeater_neighbours": "Nachbarn", "repeater_neighbors": "Nachbarn",
"repeater_neighboursSubtitle": "Anzahl der Hop-Nachbarn anzeigen.", "repeater_neighborsSubtitle": "Anzahl der Hop-Nachbarn anzeigen.",
"neighbors_receivedData": "Empfangene Nachbarsdaten", "neighbors_receivedData": "Empfangene Nachbarsdaten",
"neighbors_requestTimedOut": "Anfrage durch Timeout fehlgeschlagen.", "neighbors_requestTimedOut": "Anfrage durch Timeout fehlgeschlagen.",
"neighbors_errorLoading": "Fehler beim Laden der Nachbarn: {error}", "neighbors_errorLoading": "Fehler beim Laden der Nachbarn: {error}",
"neighbors_repeatersNeighbours": "Nachbarn", "neighbors_repeatersNeighbors": "Nachbarn",
"neighbors_noData": "Keine Nachbarsdaten verfügbar.", "neighbors_noData": "Keine Nachbarsdaten verfügbar.",
"channels_joinPrivateChannel": "Treten Sie einem privaten Kanal bei", "channels_joinPrivateChannel": "Treten Sie einem privaten Kanal bei",
"channels_joinPrivateChannelDesc": "Manuelle Eingabe eines geheimen Schlüssels.", "channels_joinPrivateChannelDesc": "Manuelle Eingabe eines geheimen Schlüssels.",
@@ -1622,7 +1624,125 @@
"scanner_bluetoothOffMessage": "Bitte aktivieren Sie Bluetooth, um nach Geräten zu suchen.", "scanner_bluetoothOffMessage": "Bitte aktivieren Sie Bluetooth, um nach Geräten zu suchen.",
"scanner_bluetoothOff": "Bluetooth ist deaktiviert.", "scanner_bluetoothOff": "Bluetooth ist deaktiviert.",
"scanner_enableBluetooth": "Bluetooth aktivieren", "scanner_enableBluetooth": "Bluetooth aktivieren",
"snrIndicator_lastSeen": "Zuletzt gesehen",
"snrIndicator_nearByRepeaters": "In der Nähe befindliche Repeater",
"chat_ShowAllPaths": "Alle Pfade anzeigen",
"settings_clientRepeat": "Wiederholung, ohne Stromanschluss", "settings_clientRepeat": "Wiederholung, ohne Stromanschluss",
"settings_clientRepeatFreqWarning": "Die Kommunikation ohne Stromversorgung erfordert Frequenzen von 433, 869 oder 918 MHz.", "settings_clientRepeatFreqWarning": "Die Kommunikation ohne Stromversorgung erfordert Frequenzen von 433, 869 oder 918 MHz.",
"settings_clientRepeatSubtitle": "Ermöglichen Sie diesem Gerät, Mesh-Pakete für andere zu wiederholen." "settings_clientRepeatSubtitle": "Ermöglichen Sie diesem Gerät, Mesh-Pakete für andere zu wiederholen.",
"settings_aboutOpenMeteoAttribution": "LOS-Höhendaten: Open-Meteo (CC BY 4.0)",
"appSettings_unitsTitle": "Einheiten",
"appSettings_unitsMetric": "Metrisch (m/km)",
"appSettings_unitsImperial": "Imperial (ft/mi)",
"map_lineOfSight": "Sichtlinie",
"map_losScreenTitle": "Sichtlinie",
"losSelectStartEnd": "Wählen Sie Start- und Endknoten für LOS aus.",
"losRunFailed": "Sichtlinienprüfung fehlgeschlagen: {error}",
"@losRunFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"losClearAllPoints": "Löschen Sie alle Punkte",
"losRunToViewElevationProfile": "Führen Sie LOS aus, um das Höhenprofil anzuzeigen",
"losMenuTitle": "LOS-Menü",
"losMenuSubtitle": "Tippen Sie auf Knoten oder drücken Sie lange auf die Karte, um benutzerdefinierte Punkte anzuzeigen",
"losShowDisplayNodes": "Anzeigeknoten anzeigen",
"losCustomPoints": "Benutzerdefinierte Punkte",
"losCustomPointLabel": "Benutzerdefiniert {index}",
"@losCustomPointLabel": {
"placeholders": {
"index": {
"type": "int"
}
}
},
"losPointA": "Punkt A",
"losPointB": "Punkt B",
"losAntennaA": "Antenne A: {value} {unit}",
"@losAntennaA": {
"placeholders": {
"value": {
"type": "String"
},
"unit": {
"type": "String"
}
}
},
"losAntennaB": "Antenne B: {value} {unit}",
"@losAntennaB": {
"placeholders": {
"value": {
"type": "String"
},
"unit": {
"type": "String"
}
}
},
"losRun": "Führen Sie LOS aus",
"losNoElevationData": "Keine Höhendaten",
"losProfileClear": "{distance} {distanceUnit}, freie Sichtlinie, Mindestabstand {clearance} {heightUnit}",
"@losProfileClear": {
"placeholders": {
"distance": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"clearance": {
"type": "String"
},
"heightUnit": {
"type": "String"
}
}
},
"losProfileBlocked": "{distance} {distanceUnit}, blockiert durch {obstruction} {heightUnit}",
"@losProfileBlocked": {
"placeholders": {
"distance": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"obstruction": {
"type": "String"
},
"heightUnit": {
"type": "String"
}
}
},
"losStatusChecking": "LOS: Überprüfen...",
"losStatusNoData": "LOS: keine Daten",
"losStatusSummary": "Sichtlinie: {clear}/{total} frei, {blocked} blockiert, {unknown} unbekannt",
"@losStatusSummary": {
"placeholders": {
"clear": {
"type": "int"
},
"total": {
"type": "int"
},
"blocked": {
"type": "int"
},
"unknown": {
"type": "int"
}
}
},
"losErrorElevationUnavailable": "Für eine oder mehrere Proben sind keine Höhendaten verfügbar.",
"losErrorInvalidInput": "Ungültige Punkte/Höhendaten für die LOS-Berechnung.",
"losRenameCustomPoint": "Benennen Sie den benutzerdefinierten Punkt um",
"losPointName": "Punktname",
"losShowPanelTooltip": "LOS-Panel anzeigen",
"losHidePanelTooltip": "LOS-Panel ausblenden",
"losElevationAttribution": "Höhendaten: Open-Meteo (CC BY 4.0)"
} }
+127 -7
View File
@@ -131,6 +131,7 @@
}, },
"settings_aboutLegalese": "2026 MeshCore Open Source Project", "settings_aboutLegalese": "2026 MeshCore Open Source Project",
"settings_aboutDescription": "An open-source Flutter client for MeshCore LoRa mesh networking devices.", "settings_aboutDescription": "An open-source Flutter client for MeshCore LoRa mesh networking devices.",
"settings_aboutOpenMeteoAttribution": "LOS elevation data: Open-Meteo (CC BY 4.0)",
"settings_infoName": "Name", "settings_infoName": "Name",
"settings_infoId": "ID", "settings_infoId": "ID",
"settings_infoStatus": "Status", "settings_infoStatus": "Status",
@@ -242,6 +243,9 @@
"appSettings_last24Hours": "Last 24 hours", "appSettings_last24Hours": "Last 24 hours",
"appSettings_lastWeek": "Last week", "appSettings_lastWeek": "Last week",
"appSettings_offlineMapCache": "Offline Map Cache", "appSettings_offlineMapCache": "Offline Map Cache",
"appSettings_unitsTitle": "Units",
"appSettings_unitsMetric": "Metric (m / km)",
"appSettings_unitsImperial": "Imperial (ft / mi)",
"appSettings_noAreaSelected": "No area selected", "appSettings_noAreaSelected": "No area selected",
"appSettings_areaSelectedZoom": "Area selected (zoom {minZoom}-{maxZoom})", "appSettings_areaSelectedZoom": "Area selected (zoom {minZoom}-{maxZoom})",
"@appSettings_areaSelectedZoom": { "@appSettings_areaSelectedZoom": {
@@ -348,6 +352,8 @@
"channels_publicChannel": "Public channel", "channels_publicChannel": "Public channel",
"channels_privateChannel": "Private channel", "channels_privateChannel": "Private channel",
"channels_editChannel": "Edit channel", "channels_editChannel": "Edit channel",
"channels_muteChannel": "Mute channel",
"channels_unmuteChannel": "Unmute channel",
"channels_deleteChannel": "Delete channel", "channels_deleteChannel": "Delete channel",
"channels_deleteChannelConfirm": "Delete \"{name}\"? This cannot be undone.", "channels_deleteChannelConfirm": "Delete \"{name}\"? This cannot be undone.",
"@channels_deleteChannelConfirm": { "@channels_deleteChannelConfirm": {
@@ -558,6 +564,7 @@
}, },
"debugFrame_hexDump": "Hex Dump:", "debugFrame_hexDump": "Hex Dump:",
"chat_pathManagement": "Path Management", "chat_pathManagement": "Path Management",
"chat_ShowAllPaths": "Show all paths",
"chat_routingMode": "Routing mode", "chat_routingMode": "Routing mode",
"chat_autoUseSavedPath": "Auto (use saved path)", "chat_autoUseSavedPath": "Auto (use saved path)",
"chat_forceFloodMode": "Force Flood Mode", "chat_forceFloodMode": "Force Flood Mode",
@@ -638,6 +645,8 @@
}, },
"chat_invalidLink": "Invalid link format", "chat_invalidLink": "Invalid link format",
"map_title": "Node Map", "map_title": "Node Map",
"map_lineOfSight": "Line of Sight",
"map_losScreenTitle": "Line of Sight",
"map_noNodesWithLocation": "No nodes with location data", "map_noNodesWithLocation": "No nodes with location data",
"map_nodesNeedGps": "Nodes need to share their GPS coordinates\nto appear on the map", "map_nodesNeedGps": "Nodes need to share their GPS coordinates\nto appear on the map",
"map_nodesCount": "Nodes: {count}", "map_nodesCount": "Nodes: {count}",
@@ -905,8 +914,8 @@
"repeater_telemetrySubtitle": "View telemetry of sensors and system stats", "repeater_telemetrySubtitle": "View telemetry of sensors and system stats",
"repeater_cli": "CLI", "repeater_cli": "CLI",
"repeater_cliSubtitle": "Send commands to the repeater", "repeater_cliSubtitle": "Send commands to the repeater",
"repeater_neighbours": "Neighbors", "repeater_neighbors": "Neighbors",
"repeater_neighboursSubtitle": "View zero hop neighbors.", "repeater_neighborsSubtitle": "View zero hop neighbors.",
"repeater_settings": "Settings", "repeater_settings": "Settings",
"repeater_settingsSubtitle": "Configure repeater parameters", "repeater_settingsSubtitle": "Configure repeater parameters",
"repeater_statusTitle": "Repeater Status", "repeater_statusTitle": "Repeater Status",
@@ -1266,8 +1275,8 @@
} }
} }
}, },
"neighbors_receivedData": "Received Neighbours Data", "neighbors_receivedData": "Received Neighbors Data",
"neighbors_requestTimedOut": "Neighbours request timed out.", "neighbors_requestTimedOut": "Neighbors request timed out.",
"neighbors_errorLoading": "Error loading neighbors: {error}", "neighbors_errorLoading": "Error loading neighbors: {error}",
"@neighbors_errorLoading": { "@neighbors_errorLoading": {
"placeholders": { "placeholders": {
@@ -1276,8 +1285,8 @@
} }
} }
}, },
"neighbors_repeatersNeighbours": "Repeaters Neighbours", "neighbors_repeatersNeighbors": "Repeaters Neighbors",
"neighbors_noData": "No neighbours data available.", "neighbors_noData": "No neighbors data available.",
"neighbors_unknownContact": "Unknown {pubkey}", "neighbors_unknownContact": "Unknown {pubkey}",
"@neighbors_unknownContact": { "@neighbors_unknownContact": {
"placeholders": { "placeholders": {
@@ -1547,6 +1556,115 @@
"pathTrace_refreshTooltip": "Refresh Path Trace.", "pathTrace_refreshTooltip": "Refresh Path Trace.",
"pathTrace_someHopsNoLocation": "One or more of the hops is missing a location!", "pathTrace_someHopsNoLocation": "One or more of the hops is missing a location!",
"pathTrace_clearTooltip": "Clear path.", "pathTrace_clearTooltip": "Clear path.",
"losSelectStartEnd": "Select start and end nodes for LOS.",
"losRunFailed": "Line-of-sight check failed: {error}",
"@losRunFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"losClearAllPoints": "Clear all points",
"losRunToViewElevationProfile": "Run LOS to view elevation profile",
"losMenuTitle": "LOS Menu",
"losMenuSubtitle": "Tap nodes or long-press map for custom points",
"losShowDisplayNodes": "Show display nodes",
"losCustomPoints": "Custom points",
"losCustomPointLabel": "Custom {index}",
"@losCustomPointLabel": {
"placeholders": {
"index": {
"type": "int"
}
}
},
"losPointA": "Point A",
"losPointB": "Point B",
"losAntennaA": "Antenna A: {value} {unit}",
"@losAntennaA": {
"placeholders": {
"value": {
"type": "String"
},
"unit": {
"type": "String"
}
}
},
"losAntennaB": "Antenna B: {value} {unit}",
"@losAntennaB": {
"placeholders": {
"value": {
"type": "String"
},
"unit": {
"type": "String"
}
}
},
"losRun": "Run LOS",
"losNoElevationData": "No elevation data",
"losProfileClear": "{distance} {distanceUnit}, clear LOS, min clearance {clearance} {heightUnit}",
"@losProfileClear": {
"placeholders": {
"distance": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"clearance": {
"type": "String"
},
"heightUnit": {
"type": "String"
}
}
},
"losProfileBlocked": "{distance} {distanceUnit}, blocked by {obstruction} {heightUnit}",
"@losProfileBlocked": {
"placeholders": {
"distance": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"obstruction": {
"type": "String"
},
"heightUnit": {
"type": "String"
}
}
},
"losStatusChecking": "LOS: checking...",
"losStatusNoData": "LOS: no data",
"losStatusSummary": "LOS: {clear}/{total} clear, {blocked} blocked, {unknown} unknown",
"@losStatusSummary": {
"placeholders": {
"clear": {
"type": "int"
},
"total": {
"type": "int"
},
"blocked": {
"type": "int"
},
"unknown": {
"type": "int"
}
}
},
"losErrorElevationUnavailable": "Elevation data unavailable for one or more samples.",
"losErrorInvalidInput": "Invalid points/elevation data for LOS calculation.",
"losRenameCustomPoint": "Rename custom point",
"losPointName": "Point name",
"losShowPanelTooltip": "Show LOS panel",
"losHidePanelTooltip": "Hide LOS panel",
"losElevationAttribution": "Elevation data: Open-Meteo (CC BY 4.0)",
"contacts_pathTrace": "Path Trace", "contacts_pathTrace": "Path Trace",
"contacts_ping": "Ping", "contacts_ping": "Ping",
"contacts_repeaterPathTrace": "Path trace to repeater", "contacts_repeaterPathTrace": "Path trace to repeater",
@@ -1624,5 +1742,7 @@
"settings_gpxExportChat": "Companion locations", "settings_gpxExportChat": "Companion locations",
"settings_gpxExportAllContacts": "All contacts locations", "settings_gpxExportAllContacts": "All contacts locations",
"settings_gpxExportShareText": "Map data exported from meshcore-open", "settings_gpxExportShareText": "Map data exported from meshcore-open",
"settings_gpxExportShareSubject": "meshcore-open GPX map data export" "settings_gpxExportShareSubject": "meshcore-open GPX map data export",
"snrIndicator_nearByRepeaters": "Nearby Repeaters",
"snrIndicator_lastSeen": "Last seen"
} }
+124 -4
View File
@@ -334,6 +334,8 @@
"channels_publicChannel": "Canal público", "channels_publicChannel": "Canal público",
"channels_privateChannel": "Canal privado", "channels_privateChannel": "Canal privado",
"channels_editChannel": "Editar canal", "channels_editChannel": "Editar canal",
"channels_muteChannel": "Silenciar canal",
"channels_unmuteChannel": "Activar canal",
"channels_deleteChannel": "Eliminar canal", "channels_deleteChannel": "Eliminar canal",
"channels_deleteChannelConfirm": "Eliminar \"{name}\"? Esto no se puede deshacer.", "channels_deleteChannelConfirm": "Eliminar \"{name}\"? Esto no se puede deshacer.",
"@channels_deleteChannelConfirm": { "@channels_deleteChannelConfirm": {
@@ -1351,12 +1353,12 @@
} }
} }
}, },
"repeater_neighbours": "Vecinos", "repeater_neighbors": "Vecinos",
"repeater_neighboursSubtitle": "Ver vecinos de salto cero.", "repeater_neighborsSubtitle": "Ver vecinos de salto cero.",
"neighbors_receivedData": "Recibidas Datos de Vecinos", "neighbors_receivedData": "Recibidas Datos de Vecinos",
"neighbors_requestTimedOut": "Los vecinos solicitan que se desconecte.", "neighbors_requestTimedOut": "Los vecinos solicitan que se desconecte.",
"neighbors_errorLoading": "Error al cargar vecinos: {error}", "neighbors_errorLoading": "Error al cargar vecinos: {error}",
"neighbors_repeatersNeighbours": "Repetidores Vecinos", "neighbors_repeatersNeighbors": "Repetidores Vecinos",
"neighbors_noData": "No hay datos de vecinos disponibles.", "neighbors_noData": "No hay datos de vecinos disponibles.",
"channels_joinPrivateChannel": "Únete a un Canal Privado", "channels_joinPrivateChannel": "Únete a un Canal Privado",
"channels_createPrivateChannel": "Crear un Canal Privado", "channels_createPrivateChannel": "Crear un Canal Privado",
@@ -1622,7 +1624,125 @@
"scanner_bluetoothOffMessage": "Por favor, active el Bluetooth para escanear dispositivos.", "scanner_bluetoothOffMessage": "Por favor, active el Bluetooth para escanear dispositivos.",
"scanner_bluetoothOff": "Bluetooth está desactivado.", "scanner_bluetoothOff": "Bluetooth está desactivado.",
"scanner_enableBluetooth": "Habilitar Bluetooth", "scanner_enableBluetooth": "Habilitar Bluetooth",
"snrIndicator_nearByRepeaters": "Repetidores cercanos",
"snrIndicator_lastSeen": "Visto por última vez",
"chat_ShowAllPaths": "Mostrar todos los caminos",
"settings_clientRepeatFreqWarning": "Para la comunicación fuera de la red, se requiere una frecuencia de 433, 869 o 918 MHz.", "settings_clientRepeatFreqWarning": "Para la comunicación fuera de la red, se requiere una frecuencia de 433, 869 o 918 MHz.",
"settings_clientRepeat": "Repetir sin conexión", "settings_clientRepeat": "Repetir sin conexión",
"settings_clientRepeatSubtitle": "Permita que este dispositivo repita los paquetes de red para otros usuarios." "settings_clientRepeatSubtitle": "Permita que este dispositivo repita los paquetes de red para otros usuarios.",
"settings_aboutOpenMeteoAttribution": "Datos de elevación LOS: Open-Meteo (CC BY 4.0)",
"appSettings_unitsTitle": "Unidades",
"appSettings_unitsMetric": "Métrico (m/km)",
"appSettings_unitsImperial": "Imperial (pies/millas)",
"map_lineOfSight": "Línea de visión",
"map_losScreenTitle": "Línea de visión",
"losSelectStartEnd": "Seleccione los nodos de inicio y fin para LOS.",
"losRunFailed": "Error en la comprobación de la línea de visión: {error}",
"@losRunFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"losClearAllPoints": "Borrar todos los puntos",
"losRunToViewElevationProfile": "Ejecute LOS para ver el perfil de elevación",
"losMenuTitle": "Menú LOS",
"losMenuSubtitle": "Toque nodos o mantenga presionado el mapa para puntos personalizados",
"losShowDisplayNodes": "Mostrar nodos de visualización",
"losCustomPoints": "Puntos personalizados",
"losCustomPointLabel": "Personalizado {index}",
"@losCustomPointLabel": {
"placeholders": {
"index": {
"type": "int"
}
}
},
"losPointA": "Punto A",
"losPointB": "Punto B",
"losAntennaA": "Antena A: {value} {unit}",
"@losAntennaA": {
"placeholders": {
"value": {
"type": "String"
},
"unit": {
"type": "String"
}
}
},
"losAntennaB": "Antena B: {value} {unit}",
"@losAntennaB": {
"placeholders": {
"value": {
"type": "String"
},
"unit": {
"type": "String"
}
}
},
"losRun": "Ejecutar LOS",
"losNoElevationData": "Sin datos de elevación",
"losProfileClear": "{distance} {distanceUnit}, despejar LOS, autorización mínima {clearance} {heightUnit}",
"@losProfileClear": {
"placeholders": {
"distance": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"clearance": {
"type": "String"
},
"heightUnit": {
"type": "String"
}
}
},
"losProfileBlocked": "{distance} {distanceUnit}, bloqueado por {obstruction} {heightUnit}",
"@losProfileBlocked": {
"placeholders": {
"distance": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"obstruction": {
"type": "String"
},
"heightUnit": {
"type": "String"
}
}
},
"losStatusChecking": "LOS: comprobando...",
"losStatusNoData": "LOS: sin datos",
"losStatusSummary": "LOS: {clear}/{total} claro, {blocked} bloqueado, {unknown} desconocido",
"@losStatusSummary": {
"placeholders": {
"clear": {
"type": "int"
},
"total": {
"type": "int"
},
"blocked": {
"type": "int"
},
"unknown": {
"type": "int"
}
}
},
"losErrorElevationUnavailable": "Datos de elevación no disponibles para una o más muestras.",
"losErrorInvalidInput": "Datos de puntos/elevación no válidos para el cálculo de LOS.",
"losRenameCustomPoint": "Cambiar el nombre del punto personalizado",
"losPointName": "Nombre del punto",
"losShowPanelTooltip": "Mostrar panel LOS",
"losHidePanelTooltip": "Ocultar panel LOS",
"losElevationAttribution": "Datos de elevación: Open-Meteo (CC BY 4.0)"
} }
+127 -7
View File
@@ -262,7 +262,7 @@
} }
}, },
"contacts_manageRepeater": "Gérer le répéteur", "contacts_manageRepeater": "Gérer le répéteur",
"contacts_roomLogin": "Connexion Salle", "contacts_roomLogin": "Connexion Room Server",
"contacts_openChat": "Ouverture du Chat", "contacts_openChat": "Ouverture du Chat",
"contacts_editGroup": "Modifier le groupe", "contacts_editGroup": "Modifier le groupe",
"contacts_deleteGroup": "Supprimer le groupe", "contacts_deleteGroup": "Supprimer le groupe",
@@ -334,6 +334,8 @@
"channels_publicChannel": "Canal public", "channels_publicChannel": "Canal public",
"channels_privateChannel": "Canal privé", "channels_privateChannel": "Canal privé",
"channels_editChannel": "Modifier le canal", "channels_editChannel": "Modifier le canal",
"channels_muteChannel": "Désactiver les notifications du canal",
"channels_unmuteChannel": "Réactiver les notifications du canal",
"channels_deleteChannel": "Supprimer le canal", "channels_deleteChannel": "Supprimer le canal",
"channels_deleteChannelConfirm": "Supprimer {name}? Cela ne peut pas être annulé.", "channels_deleteChannelConfirm": "Supprimer {name}? Cela ne peut pas être annulé.",
"@channels_deleteChannelConfirm": { "@channels_deleteChannelConfirm": {
@@ -796,7 +798,7 @@
"dialog_disconnect": "Déconnecter", "dialog_disconnect": "Déconnecter",
"dialog_disconnectConfirm": "Êtes-vous sûr de vouloir vous déconnecter de cet appareil ?", "dialog_disconnectConfirm": "Êtes-vous sûr de vouloir vous déconnecter de cet appareil ?",
"login_repeaterLogin": "Connexion au répéteur", "login_repeaterLogin": "Connexion au répéteur",
"login_roomLogin": "Connexion Salle", "login_roomLogin": "Connexion Room Server",
"login_password": "Mot de passe", "login_password": "Mot de passe",
"login_enterPassword": "Entrez votre mot de passe", "login_enterPassword": "Entrez votre mot de passe",
"login_savePassword": "Sauvegarder le mot de passe", "login_savePassword": "Sauvegarder le mot de passe",
@@ -1351,12 +1353,12 @@
} }
} }
}, },
"repeater_neighbours": "Voisins", "repeater_neighbors": "Voisins",
"repeater_neighboursSubtitle": "Afficher les voisins de saut nuls.", "repeater_neighborsSubtitle": "Afficher les voisins de saut nuls.",
"neighbors_receivedData": "Données des voisins reçues", "neighbors_receivedData": "Données des voisins reçues",
"neighbors_requestTimedOut": "Les voisins demandent un délai.", "neighbors_requestTimedOut": "Les voisins demandent un délai.",
"neighbors_errorLoading": "Erreur lors du chargement des voisins : {error}", "neighbors_errorLoading": "Erreur lors du chargement des voisins : {error}",
"neighbors_repeatersNeighbours": "Répéteurs Voisins", "neighbors_repeatersNeighbors": "Répéteurs Voisins",
"neighbors_noData": "Aucune donnée concernant les voisins disponible.", "neighbors_noData": "Aucune donnée concernant les voisins disponible.",
"channels_createPrivateChannelDesc": "Sécurisé avec une clé secrète.", "channels_createPrivateChannelDesc": "Sécurisé avec une clé secrète.",
"channels_joinPrivateChannel": "Rejoindre un Canal Privé", "channels_joinPrivateChannel": "Rejoindre un Canal Privé",
@@ -1391,7 +1393,7 @@
"settings_locationIntervalSec": "Intervalle de mise-à-jour du GPS (Secondes)", "settings_locationIntervalSec": "Intervalle de mise-à-jour du GPS (Secondes)",
"settings_locationIntervalInvalid": "L'intervalle doit être compris entre 60 et 86400 secondes.", "settings_locationIntervalInvalid": "L'intervalle doit être compris entre 60 et 86400 secondes.",
"contacts_manageRoom": "Gérer le Room Server", "contacts_manageRoom": "Gérer le Room Server",
"room_management": "Administración del Servidor de Habitación", "room_management": "Administrattion Room Server",
"@community_joinConfirmation": { "@community_joinConfirmation": {
"placeholders": { "placeholders": {
"name": { "name": {
@@ -1594,7 +1596,125 @@
"scanner_bluetoothOffMessage": "Veuillez activer le Bluetooth pour rechercher des appareils.", "scanner_bluetoothOffMessage": "Veuillez activer le Bluetooth pour rechercher des appareils.",
"scanner_bluetoothOff": "Le Bluetooth est désactivé.", "scanner_bluetoothOff": "Le Bluetooth est désactivé.",
"scanner_enableBluetooth": "Activer le Bluetooth", "scanner_enableBluetooth": "Activer le Bluetooth",
"snrIndicator_lastSeen": "Dernière fois vu",
"snrIndicator_nearByRepeaters": "Répéteurs à proximité",
"chat_ShowAllPaths": "Afficher tous les chemins",
"settings_clientRepeatFreqWarning": "Pour les transmissions hors réseau, il est nécessaire d'utiliser les fréquences de 433, 869 ou 918 MHz.", "settings_clientRepeatFreqWarning": "Pour les transmissions hors réseau, il est nécessaire d'utiliser les fréquences de 433, 869 ou 918 MHz.",
"settings_clientRepeatSubtitle": "Permettez à cet appareil de répéter les paquets de données pour les autres.", "settings_clientRepeatSubtitle": "Permettez à cet appareil de répéter les paquets de données pour les autres.",
"settings_clientRepeat": "Répétition hors réseau" "settings_clientRepeat": "Répétition hors réseau",
"settings_aboutOpenMeteoAttribution": "Données d'élévation LOS : Open-Meteo (CC BY 4.0)",
"appSettings_unitsTitle": "Unités",
"appSettings_unitsMetric": "Métrique (m/km)",
"appSettings_unitsImperial": "Impérial (ft / mi)",
"map_lineOfSight": "Ligne de vue",
"map_losScreenTitle": "Ligne de vue",
"losSelectStartEnd": "Sélectionnez les nœuds de début et de fin pour LOS.",
"losRunFailed": "Échec de la vérification de la ligne de vue : {error}",
"@losRunFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"losClearAllPoints": "Effacer tous les points",
"losRunToViewElevationProfile": "Exécutez LOS pour afficher le profil d'altitude",
"losMenuTitle": "Menu LOS",
"losMenuSubtitle": "Appuyez sur les nœuds ou appuyez longuement sur la carte pour des points personnalisés",
"losShowDisplayNodes": "Afficher les nœuds d'affichage",
"losCustomPoints": "Points personnalisés",
"losCustomPointLabel": "Personnalisé {index}",
"@losCustomPointLabel": {
"placeholders": {
"index": {
"type": "int"
}
}
},
"losPointA": "Point A",
"losPointB": "Point B",
"losAntennaA": "Antenne A : {value} {unit}",
"@losAntennaA": {
"placeholders": {
"value": {
"type": "String"
},
"unit": {
"type": "String"
}
}
},
"losAntennaB": "Antenne B : {value} {unit}",
"@losAntennaB": {
"placeholders": {
"value": {
"type": "String"
},
"unit": {
"type": "String"
}
}
},
"losRun": "Exécuter la LOS",
"losNoElevationData": "Aucune donnée d'altitude",
"losProfileClear": "{distance} {distanceUnit}, LOS clair, clairance minimale {clearance} {heightUnit}",
"@losProfileClear": {
"placeholders": {
"distance": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"clearance": {
"type": "String"
},
"heightUnit": {
"type": "String"
}
}
},
"losProfileBlocked": "{distance} {distanceUnit}, bloqué par {obstruction} {heightUnit}",
"@losProfileBlocked": {
"placeholders": {
"distance": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"obstruction": {
"type": "String"
},
"heightUnit": {
"type": "String"
}
}
},
"losStatusChecking": "LOS : vérification...",
"losStatusNoData": "LOS : aucune donnée",
"losStatusSummary": "LOS : {clear}/{total} clair, {blocked} bloqué, {unknown} inconnu",
"@losStatusSummary": {
"placeholders": {
"clear": {
"type": "int"
},
"total": {
"type": "int"
},
"blocked": {
"type": "int"
},
"unknown": {
"type": "int"
}
}
},
"losErrorElevationUnavailable": "Données d'altitude indisponibles pour un ou plusieurs échantillons.",
"losErrorInvalidInput": "Données de points/d'altitude non valides pour le calcul de la LOS.",
"losRenameCustomPoint": "Renommer le point personnalisé",
"losPointName": "Nom du point",
"losShowPanelTooltip": "Afficher le panneau LOS",
"losHidePanelTooltip": "Masquer le panneau LOS",
"losElevationAttribution": "Données d'altitude : Open-Meteo (CC BY 4.0)"
} }
+124 -4
View File
@@ -334,6 +334,8 @@
"channels_publicChannel": "Canale pubblico", "channels_publicChannel": "Canale pubblico",
"channels_privateChannel": "Canale privato", "channels_privateChannel": "Canale privato",
"channels_editChannel": "Modifica canale", "channels_editChannel": "Modifica canale",
"channels_muteChannel": "Silenzia canale",
"channels_unmuteChannel": "Attiva notifiche canale",
"channels_deleteChannel": "Elimina canale", "channels_deleteChannel": "Elimina canale",
"channels_deleteChannelConfirm": "Eliminare \"{name}\"? Non può essere annullato.", "channels_deleteChannelConfirm": "Eliminare \"{name}\"? Non può essere annullato.",
"@channels_deleteChannelConfirm": { "@channels_deleteChannelConfirm": {
@@ -1351,12 +1353,12 @@
} }
} }
}, },
"repeater_neighbours": "Vicini", "repeater_neighbors": "Vicini",
"repeater_neighboursSubtitle": "Visualizza vicini di salto pari a zero.", "repeater_neighborsSubtitle": "Visualizza vicini di salto pari a zero.",
"neighbors_receivedData": "Ricevute dati vicini", "neighbors_receivedData": "Ricevute dati vicini",
"neighbors_requestTimedOut": "I vicini richiedono un timeout.", "neighbors_requestTimedOut": "I vicini richiedono un timeout.",
"neighbors_errorLoading": "Errore nel caricamento dei vicini: {error}", "neighbors_errorLoading": "Errore nel caricamento dei vicini: {error}",
"neighbors_repeatersNeighbours": "Ripetitori Vicini", "neighbors_repeatersNeighbors": "Ripetitori Vicini",
"neighbors_noData": "Nessun dato sugli vicini disponibile.", "neighbors_noData": "Nessun dato sugli vicini disponibile.",
"channels_createPrivateChannel": "Crea un Canale Privato", "channels_createPrivateChannel": "Crea un Canale Privato",
"channels_createPrivateChannelDesc": "Protetta con una chiave segreta.", "channels_createPrivateChannelDesc": "Protetta con una chiave segreta.",
@@ -1594,7 +1596,125 @@
"scanner_bluetoothOff": "Il Bluetooth è disattivato.", "scanner_bluetoothOff": "Il Bluetooth è disattivato.",
"scanner_bluetoothOffMessage": "Si prega di attivare il Bluetooth per effettuare la scansione dei dispositivi.", "scanner_bluetoothOffMessage": "Si prega di attivare il Bluetooth per effettuare la scansione dei dispositivi.",
"scanner_enableBluetooth": "Abilita il Bluetooth", "scanner_enableBluetooth": "Abilita il Bluetooth",
"snrIndicator_nearByRepeaters": "Ripetitori vicini",
"snrIndicator_lastSeen": "Ultimo accesso",
"chat_ShowAllPaths": "Mostra tutti i percorsi",
"settings_clientRepeat": "Ripetizione \"fuori dalla rete\"", "settings_clientRepeat": "Ripetizione \"fuori dalla rete\"",
"settings_clientRepeatFreqWarning": "Per la comunicazione fuori rete, è necessario utilizzare frequenze di 433, 869 o 918 MHz.", "settings_clientRepeatFreqWarning": "Per la comunicazione fuori rete, è necessario utilizzare frequenze di 433, 869 o 918 MHz.",
"settings_clientRepeatSubtitle": "Permetti a questo dispositivo di ripetere i pacchetti di rete per gli altri." "settings_clientRepeatSubtitle": "Permetti a questo dispositivo di ripetere i pacchetti di rete per gli altri.",
"settings_aboutOpenMeteoAttribution": "Dati di elevazione LOS: Open-Meteo (CC BY 4.0)",
"appSettings_unitsTitle": "Unità",
"appSettings_unitsMetric": "Metrico (m/km)",
"appSettings_unitsImperial": "Imperiale (ft / mi)",
"map_lineOfSight": "Linea di vista",
"map_losScreenTitle": "Linea di vista",
"losSelectStartEnd": "Seleziona i nodi iniziali e finali per la LOS.",
"losRunFailed": "Controllo della linea di vista fallito: {error}",
"@losRunFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"losClearAllPoints": "Cancella tutti i punti",
"losRunToViewElevationProfile": "Eseguire LOS per visualizzare il profilo altimetrico",
"losMenuTitle": "Menù LOS",
"losMenuSubtitle": "Tocca i nodi o premi a lungo la mappa per punti personalizzati",
"losShowDisplayNodes": "Mostra i nodi di visualizzazione",
"losCustomPoints": "Punti personalizzati",
"losCustomPointLabel": "Personalizzato {index}",
"@losCustomPointLabel": {
"placeholders": {
"index": {
"type": "int"
}
}
},
"losPointA": "Punto A",
"losPointB": "Punto B",
"losAntennaA": "Antenna A: {value} {unit}",
"@losAntennaA": {
"placeholders": {
"value": {
"type": "String"
},
"unit": {
"type": "String"
}
}
},
"losAntennaB": "Antenna B: {value} {unit}",
"@losAntennaB": {
"placeholders": {
"value": {
"type": "String"
},
"unit": {
"type": "String"
}
}
},
"losRun": "Esegui LOS",
"losNoElevationData": "Nessun dato di elevazione",
"losProfileClear": "{distance} {distanceUnit}, libera LOS, distanza minima {clearance} {heightUnit}",
"@losProfileClear": {
"placeholders": {
"distance": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"clearance": {
"type": "String"
},
"heightUnit": {
"type": "String"
}
}
},
"losProfileBlocked": "{distance} {distanceUnit}, bloccato da {obstruction} {heightUnit}",
"@losProfileBlocked": {
"placeholders": {
"distance": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"obstruction": {
"type": "String"
},
"heightUnit": {
"type": "String"
}
}
},
"losStatusChecking": "LOS: controllo...",
"losStatusNoData": "LOS: nessun dato",
"losStatusSummary": "LOS: {clear}/{total} libera, {blocked} bloccato, {unknown} sconosciuto",
"@losStatusSummary": {
"placeholders": {
"clear": {
"type": "int"
},
"total": {
"type": "int"
},
"blocked": {
"type": "int"
},
"unknown": {
"type": "int"
}
}
},
"losErrorElevationUnavailable": "Dati di elevazione non disponibili per uno o più campioni.",
"losErrorInvalidInput": "Dati punti/elevazione non validi per il calcolo della LOS.",
"losRenameCustomPoint": "Rinomina punto personalizzato",
"losPointName": "Nome del punto",
"losShowPanelTooltip": "Mostra il pannello LOS",
"losHidePanelTooltip": "Nascondi il pannello LOS",
"losElevationAttribution": "Dati di elevazione: Open-Meteo (CC BY 4.0)"
} }
+248 -10
View File
@@ -700,6 +700,12 @@ abstract class AppLocalizations {
/// **'An open-source Flutter client for MeshCore LoRa mesh networking devices.'** /// **'An open-source Flutter client for MeshCore LoRa mesh networking devices.'**
String get settings_aboutDescription; String get settings_aboutDescription;
/// No description provided for @settings_aboutOpenMeteoAttribution.
///
/// In en, this message translates to:
/// **'LOS elevation data: Open-Meteo (CC BY 4.0)'**
String get settings_aboutOpenMeteoAttribution;
/// No description provided for @settings_infoName. /// No description provided for @settings_infoName.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1240,6 +1246,24 @@ abstract class AppLocalizations {
/// **'Offline Map Cache'** /// **'Offline Map Cache'**
String get appSettings_offlineMapCache; String get appSettings_offlineMapCache;
/// No description provided for @appSettings_unitsTitle.
///
/// In en, this message translates to:
/// **'Units'**
String get appSettings_unitsTitle;
/// No description provided for @appSettings_unitsMetric.
///
/// In en, this message translates to:
/// **'Metric (m / km)'**
String get appSettings_unitsMetric;
/// No description provided for @appSettings_unitsImperial.
///
/// In en, this message translates to:
/// **'Imperial (ft / mi)'**
String get appSettings_unitsImperial;
/// No description provided for @appSettings_noAreaSelected. /// No description provided for @appSettings_noAreaSelected.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1522,6 +1546,18 @@ abstract class AppLocalizations {
/// **'Edit channel'** /// **'Edit channel'**
String get channels_editChannel; String get channels_editChannel;
/// No description provided for @channels_muteChannel.
///
/// In en, this message translates to:
/// **'Mute channel'**
String get channels_muteChannel;
/// No description provided for @channels_unmuteChannel.
///
/// In en, this message translates to:
/// **'Unmute channel'**
String get channels_unmuteChannel;
/// No description provided for @channels_deleteChannel. /// No description provided for @channels_deleteChannel.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -2032,6 +2068,12 @@ abstract class AppLocalizations {
/// **'Path Management'** /// **'Path Management'**
String get chat_pathManagement; String get chat_pathManagement;
/// No description provided for @chat_ShowAllPaths.
///
/// In en, this message translates to:
/// **'Show all paths'**
String get chat_ShowAllPaths;
/// No description provided for @chat_routingMode. /// No description provided for @chat_routingMode.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -2284,6 +2326,18 @@ abstract class AppLocalizations {
/// **'Node Map'** /// **'Node Map'**
String get map_title; String get map_title;
/// No description provided for @map_lineOfSight.
///
/// In en, this message translates to:
/// **'Line of Sight'**
String get map_lineOfSight;
/// No description provided for @map_losScreenTitle.
///
/// In en, this message translates to:
/// **'Line of Sight'**
String get map_losScreenTitle;
/// No description provided for @map_noNodesWithLocation. /// No description provided for @map_noNodesWithLocation.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -3027,17 +3081,17 @@ abstract class AppLocalizations {
/// **'Send commands to the repeater'** /// **'Send commands to the repeater'**
String get repeater_cliSubtitle; String get repeater_cliSubtitle;
/// No description provided for @repeater_neighbours. /// No description provided for @repeater_neighbors.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Neighbors'** /// **'Neighbors'**
String get repeater_neighbours; String get repeater_neighbors;
/// No description provided for @repeater_neighboursSubtitle. /// No description provided for @repeater_neighborsSubtitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'View zero hop neighbors.'** /// **'View zero hop neighbors.'**
String get repeater_neighboursSubtitle; String get repeater_neighborsSubtitle;
/// No description provided for @repeater_settings. /// No description provided for @repeater_settings.
/// ///
@@ -4181,13 +4235,13 @@ abstract class AppLocalizations {
/// No description provided for @neighbors_receivedData. /// No description provided for @neighbors_receivedData.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Received Neighbours Data'** /// **'Received Neighbors Data'**
String get neighbors_receivedData; String get neighbors_receivedData;
/// No description provided for @neighbors_requestTimedOut. /// No description provided for @neighbors_requestTimedOut.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Neighbours request timed out.'** /// **'Neighbors request timed out.'**
String get neighbors_requestTimedOut; String get neighbors_requestTimedOut;
/// No description provided for @neighbors_errorLoading. /// No description provided for @neighbors_errorLoading.
@@ -4196,16 +4250,16 @@ abstract class AppLocalizations {
/// **'Error loading neighbors: {error}'** /// **'Error loading neighbors: {error}'**
String neighbors_errorLoading(String error); String neighbors_errorLoading(String error);
/// No description provided for @neighbors_repeatersNeighbours. /// No description provided for @neighbors_repeatersNeighbors.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Repeaters Neighbours'** /// **'Repeaters Neighbors'**
String get neighbors_repeatersNeighbours; String get neighbors_repeatersNeighbors;
/// No description provided for @neighbors_noData. /// No description provided for @neighbors_noData.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'No neighbours data available.'** /// **'No neighbors data available.'**
String get neighbors_noData; String get neighbors_noData;
/// No description provided for @neighbors_unknownContact. /// No description provided for @neighbors_unknownContact.
@@ -4766,6 +4820,178 @@ abstract class AppLocalizations {
/// **'Clear path.'** /// **'Clear path.'**
String get pathTrace_clearTooltip; String get pathTrace_clearTooltip;
/// No description provided for @losSelectStartEnd.
///
/// In en, this message translates to:
/// **'Select start and end nodes for LOS.'**
String get losSelectStartEnd;
/// No description provided for @losRunFailed.
///
/// In en, this message translates to:
/// **'Line-of-sight check failed: {error}'**
String losRunFailed(String error);
/// No description provided for @losClearAllPoints.
///
/// In en, this message translates to:
/// **'Clear all points'**
String get losClearAllPoints;
/// No description provided for @losRunToViewElevationProfile.
///
/// In en, this message translates to:
/// **'Run LOS to view elevation profile'**
String get losRunToViewElevationProfile;
/// No description provided for @losMenuTitle.
///
/// In en, this message translates to:
/// **'LOS Menu'**
String get losMenuTitle;
/// No description provided for @losMenuSubtitle.
///
/// In en, this message translates to:
/// **'Tap nodes or long-press map for custom points'**
String get losMenuSubtitle;
/// No description provided for @losShowDisplayNodes.
///
/// In en, this message translates to:
/// **'Show display nodes'**
String get losShowDisplayNodes;
/// No description provided for @losCustomPoints.
///
/// In en, this message translates to:
/// **'Custom points'**
String get losCustomPoints;
/// No description provided for @losCustomPointLabel.
///
/// In en, this message translates to:
/// **'Custom {index}'**
String losCustomPointLabel(int index);
/// No description provided for @losPointA.
///
/// In en, this message translates to:
/// **'Point A'**
String get losPointA;
/// No description provided for @losPointB.
///
/// In en, this message translates to:
/// **'Point B'**
String get losPointB;
/// No description provided for @losAntennaA.
///
/// In en, this message translates to:
/// **'Antenna A: {value} {unit}'**
String losAntennaA(String value, String unit);
/// No description provided for @losAntennaB.
///
/// In en, this message translates to:
/// **'Antenna B: {value} {unit}'**
String losAntennaB(String value, String unit);
/// No description provided for @losRun.
///
/// In en, this message translates to:
/// **'Run LOS'**
String get losRun;
/// No description provided for @losNoElevationData.
///
/// In en, this message translates to:
/// **'No elevation data'**
String get losNoElevationData;
/// No description provided for @losProfileClear.
///
/// In en, this message translates to:
/// **'{distance} {distanceUnit}, clear LOS, min clearance {clearance} {heightUnit}'**
String losProfileClear(
String distance,
String distanceUnit,
String clearance,
String heightUnit,
);
/// No description provided for @losProfileBlocked.
///
/// In en, this message translates to:
/// **'{distance} {distanceUnit}, blocked by {obstruction} {heightUnit}'**
String losProfileBlocked(
String distance,
String distanceUnit,
String obstruction,
String heightUnit,
);
/// No description provided for @losStatusChecking.
///
/// In en, this message translates to:
/// **'LOS: checking...'**
String get losStatusChecking;
/// No description provided for @losStatusNoData.
///
/// In en, this message translates to:
/// **'LOS: no data'**
String get losStatusNoData;
/// No description provided for @losStatusSummary.
///
/// In en, this message translates to:
/// **'LOS: {clear}/{total} clear, {blocked} blocked, {unknown} unknown'**
String losStatusSummary(int clear, int total, int blocked, int unknown);
/// No description provided for @losErrorElevationUnavailable.
///
/// In en, this message translates to:
/// **'Elevation data unavailable for one or more samples.'**
String get losErrorElevationUnavailable;
/// No description provided for @losErrorInvalidInput.
///
/// In en, this message translates to:
/// **'Invalid points/elevation data for LOS calculation.'**
String get losErrorInvalidInput;
/// No description provided for @losRenameCustomPoint.
///
/// In en, this message translates to:
/// **'Rename custom point'**
String get losRenameCustomPoint;
/// No description provided for @losPointName.
///
/// In en, this message translates to:
/// **'Point name'**
String get losPointName;
/// No description provided for @losShowPanelTooltip.
///
/// In en, this message translates to:
/// **'Show LOS panel'**
String get losShowPanelTooltip;
/// No description provided for @losHidePanelTooltip.
///
/// In en, this message translates to:
/// **'Hide LOS panel'**
String get losHidePanelTooltip;
/// No description provided for @losElevationAttribution.
///
/// In en, this message translates to:
/// **'Elevation data: Open-Meteo (CC BY 4.0)'**
String get losElevationAttribution;
/// No description provided for @contacts_pathTrace. /// No description provided for @contacts_pathTrace.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -5023,6 +5249,18 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'meshcore-open GPX map data export'** /// **'meshcore-open GPX map data export'**
String get settings_gpxExportShareSubject; String get settings_gpxExportShareSubject;
/// No description provided for @snrIndicator_nearByRepeaters.
///
/// In en, this message translates to:
/// **'Nearby Repeaters'**
String get snrIndicator_nearByRepeaters;
/// No description provided for @snrIndicator_lastSeen.
///
/// In en, this message translates to:
/// **'Last seen'**
String get snrIndicator_lastSeen;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate
+147 -3
View File
@@ -326,6 +326,10 @@ class AppLocalizationsBg extends AppLocalizations {
String get settings_aboutDescription => String get settings_aboutDescription =>
'Отворен софтуер за Flutter клиент за MeshCore LoRa мрежови устройства.'; 'Отворен софтуер за Flutter клиент за MeshCore LoRa мрежови устройства.';
@override
String get settings_aboutOpenMeteoAttribution =>
'Данни за надморска височина на LOS: Open-Meteo (CC BY 4.0)';
@override @override
String get settings_infoName => 'Име'; String get settings_infoName => 'Име';
@@ -622,6 +626,15 @@ class AppLocalizationsBg extends AppLocalizations {
@override @override
String get appSettings_offlineMapCache => 'Кеш на офлайн карти'; String get appSettings_offlineMapCache => 'Кеш на офлайн карти';
@override
String get appSettings_unitsTitle => 'единици';
@override
String get appSettings_unitsMetric => 'Метрика (m / km)';
@override
String get appSettings_unitsImperial => 'Имперска (ft / mi)';
@override @override
String get appSettings_noAreaSelected => 'Няма избрана област'; String get appSettings_noAreaSelected => 'Няма избрана област';
@@ -785,6 +798,12 @@ class AppLocalizationsBg extends AppLocalizations {
@override @override
String get channels_editChannel => 'Редактирай канал'; String get channels_editChannel => 'Редактирай канал';
@override
String get channels_muteChannel => 'Заглуши канала';
@override
String get channels_unmuteChannel => 'Включи известията на канала';
@override @override
String get channels_deleteChannel => 'Изтрий канала'; String get channels_deleteChannel => 'Изтрий канала';
@@ -1080,6 +1099,9 @@ class AppLocalizationsBg extends AppLocalizations {
@override @override
String get chat_pathManagement => 'Управление на пътища'; String get chat_pathManagement => 'Управление на пътища';
@override
String get chat_ShowAllPaths => 'Покажи всички пътища';
@override @override
String get chat_routingMode => 'Режим на маршрутизиране'; String get chat_routingMode => 'Режим на маршрутизиране';
@@ -1240,6 +1262,12 @@ class AppLocalizationsBg extends AppLocalizations {
@override @override
String get map_title => 'Карта на възлите'; String get map_title => 'Карта на възлите';
@override
String get map_lineOfSight => 'Линия на видимост';
@override
String get map_losScreenTitle => 'Линия на видимост';
@override @override
String get map_noNodesWithLocation => 'Няма възли с данни за местоположение.'; String get map_noNodesWithLocation => 'Няма възли с данни за местоположение.';
@@ -1677,10 +1705,10 @@ class AppLocalizationsBg extends AppLocalizations {
String get repeater_cliSubtitle => 'Изпрати команди към ретранслатора'; String get repeater_cliSubtitle => 'Изпрати команди към ретранслатора';
@override @override
String get repeater_neighbours => 'Съседи'; String get repeater_neighbors => 'Съседи';
@override @override
String get repeater_neighboursSubtitle => String get repeater_neighborsSubtitle =>
'Преглед на съседни възли с нулев скок.'; 'Преглед на съседни възли с нулев скок.';
@override @override
@@ -2380,7 +2408,7 @@ class AppLocalizationsBg extends AppLocalizations {
} }
@override @override
String get neighbors_repeatersNeighbours => 'Повторители Съседи'; String get neighbors_repeatersNeighbors => 'Повторители Съседи';
@override @override
String get neighbors_noData => 'Няма налични данни за съседи.'; String get neighbors_noData => 'Няма налични данни за съседи.';
@@ -2721,6 +2749,116 @@ class AppLocalizationsBg extends AppLocalizations {
@override @override
String get pathTrace_clearTooltip => 'Изчисти пътя'; String get pathTrace_clearTooltip => 'Изчисти пътя';
@override
String get losSelectStartEnd => 'Изберете начални и крайни възли за LOS.';
@override
String losRunFailed(String error) {
return 'Проверката на пряката видимост е неуспешна: $error';
}
@override
String get losClearAllPoints => 'Изчистете всички точки';
@override
String get losRunToViewElevationProfile =>
'Стартирайте LOS, за да видите профила на надморската височина';
@override
String get losMenuTitle => 'LOS меню';
@override
String get losMenuSubtitle =>
'Докоснете възли или натиснете продължително карта за персонализирани точки';
@override
String get losShowDisplayNodes => 'Показване на възли на дисплея';
@override
String get losCustomPoints => 'Персонализирани точки';
@override
String losCustomPointLabel(int index) {
return 'Персонализирано $index';
}
@override
String get losPointA => 'Точка А';
@override
String get losPointB => 'Точка Б';
@override
String losAntennaA(String value, String unit) {
return 'Антена A: $value $unit';
}
@override
String losAntennaB(String value, String unit) {
return 'Антена B: $value $unit';
}
@override
String get losRun => 'Стартирайте LOS';
@override
String get losNoElevationData => 'Няма данни за надморска височина';
@override
String losProfileClear(
String distance,
String distanceUnit,
String clearance,
String heightUnit,
) {
return '$distance $distanceUnit, чист LOS, минимално разстояние $clearance $heightUnit';
}
@override
String losProfileBlocked(
String distance,
String distanceUnit,
String obstruction,
String heightUnit,
) {
return '$distance $distanceUnit, блокиран от $obstruction $heightUnit';
}
@override
String get losStatusChecking => 'LOS: проверка...';
@override
String get losStatusNoData => 'LOS: няма данни';
@override
String losStatusSummary(int clear, int total, int blocked, int unknown) {
return 'LOS: $clear/$total ясно, $blocked блокирано, $unknown неизвестно';
}
@override
String get losErrorElevationUnavailable =>
'Няма налични данни за надморска височина за една или повече проби.';
@override
String get losErrorInvalidInput =>
'Невалидни данни за точки/надморска височина за изчисляване на LOS.';
@override
String get losRenameCustomPoint => 'Преименувайте персонализирана точка';
@override
String get losPointName => 'Име на точката';
@override
String get losShowPanelTooltip => 'Показване на LOS панел';
@override
String get losHidePanelTooltip => 'Скриване на LOS панела';
@override
String get losElevationAttribution =>
'Данни за надморска височина: Open-Meteo (CC BY 4.0)';
@override @override
String get contacts_pathTrace => 'Пътен проследяване'; String get contacts_pathTrace => 'Пътен проследяване';
@@ -2890,4 +3028,10 @@ class AppLocalizationsBg extends AppLocalizations {
@override @override
String get settings_gpxExportShareSubject => String get settings_gpxExportShareSubject =>
'meshcore-open износ на данни за карта в формат GPX'; 'meshcore-open износ на данни за карта в формат GPX';
@override
String get snrIndicator_nearByRepeaters => 'Близки повтарящи се устройства';
@override
String get snrIndicator_lastSeen => 'Последно видян';
} }
+148 -3
View File
@@ -320,6 +320,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get settings_aboutDescription => String get settings_aboutDescription =>
'Ein Open-Source-Flutter-Client für MeshCore LoRa-Meshnetzwerkgeräte.'; 'Ein Open-Source-Flutter-Client für MeshCore LoRa-Meshnetzwerkgeräte.';
@override
String get settings_aboutOpenMeteoAttribution =>
'LOS-Höhendaten: Open-Meteo (CC BY 4.0)';
@override @override
String get settings_infoName => 'Name'; String get settings_infoName => 'Name';
@@ -619,6 +623,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get appSettings_offlineMapCache => 'Offline-Karten-Cache'; String get appSettings_offlineMapCache => 'Offline-Karten-Cache';
@override
String get appSettings_unitsTitle => 'Einheiten';
@override
String get appSettings_unitsMetric => 'Metrisch (m/km)';
@override
String get appSettings_unitsImperial => 'Imperial (ft/mi)';
@override @override
String get appSettings_noAreaSelected => 'Kein Bereich ausgewählt'; String get appSettings_noAreaSelected => 'Kein Bereich ausgewählt';
@@ -782,6 +795,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get channels_editChannel => 'Kanal bearbeiten'; String get channels_editChannel => 'Kanal bearbeiten';
@override
String get channels_muteChannel => 'Kanal stummschalten';
@override
String get channels_unmuteChannel => 'Kanal Stummschaltung aufheben';
@override @override
String get channels_deleteChannel => 'Lösche den Kanal'; String get channels_deleteChannel => 'Lösche den Kanal';
@@ -1080,6 +1099,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get chat_pathManagement => 'Pfadverwaltung'; String get chat_pathManagement => 'Pfadverwaltung';
@override
String get chat_ShowAllPaths => 'Alle Pfade anzeigen';
@override @override
String get chat_routingMode => 'Routenmodus'; String get chat_routingMode => 'Routenmodus';
@@ -1239,6 +1261,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get map_title => 'Karte'; String get map_title => 'Karte';
@override
String get map_lineOfSight => 'Sichtlinie';
@override
String get map_losScreenTitle => 'Sichtlinie';
@override @override
String get map_noNodesWithLocation => 'Keine Knoten mit Standortdaten'; String get map_noNodesWithLocation => 'Keine Knoten mit Standortdaten';
@@ -1676,10 +1704,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get repeater_cliSubtitle => 'Sende Befehle an den Repeater'; String get repeater_cliSubtitle => 'Sende Befehle an den Repeater';
@override @override
String get repeater_neighbours => 'Nachbarn'; String get repeater_neighbors => 'Nachbarn';
@override @override
String get repeater_neighboursSubtitle => 'Anzahl der Hop-Nachbarn anzeigen.'; String get repeater_neighborsSubtitle => 'Anzahl der Hop-Nachbarn anzeigen.';
@override @override
String get repeater_settings => 'Einstellungen'; String get repeater_settings => 'Einstellungen';
@@ -2382,7 +2410,7 @@ class AppLocalizationsDe extends AppLocalizations {
} }
@override @override
String get neighbors_repeatersNeighbours => 'Nachbarn'; String get neighbors_repeatersNeighbors => 'Nachbarn';
@override @override
String get neighbors_noData => 'Keine Nachbarsdaten verfügbar.'; String get neighbors_noData => 'Keine Nachbarsdaten verfügbar.';
@@ -2726,6 +2754,117 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get pathTrace_clearTooltip => 'Pfad löschen'; String get pathTrace_clearTooltip => 'Pfad löschen';
@override
String get losSelectStartEnd =>
'Wählen Sie Start- und Endknoten für LOS aus.';
@override
String losRunFailed(String error) {
return 'Sichtlinienprüfung fehlgeschlagen: $error';
}
@override
String get losClearAllPoints => 'Löschen Sie alle Punkte';
@override
String get losRunToViewElevationProfile =>
'Führen Sie LOS aus, um das Höhenprofil anzuzeigen';
@override
String get losMenuTitle => 'LOS-Menü';
@override
String get losMenuSubtitle =>
'Tippen Sie auf Knoten oder drücken Sie lange auf die Karte, um benutzerdefinierte Punkte anzuzeigen';
@override
String get losShowDisplayNodes => 'Anzeigeknoten anzeigen';
@override
String get losCustomPoints => 'Benutzerdefinierte Punkte';
@override
String losCustomPointLabel(int index) {
return 'Benutzerdefiniert $index';
}
@override
String get losPointA => 'Punkt A';
@override
String get losPointB => 'Punkt B';
@override
String losAntennaA(String value, String unit) {
return 'Antenne A: $value $unit';
}
@override
String losAntennaB(String value, String unit) {
return 'Antenne B: $value $unit';
}
@override
String get losRun => 'Führen Sie LOS aus';
@override
String get losNoElevationData => 'Keine Höhendaten';
@override
String losProfileClear(
String distance,
String distanceUnit,
String clearance,
String heightUnit,
) {
return '$distance $distanceUnit, freie Sichtlinie, Mindestabstand $clearance $heightUnit';
}
@override
String losProfileBlocked(
String distance,
String distanceUnit,
String obstruction,
String heightUnit,
) {
return '$distance $distanceUnit, blockiert durch $obstruction $heightUnit';
}
@override
String get losStatusChecking => 'LOS: Überprüfen...';
@override
String get losStatusNoData => 'LOS: keine Daten';
@override
String losStatusSummary(int clear, int total, int blocked, int unknown) {
return 'Sichtlinie: $clear/$total frei, $blocked blockiert, $unknown unbekannt';
}
@override
String get losErrorElevationUnavailable =>
'Für eine oder mehrere Proben sind keine Höhendaten verfügbar.';
@override
String get losErrorInvalidInput =>
'Ungültige Punkte/Höhendaten für die LOS-Berechnung.';
@override
String get losRenameCustomPoint =>
'Benennen Sie den benutzerdefinierten Punkt um';
@override
String get losPointName => 'Punktname';
@override
String get losShowPanelTooltip => 'LOS-Panel anzeigen';
@override
String get losHidePanelTooltip => 'LOS-Panel ausblenden';
@override
String get losElevationAttribution => 'Höhendaten: Open-Meteo (CC BY 4.0)';
@override @override
String get contacts_pathTrace => 'Pfadverfolgung'; String get contacts_pathTrace => 'Pfadverfolgung';
@@ -2898,4 +3037,10 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get settings_gpxExportShareSubject => String get settings_gpxExportShareSubject =>
'GPX-Kartendaten aus meshcore-open exportieren'; 'GPX-Kartendaten aus meshcore-open exportieren';
@override
String get snrIndicator_nearByRepeaters => 'In der Nähe befindliche Repeater';
@override
String get snrIndicator_lastSeen => 'Zuletzt gesehen';
} }
+149 -6
View File
@@ -318,6 +318,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get settings_aboutDescription => String get settings_aboutDescription =>
'An open-source Flutter client for MeshCore LoRa mesh networking devices.'; 'An open-source Flutter client for MeshCore LoRa mesh networking devices.';
@override
String get settings_aboutOpenMeteoAttribution =>
'LOS elevation data: Open-Meteo (CC BY 4.0)';
@override @override
String get settings_infoName => 'Name'; String get settings_infoName => 'Name';
@@ -614,6 +618,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get appSettings_offlineMapCache => 'Offline Map Cache'; String get appSettings_offlineMapCache => 'Offline Map Cache';
@override
String get appSettings_unitsTitle => 'Units';
@override
String get appSettings_unitsMetric => 'Metric (m / km)';
@override
String get appSettings_unitsImperial => 'Imperial (ft / mi)';
@override @override
String get appSettings_noAreaSelected => 'No area selected'; String get appSettings_noAreaSelected => 'No area selected';
@@ -774,6 +787,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get channels_editChannel => 'Edit channel'; String get channels_editChannel => 'Edit channel';
@override
String get channels_muteChannel => 'Mute channel';
@override
String get channels_unmuteChannel => 'Unmute channel';
@override @override
String get channels_deleteChannel => 'Delete channel'; String get channels_deleteChannel => 'Delete channel';
@@ -1065,6 +1084,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get chat_pathManagement => 'Path Management'; String get chat_pathManagement => 'Path Management';
@override
String get chat_ShowAllPaths => 'Show all paths';
@override @override
String get chat_routingMode => 'Routing mode'; String get chat_routingMode => 'Routing mode';
@@ -1219,6 +1241,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get map_title => 'Node Map'; String get map_title => 'Node Map';
@override
String get map_lineOfSight => 'Line of Sight';
@override
String get map_losScreenTitle => 'Line of Sight';
@override @override
String get map_noNodesWithLocation => 'No nodes with location data'; String get map_noNodesWithLocation => 'No nodes with location data';
@@ -1650,10 +1678,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get repeater_cliSubtitle => 'Send commands to the repeater'; String get repeater_cliSubtitle => 'Send commands to the repeater';
@override @override
String get repeater_neighbours => 'Neighbors'; String get repeater_neighbors => 'Neighbors';
@override @override
String get repeater_neighboursSubtitle => 'View zero hop neighbors.'; String get repeater_neighborsSubtitle => 'View zero hop neighbors.';
@override @override
String get repeater_settings => 'Settings'; String get repeater_settings => 'Settings';
@@ -2329,10 +2357,10 @@ class AppLocalizationsEn extends AppLocalizations {
} }
@override @override
String get neighbors_receivedData => 'Received Neighbours Data'; String get neighbors_receivedData => 'Received Neighbors Data';
@override @override
String get neighbors_requestTimedOut => 'Neighbours request timed out.'; String get neighbors_requestTimedOut => 'Neighbors request timed out.';
@override @override
String neighbors_errorLoading(String error) { String neighbors_errorLoading(String error) {
@@ -2340,10 +2368,10 @@ class AppLocalizationsEn extends AppLocalizations {
} }
@override @override
String get neighbors_repeatersNeighbours => 'Repeaters Neighbours'; String get neighbors_repeatersNeighbors => 'Repeaters Neighbors';
@override @override
String get neighbors_noData => 'No neighbours data available.'; String get neighbors_noData => 'No neighbors data available.';
@override @override
String neighbors_unknownContact(String pubkey) { String neighbors_unknownContact(String pubkey) {
@@ -2680,6 +2708,115 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get pathTrace_clearTooltip => 'Clear path.'; String get pathTrace_clearTooltip => 'Clear path.';
@override
String get losSelectStartEnd => 'Select start and end nodes for LOS.';
@override
String losRunFailed(String error) {
return 'Line-of-sight check failed: $error';
}
@override
String get losClearAllPoints => 'Clear all points';
@override
String get losRunToViewElevationProfile =>
'Run LOS to view elevation profile';
@override
String get losMenuTitle => 'LOS Menu';
@override
String get losMenuSubtitle => 'Tap nodes or long-press map for custom points';
@override
String get losShowDisplayNodes => 'Show display nodes';
@override
String get losCustomPoints => 'Custom points';
@override
String losCustomPointLabel(int index) {
return 'Custom $index';
}
@override
String get losPointA => 'Point A';
@override
String get losPointB => 'Point B';
@override
String losAntennaA(String value, String unit) {
return 'Antenna A: $value $unit';
}
@override
String losAntennaB(String value, String unit) {
return 'Antenna B: $value $unit';
}
@override
String get losRun => 'Run LOS';
@override
String get losNoElevationData => 'No elevation data';
@override
String losProfileClear(
String distance,
String distanceUnit,
String clearance,
String heightUnit,
) {
return '$distance $distanceUnit, clear LOS, min clearance $clearance $heightUnit';
}
@override
String losProfileBlocked(
String distance,
String distanceUnit,
String obstruction,
String heightUnit,
) {
return '$distance $distanceUnit, blocked by $obstruction $heightUnit';
}
@override
String get losStatusChecking => 'LOS: checking...';
@override
String get losStatusNoData => 'LOS: no data';
@override
String losStatusSummary(int clear, int total, int blocked, int unknown) {
return 'LOS: $clear/$total clear, $blocked blocked, $unknown unknown';
}
@override
String get losErrorElevationUnavailable =>
'Elevation data unavailable for one or more samples.';
@override
String get losErrorInvalidInput =>
'Invalid points/elevation data for LOS calculation.';
@override
String get losRenameCustomPoint => 'Rename custom point';
@override
String get losPointName => 'Point name';
@override
String get losShowPanelTooltip => 'Show LOS panel';
@override
String get losHidePanelTooltip => 'Hide LOS panel';
@override
String get losElevationAttribution =>
'Elevation data: Open-Meteo (CC BY 4.0)';
@override @override
String get contacts_pathTrace => 'Path Trace'; String get contacts_pathTrace => 'Path Trace';
@@ -2845,4 +2982,10 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get settings_gpxExportShareSubject => String get settings_gpxExportShareSubject =>
'meshcore-open GPX map data export'; 'meshcore-open GPX map data export';
@override
String get snrIndicator_nearByRepeaters => 'Nearby Repeaters';
@override
String get snrIndicator_lastSeen => 'Last seen';
} }
+149 -3
View File
@@ -323,6 +323,10 @@ class AppLocalizationsEs extends AppLocalizations {
String get settings_aboutDescription => String get settings_aboutDescription =>
'Un cliente de código abierto de Flutter para dispositivos de red mesh LoRa de MeshCore.'; 'Un cliente de código abierto de Flutter para dispositivos de red mesh LoRa de MeshCore.';
@override
String get settings_aboutOpenMeteoAttribution =>
'Datos de elevación LOS: Open-Meteo (CC BY 4.0)';
@override @override
String get settings_infoName => 'Nombre'; String get settings_infoName => 'Nombre';
@@ -620,6 +624,15 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get appSettings_offlineMapCache => 'Caché de Mapa Offline'; String get appSettings_offlineMapCache => 'Caché de Mapa Offline';
@override
String get appSettings_unitsTitle => 'Unidades';
@override
String get appSettings_unitsMetric => 'Métrico (m/km)';
@override
String get appSettings_unitsImperial => 'Imperial (pies/millas)';
@override @override
String get appSettings_noAreaSelected => 'No se ha seleccionado ningún área'; String get appSettings_noAreaSelected => 'No se ha seleccionado ningún área';
@@ -783,6 +796,12 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get channels_editChannel => 'Editar canal'; String get channels_editChannel => 'Editar canal';
@override
String get channels_muteChannel => 'Silenciar canal';
@override
String get channels_unmuteChannel => 'Activar canal';
@override @override
String get channels_deleteChannel => 'Eliminar canal'; String get channels_deleteChannel => 'Eliminar canal';
@@ -1079,6 +1098,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get chat_pathManagement => 'Gestión de Rutas'; String get chat_pathManagement => 'Gestión de Rutas';
@override
String get chat_ShowAllPaths => 'Mostrar todos los caminos';
@override @override
String get chat_routingMode => 'Modo de enrutamiento'; String get chat_routingMode => 'Modo de enrutamiento';
@@ -1237,6 +1259,12 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get map_title => 'Mapa de Nodos'; String get map_title => 'Mapa de Nodos';
@override
String get map_lineOfSight => 'Línea de visión';
@override
String get map_losScreenTitle => 'Línea de visión';
@override @override
String get map_noNodesWithLocation => 'No hay nodos con datos de ubicación'; String get map_noNodesWithLocation => 'No hay nodos con datos de ubicación';
@@ -1674,10 +1702,10 @@ class AppLocalizationsEs extends AppLocalizations {
String get repeater_cliSubtitle => 'Enviar comandos al repetidor'; String get repeater_cliSubtitle => 'Enviar comandos al repetidor';
@override @override
String get repeater_neighbours => 'Vecinos'; String get repeater_neighbors => 'Vecinos';
@override @override
String get repeater_neighboursSubtitle => 'Ver vecinos de salto cero.'; String get repeater_neighborsSubtitle => 'Ver vecinos de salto cero.';
@override @override
String get repeater_settings => 'Configuración'; String get repeater_settings => 'Configuración';
@@ -2376,7 +2404,7 @@ class AppLocalizationsEs extends AppLocalizations {
} }
@override @override
String get neighbors_repeatersNeighbours => 'Repetidores Vecinos'; String get neighbors_repeatersNeighbors => 'Repetidores Vecinos';
@override @override
String get neighbors_noData => 'No hay datos de vecinos disponibles.'; String get neighbors_noData => 'No hay datos de vecinos disponibles.';
@@ -2719,6 +2747,118 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get pathTrace_clearTooltip => 'Borrar ruta'; String get pathTrace_clearTooltip => 'Borrar ruta';
@override
String get losSelectStartEnd =>
'Seleccione los nodos de inicio y fin para LOS.';
@override
String losRunFailed(String error) {
return 'Error en la comprobación de la línea de visión: $error';
}
@override
String get losClearAllPoints => 'Borrar todos los puntos';
@override
String get losRunToViewElevationProfile =>
'Ejecute LOS para ver el perfil de elevación';
@override
String get losMenuTitle => 'Menú LOS';
@override
String get losMenuSubtitle =>
'Toque nodos o mantenga presionado el mapa para puntos personalizados';
@override
String get losShowDisplayNodes => 'Mostrar nodos de visualización';
@override
String get losCustomPoints => 'Puntos personalizados';
@override
String losCustomPointLabel(int index) {
return 'Personalizado $index';
}
@override
String get losPointA => 'Punto A';
@override
String get losPointB => 'Punto B';
@override
String losAntennaA(String value, String unit) {
return 'Antena A: $value $unit';
}
@override
String losAntennaB(String value, String unit) {
return 'Antena B: $value $unit';
}
@override
String get losRun => 'Ejecutar LOS';
@override
String get losNoElevationData => 'Sin datos de elevación';
@override
String losProfileClear(
String distance,
String distanceUnit,
String clearance,
String heightUnit,
) {
return '$distance $distanceUnit, despejar LOS, autorización mínima $clearance $heightUnit';
}
@override
String losProfileBlocked(
String distance,
String distanceUnit,
String obstruction,
String heightUnit,
) {
return '$distance $distanceUnit, bloqueado por $obstruction $heightUnit';
}
@override
String get losStatusChecking => 'LOS: comprobando...';
@override
String get losStatusNoData => 'LOS: sin datos';
@override
String losStatusSummary(int clear, int total, int blocked, int unknown) {
return 'LOS: $clear/$total claro, $blocked bloqueado, $unknown desconocido';
}
@override
String get losErrorElevationUnavailable =>
'Datos de elevación no disponibles para una o más muestras.';
@override
String get losErrorInvalidInput =>
'Datos de puntos/elevación no válidos para el cálculo de LOS.';
@override
String get losRenameCustomPoint =>
'Cambiar el nombre del punto personalizado';
@override
String get losPointName => 'Nombre del punto';
@override
String get losShowPanelTooltip => 'Mostrar panel LOS';
@override
String get losHidePanelTooltip => 'Ocultar panel LOS';
@override
String get losElevationAttribution =>
'Datos de elevación: Open-Meteo (CC BY 4.0)';
@override @override
String get contacts_pathTrace => 'Rastreo de caminos'; String get contacts_pathTrace => 'Rastreo de caminos';
@@ -2889,4 +3029,10 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get settings_gpxExportShareSubject => String get settings_gpxExportShareSubject =>
'meshcore-open exportación de datos de mapa GPX'; 'meshcore-open exportación de datos de mapa GPX';
@override
String get snrIndicator_nearByRepeaters => 'Repetidores cercanos';
@override
String get snrIndicator_lastSeen => 'Visto por última vez';
} }
+151 -7
View File
@@ -324,6 +324,10 @@ class AppLocalizationsFr extends AppLocalizations {
String get settings_aboutDescription => String get settings_aboutDescription =>
'Un client Flutter open source pour les appareils de réseau mesh MeshCore LoRa.'; 'Un client Flutter open source pour les appareils de réseau mesh MeshCore LoRa.';
@override
String get settings_aboutOpenMeteoAttribution =>
'Données d\'élévation LOS : Open-Meteo (CC BY 4.0)';
@override @override
String get settings_infoName => 'Nom'; String get settings_infoName => 'Nom';
@@ -622,6 +626,15 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get appSettings_offlineMapCache => 'Cache de Carte Hors Ligne'; String get appSettings_offlineMapCache => 'Cache de Carte Hors Ligne';
@override
String get appSettings_unitsTitle => 'Unités';
@override
String get appSettings_unitsMetric => 'Métrique (m/km)';
@override
String get appSettings_unitsImperial => 'Impérial (ft / mi)';
@override @override
String get appSettings_noAreaSelected => 'Aucune zone sélectionnée'; String get appSettings_noAreaSelected => 'Aucune zone sélectionnée';
@@ -683,7 +696,7 @@ class AppLocalizationsFr extends AppLocalizations {
String get contacts_manageRoom => 'Gérer le Room Server'; String get contacts_manageRoom => 'Gérer le Room Server';
@override @override
String get contacts_roomLogin => 'Connexion Salle'; String get contacts_roomLogin => 'Connexion Room Server';
@override @override
String get contacts_openChat => 'Ouverture du Chat'; String get contacts_openChat => 'Ouverture du Chat';
@@ -785,6 +798,12 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get channels_editChannel => 'Modifier le canal'; String get channels_editChannel => 'Modifier le canal';
@override
String get channels_muteChannel => 'Désactiver les notifications du canal';
@override
String get channels_unmuteChannel => 'Réactiver les notifications du canal';
@override @override
String get channels_deleteChannel => 'Supprimer le canal'; String get channels_deleteChannel => 'Supprimer le canal';
@@ -1082,6 +1101,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get chat_pathManagement => 'Gestion des chemins'; String get chat_pathManagement => 'Gestion des chemins';
@override
String get chat_ShowAllPaths => 'Afficher tous les chemins';
@override @override
String get chat_routingMode => 'Mode de routage'; String get chat_routingMode => 'Mode de routage';
@@ -1243,6 +1265,12 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get map_title => 'Carte des nœuds'; String get map_title => 'Carte des nœuds';
@override
String get map_lineOfSight => 'Ligne de vue';
@override
String get map_losScreenTitle => 'Ligne de vue';
@override @override
String get map_noNodesWithLocation => String get map_noNodesWithLocation =>
'Aucun nœud avec des données de localisation'; 'Aucun nœud avec des données de localisation';
@@ -1531,7 +1559,7 @@ class AppLocalizationsFr extends AppLocalizations {
String get login_repeaterLogin => 'Connexion au répéteur'; String get login_repeaterLogin => 'Connexion au répéteur';
@override @override
String get login_roomLogin => 'Connexion Salle'; String get login_roomLogin => 'Connexion Room Server';
@override @override
String get login_password => 'Mot de passe'; String get login_password => 'Mot de passe';
@@ -1656,7 +1684,7 @@ class AppLocalizationsFr extends AppLocalizations {
String get repeater_management => 'Gestion des répéteurs'; String get repeater_management => 'Gestion des répéteurs';
@override @override
String get room_management => 'Administración del Servidor de Habitación'; String get room_management => 'Administrattion Room Server';
@override @override
String get repeater_managementTools => 'Outils de Gestion'; String get repeater_managementTools => 'Outils de Gestion';
@@ -1682,11 +1710,10 @@ class AppLocalizationsFr extends AppLocalizations {
String get repeater_cliSubtitle => 'Envoyer des commandes au répéteur'; String get repeater_cliSubtitle => 'Envoyer des commandes au répéteur';
@override @override
String get repeater_neighbours => 'Voisins'; String get repeater_neighbors => 'Voisins';
@override @override
String get repeater_neighboursSubtitle => String get repeater_neighborsSubtitle => 'Afficher les voisins de saut nuls.';
'Afficher les voisins de saut nuls.';
@override @override
String get repeater_settings => 'Paramètres'; String get repeater_settings => 'Paramètres';
@@ -2391,7 +2418,7 @@ class AppLocalizationsFr extends AppLocalizations {
} }
@override @override
String get neighbors_repeatersNeighbours => 'Répéteurs Voisins'; String get neighbors_repeatersNeighbors => 'Répéteurs Voisins';
@override @override
String get neighbors_noData => String get neighbors_noData =>
@@ -2736,6 +2763,117 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get pathTrace_clearTooltip => 'Effacer le chemin'; String get pathTrace_clearTooltip => 'Effacer le chemin';
@override
String get losSelectStartEnd =>
'Sélectionnez les nœuds de début et de fin pour LOS.';
@override
String losRunFailed(String error) {
return 'Échec de la vérification de la ligne de vue : $error';
}
@override
String get losClearAllPoints => 'Effacer tous les points';
@override
String get losRunToViewElevationProfile =>
'Exécutez LOS pour afficher le profil d\'altitude';
@override
String get losMenuTitle => 'Menu LOS';
@override
String get losMenuSubtitle =>
'Appuyez sur les nœuds ou appuyez longuement sur la carte pour des points personnalisés';
@override
String get losShowDisplayNodes => 'Afficher les nœuds d\'affichage';
@override
String get losCustomPoints => 'Points personnalisés';
@override
String losCustomPointLabel(int index) {
return 'Personnalisé $index';
}
@override
String get losPointA => 'Point A';
@override
String get losPointB => 'Point B';
@override
String losAntennaA(String value, String unit) {
return 'Antenne A : $value $unit';
}
@override
String losAntennaB(String value, String unit) {
return 'Antenne B : $value $unit';
}
@override
String get losRun => 'Exécuter la LOS';
@override
String get losNoElevationData => 'Aucune donnée d\'altitude';
@override
String losProfileClear(
String distance,
String distanceUnit,
String clearance,
String heightUnit,
) {
return '$distance $distanceUnit, LOS clair, clairance minimale $clearance $heightUnit';
}
@override
String losProfileBlocked(
String distance,
String distanceUnit,
String obstruction,
String heightUnit,
) {
return '$distance $distanceUnit, bloqué par $obstruction $heightUnit';
}
@override
String get losStatusChecking => 'LOS : vérification...';
@override
String get losStatusNoData => 'LOS : aucune donnée';
@override
String losStatusSummary(int clear, int total, int blocked, int unknown) {
return 'LOS : $clear/$total clair, $blocked bloqué, $unknown inconnu';
}
@override
String get losErrorElevationUnavailable =>
'Données d\'altitude indisponibles pour un ou plusieurs échantillons.';
@override
String get losErrorInvalidInput =>
'Données de points/d\'altitude non valides pour le calcul de la LOS.';
@override
String get losRenameCustomPoint => 'Renommer le point personnalisé';
@override
String get losPointName => 'Nom du point';
@override
String get losShowPanelTooltip => 'Afficher le panneau LOS';
@override
String get losHidePanelTooltip => 'Masquer le panneau LOS';
@override
String get losElevationAttribution =>
'Données d\'altitude : Open-Meteo (CC BY 4.0)';
@override @override
String get contacts_pathTrace => 'Traçage de chemin'; String get contacts_pathTrace => 'Traçage de chemin';
@@ -2913,4 +3051,10 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get settings_gpxExportShareSubject => String get settings_gpxExportShareSubject =>
'meshcore-open exporter les données de carte GPX'; 'meshcore-open exporter les données de carte GPX';
@override
String get snrIndicator_nearByRepeaters => 'Répéteurs à proximité';
@override
String get snrIndicator_lastSeen => 'Dernière fois vu';
} }
+148 -3
View File
@@ -322,6 +322,10 @@ class AppLocalizationsIt extends AppLocalizations {
String get settings_aboutDescription => String get settings_aboutDescription =>
'Un client Flutter open-source per i dispositivi di rete mesh LoRa Core di MeshCore.'; 'Un client Flutter open-source per i dispositivi di rete mesh LoRa Core di MeshCore.';
@override
String get settings_aboutOpenMeteoAttribution =>
'Dati di elevazione LOS: Open-Meteo (CC BY 4.0)';
@override @override
String get settings_infoName => 'Nome'; String get settings_infoName => 'Nome';
@@ -619,6 +623,15 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get appSettings_offlineMapCache => 'Cache Mappa Offline'; String get appSettings_offlineMapCache => 'Cache Mappa Offline';
@override
String get appSettings_unitsTitle => 'Unità';
@override
String get appSettings_unitsMetric => 'Metrico (m/km)';
@override
String get appSettings_unitsImperial => 'Imperiale (ft / mi)';
@override @override
String get appSettings_noAreaSelected => 'Nessun\'area selezionata'; String get appSettings_noAreaSelected => 'Nessun\'area selezionata';
@@ -781,6 +794,12 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get channels_editChannel => 'Modifica canale'; String get channels_editChannel => 'Modifica canale';
@override
String get channels_muteChannel => 'Silenzia canale';
@override
String get channels_unmuteChannel => 'Attiva notifiche canale';
@override @override
String get channels_deleteChannel => 'Elimina canale'; String get channels_deleteChannel => 'Elimina canale';
@@ -1077,6 +1096,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get chat_pathManagement => 'Gestione Percorsi'; String get chat_pathManagement => 'Gestione Percorsi';
@override
String get chat_ShowAllPaths => 'Mostra tutti i percorsi';
@override @override
String get chat_routingMode => 'Modalità di routing'; String get chat_routingMode => 'Modalità di routing';
@@ -1236,6 +1258,12 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get map_title => 'Mappa Nodi'; String get map_title => 'Mappa Nodi';
@override
String get map_lineOfSight => 'Linea di vista';
@override
String get map_losScreenTitle => 'Linea di vista';
@override @override
String get map_noNodesWithLocation => 'Nessun nodo con dati di posizione'; String get map_noNodesWithLocation => 'Nessun nodo con dati di posizione';
@@ -1672,10 +1700,10 @@ class AppLocalizationsIt extends AppLocalizations {
String get repeater_cliSubtitle => 'Invia comandi al ripetitore'; String get repeater_cliSubtitle => 'Invia comandi al ripetitore';
@override @override
String get repeater_neighbours => 'Vicini'; String get repeater_neighbors => 'Vicini';
@override @override
String get repeater_neighboursSubtitle => String get repeater_neighborsSubtitle =>
'Visualizza vicini di salto pari a zero.'; 'Visualizza vicini di salto pari a zero.';
@override @override
@@ -2376,7 +2404,7 @@ class AppLocalizationsIt extends AppLocalizations {
} }
@override @override
String get neighbors_repeatersNeighbours => 'Ripetitori Vicini'; String get neighbors_repeatersNeighbors => 'Ripetitori Vicini';
@override @override
String get neighbors_noData => 'Nessun dato sugli vicini disponibile.'; String get neighbors_noData => 'Nessun dato sugli vicini disponibile.';
@@ -2720,6 +2748,117 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get pathTrace_clearTooltip => 'Pulisci percorso'; String get pathTrace_clearTooltip => 'Pulisci percorso';
@override
String get losSelectStartEnd =>
'Seleziona i nodi iniziali e finali per la LOS.';
@override
String losRunFailed(String error) {
return 'Controllo della linea di vista fallito: $error';
}
@override
String get losClearAllPoints => 'Cancella tutti i punti';
@override
String get losRunToViewElevationProfile =>
'Eseguire LOS per visualizzare il profilo altimetrico';
@override
String get losMenuTitle => 'Menù LOS';
@override
String get losMenuSubtitle =>
'Tocca i nodi o premi a lungo la mappa per punti personalizzati';
@override
String get losShowDisplayNodes => 'Mostra i nodi di visualizzazione';
@override
String get losCustomPoints => 'Punti personalizzati';
@override
String losCustomPointLabel(int index) {
return 'Personalizzato $index';
}
@override
String get losPointA => 'Punto A';
@override
String get losPointB => 'Punto B';
@override
String losAntennaA(String value, String unit) {
return 'Antenna A: $value $unit';
}
@override
String losAntennaB(String value, String unit) {
return 'Antenna B: $value $unit';
}
@override
String get losRun => 'Esegui LOS';
@override
String get losNoElevationData => 'Nessun dato di elevazione';
@override
String losProfileClear(
String distance,
String distanceUnit,
String clearance,
String heightUnit,
) {
return '$distance $distanceUnit, libera LOS, distanza minima $clearance $heightUnit';
}
@override
String losProfileBlocked(
String distance,
String distanceUnit,
String obstruction,
String heightUnit,
) {
return '$distance $distanceUnit, bloccato da $obstruction $heightUnit';
}
@override
String get losStatusChecking => 'LOS: controllo...';
@override
String get losStatusNoData => 'LOS: nessun dato';
@override
String losStatusSummary(int clear, int total, int blocked, int unknown) {
return 'LOS: $clear/$total libera, $blocked bloccato, $unknown sconosciuto';
}
@override
String get losErrorElevationUnavailable =>
'Dati di elevazione non disponibili per uno o più campioni.';
@override
String get losErrorInvalidInput =>
'Dati punti/elevazione non validi per il calcolo della LOS.';
@override
String get losRenameCustomPoint => 'Rinomina punto personalizzato';
@override
String get losPointName => 'Nome del punto';
@override
String get losShowPanelTooltip => 'Mostra il pannello LOS';
@override
String get losHidePanelTooltip => 'Nascondi il pannello LOS';
@override
String get losElevationAttribution =>
'Dati di elevazione: Open-Meteo (CC BY 4.0)';
@override @override
String get contacts_pathTrace => 'Traccia Percorso'; String get contacts_pathTrace => 'Traccia Percorso';
@@ -2893,4 +3032,10 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get settings_gpxExportShareSubject => String get settings_gpxExportShareSubject =>
'meshcore-open esportazione dati mappa GPX'; 'meshcore-open esportazione dati mappa GPX';
@override
String get snrIndicator_nearByRepeaters => 'Ripetitori vicini';
@override
String get snrIndicator_lastSeen => 'Ultimo accesso';
} }
+148 -3
View File
@@ -320,6 +320,10 @@ class AppLocalizationsNl extends AppLocalizations {
String get settings_aboutDescription => String get settings_aboutDescription =>
'Een open-source Flutter client voor MeshCore LoRa mesh netwerkapparaten.'; 'Een open-source Flutter client voor MeshCore LoRa mesh netwerkapparaten.';
@override
String get settings_aboutOpenMeteoAttribution =>
'LOS-hoogtegegevens: Open-Meteo (CC BY 4.0)';
@override @override
String get settings_infoName => 'Naam'; String get settings_infoName => 'Naam';
@@ -617,6 +621,15 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get appSettings_offlineMapCache => 'Offline Kaarten Cache'; String get appSettings_offlineMapCache => 'Offline Kaarten Cache';
@override
String get appSettings_unitsTitle => 'Eenheden';
@override
String get appSettings_unitsMetric => 'Metrisch (m / km)';
@override
String get appSettings_unitsImperial => 'Imperiaal (ft / mi)';
@override @override
String get appSettings_noAreaSelected => 'Geen gebied geselecteerd'; String get appSettings_noAreaSelected => 'Geen gebied geselecteerd';
@@ -779,6 +792,12 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get channels_editChannel => 'Kanaal bewerken'; String get channels_editChannel => 'Kanaal bewerken';
@override
String get channels_muteChannel => 'Kanaal dempen';
@override
String get channels_unmuteChannel => 'Kanaal dempen opheffen';
@override @override
String get channels_deleteChannel => 'Kanaal verwijderen'; String get channels_deleteChannel => 'Kanaal verwijderen';
@@ -1074,6 +1093,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get chat_pathManagement => 'Beheer van Paden'; String get chat_pathManagement => 'Beheer van Paden';
@override
String get chat_ShowAllPaths => 'Toon alle paden';
@override @override
String get chat_routingMode => 'Routeerwijze'; String get chat_routingMode => 'Routeerwijze';
@@ -1232,6 +1254,12 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get map_title => 'Node Map'; String get map_title => 'Node Map';
@override
String get map_lineOfSight => 'Zichtlijn';
@override
String get map_losScreenTitle => 'Zichtlijn';
@override @override
String get map_noNodesWithLocation => 'Geen nodes met locatiegegevens'; String get map_noNodesWithLocation => 'Geen nodes met locatiegegevens';
@@ -1668,10 +1696,10 @@ class AppLocalizationsNl extends AppLocalizations {
String get repeater_cliSubtitle => 'Verzend commando\'s naar de repeater'; String get repeater_cliSubtitle => 'Verzend commando\'s naar de repeater';
@override @override
String get repeater_neighbours => 'Buren'; String get repeater_neighbors => 'Buren';
@override @override
String get repeater_neighboursSubtitle => 'Bekijk nul hops buren.'; String get repeater_neighborsSubtitle => 'Bekijk nul hops buren.';
@override @override
String get repeater_settings => 'Instellingen'; String get repeater_settings => 'Instellingen';
@@ -2367,7 +2395,7 @@ class AppLocalizationsNl extends AppLocalizations {
} }
@override @override
String get neighbors_repeatersNeighbours => 'Herhalingen Buren'; String get neighbors_repeatersNeighbors => 'Herhalingen Buren';
@override @override
String get neighbors_noData => 'Geen gegevens van buren beschikbaar.'; String get neighbors_noData => 'Geen gegevens van buren beschikbaar.';
@@ -2711,6 +2739,117 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get pathTrace_clearTooltip => 'Weg wissen'; String get pathTrace_clearTooltip => 'Weg wissen';
@override
String get losSelectStartEnd =>
'Selecteer begin- en eindknooppunten voor LOS.';
@override
String losRunFailed(String error) {
return 'Zichtlijncontrole mislukt: $error';
}
@override
String get losClearAllPoints => 'Wis alle punten';
@override
String get losRunToViewElevationProfile =>
'Voer LOS uit om het hoogteprofiel te bekijken';
@override
String get losMenuTitle => 'LOS-menu';
@override
String get losMenuSubtitle =>
'Tik op knooppunten of druk lang op de kaart voor aangepaste punten';
@override
String get losShowDisplayNodes => 'Toon weergaveknooppunten';
@override
String get losCustomPoints => 'Aangepaste punten';
@override
String losCustomPointLabel(int index) {
return 'Aangepast $index';
}
@override
String get losPointA => 'Punt A';
@override
String get losPointB => 'Punt B';
@override
String losAntennaA(String value, String unit) {
return 'Antenne A: $value $unit';
}
@override
String losAntennaB(String value, String unit) {
return 'Antenne B: $value $unit';
}
@override
String get losRun => 'Voer LOS uit';
@override
String get losNoElevationData => 'Geen hoogtegegevens';
@override
String losProfileClear(
String distance,
String distanceUnit,
String clearance,
String heightUnit,
) {
return '$distance $distanceUnit, vrije LOS, min. vrije ruimte $clearance $heightUnit';
}
@override
String losProfileBlocked(
String distance,
String distanceUnit,
String obstruction,
String heightUnit,
) {
return '$distance $distanceUnit, geblokkeerd door $obstruction $heightUnit';
}
@override
String get losStatusChecking => 'LOS: controleren...';
@override
String get losStatusNoData => 'LOS: geen gegevens';
@override
String losStatusSummary(int clear, int total, int blocked, int unknown) {
return 'LOS: $clear/$total gewist, $blocked geblokkeerd, $unknown onbekend';
}
@override
String get losErrorElevationUnavailable =>
'Hoogtegegevens niet beschikbaar voor een of meer monsters.';
@override
String get losErrorInvalidInput =>
'Ongeldige punten/hoogtegegevens voor LOS-berekening.';
@override
String get losRenameCustomPoint => 'Hernoem aangepast punt';
@override
String get losPointName => 'Puntnaam';
@override
String get losShowPanelTooltip => 'Toon LOS-paneel';
@override
String get losHidePanelTooltip => 'LOS-paneel verbergen';
@override
String get losElevationAttribution =>
'Hoogtegegevens: Open-Meteo (CC BY 4.0)';
@override @override
String get contacts_pathTrace => 'Pad Traceren'; String get contacts_pathTrace => 'Pad Traceren';
@@ -2881,4 +3020,10 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get settings_gpxExportShareSubject => String get settings_gpxExportShareSubject =>
'meshcore-open GPX kaartgegevens exporteren'; 'meshcore-open GPX kaartgegevens exporteren';
@override
String get snrIndicator_nearByRepeaters => 'Nabije herhalingseenheden';
@override
String get snrIndicator_lastSeen => 'Laatst gezien';
} }
+147 -3
View File
@@ -323,6 +323,10 @@ class AppLocalizationsPl extends AppLocalizations {
String get settings_aboutDescription => String get settings_aboutDescription =>
'Otwarty kod źródłowy klient Flutter dla urządzeń do sieci mesh LoRa MeshCore.'; 'Otwarty kod źródłowy klient Flutter dla urządzeń do sieci mesh LoRa MeshCore.';
@override
String get settings_aboutOpenMeteoAttribution =>
'Dane wysokościowe LOS: Open-Meteo (CC BY 4.0)';
@override @override
String get settings_infoName => 'Imię'; String get settings_infoName => 'Imię';
@@ -621,6 +625,15 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get appSettings_offlineMapCache => 'Bufor Map Offline'; String get appSettings_offlineMapCache => 'Bufor Map Offline';
@override
String get appSettings_unitsTitle => 'Jednostki';
@override
String get appSettings_unitsMetric => 'Metryczne (m / km)';
@override
String get appSettings_unitsImperial => 'Imperialne (ft / mi)';
@override @override
String get appSettings_noAreaSelected => 'Nie zaznaczono żadnej powierzchni.'; String get appSettings_noAreaSelected => 'Nie zaznaczono żadnej powierzchni.';
@@ -784,6 +797,12 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get channels_editChannel => 'Edytuj kanał'; String get channels_editChannel => 'Edytuj kanał';
@override
String get channels_muteChannel => 'Wycisz kanał';
@override
String get channels_unmuteChannel => 'Wyłącz wyciszenie kanału';
@override @override
String get channels_deleteChannel => 'Usuń kanał'; String get channels_deleteChannel => 'Usuń kanał';
@@ -1079,6 +1098,9 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get chat_pathManagement => 'Zarządzanie ścieżkami'; String get chat_pathManagement => 'Zarządzanie ścieżkami';
@override
String get chat_ShowAllPaths => 'Pokaż wszystkie ścieżki';
@override @override
String get chat_routingMode => 'Tryb routingu'; String get chat_routingMode => 'Tryb routingu';
@@ -1238,6 +1260,12 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get map_title => 'Mapa węzłów'; String get map_title => 'Mapa węzłów';
@override
String get map_lineOfSight => 'Linia wzroku';
@override
String get map_losScreenTitle => 'Linia wzroku';
@override @override
String get map_noNodesWithLocation => 'Brak węzłów z danymi lokalizacyjnymi'; String get map_noNodesWithLocation => 'Brak węzłów z danymi lokalizacyjnymi';
@@ -1676,10 +1704,10 @@ class AppLocalizationsPl extends AppLocalizations {
String get repeater_cliSubtitle => 'Wyślij polecenia do powielacza'; String get repeater_cliSubtitle => 'Wyślij polecenia do powielacza';
@override @override
String get repeater_neighbours => 'Sąsiedzi'; String get repeater_neighbors => 'Sąsiedzi';
@override @override
String get repeater_neighboursSubtitle => String get repeater_neighborsSubtitle =>
'Wyświetl sąsiedztwo zerowych hopów.'; 'Wyświetl sąsiedztwo zerowych hopów.';
@override @override
@@ -2375,7 +2403,7 @@ class AppLocalizationsPl extends AppLocalizations {
} }
@override @override
String get neighbors_repeatersNeighbours => 'Powtarzacze Sąsiedzi'; String get neighbors_repeatersNeighbors => 'Powtarzacze Sąsiedzi';
@override @override
String get neighbors_noData => 'Brak danych dotyczących sąsiadów.'; String get neighbors_noData => 'Brak danych dotyczących sąsiadów.';
@@ -2718,6 +2746,116 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get pathTrace_clearTooltip => 'Wyczyść ścieżkę'; String get pathTrace_clearTooltip => 'Wyczyść ścieżkę';
@override
String get losSelectStartEnd => 'Wybierz węzły początkowe i końcowe dla LOS.';
@override
String losRunFailed(String error) {
return 'Sprawdzenie pola widzenia nie powiodło się: $error';
}
@override
String get losClearAllPoints => 'Wyczyść wszystkie punkty';
@override
String get losRunToViewElevationProfile =>
'Uruchom LOS, aby wyświetlić profil wysokości';
@override
String get losMenuTitle => 'Menu LOS';
@override
String get losMenuSubtitle =>
'Stuknij węzły lub naciśnij i przytrzymaj mapę, aby uzyskać niestandardowe punkty';
@override
String get losShowDisplayNodes => 'Pokaż węzły wyświetlające';
@override
String get losCustomPoints => 'Punkty niestandardowe';
@override
String losCustomPointLabel(int index) {
return 'Niestandardowe $index';
}
@override
String get losPointA => 'Punkt A';
@override
String get losPointB => 'Punkt B';
@override
String losAntennaA(String value, String unit) {
return 'Antena A: $value $unit';
}
@override
String losAntennaB(String value, String unit) {
return 'Antena B: $value $unit';
}
@override
String get losRun => 'Uruchom LOS-a';
@override
String get losNoElevationData => 'Brak danych o wysokości';
@override
String losProfileClear(
String distance,
String distanceUnit,
String clearance,
String heightUnit,
) {
return '$distance $distanceUnit, czysty LOS, minimalny prześwit $clearance $heightUnit';
}
@override
String losProfileBlocked(
String distance,
String distanceUnit,
String obstruction,
String heightUnit,
) {
return '$distance $distanceUnit, zablokowane przez $obstruction $heightUnit';
}
@override
String get losStatusChecking => 'LOS: sprawdzam...';
@override
String get losStatusNoData => 'LOS: brak danych';
@override
String losStatusSummary(int clear, int total, int blocked, int unknown) {
return 'LOS: $clear/$total jasne, $blocked zablokowane, $unknown nieznane';
}
@override
String get losErrorElevationUnavailable =>
'Dane dotyczące wysokości są niedostępne dla jednej lub większej liczby próbek.';
@override
String get losErrorInvalidInput =>
'Nieprawidłowe dane punktów/wysokości do obliczenia LOS.';
@override
String get losRenameCustomPoint => 'Zmień nazwę punktu niestandardowego';
@override
String get losPointName => 'Nazwa punktu';
@override
String get losShowPanelTooltip => 'Pokaż panel LOS';
@override
String get losHidePanelTooltip => 'Ukryj panel LOS';
@override
String get losElevationAttribution =>
'Dane dotyczące wysokości: Open-Meteo (CC BY 4.0)';
@override @override
String get contacts_pathTrace => 'Śledzenie Ścieżek'; String get contacts_pathTrace => 'Śledzenie Ścieżek';
@@ -2895,4 +3033,10 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get settings_gpxExportShareSubject => String get settings_gpxExportShareSubject =>
'Eksport danych mapy GPX meshcore-open'; 'Eksport danych mapy GPX meshcore-open';
@override
String get snrIndicator_nearByRepeaters => 'Nadajniki w pobliżu';
@override
String get snrIndicator_lastSeen => 'Ostatnio widziany';
} }
+147 -4
View File
@@ -324,6 +324,10 @@ class AppLocalizationsPt extends AppLocalizations {
String get settings_aboutDescription => String get settings_aboutDescription =>
'Um cliente Flutter de código aberto para dispositivos de rede mesh LoRa Core da MeshCore.'; 'Um cliente Flutter de código aberto para dispositivos de rede mesh LoRa Core da MeshCore.';
@override
String get settings_aboutOpenMeteoAttribution =>
'Dados de elevação LOS: Open-Meteo (CC BY 4.0)';
@override @override
String get settings_infoName => 'Nome'; String get settings_infoName => 'Nome';
@@ -620,6 +624,15 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get appSettings_offlineMapCache => 'Cache de Mapa Offline'; String get appSettings_offlineMapCache => 'Cache de Mapa Offline';
@override
String get appSettings_unitsTitle => 'Unidades';
@override
String get appSettings_unitsMetric => 'Métrico (m/km)';
@override
String get appSettings_unitsImperial => 'Imperial (ft/mi)';
@override @override
String get appSettings_noAreaSelected => 'Nenhuma área selecionada'; String get appSettings_noAreaSelected => 'Nenhuma área selecionada';
@@ -784,6 +797,12 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get channels_editChannel => 'Editar canal'; String get channels_editChannel => 'Editar canal';
@override
String get channels_muteChannel => 'Silenciar canal';
@override
String get channels_unmuteChannel => 'Ativar canal';
@override @override
String get channels_deleteChannel => 'Excluir canal'; String get channels_deleteChannel => 'Excluir canal';
@@ -1079,6 +1098,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get chat_pathManagement => 'Gerenciamento de Caminhos'; String get chat_pathManagement => 'Gerenciamento de Caminhos';
@override
String get chat_ShowAllPaths => 'Mostrar todos os caminhos';
@override @override
String get chat_routingMode => 'Modo de roteamento'; String get chat_routingMode => 'Modo de roteamento';
@@ -1237,6 +1259,12 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get map_title => 'Mapa de Nós'; String get map_title => 'Mapa de Nós';
@override
String get map_lineOfSight => 'Linha de visão';
@override
String get map_losScreenTitle => 'Linha de visão';
@override @override
String get map_noNodesWithLocation => String get map_noNodesWithLocation =>
'Não existem nós com dados de localização.'; 'Não existem nós com dados de localização.';
@@ -1674,11 +1702,10 @@ class AppLocalizationsPt extends AppLocalizations {
String get repeater_cliSubtitle => 'Enviar comandos ao repetidor'; String get repeater_cliSubtitle => 'Enviar comandos ao repetidor';
@override @override
String get repeater_neighbours => 'Vizinhos'; String get repeater_neighbors => 'Vizinhos';
@override @override
String get repeater_neighboursSubtitle => String get repeater_neighborsSubtitle => 'Visualizar vizinhos de salto zero.';
'Visualizar vizinhos de salto zero.';
@override @override
String get repeater_settings => 'Configurações'; String get repeater_settings => 'Configurações';
@@ -2377,7 +2404,7 @@ class AppLocalizationsPt extends AppLocalizations {
} }
@override @override
String get neighbors_repeatersNeighbours => 'Repetidores Vizinhos'; String get neighbors_repeatersNeighbors => 'Repetidores Vizinhos';
@override @override
String get neighbors_noData => 'Não estão disponíveis dados de vizinhos.'; String get neighbors_noData => 'Não estão disponíveis dados de vizinhos.';
@@ -2721,6 +2748,116 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get pathTrace_clearTooltip => 'Limpar caminho'; String get pathTrace_clearTooltip => 'Limpar caminho';
@override
String get losSelectStartEnd => 'Selecione nós iniciais e finais para LOS.';
@override
String losRunFailed(String error) {
return 'Falha na verificação da linha de visão: $error';
}
@override
String get losClearAllPoints => 'Limpe todos os pontos';
@override
String get losRunToViewElevationProfile =>
'Execute o LOS para visualizar o perfil de elevação';
@override
String get losMenuTitle => 'Menu LOS';
@override
String get losMenuSubtitle =>
'Toque nos nós ou mantenha pressionado o mapa para obter pontos personalizados';
@override
String get losShowDisplayNodes => 'Mostrar nós de exibição';
@override
String get losCustomPoints => 'Pontos personalizados';
@override
String losCustomPointLabel(int index) {
return '$index personalizado';
}
@override
String get losPointA => 'Ponto A';
@override
String get losPointB => 'Ponto B';
@override
String losAntennaA(String value, String unit) {
return 'Antena A: $value $unit';
}
@override
String losAntennaB(String value, String unit) {
return 'Antena B: $value $unit';
}
@override
String get losRun => 'Executar LOS';
@override
String get losNoElevationData => 'Sem dados de elevação';
@override
String losProfileClear(
String distance,
String distanceUnit,
String clearance,
String heightUnit,
) {
return '$distance $distanceUnit, limpar LOS, liberação mínima $clearance $heightUnit';
}
@override
String losProfileBlocked(
String distance,
String distanceUnit,
String obstruction,
String heightUnit,
) {
return '$distance $distanceUnit, bloqueado por $obstruction $heightUnit';
}
@override
String get losStatusChecking => 'LOS: verificando...';
@override
String get losStatusNoData => 'LOS: sem dados';
@override
String losStatusSummary(int clear, int total, int blocked, int unknown) {
return 'LOS: $clear/$total limpo, $blocked bloqueado, $unknown desconhecido';
}
@override
String get losErrorElevationUnavailable =>
'Dados de elevação indisponíveis para uma ou mais amostras.';
@override
String get losErrorInvalidInput =>
'Dados de pontos/elevação inválidos para cálculo de LOS.';
@override
String get losRenameCustomPoint => 'Renomear ponto personalizado';
@override
String get losPointName => 'Nome do ponto';
@override
String get losShowPanelTooltip => 'Mostrar painel LOS';
@override
String get losHidePanelTooltip => 'Ocultar painel LOS';
@override
String get losElevationAttribution =>
'Dados de elevação: Open-Meteo (CC BY 4.0)';
@override @override
String get contacts_pathTrace => 'Traçado de Caminho'; String get contacts_pathTrace => 'Traçado de Caminho';
@@ -2890,4 +3027,10 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get settings_gpxExportShareSubject => String get settings_gpxExportShareSubject =>
'meshcore-open exportação de dados de mapa GPX'; 'meshcore-open exportação de dados de mapa GPX';
@override
String get snrIndicator_nearByRepeaters => 'Repetidores Próximos';
@override
String get snrIndicator_lastSeen => 'Visto pela última vez';
} }
+147 -3
View File
@@ -321,6 +321,10 @@ class AppLocalizationsRu extends AppLocalizations {
String get settings_aboutDescription => String get settings_aboutDescription =>
'Открытое клиентское приложение на Flutter для устройств MeshCore с LoRa-сетями.'; 'Открытое клиентское приложение на Flutter для устройств MeshCore с LoRa-сетями.';
@override
String get settings_aboutOpenMeteoAttribution =>
'Данные о высоте LOS: Open-Meteo (CC BY 4.0)';
@override @override
String get settings_infoName => 'Имя'; String get settings_infoName => 'Имя';
@@ -620,6 +624,15 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get appSettings_offlineMapCache => 'Кэш офлайн-карты'; String get appSettings_offlineMapCache => 'Кэш офлайн-карты';
@override
String get appSettings_unitsTitle => 'Единицы';
@override
String get appSettings_unitsMetric => 'Метрическая (м/км)';
@override
String get appSettings_unitsImperial => 'Имперская (ft / mi)';
@override @override
String get appSettings_noAreaSelected => 'Область не выбрана'; String get appSettings_noAreaSelected => 'Область не выбрана';
@@ -782,6 +795,12 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get channels_editChannel => 'Изменить канал'; String get channels_editChannel => 'Изменить канал';
@override
String get channels_muteChannel => 'Отключить уведомления канала';
@override
String get channels_unmuteChannel => 'Включить уведомления канала';
@override @override
String get channels_deleteChannel => 'Удалить канал'; String get channels_deleteChannel => 'Удалить канал';
@@ -1077,6 +1096,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get chat_pathManagement => 'Управление маршрутами'; String get chat_pathManagement => 'Управление маршрутами';
@override
String get chat_ShowAllPaths => 'Показать все пути';
@override @override
String get chat_routingMode => 'Режим маршрутизации'; String get chat_routingMode => 'Режим маршрутизации';
@@ -1239,6 +1261,12 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get map_title => 'Карта нод'; String get map_title => 'Карта нод';
@override
String get map_lineOfSight => 'Линия видимости';
@override
String get map_losScreenTitle => 'Линия видимости';
@override @override
String get map_noNodesWithLocation => 'Нет нод с данными о местоположении'; String get map_noNodesWithLocation => 'Нет нод с данными о местоположении';
@@ -1676,10 +1704,10 @@ class AppLocalizationsRu extends AppLocalizations {
String get repeater_cliSubtitle => 'Отправка команд репитеру'; String get repeater_cliSubtitle => 'Отправка команд репитеру';
@override @override
String get repeater_neighbours => 'Соседи'; String get repeater_neighbors => 'Соседи';
@override @override
String get repeater_neighboursSubtitle => 'Просмотр соседей на нулевом хопе.'; String get repeater_neighborsSubtitle => 'Просмотр соседей на нулевом хопе.';
@override @override
String get repeater_settings => 'Настройки'; String get repeater_settings => 'Настройки';
@@ -2379,7 +2407,7 @@ class AppLocalizationsRu extends AppLocalizations {
} }
@override @override
String get neighbors_repeatersNeighbours => 'Соседи репитеров'; String get neighbors_repeatersNeighbors => 'Соседи репитеров';
@override @override
String get neighbors_noData => 'Данные о соседях недоступны.'; String get neighbors_noData => 'Данные о соседях недоступны.';
@@ -2723,6 +2751,116 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get pathTrace_clearTooltip => 'Очистить путь'; String get pathTrace_clearTooltip => 'Очистить путь';
@override
String get losSelectStartEnd => 'Выберите начальный и конечный узлы для LOS.';
@override
String losRunFailed(String error) {
return 'Проверка прямой видимости не удалась: $error';
}
@override
String get losClearAllPoints => 'Очистить все точки';
@override
String get losRunToViewElevationProfile =>
'Запустите LOS, чтобы просмотреть профиль высот.';
@override
String get losMenuTitle => 'ЛОС Меню';
@override
String get losMenuSubtitle =>
'Коснитесь узлов или нажмите и удерживайте карту для выбора пользовательских точек.';
@override
String get losShowDisplayNodes => 'Показать узлы отображения';
@override
String get losCustomPoints => 'Пользовательские точки';
@override
String losCustomPointLabel(int index) {
return 'Пользовательский $index';
}
@override
String get losPointA => 'Точка А';
@override
String get losPointB => 'Точка Б';
@override
String losAntennaA(String value, String unit) {
return 'Антенна А: $value $unit';
}
@override
String losAntennaB(String value, String unit) {
return 'Антенна Б: $value $unit';
}
@override
String get losRun => 'Запустить ЛОС';
@override
String get losNoElevationData => 'Нет данных о высоте';
@override
String losProfileClear(
String distance,
String distanceUnit,
String clearance,
String heightUnit,
) {
return '$distance $distanceUnit, свободная зона видимости, минимальный зазор $clearance $heightUnit';
}
@override
String losProfileBlocked(
String distance,
String distanceUnit,
String obstruction,
String heightUnit,
) {
return '$distance $distanceUnit, заблокирован $obstruction $heightUnit';
}
@override
String get losStatusChecking => 'ЛОС: проверяю...';
@override
String get losStatusNoData => 'ЛОС: нет данных';
@override
String losStatusSummary(int clear, int total, int blocked, int unknown) {
return 'LOS: $clear/$total очищено, $blocked заблокировано, $unknown неизвестно.';
}
@override
String get losErrorElevationUnavailable =>
'Данные о высоте недоступны для одного или нескольких образцов.';
@override
String get losErrorInvalidInput =>
'Неверные данные о точках/высоте для расчета LOS.';
@override
String get losRenameCustomPoint => 'Переименовать пользовательскую точку';
@override
String get losPointName => 'Имя точки';
@override
String get losShowPanelTooltip => 'Показать панель LOS';
@override
String get losHidePanelTooltip => 'Скрыть панель LOS';
@override
String get losElevationAttribution =>
'Данные о высоте: Open-Meteo (CC BY 4.0)';
@override @override
String get contacts_pathTrace => 'Трассировка пути'; String get contacts_pathTrace => 'Трассировка пути';
@@ -2901,4 +3039,10 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get settings_gpxExportShareSubject => String get settings_gpxExportShareSubject =>
'meshcore-open экспорт данных карты GPX'; 'meshcore-open экспорт данных карты GPX';
@override
String get snrIndicator_nearByRepeaters => 'Ближайшие ретрансляторы';
@override
String get snrIndicator_lastSeen => 'Последний раз видели';
} }
+147 -3
View File
@@ -320,6 +320,10 @@ class AppLocalizationsSk extends AppLocalizations {
String get settings_aboutDescription => String get settings_aboutDescription =>
'Otvorený zdrojový Flutter klient pre MeshCore LoRa sieťové zariadenia.'; 'Otvorený zdrojový Flutter klient pre MeshCore LoRa sieťové zariadenia.';
@override
String get settings_aboutOpenMeteoAttribution =>
'Údaje o nadmorskej výške LOS: Open-Meteo (CC BY 4.0)';
@override @override
String get settings_infoName => 'Meno'; String get settings_infoName => 'Meno';
@@ -614,6 +618,15 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get appSettings_offlineMapCache => 'Offline Mapa Pamäť'; String get appSettings_offlineMapCache => 'Offline Mapa Pamäť';
@override
String get appSettings_unitsTitle => 'Jednotky';
@override
String get appSettings_unitsMetric => 'Metrické (m / km)';
@override
String get appSettings_unitsImperial => 'Imperiálne (ft / mi)';
@override @override
String get appSettings_noAreaSelected => 'Neoznačila sa žiadna oblasť'; String get appSettings_noAreaSelected => 'Neoznačila sa žiadna oblasť';
@@ -779,6 +792,12 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get channels_editChannel => 'Upraviť kanál'; String get channels_editChannel => 'Upraviť kanál';
@override
String get channels_muteChannel => 'Stlmiť kanál';
@override
String get channels_unmuteChannel => 'Zrušiť stlmenie kanála';
@override @override
String get channels_deleteChannel => 'Odstrániť kanál'; String get channels_deleteChannel => 'Odstrániť kanál';
@@ -1074,6 +1093,9 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get chat_pathManagement => 'Správa ciest'; String get chat_pathManagement => 'Správa ciest';
@override
String get chat_ShowAllPaths => 'Zobraziť všetky cesty';
@override @override
String get chat_routingMode => 'Režim trasy'; String get chat_routingMode => 'Režim trasy';
@@ -1233,6 +1255,12 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get map_title => 'Mapa uzlov'; String get map_title => 'Mapa uzlov';
@override
String get map_lineOfSight => 'Line of Sight';
@override
String get map_losScreenTitle => 'Line of Sight';
@override @override
String get map_noNodesWithLocation => 'Žiadne uzly s údajmi o polohe'; String get map_noNodesWithLocation => 'Žiadne uzly s údajmi o polohe';
@@ -1669,10 +1697,10 @@ class AppLocalizationsSk extends AppLocalizations {
String get repeater_cliSubtitle => 'Pošlite príkazy opakovaču'; String get repeater_cliSubtitle => 'Pošlite príkazy opakovaču';
@override @override
String get repeater_neighbours => 'Súsezný'; String get repeater_neighbors => 'Súsezný';
@override @override
String get repeater_neighboursSubtitle => 'Zobraziť susedné body bez skokov.'; String get repeater_neighborsSubtitle => 'Zobraziť susedné body bez skokov.';
@override @override
String get repeater_settings => 'Nastavenia'; String get repeater_settings => 'Nastavenia';
@@ -2363,7 +2391,7 @@ class AppLocalizationsSk extends AppLocalizations {
} }
@override @override
String get neighbors_repeatersNeighbours => 'Opakovadlá Súsezná'; String get neighbors_repeatersNeighbors => 'Opakovadlá Súsezná';
@override @override
String get neighbors_noData => String get neighbors_noData =>
@@ -2706,6 +2734,116 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get pathTrace_clearTooltip => 'Zmazať cestu'; String get pathTrace_clearTooltip => 'Zmazať cestu';
@override
String get losSelectStartEnd => 'Vyberte počiatočný a koncový uzol pre LOS.';
@override
String losRunFailed(String error) {
return 'Kontrola priamej viditeľnosti zlyhala: $error';
}
@override
String get losClearAllPoints => 'Vymazať všetky body';
@override
String get losRunToViewElevationProfile =>
'Ak chcete zobraziť výškový profil, spustite LOS';
@override
String get losMenuTitle => 'Menu LOS';
@override
String get losMenuSubtitle =>
'Klepnutím na uzly alebo dlhým stlačením mapy získate vlastné body';
@override
String get losShowDisplayNodes => 'Zobraziť uzly zobrazenia';
@override
String get losCustomPoints => 'Vlastné body';
@override
String losCustomPointLabel(int index) {
return 'Vlastné $index';
}
@override
String get losPointA => 'Bod A';
@override
String get losPointB => 'Bod B';
@override
String losAntennaA(String value, String unit) {
return 'Anténa A: $value $unit';
}
@override
String losAntennaB(String value, String unit) {
return 'Anténa B: $value $unit';
}
@override
String get losRun => 'Spustite LOS';
@override
String get losNoElevationData => 'Žiadne údaje o nadmorskej výške';
@override
String losProfileClear(
String distance,
String distanceUnit,
String clearance,
String heightUnit,
) {
return '$distance $distanceUnit, vymazať LOS, min. vôľa $clearance $heightUnit';
}
@override
String losProfileBlocked(
String distance,
String distanceUnit,
String obstruction,
String heightUnit,
) {
return '$distance $distanceUnit, blokovaný $obstruction $heightUnit';
}
@override
String get losStatusChecking => 'LOS: kontrolujem...';
@override
String get losStatusNoData => 'LOS: žiadne údaje';
@override
String losStatusSummary(int clear, int total, int blocked, int unknown) {
return 'LOS: $clear/$total vymazané, $blocked blokované, $unknown neznáme';
}
@override
String get losErrorElevationUnavailable =>
'Údaje o nadmorskej výške nie sú k dispozícii pre jednu alebo viacero vzoriek.';
@override
String get losErrorInvalidInput =>
'Neplatné body/údaje o nadmorskej výške pre výpočet LOS.';
@override
String get losRenameCustomPoint => 'Premenovať vlastný bod';
@override
String get losPointName => 'Názov bodu';
@override
String get losShowPanelTooltip => 'Zobraziť panel LOS';
@override
String get losHidePanelTooltip => 'Skryť panel LOS';
@override
String get losElevationAttribution =>
'Údaje o nadmorskej výške: Open-Meteo (CC BY 4.0)';
@override @override
String get contacts_pathTrace => 'Sledovanie lúčov'; String get contacts_pathTrace => 'Sledovanie lúčov';
@@ -2877,4 +3015,10 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get settings_gpxExportShareSubject => String get settings_gpxExportShareSubject =>
'meshcore-open export dát GPX mapových údajov'; 'meshcore-open export dát GPX mapových údajov';
@override
String get snrIndicator_nearByRepeaters => 'Miestne opakovače';
@override
String get snrIndicator_lastSeen => 'Naposledy videný';
} }
+147 -3
View File
@@ -319,6 +319,10 @@ class AppLocalizationsSl extends AppLocalizations {
String get settings_aboutDescription => String get settings_aboutDescription =>
'Odprtokodni Flutter klient za naprave za LoRa omrežje MeshCore.'; 'Odprtokodni Flutter klient za naprave za LoRa omrežje MeshCore.';
@override
String get settings_aboutOpenMeteoAttribution =>
'Podatki o višini LOS: Open-Meteo (CC BY 4.0)';
@override @override
String get settings_infoName => 'Ime'; String get settings_infoName => 'Ime';
@@ -615,6 +619,15 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get appSettings_offlineMapCache => 'Shramba zemljevidov brez povezave'; String get appSettings_offlineMapCache => 'Shramba zemljevidov brez povezave';
@override
String get appSettings_unitsTitle => 'Enote';
@override
String get appSettings_unitsMetric => 'Metrična (m/km)';
@override
String get appSettings_unitsImperial => 'Imperialno (ft / mi)';
@override @override
String get appSettings_noAreaSelected => 'Območje ni izbrano'; String get appSettings_noAreaSelected => 'Območje ni izbrano';
@@ -777,6 +790,12 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get channels_editChannel => 'Uredi kanal'; String get channels_editChannel => 'Uredi kanal';
@override
String get channels_muteChannel => 'Utišaj kanal';
@override
String get channels_unmuteChannel => 'Vklopi obvestila kanala';
@override @override
String get channels_deleteChannel => 'Pošlji kanal'; String get channels_deleteChannel => 'Pošlji kanal';
@@ -1072,6 +1091,9 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get chat_pathManagement => 'Upravljanje poti'; String get chat_pathManagement => 'Upravljanje poti';
@override
String get chat_ShowAllPaths => 'Prikaži vse poti';
@override @override
String get chat_routingMode => 'Navodilo za usmerjevalni način'; String get chat_routingMode => 'Navodilo za usmerjevalni način';
@@ -1228,6 +1250,12 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get map_title => 'Mapa omrežja'; String get map_title => 'Mapa omrežja';
@override
String get map_lineOfSight => 'Linija vida';
@override
String get map_losScreenTitle => 'Linija vida';
@override @override
String get map_noNodesWithLocation => String get map_noNodesWithLocation =>
'Nihče od notranjih elementov nima podatkov o lokaciji.'; 'Nihče od notranjih elementov nima podatkov o lokaciji.';
@@ -1668,10 +1696,10 @@ class AppLocalizationsSl extends AppLocalizations {
'Pošlji ukazne povelje na ponovitveno enoto.'; 'Pošlji ukazne povelje na ponovitveno enoto.';
@override @override
String get repeater_neighbours => 'Sosedi'; String get repeater_neighbors => 'Sosedi';
@override @override
String get repeater_neighboursSubtitle => 'Pogledati nič sosednjih hopjev.'; String get repeater_neighborsSubtitle => 'Pogledati nič sosednjih hopjev.';
@override @override
String get repeater_settings => 'Nastavitve'; String get repeater_settings => 'Nastavitve';
@@ -2367,7 +2395,7 @@ class AppLocalizationsSl extends AppLocalizations {
} }
@override @override
String get neighbors_repeatersNeighbours => 'Ponovitve Sosedi'; String get neighbors_repeatersNeighbors => 'Ponovitve Sosedi';
@override @override
String get neighbors_noData => 'Niso na voljo podatki o sosedih.'; String get neighbors_noData => 'Niso na voljo podatki o sosedih.';
@@ -2709,6 +2737,116 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get pathTrace_clearTooltip => 'Počisti pot'; String get pathTrace_clearTooltip => 'Počisti pot';
@override
String get losSelectStartEnd => 'Izberite začetno in končno vozlišče za LOS.';
@override
String losRunFailed(String error) {
return 'Preverjanje vidnega polja ni uspelo: $error';
}
@override
String get losClearAllPoints => 'Počisti vse točke';
@override
String get losRunToViewElevationProfile =>
'Zaženite LOS za ogled višinskega profila';
@override
String get losMenuTitle => 'LOS meni';
@override
String get losMenuSubtitle =>
'Tapnite vozlišča ali dolgo pritisnite na zemljevid za točke po meri';
@override
String get losShowDisplayNodes => 'Pokaži prikazna vozlišča';
@override
String get losCustomPoints => 'Točke po meri';
@override
String losCustomPointLabel(int index) {
return 'Po meri $index';
}
@override
String get losPointA => 'Točka A';
@override
String get losPointB => 'Točka B';
@override
String losAntennaA(String value, String unit) {
return 'Antena A: $value $unit';
}
@override
String losAntennaB(String value, String unit) {
return 'Antena B: $value $unit';
}
@override
String get losRun => 'Zaženi LOS';
@override
String get losNoElevationData => 'Ni podatkov o višini';
@override
String losProfileClear(
String distance,
String distanceUnit,
String clearance,
String heightUnit,
) {
return '$distance $distanceUnit, čisti LOS, najmanjša razdalja $clearance $heightUnit';
}
@override
String losProfileBlocked(
String distance,
String distanceUnit,
String obstruction,
String heightUnit,
) {
return '$distance $distanceUnit, blokiral $obstruction $heightUnit';
}
@override
String get losStatusChecking => 'LOS: preverjam ...';
@override
String get losStatusNoData => 'LOS: ni podatkov';
@override
String losStatusSummary(int clear, int total, int blocked, int unknown) {
return 'LOS: $clear/$total jasno, $blocked blokirano, $unknown neznano';
}
@override
String get losErrorElevationUnavailable =>
'Podatki o nadmorski višini niso na voljo za enega ali več vzorcev.';
@override
String get losErrorInvalidInput =>
'Neveljavni podatki o točkah/višini za izračun LOS.';
@override
String get losRenameCustomPoint => 'Preimenujte točko po meri';
@override
String get losPointName => 'Ime točke';
@override
String get losShowPanelTooltip => 'Pokaži ploščo LOS';
@override
String get losHidePanelTooltip => 'Skrij ploščo LOS';
@override
String get losElevationAttribution =>
'Podatki o višini: Open-Meteo (CC BY 4.0)';
@override @override
String get contacts_pathTrace => 'Sledenje poti'; String get contacts_pathTrace => 'Sledenje poti';
@@ -2882,4 +3020,10 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get settings_gpxExportShareSubject => String get settings_gpxExportShareSubject =>
'meshcore-open izvoz podatkov GPX karte'; 'meshcore-open izvoz podatkov GPX karte';
@override
String get snrIndicator_nearByRepeaters => 'Bližnji ponovitelji';
@override
String get snrIndicator_lastSeen => 'Zadnjič videno';
} }
+145 -3
View File
@@ -317,6 +317,10 @@ class AppLocalizationsSv extends AppLocalizations {
String get settings_aboutDescription => String get settings_aboutDescription =>
'En öppen källkods Flutter-klient för MeshCore LoRa meshnätverksenheter.'; 'En öppen källkods Flutter-klient för MeshCore LoRa meshnätverksenheter.';
@override
String get settings_aboutOpenMeteoAttribution =>
'LOS-höjddata: Open-Meteo (CC BY 4.0)';
@override @override
String get settings_infoName => 'Namn'; String get settings_infoName => 'Namn';
@@ -610,6 +614,15 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get appSettings_offlineMapCache => 'Offline Kartcache'; String get appSettings_offlineMapCache => 'Offline Kartcache';
@override
String get appSettings_unitsTitle => 'Enheter';
@override
String get appSettings_unitsMetric => 'Metriskt (m/km)';
@override
String get appSettings_unitsImperial => 'Imperialt (ft / mi)';
@override @override
String get appSettings_noAreaSelected => 'Ingen area markerad'; String get appSettings_noAreaSelected => 'Ingen area markerad';
@@ -773,6 +786,12 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get channels_editChannel => 'Redigera kanal'; String get channels_editChannel => 'Redigera kanal';
@override
String get channels_muteChannel => 'Tysta kanal';
@override
String get channels_unmuteChannel => 'Slå på ljud för kanal';
@override @override
String get channels_deleteChannel => 'Ta bort kanal'; String get channels_deleteChannel => 'Ta bort kanal';
@@ -1069,6 +1088,9 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get chat_pathManagement => 'Stigarhantering'; String get chat_pathManagement => 'Stigarhantering';
@override
String get chat_ShowAllPaths => 'Visa alla vägar';
@override @override
String get chat_routingMode => 'Ruttläge'; String get chat_routingMode => 'Ruttläge';
@@ -1225,6 +1247,12 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get map_title => 'Nodkarta'; String get map_title => 'Nodkarta';
@override
String get map_lineOfSight => 'Synlinje';
@override
String get map_losScreenTitle => 'Synlinje';
@override @override
String get map_noNodesWithLocation => 'Inga noder med platsinformation'; String get map_noNodesWithLocation => 'Inga noder med platsinformation';
@@ -1658,10 +1686,10 @@ class AppLocalizationsSv extends AppLocalizations {
String get repeater_cliSubtitle => 'Skicka kommandon till repetitorn'; String get repeater_cliSubtitle => 'Skicka kommandon till repetitorn';
@override @override
String get repeater_neighbours => 'Grannar'; String get repeater_neighbors => 'Grannar';
@override @override
String get repeater_neighboursSubtitle => 'Visa noll hoppgrannar.'; String get repeater_neighborsSubtitle => 'Visa noll hoppgrannar.';
@override @override
String get repeater_settings => 'Inställningar'; String get repeater_settings => 'Inställningar';
@@ -2352,7 +2380,7 @@ class AppLocalizationsSv extends AppLocalizations {
} }
@override @override
String get neighbors_repeatersNeighbours => 'Upprepar grannar'; String get neighbors_repeatersNeighbors => 'Upprepar grannar';
@override @override
String get neighbors_noData => 'Inga grannuppgifter finns tillgängliga.'; String get neighbors_noData => 'Inga grannuppgifter finns tillgängliga.';
@@ -2694,6 +2722,114 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get pathTrace_clearTooltip => 'Rensa väg'; String get pathTrace_clearTooltip => 'Rensa väg';
@override
String get losSelectStartEnd => 'Välj start- och slutnoder för LOS.';
@override
String losRunFailed(String error) {
return 'Synlinjekontroll misslyckades: $error';
}
@override
String get losClearAllPoints => 'Rensa alla punkter';
@override
String get losRunToViewElevationProfile => 'Kör LOS för att se höjdprofil';
@override
String get losMenuTitle => 'LOS-menyn';
@override
String get losMenuSubtitle =>
'Tryck på noder eller tryck länge på kartan för anpassade punkter';
@override
String get losShowDisplayNodes => 'Visa displaynoder';
@override
String get losCustomPoints => 'Anpassade poäng';
@override
String losCustomPointLabel(int index) {
return 'Anpassad $index';
}
@override
String get losPointA => 'Punkt A';
@override
String get losPointB => 'Punkt B';
@override
String losAntennaA(String value, String unit) {
return 'Antenn A: $value $unit';
}
@override
String losAntennaB(String value, String unit) {
return 'Antenn B: $value $unit';
}
@override
String get losRun => 'Kör LOS';
@override
String get losNoElevationData => 'Inga höjddata';
@override
String losProfileClear(
String distance,
String distanceUnit,
String clearance,
String heightUnit,
) {
return '$distance $distanceUnit, rensa LOS, min clearance $clearance $heightUnit';
}
@override
String losProfileBlocked(
String distance,
String distanceUnit,
String obstruction,
String heightUnit,
) {
return '$distance $distanceUnit, blockerad av $obstruction $heightUnit';
}
@override
String get losStatusChecking => 'LOS: kollar...';
@override
String get losStatusNoData => 'LOS: inga data';
@override
String losStatusSummary(int clear, int total, int blocked, int unknown) {
return 'LOS: $clear/$total rensa, $blocked blockerad, $unknown okänd';
}
@override
String get losErrorElevationUnavailable =>
'Höjddata är inte tillgänglig för ett eller flera prover.';
@override
String get losErrorInvalidInput =>
'Ogiltiga poäng/höjddata för LOS-beräkning.';
@override
String get losRenameCustomPoint => 'Byt namn på anpassad punkt';
@override
String get losPointName => 'Punktnamn';
@override
String get losShowPanelTooltip => 'Visa LOS-panelen';
@override
String get losHidePanelTooltip => 'Dölj LOS-panelen';
@override
String get losElevationAttribution => 'Höjddata: Open-Meteo (CC BY 4.0)';
@override @override
String get contacts_pathTrace => 'Path Trace'; String get contacts_pathTrace => 'Path Trace';
@@ -2862,4 +2998,10 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get settings_gpxExportShareSubject => String get settings_gpxExportShareSubject =>
'meshcore-open export av GPX-kartdata'; 'meshcore-open export av GPX-kartdata';
@override
String get snrIndicator_nearByRepeaters => 'Närliggande uppreparstationer';
@override
String get snrIndicator_lastSeen => 'Senast sedd';
} }
+148 -3
View File
@@ -322,6 +322,10 @@ class AppLocalizationsUk extends AppLocalizations {
String get settings_aboutDescription => String get settings_aboutDescription =>
'Клієнт Flutter з відкритим вихідним кодом для пристроїв мережі MeshCore LoRa.'; 'Клієнт Flutter з відкритим вихідним кодом для пристроїв мережі MeshCore LoRa.';
@override
String get settings_aboutOpenMeteoAttribution =>
'Дані про висоту LOS: Open-Meteo (CC BY 4.0)';
@override @override
String get settings_infoName => 'Ім\'я'; String get settings_infoName => 'Ім\'я';
@@ -618,6 +622,15 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get appSettings_offlineMapCache => 'Офлайн-кеш карти'; String get appSettings_offlineMapCache => 'Офлайн-кеш карти';
@override
String get appSettings_unitsTitle => 'одиниці';
@override
String get appSettings_unitsMetric => 'Метричний (м / км)';
@override
String get appSettings_unitsImperial => 'Імперська (ft / mi)';
@override @override
String get appSettings_noAreaSelected => 'Область не вибрано'; String get appSettings_noAreaSelected => 'Область не вибрано';
@@ -780,6 +793,12 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get channels_editChannel => 'Редагувати канал'; String get channels_editChannel => 'Редагувати канал';
@override
String get channels_muteChannel => 'Вимкнути сповіщення каналу';
@override
String get channels_unmuteChannel => 'Увімкнути сповіщення каналу';
@override @override
String get channels_deleteChannel => 'Видалити канал'; String get channels_deleteChannel => 'Видалити канал';
@@ -1075,6 +1094,9 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get chat_pathManagement => 'Керування шляхами'; String get chat_pathManagement => 'Керування шляхами';
@override
String get chat_ShowAllPaths => 'Показати всі шляхи';
@override @override
String get chat_routingMode => 'Режим маршрутизації'; String get chat_routingMode => 'Режим маршрутизації';
@@ -1237,6 +1259,12 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get map_title => 'Карта вузлів'; String get map_title => 'Карта вузлів';
@override
String get map_lineOfSight => 'Пряма видимість';
@override
String get map_losScreenTitle => 'Пряма видимість';
@override @override
String get map_noNodesWithLocation => String get map_noNodesWithLocation =>
'Немає вузлів з даними про розташування'; 'Немає вузлів з даними про розташування';
@@ -1675,10 +1703,10 @@ class AppLocalizationsUk extends AppLocalizations {
String get repeater_cliSubtitle => 'Надіслати команди ретранслятору'; String get repeater_cliSubtitle => 'Надіслати команди ретранслятору';
@override @override
String get repeater_neighbours => 'Сусіди'; String get repeater_neighbors => 'Сусіди';
@override @override
String get repeater_neighboursSubtitle => String get repeater_neighborsSubtitle =>
'Показати сусідів нульового стрибка.'; 'Показати сусідів нульового стрибка.';
@override @override
@@ -2380,7 +2408,7 @@ class AppLocalizationsUk extends AppLocalizations {
} }
@override @override
String get neighbors_repeatersNeighbours => 'Ретранслятори-сусіди'; String get neighbors_repeatersNeighbors => 'Ретранслятори-сусіди';
@override @override
String get neighbors_noData => 'Дані про сусідів недоступні.'; String get neighbors_noData => 'Дані про сусідів недоступні.';
@@ -2730,6 +2758,117 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get pathTrace_clearTooltip => 'Очистити шлях'; String get pathTrace_clearTooltip => 'Очистити шлях';
@override
String get losSelectStartEnd =>
'Виберіть початковий і кінцевий вузли для LOS.';
@override
String losRunFailed(String error) {
return 'Помилка перевірки прямої видимості: $error';
}
@override
String get losClearAllPoints => 'Очистити всі пункти';
@override
String get losRunToViewElevationProfile =>
'Запустіть LOS, щоб переглянути профіль висоти';
@override
String get losMenuTitle => 'Меню LOS';
@override
String get losMenuSubtitle =>
'Торкніться вузлів або утримуйте карту, щоб отримати власні точки';
@override
String get losShowDisplayNodes => 'Показати вузли відображення';
@override
String get losCustomPoints => 'Користувальницькі точки';
@override
String losCustomPointLabel(int index) {
return 'Спеціальний $index';
}
@override
String get losPointA => 'Точка А';
@override
String get losPointB => 'Точка Б';
@override
String losAntennaA(String value, String unit) {
return 'Антена A: $value $unit';
}
@override
String losAntennaB(String value, String unit) {
return 'Антена B: $value $unit';
}
@override
String get losRun => 'Запустіть LOS';
@override
String get losNoElevationData => 'Немає даних про висоту';
@override
String losProfileClear(
String distance,
String distanceUnit,
String clearance,
String heightUnit,
) {
return '$distance $distanceUnit, чистий LOS, мінімальний зазор $clearance $heightUnit';
}
@override
String losProfileBlocked(
String distance,
String distanceUnit,
String obstruction,
String heightUnit,
) {
return '$distance $distanceUnit, заблоковано $obstruction $heightUnit';
}
@override
String get losStatusChecking => 'LOS: перевірка...';
@override
String get losStatusNoData => 'LOS: немає даних';
@override
String losStatusSummary(int clear, int total, int blocked, int unknown) {
return 'LOS: $clear/$total очищено, $blocked заблоковано, $unknown невідомо';
}
@override
String get losErrorElevationUnavailable =>
'Дані про висоту недоступні для одного чи кількох зразків.';
@override
String get losErrorInvalidInput =>
'Недійсні дані про точки/висоту для розрахунку LOS.';
@override
String get losRenameCustomPoint => 'Перейменуйте спеціальну точку';
@override
String get losPointName => 'Назва точки';
@override
String get losShowPanelTooltip => 'Показати панель LOS';
@override
String get losHidePanelTooltip => 'Приховати панель LOS';
@override
String get losElevationAttribution =>
'Дані про висоту: Open-Meteo (CC BY 4.0)';
@override @override
String get contacts_pathTrace => 'Трасування шляхів'; String get contacts_pathTrace => 'Трасування шляхів';
@@ -2907,4 +3046,10 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get settings_gpxExportShareSubject => String get settings_gpxExportShareSubject =>
'експорт даних карти meshcore-open у форматі GPX'; 'експорт даних карти meshcore-open у форматі GPX';
@override
String get snrIndicator_nearByRepeaters => 'Ближні ретранслятори';
@override
String get snrIndicator_lastSeen => 'Останній раз бачили';
} }
+142 -3
View File
@@ -307,6 +307,10 @@ class AppLocalizationsZh extends AppLocalizations {
String get settings_aboutDescription => String get settings_aboutDescription =>
'一个开源的 Flutter 客户端,用于 MeshCore LoRa 无线网络设备。'; '一个开源的 Flutter 客户端,用于 MeshCore LoRa 无线网络设备。';
@override
String get settings_aboutOpenMeteoAttribution =>
'LOS 高程数据:Open-Meteo (CC BY 4.0)';
@override @override
String get settings_infoName => '姓名'; String get settings_infoName => '姓名';
@@ -585,6 +589,15 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get appSettings_offlineMapCache => '离线地图缓存'; String get appSettings_offlineMapCache => '离线地图缓存';
@override
String get appSettings_unitsTitle => '单位';
@override
String get appSettings_unitsMetric => '公制(米/公里)';
@override
String get appSettings_unitsImperial => '英制 (ft / mi)';
@override @override
String get appSettings_noAreaSelected => '未选择任何区域'; String get appSettings_noAreaSelected => '未选择任何区域';
@@ -742,6 +755,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get channels_editChannel => '编辑频道'; String get channels_editChannel => '编辑频道';
@override
String get channels_muteChannel => '静音频道';
@override
String get channels_unmuteChannel => '取消静音频道';
@override @override
String get channels_deleteChannel => '删除频道'; String get channels_deleteChannel => '删除频道';
@@ -1031,6 +1050,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get chat_pathManagement => '路径管理'; String get chat_pathManagement => '路径管理';
@override
String get chat_ShowAllPaths => '显示所有路径';
@override @override
String get chat_routingMode => '路由模式'; String get chat_routingMode => '路由模式';
@@ -1179,6 +1201,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get map_title => '节点图'; String get map_title => '节点图';
@override
String get map_lineOfSight => '视线';
@override
String get map_losScreenTitle => '视线';
@override @override
String get map_noNodesWithLocation => '没有包含位置信息的节点'; String get map_noNodesWithLocation => '没有包含位置信息的节点';
@@ -1596,10 +1624,10 @@ class AppLocalizationsZh extends AppLocalizations {
String get repeater_cliSubtitle => '向复用器发送指令'; String get repeater_cliSubtitle => '向复用器发送指令';
@override @override
String get repeater_neighbours => '邻居'; String get repeater_neighbors => '邻居';
@override @override
String get repeater_neighboursSubtitle => '查看邻居节点(无需中间节点)。'; String get repeater_neighborsSubtitle => '查看邻居节点(无需中间节点)。';
@override @override
String get repeater_settings => '设置'; String get repeater_settings => '设置';
@@ -2246,7 +2274,7 @@ class AppLocalizationsZh extends AppLocalizations {
} }
@override @override
String get neighbors_repeatersNeighbours => '重复使用的邻居'; String get neighbors_repeatersNeighbors => '重复使用的邻居';
@override @override
String get neighbors_noData => '没有可用的邻居信息。'; String get neighbors_noData => '没有可用的邻居信息。';
@@ -2576,6 +2604,111 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get pathTrace_clearTooltip => '清除路径'; String get pathTrace_clearTooltip => '清除路径';
@override
String get losSelectStartEnd => '选择 LOS 的起始节点和结束节点。';
@override
String losRunFailed(String error) {
return '视线检查失败:$error';
}
@override
String get losClearAllPoints => '清除所有点';
@override
String get losRunToViewElevationProfile => '运行 LOS 查看高程剖面';
@override
String get losMenuTitle => '服务水平菜单';
@override
String get losMenuSubtitle => '点击节点或长按地图以获取自定义点';
@override
String get losShowDisplayNodes => '显示显示节点';
@override
String get losCustomPoints => '自定义积分';
@override
String losCustomPointLabel(int index) {
return '自定义 $index';
}
@override
String get losPointA => 'A点';
@override
String get losPointB => 'B点';
@override
String losAntennaA(String value, String unit) {
return '天线 A $value $unit';
}
@override
String losAntennaB(String value, String unit) {
return '天线 B$value $unit';
}
@override
String get losRun => '运行视距';
@override
String get losNoElevationData => '无海拔数据';
@override
String losProfileClear(
String distance,
String distanceUnit,
String clearance,
String heightUnit,
) {
return '$distance $distanceUnit,清除 LOS,最小间隙 $clearance $heightUnit';
}
@override
String losProfileBlocked(
String distance,
String distanceUnit,
String obstruction,
String heightUnit,
) {
return '$distance $distanceUnit,被 $obstruction $heightUnit 阻止';
}
@override
String get losStatusChecking => '洛斯:正在检查...';
@override
String get losStatusNoData => 'LOS:无数据';
@override
String losStatusSummary(int clear, int total, int blocked, int unknown) {
return 'LOS$clear/$total 清除,$blocked 阻塞,$unknown 未知';
}
@override
String get losErrorElevationUnavailable => '一个或多个样本的海拔数据不可用。';
@override
String get losErrorInvalidInput => '用于 LOS 计算的点/高程数据无效。';
@override
String get losRenameCustomPoint => '重命名自定义点';
@override
String get losPointName => '点名称';
@override
String get losShowPanelTooltip => '显示 LOS 面板';
@override
String get losHidePanelTooltip => '隐藏 LOS 面板';
@override
String get losElevationAttribution => '高程数据:Open-Meteo (CC BY 4.0)';
@override @override
String get contacts_pathTrace => '路径追踪'; String get contacts_pathTrace => '路径追踪';
@@ -2714,4 +2847,10 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get settings_gpxExportShareSubject => 'meshcore-open GPX 地图数据导出'; String get settings_gpxExportShareSubject => 'meshcore-open GPX 地图数据导出';
@override
String get snrIndicator_nearByRepeaters => '附近的重复器';
@override
String get snrIndicator_lastSeen => '最近访问';
} }
+124 -4
View File
@@ -334,6 +334,8 @@
"channels_publicChannel": "Open kanaal", "channels_publicChannel": "Open kanaal",
"channels_privateChannel": "Private kanaal", "channels_privateChannel": "Private kanaal",
"channels_editChannel": "Kanaal bewerken", "channels_editChannel": "Kanaal bewerken",
"channels_muteChannel": "Kanaal dempen",
"channels_unmuteChannel": "Kanaal dempen opheffen",
"channels_deleteChannel": "Kanaal verwijderen", "channels_deleteChannel": "Kanaal verwijderen",
"channels_deleteChannelConfirm": "Verwijderen \"{name}\"? Dit kan niet worden teruggedraaid.", "channels_deleteChannelConfirm": "Verwijderen \"{name}\"? Dit kan niet worden teruggedraaid.",
"@channels_deleteChannelConfirm": { "@channels_deleteChannelConfirm": {
@@ -1351,12 +1353,12 @@
} }
} }
}, },
"repeater_neighbours": "Buren", "repeater_neighbors": "Buren",
"repeater_neighboursSubtitle": "Bekijk nul hops buren.", "repeater_neighborsSubtitle": "Bekijk nul hops buren.",
"neighbors_receivedData": "Ontvangen Buurdata", "neighbors_receivedData": "Ontvangen Buurdata",
"neighbors_requestTimedOut": "Buren vragen om tijdelijk uitgeschakeld.", "neighbors_requestTimedOut": "Buren vragen om tijdelijk uitgeschakeld.",
"neighbors_errorLoading": "Fout bij het laden van buren: {error}", "neighbors_errorLoading": "Fout bij het laden van buren: {error}",
"neighbors_repeatersNeighbours": "Herhalingen Buren", "neighbors_repeatersNeighbors": "Herhalingen Buren",
"neighbors_noData": "Geen gegevens van buren beschikbaar.", "neighbors_noData": "Geen gegevens van buren beschikbaar.",
"channels_createPrivateChannelDesc": "Beveiligd met een geheime sleutel.", "channels_createPrivateChannelDesc": "Beveiligd met een geheime sleutel.",
"channels_createPrivateChannel": "Maak een Privé Kanaal", "channels_createPrivateChannel": "Maak een Privé Kanaal",
@@ -1594,7 +1596,125 @@
"scanner_enableBluetooth": "Activeer Bluetooth", "scanner_enableBluetooth": "Activeer Bluetooth",
"scanner_bluetoothOffMessage": "Zorg ervoor dat Bluetooth is ingeschakeld om naar apparaten te zoeken.", "scanner_bluetoothOffMessage": "Zorg ervoor dat Bluetooth is ingeschakeld om naar apparaten te zoeken.",
"scanner_bluetoothOff": "Bluetooth is uitgeschakeld", "scanner_bluetoothOff": "Bluetooth is uitgeschakeld",
"snrIndicator_lastSeen": "Laatst gezien",
"snrIndicator_nearByRepeaters": "Nabije herhalingseenheden",
"chat_ShowAllPaths": "Toon alle paden",
"settings_clientRepeat": "Herhalen: Afgekoppeld", "settings_clientRepeat": "Herhalen: Afgekoppeld",
"settings_clientRepeatSubtitle": "Laat dit apparaat de mesh-pakketten opnieuw verzenden voor andere apparaten.", "settings_clientRepeatSubtitle": "Laat dit apparaat de mesh-pakketten opnieuw verzenden voor andere apparaten.",
"settings_clientRepeatFreqWarning": "Om een signaal buiten het netwerk te versturen, zijn frequenties van 433, 869 of 918 MHz vereist." "settings_clientRepeatFreqWarning": "Om een signaal buiten het netwerk te versturen, zijn frequenties van 433, 869 of 918 MHz vereist.",
"settings_aboutOpenMeteoAttribution": "LOS-hoogtegegevens: Open-Meteo (CC BY 4.0)",
"appSettings_unitsTitle": "Eenheden",
"appSettings_unitsMetric": "Metrisch (m / km)",
"appSettings_unitsImperial": "Imperiaal (ft / mi)",
"map_lineOfSight": "Zichtlijn",
"map_losScreenTitle": "Zichtlijn",
"losSelectStartEnd": "Selecteer begin- en eindknooppunten voor LOS.",
"losRunFailed": "Zichtlijncontrole mislukt: {error}",
"@losRunFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"losClearAllPoints": "Wis alle punten",
"losRunToViewElevationProfile": "Voer LOS uit om het hoogteprofiel te bekijken",
"losMenuTitle": "LOS-menu",
"losMenuSubtitle": "Tik op knooppunten of druk lang op de kaart voor aangepaste punten",
"losShowDisplayNodes": "Toon weergaveknooppunten",
"losCustomPoints": "Aangepaste punten",
"losCustomPointLabel": "Aangepast {index}",
"@losCustomPointLabel": {
"placeholders": {
"index": {
"type": "int"
}
}
},
"losPointA": "Punt A",
"losPointB": "Punt B",
"losAntennaA": "Antenne A: {value} {unit}",
"@losAntennaA": {
"placeholders": {
"value": {
"type": "String"
},
"unit": {
"type": "String"
}
}
},
"losAntennaB": "Antenne B: {value} {unit}",
"@losAntennaB": {
"placeholders": {
"value": {
"type": "String"
},
"unit": {
"type": "String"
}
}
},
"losRun": "Voer LOS uit",
"losNoElevationData": "Geen hoogtegegevens",
"losProfileClear": "{distance} {distanceUnit}, vrije LOS, min. vrije ruimte {clearance} {heightUnit}",
"@losProfileClear": {
"placeholders": {
"distance": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"clearance": {
"type": "String"
},
"heightUnit": {
"type": "String"
}
}
},
"losProfileBlocked": "{distance} {distanceUnit}, geblokkeerd door {obstruction} {heightUnit}",
"@losProfileBlocked": {
"placeholders": {
"distance": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"obstruction": {
"type": "String"
},
"heightUnit": {
"type": "String"
}
}
},
"losStatusChecking": "LOS: controleren...",
"losStatusNoData": "LOS: geen gegevens",
"losStatusSummary": "LOS: {clear}/{total} gewist, {blocked} geblokkeerd, {unknown} onbekend",
"@losStatusSummary": {
"placeholders": {
"clear": {
"type": "int"
},
"total": {
"type": "int"
},
"blocked": {
"type": "int"
},
"unknown": {
"type": "int"
}
}
},
"losErrorElevationUnavailable": "Hoogtegegevens niet beschikbaar voor een of meer monsters.",
"losErrorInvalidInput": "Ongeldige punten/hoogtegegevens voor LOS-berekening.",
"losRenameCustomPoint": "Hernoem aangepast punt",
"losPointName": "Puntnaam",
"losShowPanelTooltip": "Toon LOS-paneel",
"losHidePanelTooltip": "LOS-paneel verbergen",
"losElevationAttribution": "Hoogtegegevens: Open-Meteo (CC BY 4.0)"
} }
+124 -4
View File
@@ -334,6 +334,8 @@
"channels_publicChannel": "Kanał publiczny", "channels_publicChannel": "Kanał publiczny",
"channels_privateChannel": "Prywatny kanał", "channels_privateChannel": "Prywatny kanał",
"channels_editChannel": "Edytuj kanał", "channels_editChannel": "Edytuj kanał",
"channels_muteChannel": "Wycisz kanał",
"channels_unmuteChannel": "Wyłącz wyciszenie kanału",
"channels_deleteChannel": "Usuń kanał", "channels_deleteChannel": "Usuń kanał",
"channels_deleteChannelConfirm": "Usuń \"{name}\"? Nie można tego cofnąć.", "channels_deleteChannelConfirm": "Usuń \"{name}\"? Nie można tego cofnąć.",
"@channels_deleteChannelConfirm": { "@channels_deleteChannelConfirm": {
@@ -1351,12 +1353,12 @@
} }
} }
}, },
"repeater_neighbours": "Sąsiedzi", "repeater_neighbors": "Sąsiedzi",
"repeater_neighboursSubtitle": "Wyświetl sąsiedztwo zerowych hopów.", "repeater_neighborsSubtitle": "Wyświetl sąsiedztwo zerowych hopów.",
"neighbors_receivedData": "Otrzymano dane sąsiedztwa", "neighbors_receivedData": "Otrzymano dane sąsiedztwa",
"neighbors_requestTimedOut": "Sąsiedzi proszą o wyłączenie timingu.", "neighbors_requestTimedOut": "Sąsiedzi proszą o wyłączenie timingu.",
"neighbors_errorLoading": "Błąd podczas ładowania sąsiadów: {error}", "neighbors_errorLoading": "Błąd podczas ładowania sąsiadów: {error}",
"neighbors_repeatersNeighbours": "Powtarzacze Sąsiedzi", "neighbors_repeatersNeighbors": "Powtarzacze Sąsiedzi",
"neighbors_noData": "Brak danych dotyczących sąsiadów.", "neighbors_noData": "Brak danych dotyczących sąsiadów.",
"channels_joinPrivateChannelDesc": "Ręcznie wprowadź klucz tajny.", "channels_joinPrivateChannelDesc": "Ręcznie wprowadź klucz tajny.",
"channels_createPrivateChannel": "Utwórz Prywatny Kanał", "channels_createPrivateChannel": "Utwórz Prywatny Kanał",
@@ -1594,7 +1596,125 @@
"scanner_bluetoothOffMessage": "Prosimy włączyć Bluetooth, aby przeskanować urządzenia.", "scanner_bluetoothOffMessage": "Prosimy włączyć Bluetooth, aby przeskanować urządzenia.",
"scanner_bluetoothOff": "Bluetooth jest wyłączony", "scanner_bluetoothOff": "Bluetooth jest wyłączony",
"scanner_enableBluetooth": "Włącz Bluetooth", "scanner_enableBluetooth": "Włącz Bluetooth",
"snrIndicator_lastSeen": "Ostatnio widziany",
"snrIndicator_nearByRepeaters": "Nadajniki w pobliżu",
"chat_ShowAllPaths": "Pokaż wszystkie ścieżki",
"settings_clientRepeatSubtitle": "Pozwól temu urządzeniu powtarzać pakiety danych dla innych urządzeń.", "settings_clientRepeatSubtitle": "Pozwól temu urządzeniu powtarzać pakiety danych dla innych urządzeń.",
"settings_clientRepeat": "Powtórzenie: Niezależne od sieci", "settings_clientRepeat": "Powtórzenie: Niezależne od sieci",
"settings_clientRepeatFreqWarning": "Powtórka poza siecią wymaga częstotliwości 433, 869 lub 918 MHz." "settings_clientRepeatFreqWarning": "Powtórka poza siecią wymaga częstotliwości 433, 869 lub 918 MHz.",
"settings_aboutOpenMeteoAttribution": "Dane wysokościowe LOS: Open-Meteo (CC BY 4.0)",
"appSettings_unitsTitle": "Jednostki",
"appSettings_unitsMetric": "Metryczne (m / km)",
"appSettings_unitsImperial": "Imperialne (ft / mi)",
"map_lineOfSight": "Linia wzroku",
"map_losScreenTitle": "Linia wzroku",
"losSelectStartEnd": "Wybierz węzły początkowe i końcowe dla LOS.",
"losRunFailed": "Sprawdzenie pola widzenia nie powiodło się: {error}",
"@losRunFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"losClearAllPoints": "Wyczyść wszystkie punkty",
"losRunToViewElevationProfile": "Uruchom LOS, aby wyświetlić profil wysokości",
"losMenuTitle": "Menu LOS",
"losMenuSubtitle": "Stuknij węzły lub naciśnij i przytrzymaj mapę, aby uzyskać niestandardowe punkty",
"losShowDisplayNodes": "Pokaż węzły wyświetlające",
"losCustomPoints": "Punkty niestandardowe",
"losCustomPointLabel": "Niestandardowe {index}",
"@losCustomPointLabel": {
"placeholders": {
"index": {
"type": "int"
}
}
},
"losPointA": "Punkt A",
"losPointB": "Punkt B",
"losAntennaA": "Antena A: {value} {unit}",
"@losAntennaA": {
"placeholders": {
"value": {
"type": "String"
},
"unit": {
"type": "String"
}
}
},
"losAntennaB": "Antena B: {value} {unit}",
"@losAntennaB": {
"placeholders": {
"value": {
"type": "String"
},
"unit": {
"type": "String"
}
}
},
"losRun": "Uruchom LOS-a",
"losNoElevationData": "Brak danych o wysokości",
"losProfileClear": "{distance} {distanceUnit}, czysty LOS, minimalny prześwit {clearance} {heightUnit}",
"@losProfileClear": {
"placeholders": {
"distance": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"clearance": {
"type": "String"
},
"heightUnit": {
"type": "String"
}
}
},
"losProfileBlocked": "{distance} {distanceUnit}, zablokowane przez {obstruction} {heightUnit}",
"@losProfileBlocked": {
"placeholders": {
"distance": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"obstruction": {
"type": "String"
},
"heightUnit": {
"type": "String"
}
}
},
"losStatusChecking": "LOS: sprawdzam...",
"losStatusNoData": "LOS: brak danych",
"losStatusSummary": "LOS: {clear}/{total} jasne, {blocked} zablokowane, {unknown} nieznane",
"@losStatusSummary": {
"placeholders": {
"clear": {
"type": "int"
},
"total": {
"type": "int"
},
"blocked": {
"type": "int"
},
"unknown": {
"type": "int"
}
}
},
"losErrorElevationUnavailable": "Dane dotyczące wysokości są niedostępne dla jednej lub większej liczby próbek.",
"losErrorInvalidInput": "Nieprawidłowe dane punktów/wysokości do obliczenia LOS.",
"losRenameCustomPoint": "Zmień nazwę punktu niestandardowego",
"losPointName": "Nazwa punktu",
"losShowPanelTooltip": "Pokaż panel LOS",
"losHidePanelTooltip": "Ukryj panel LOS",
"losElevationAttribution": "Dane dotyczące wysokości: Open-Meteo (CC BY 4.0)"
} }
+124 -4
View File
@@ -334,6 +334,8 @@
"channels_publicChannel": "Canal público", "channels_publicChannel": "Canal público",
"channels_privateChannel": "Canal privado", "channels_privateChannel": "Canal privado",
"channels_editChannel": "Editar canal", "channels_editChannel": "Editar canal",
"channels_muteChannel": "Silenciar canal",
"channels_unmuteChannel": "Ativar canal",
"channels_deleteChannel": "Excluir canal", "channels_deleteChannel": "Excluir canal",
"channels_deleteChannelConfirm": "Excluir \"{name}\"? Não pode ser desfeito.", "channels_deleteChannelConfirm": "Excluir \"{name}\"? Não pode ser desfeito.",
"@channels_deleteChannelConfirm": { "@channels_deleteChannelConfirm": {
@@ -1351,12 +1353,12 @@
} }
} }
}, },
"repeater_neighbours": "Vizinhos", "repeater_neighbors": "Vizinhos",
"neighbors_receivedData": "Dados dos Vizinhos Recebidos", "neighbors_receivedData": "Dados dos Vizinhos Recebidos",
"repeater_neighboursSubtitle": "Visualizar vizinhos de salto zero.", "repeater_neighborsSubtitle": "Visualizar vizinhos de salto zero.",
"neighbors_requestTimedOut": "Vizinhos solicitam tempo limite esgotado.", "neighbors_requestTimedOut": "Vizinhos solicitam tempo limite esgotado.",
"neighbors_errorLoading": "Erro ao carregar vizinhos: {error}", "neighbors_errorLoading": "Erro ao carregar vizinhos: {error}",
"neighbors_repeatersNeighbours": "Repetidores Vizinhos", "neighbors_repeatersNeighbors": "Repetidores Vizinhos",
"neighbors_noData": "Não estão disponíveis dados de vizinhos.", "neighbors_noData": "Não estão disponíveis dados de vizinhos.",
"channels_createPrivateChannelDesc": "Protegido com uma chave secreta.", "channels_createPrivateChannelDesc": "Protegido com uma chave secreta.",
"channels_joinPrivateChannelDesc": "Inserir uma chave secreta manualmente.", "channels_joinPrivateChannelDesc": "Inserir uma chave secreta manualmente.",
@@ -1594,7 +1596,125 @@
"scanner_enableBluetooth": "Ative o Bluetooth", "scanner_enableBluetooth": "Ative o Bluetooth",
"scanner_bluetoothOff": "Bluetooth está desativado", "scanner_bluetoothOff": "Bluetooth está desativado",
"scanner_bluetoothOffMessage": "Por favor, ative o Bluetooth para escanear por dispositivos.", "scanner_bluetoothOffMessage": "Por favor, ative o Bluetooth para escanear por dispositivos.",
"snrIndicator_nearByRepeaters": "Repetidores Próximos",
"snrIndicator_lastSeen": "Visto pela última vez",
"chat_ShowAllPaths": "Mostrar todos os caminhos",
"settings_clientRepeatFreqWarning": "A repetição fora da rede requer frequências de 433, 869 ou 918 MHz.", "settings_clientRepeatFreqWarning": "A repetição fora da rede requer frequências de 433, 869 ou 918 MHz.",
"settings_clientRepeat": "Repetição sem rede", "settings_clientRepeat": "Repetição sem rede",
"settings_clientRepeatSubtitle": "Permita que este dispositivo repita pacotes de rede para outros dispositivos." "settings_clientRepeatSubtitle": "Permita que este dispositivo repita pacotes de rede para outros dispositivos.",
"settings_aboutOpenMeteoAttribution": "Dados de elevação LOS: Open-Meteo (CC BY 4.0)",
"appSettings_unitsTitle": "Unidades",
"appSettings_unitsMetric": "Métrico (m/km)",
"appSettings_unitsImperial": "Imperial (ft/mi)",
"map_lineOfSight": "Linha de visão",
"map_losScreenTitle": "Linha de visão",
"losSelectStartEnd": "Selecione nós iniciais e finais para LOS.",
"losRunFailed": "Falha na verificação da linha de visão: {error}",
"@losRunFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"losClearAllPoints": "Limpe todos os pontos",
"losRunToViewElevationProfile": "Execute o LOS para visualizar o perfil de elevação",
"losMenuTitle": "Menu LOS",
"losMenuSubtitle": "Toque nos nós ou mantenha pressionado o mapa para obter pontos personalizados",
"losShowDisplayNodes": "Mostrar nós de exibição",
"losCustomPoints": "Pontos personalizados",
"losCustomPointLabel": "{index} personalizado",
"@losCustomPointLabel": {
"placeholders": {
"index": {
"type": "int"
}
}
},
"losPointA": "Ponto A",
"losPointB": "Ponto B",
"losAntennaA": "Antena A: {value} {unit}",
"@losAntennaA": {
"placeholders": {
"value": {
"type": "String"
},
"unit": {
"type": "String"
}
}
},
"losAntennaB": "Antena B: {value} {unit}",
"@losAntennaB": {
"placeholders": {
"value": {
"type": "String"
},
"unit": {
"type": "String"
}
}
},
"losRun": "Executar LOS",
"losNoElevationData": "Sem dados de elevação",
"losProfileClear": "{distance} {distanceUnit}, limpar LOS, liberação mínima {clearance} {heightUnit}",
"@losProfileClear": {
"placeholders": {
"distance": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"clearance": {
"type": "String"
},
"heightUnit": {
"type": "String"
}
}
},
"losProfileBlocked": "{distance} {distanceUnit}, bloqueado por {obstruction} {heightUnit}",
"@losProfileBlocked": {
"placeholders": {
"distance": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"obstruction": {
"type": "String"
},
"heightUnit": {
"type": "String"
}
}
},
"losStatusChecking": "LOS: verificando...",
"losStatusNoData": "LOS: sem dados",
"losStatusSummary": "LOS: {clear}/{total} limpo, {blocked} bloqueado, {unknown} desconhecido",
"@losStatusSummary": {
"placeholders": {
"clear": {
"type": "int"
},
"total": {
"type": "int"
},
"blocked": {
"type": "int"
},
"unknown": {
"type": "int"
}
}
},
"losErrorElevationUnavailable": "Dados de elevação indisponíveis para uma ou mais amostras.",
"losErrorInvalidInput": "Dados de pontos/elevação inválidos para cálculo de LOS.",
"losRenameCustomPoint": "Renomear ponto personalizado",
"losPointName": "Nome do ponto",
"losShowPanelTooltip": "Mostrar painel LOS",
"losHidePanelTooltip": "Ocultar painel LOS",
"losElevationAttribution": "Dados de elevação: Open-Meteo (CC BY 4.0)"
} }
+124 -4
View File
@@ -226,6 +226,8 @@
"channels_publicChannel": "Публичный канал", "channels_publicChannel": "Публичный канал",
"channels_privateChannel": "Приватный канал", "channels_privateChannel": "Приватный канал",
"channels_editChannel": "Изменить канал", "channels_editChannel": "Изменить канал",
"channels_muteChannel": "Отключить уведомления канала",
"channels_unmuteChannel": "Включить уведомления канала",
"channels_deleteChannel": "Удалить канал", "channels_deleteChannel": "Удалить канал",
"channels_deleteChannelConfirm": "Удалить \"{name}\"? Это действие нельзя отменить.", "channels_deleteChannelConfirm": "Удалить \"{name}\"? Это действие нельзя отменить.",
"channels_channelDeleted": "Канал \"{name}\" удалён", "channels_channelDeleted": "Канал \"{name}\" удалён",
@@ -467,8 +469,8 @@
"repeater_telemetrySubtitle": "Просмотр телеметрии датчиков и системной статистики", "repeater_telemetrySubtitle": "Просмотр телеметрии датчиков и системной статистики",
"repeater_cli": "CLI", "repeater_cli": "CLI",
"repeater_cliSubtitle": "Отправка команд репитеру", "repeater_cliSubtitle": "Отправка команд репитеру",
"repeater_neighbours": "Соседи", "repeater_neighbors": "Соседи",
"repeater_neighboursSubtitle": "Просмотр соседей на нулевом хопе.", "repeater_neighborsSubtitle": "Просмотр соседей на нулевом хопе.",
"repeater_settings": "Настройки", "repeater_settings": "Настройки",
"repeater_settingsSubtitle": "Настройка параметров репитера", "repeater_settingsSubtitle": "Настройка параметров репитера",
"repeater_statusTitle": "Статус репитера", "repeater_statusTitle": "Статус репитера",
@@ -661,7 +663,7 @@
"neighbors_receivedData": "Полученные данные о соседях", "neighbors_receivedData": "Полученные данные о соседях",
"neighbors_requestTimedOut": "Время ожидания данных о соседях истекло.", "neighbors_requestTimedOut": "Время ожидания данных о соседях истекло.",
"neighbors_errorLoading": "Ошибка загрузки соседей: {error}", "neighbors_errorLoading": "Ошибка загрузки соседей: {error}",
"neighbors_repeatersNeighbours": "Соседи репитеров", "neighbors_repeatersNeighbors": "Соседи репитеров",
"neighbors_noData": "Данные о соседях недоступны.", "neighbors_noData": "Данные о соседях недоступны.",
"neighbors_unknownContact": "Неизвестный {pubkey}", "neighbors_unknownContact": "Неизвестный {pubkey}",
"neighbors_heardA ago": "Слышали: {time} назад", "neighbors_heardA ago": "Слышали: {time} назад",
@@ -834,7 +836,125 @@
"scanner_enableBluetooth": "Включите Bluetooth", "scanner_enableBluetooth": "Включите Bluetooth",
"scanner_bluetoothOff": "Bluetooth выключен", "scanner_bluetoothOff": "Bluetooth выключен",
"scanner_bluetoothOffMessage": "Пожалуйста, включите Bluetooth, чтобы найти устройства.", "scanner_bluetoothOffMessage": "Пожалуйста, включите Bluetooth, чтобы найти устройства.",
"snrIndicator_nearByRepeaters": "Ближайшие ретрансляторы",
"snrIndicator_lastSeen": "Последний раз видели",
"chat_ShowAllPaths": "Показать все пути",
"settings_clientRepeatFreqWarning": "Для работы в режиме \"без подключения к сети\" требуется частота 433, 869 или 918 МГц.", "settings_clientRepeatFreqWarning": "Для работы в режиме \"без подключения к сети\" требуется частота 433, 869 или 918 МГц.",
"settings_clientRepeatSubtitle": "Позвольте этому устройству повторять пакеты данных для других устройств.", "settings_clientRepeatSubtitle": "Позвольте этому устройству повторять пакеты данных для других устройств.",
"settings_clientRepeat": "Повторение \"вне сети\"" "settings_clientRepeat": "Повторение \"вне сети\"",
"settings_aboutOpenMeteoAttribution": "Данные о высоте LOS: Open-Meteo (CC BY 4.0)",
"appSettings_unitsTitle": "Единицы",
"appSettings_unitsMetric": "Метрическая (м/км)",
"appSettings_unitsImperial": "Имперская (ft / mi)",
"map_lineOfSight": "Линия видимости",
"map_losScreenTitle": "Линия видимости",
"losSelectStartEnd": "Выберите начальный и конечный узлы для LOS.",
"losRunFailed": "Проверка прямой видимости не удалась: {error}",
"@losRunFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"losClearAllPoints": "Очистить все точки",
"losRunToViewElevationProfile": "Запустите LOS, чтобы просмотреть профиль высот.",
"losMenuTitle": "ЛОС Меню",
"losMenuSubtitle": "Коснитесь узлов или нажмите и удерживайте карту для выбора пользовательских точек.",
"losShowDisplayNodes": "Показать узлы отображения",
"losCustomPoints": "Пользовательские точки",
"losCustomPointLabel": "Пользовательский {index}",
"@losCustomPointLabel": {
"placeholders": {
"index": {
"type": "int"
}
}
},
"losPointA": "Точка А",
"losPointB": "Точка Б",
"losAntennaA": "Антенна А: {value} {unit}",
"@losAntennaA": {
"placeholders": {
"value": {
"type": "String"
},
"unit": {
"type": "String"
}
}
},
"losAntennaB": "Антенна Б: {value} {unit}",
"@losAntennaB": {
"placeholders": {
"value": {
"type": "String"
},
"unit": {
"type": "String"
}
}
},
"losRun": "Запустить ЛОС",
"losNoElevationData": "Нет данных о высоте",
"losProfileClear": "{distance} {distanceUnit}, свободная зона видимости, минимальный зазор {clearance} {heightUnit}",
"@losProfileClear": {
"placeholders": {
"distance": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"clearance": {
"type": "String"
},
"heightUnit": {
"type": "String"
}
}
},
"losProfileBlocked": "{distance} {distanceUnit}, заблокирован {obstruction} {heightUnit}",
"@losProfileBlocked": {
"placeholders": {
"distance": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"obstruction": {
"type": "String"
},
"heightUnit": {
"type": "String"
}
}
},
"losStatusChecking": "ЛОС: проверяю...",
"losStatusNoData": "ЛОС: нет данных",
"losStatusSummary": "LOS: {clear}/{total} очищено, {blocked} заблокировано, {unknown} неизвестно.",
"@losStatusSummary": {
"placeholders": {
"clear": {
"type": "int"
},
"total": {
"type": "int"
},
"blocked": {
"type": "int"
},
"unknown": {
"type": "int"
}
}
},
"losErrorElevationUnavailable": "Данные о высоте недоступны для одного или нескольких образцов.",
"losErrorInvalidInput": "Неверные данные о точках/высоте для расчета LOS.",
"losRenameCustomPoint": "Переименовать пользовательскую точку",
"losPointName": "Имя точки",
"losShowPanelTooltip": "Показать панель LOS",
"losHidePanelTooltip": "Скрыть панель LOS",
"losElevationAttribution": "Данные о высоте: Open-Meteo (CC BY 4.0)"
} }
+124 -4
View File
@@ -334,6 +334,8 @@
"channels_publicChannel": "Veľké verejne kanály", "channels_publicChannel": "Veľké verejne kanály",
"channels_privateChannel": "Osobné kanál", "channels_privateChannel": "Osobné kanál",
"channels_editChannel": "Upraviť kanál", "channels_editChannel": "Upraviť kanál",
"channels_muteChannel": "Stlmiť kanál",
"channels_unmuteChannel": "Zrušiť stlmenie kanála",
"channels_deleteChannel": "Odstrániť kanál", "channels_deleteChannel": "Odstrániť kanál",
"channels_deleteChannelConfirm": "Odstrániť \"{name}\"? To sa nedá zrušiť.", "channels_deleteChannelConfirm": "Odstrániť \"{name}\"? To sa nedá zrušiť.",
"@channels_deleteChannelConfirm": { "@channels_deleteChannelConfirm": {
@@ -1351,12 +1353,12 @@
} }
} }
}, },
"repeater_neighboursSubtitle": "Zobraziť susedné body bez skokov.", "repeater_neighborsSubtitle": "Zobraziť susedné body bez skokov.",
"neighbors_requestTimedOut": "Súďia žiadajú o časové ukončenie.", "neighbors_requestTimedOut": "Súďia žiadajú o časové ukončenie.",
"neighbors_receivedData": "Obdielo dáta suseda", "neighbors_receivedData": "Obdielo dáta suseda",
"repeater_neighbours": "Súsezný", "repeater_neighbors": "Súsezný",
"neighbors_errorLoading": "Chyba pri načítaní susedov: {error}", "neighbors_errorLoading": "Chyba pri načítaní susedov: {error}",
"neighbors_repeatersNeighbours": "Opakovadlá Súsezná", "neighbors_repeatersNeighbors": "Opakovadlá Súsezná",
"neighbors_noData": "Nie je dostupná žiadna informácia o susedoch.", "neighbors_noData": "Nie je dostupná žiadna informácia o susedoch.",
"channels_createPrivateChannel": "Vytvorte súkromný kanál", "channels_createPrivateChannel": "Vytvorte súkromný kanál",
"channels_joinPrivateChannel": "Pripojiť sa k súkromnému kanálu", "channels_joinPrivateChannel": "Pripojiť sa k súkromnému kanálu",
@@ -1594,7 +1596,125 @@
"scanner_bluetoothOffMessage": "Prosím, zapnite Bluetooth, aby ste mohli skenovať pre zariadenia.", "scanner_bluetoothOffMessage": "Prosím, zapnite Bluetooth, aby ste mohli skenovať pre zariadenia.",
"scanner_bluetoothOff": "Bluetooth je vypnutý", "scanner_bluetoothOff": "Bluetooth je vypnutý",
"scanner_enableBluetooth": "Povolte Bluetooth", "scanner_enableBluetooth": "Povolte Bluetooth",
"snrIndicator_lastSeen": "Naposledy videný",
"snrIndicator_nearByRepeaters": "Miestne opakovače",
"chat_ShowAllPaths": "Zobraziť všetky cesty",
"settings_clientRepeat": "Opätovné použitie bez elektrickej siete", "settings_clientRepeat": "Opätovné použitie bez elektrickej siete",
"settings_clientRepeatFreqWarning": "Použitie off-grid systému vyžaduje frekvencie 433, 869 alebo 918 MHz.", "settings_clientRepeatFreqWarning": "Použitie off-grid systému vyžaduje frekvencie 433, 869 alebo 918 MHz.",
"settings_clientRepeatSubtitle": "Umožnite, aby toto zariadenie opakovávalo siete pre ostatných." "settings_clientRepeatSubtitle": "Umožnite, aby toto zariadenie opakovávalo siete pre ostatných.",
"settings_aboutOpenMeteoAttribution": "Údaje o nadmorskej výške LOS: Open-Meteo (CC BY 4.0)",
"appSettings_unitsTitle": "Jednotky",
"appSettings_unitsMetric": "Metrické (m / km)",
"appSettings_unitsImperial": "Imperiálne (ft / mi)",
"map_lineOfSight": "Line of Sight",
"map_losScreenTitle": "Line of Sight",
"losSelectStartEnd": "Vyberte počiatočný a koncový uzol pre LOS.",
"losRunFailed": "Kontrola priamej viditeľnosti zlyhala: {error}",
"@losRunFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"losClearAllPoints": "Vymazať všetky body",
"losRunToViewElevationProfile": "Ak chcete zobraziť výškový profil, spustite LOS",
"losMenuTitle": "Menu LOS",
"losMenuSubtitle": "Klepnutím na uzly alebo dlhým stlačením mapy získate vlastné body",
"losShowDisplayNodes": "Zobraziť uzly zobrazenia",
"losCustomPoints": "Vlastné body",
"losCustomPointLabel": "Vlastné {index}",
"@losCustomPointLabel": {
"placeholders": {
"index": {
"type": "int"
}
}
},
"losPointA": "Bod A",
"losPointB": "Bod B",
"losAntennaA": "Anténa A: {value} {unit}",
"@losAntennaA": {
"placeholders": {
"value": {
"type": "String"
},
"unit": {
"type": "String"
}
}
},
"losAntennaB": "Anténa B: {value} {unit}",
"@losAntennaB": {
"placeholders": {
"value": {
"type": "String"
},
"unit": {
"type": "String"
}
}
},
"losRun": "Spustite LOS",
"losNoElevationData": "Žiadne údaje o nadmorskej výške",
"losProfileClear": "{distance} {distanceUnit}, vymazať LOS, min. vôľa {clearance} {heightUnit}",
"@losProfileClear": {
"placeholders": {
"distance": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"clearance": {
"type": "String"
},
"heightUnit": {
"type": "String"
}
}
},
"losProfileBlocked": "{distance} {distanceUnit}, blokovaný {obstruction} {heightUnit}",
"@losProfileBlocked": {
"placeholders": {
"distance": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"obstruction": {
"type": "String"
},
"heightUnit": {
"type": "String"
}
}
},
"losStatusChecking": "LOS: kontrolujem...",
"losStatusNoData": "LOS: žiadne údaje",
"losStatusSummary": "LOS: {clear}/{total} vymazané, {blocked} blokované, {unknown} neznáme",
"@losStatusSummary": {
"placeholders": {
"clear": {
"type": "int"
},
"total": {
"type": "int"
},
"blocked": {
"type": "int"
},
"unknown": {
"type": "int"
}
}
},
"losErrorElevationUnavailable": "Údaje o nadmorskej výške nie sú k dispozícii pre jednu alebo viacero vzoriek.",
"losErrorInvalidInput": "Neplatné body/údaje o nadmorskej výške pre výpočet LOS.",
"losRenameCustomPoint": "Premenovať vlastný bod",
"losPointName": "Názov bodu",
"losShowPanelTooltip": "Zobraziť panel LOS",
"losHidePanelTooltip": "Skryť panel LOS",
"losElevationAttribution": "Údaje o nadmorskej výške: Open-Meteo (CC BY 4.0)"
} }
+124 -4
View File
@@ -334,6 +334,8 @@
"channels_publicChannel": "Javni kanal", "channels_publicChannel": "Javni kanal",
"channels_privateChannel": "Zasebni kanal", "channels_privateChannel": "Zasebni kanal",
"channels_editChannel": "Uredi kanal", "channels_editChannel": "Uredi kanal",
"channels_muteChannel": "Utišaj kanal",
"channels_unmuteChannel": "Vklopi obvestila kanala",
"channels_deleteChannel": "Pošlji kanal", "channels_deleteChannel": "Pošlji kanal",
"channels_deleteChannelConfirm": "Izbrišem \"{name}\"? To se ne da povrniti.", "channels_deleteChannelConfirm": "Izbrišem \"{name}\"? To se ne da povrniti.",
"@channels_deleteChannelConfirm": { "@channels_deleteChannelConfirm": {
@@ -1351,12 +1353,12 @@
} }
} }
}, },
"repeater_neighboursSubtitle": "Pogledati nič sosednjih hopjev.", "repeater_neighborsSubtitle": "Pogledati nič sosednjih hopjev.",
"repeater_neighbours": "Sosedi", "repeater_neighbors": "Sosedi",
"neighbors_receivedData": "Prejeto podatke o sosedih", "neighbors_receivedData": "Prejeto podatke o sosedih",
"neighbors_requestTimedOut": "Sosedi zahtevajo izklop po dogovoru.", "neighbors_requestTimedOut": "Sosedi zahtevajo izklop po dogovoru.",
"neighbors_errorLoading": "Napaka pri obnašanju sosedov: {error}", "neighbors_errorLoading": "Napaka pri obnašanju sosedov: {error}",
"neighbors_repeatersNeighbours": "Ponovitve Sosedi", "neighbors_repeatersNeighbors": "Ponovitve Sosedi",
"neighbors_noData": "Niso na voljo podatki o sosedih.", "neighbors_noData": "Niso na voljo podatki o sosedih.",
"channels_joinPrivateChannel": "Pridružite se zasebni skupini", "channels_joinPrivateChannel": "Pridružite se zasebni skupini",
"channels_createPrivateChannelDesc": "Varno zaklenjeno s skrivnim ključem.", "channels_createPrivateChannelDesc": "Varno zaklenjeno s skrivnim ključem.",
@@ -1594,7 +1596,125 @@
"scanner_enableBluetooth": "Omogočite Bluetooth", "scanner_enableBluetooth": "Omogočite Bluetooth",
"scanner_bluetoothOffMessage": "Prosimo, vklopite Bluetooth, da lahko poiščete naprave.", "scanner_bluetoothOffMessage": "Prosimo, vklopite Bluetooth, da lahko poiščete naprave.",
"scanner_bluetoothOff": "Bluetooth je izklopljen", "scanner_bluetoothOff": "Bluetooth je izklopljen",
"snrIndicator_lastSeen": "Zadnjič videno",
"snrIndicator_nearByRepeaters": "Bližnji ponovitelji",
"chat_ShowAllPaths": "Prikaži vse poti",
"settings_clientRepeatFreqWarning": "Za ponovni prenos na brezžični način so potrebne frekvence 433, 869 ali 918 MHz.", "settings_clientRepeatFreqWarning": "Za ponovni prenos na brezžični način so potrebne frekvence 433, 869 ali 918 MHz.",
"settings_clientRepeatSubtitle": "Omogočite temu naprave, da ponavlja paketne sporočila za druge.", "settings_clientRepeatSubtitle": "Omogočite temu naprave, da ponavlja paketne sporočila za druge.",
"settings_clientRepeat": "Neovadno ponavljanje" "settings_clientRepeat": "Neovadno ponavljanje",
"settings_aboutOpenMeteoAttribution": "Podatki o višini LOS: Open-Meteo (CC BY 4.0)",
"appSettings_unitsTitle": "Enote",
"appSettings_unitsMetric": "Metrična (m/km)",
"appSettings_unitsImperial": "Imperialno (ft / mi)",
"map_lineOfSight": "Linija vida",
"map_losScreenTitle": "Linija vida",
"losSelectStartEnd": "Izberite začetno in končno vozlišče za LOS.",
"losRunFailed": "Preverjanje vidnega polja ni uspelo: {error}",
"@losRunFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"losClearAllPoints": "Počisti vse točke",
"losRunToViewElevationProfile": "Zaženite LOS za ogled višinskega profila",
"losMenuTitle": "LOS meni",
"losMenuSubtitle": "Tapnite vozlišča ali dolgo pritisnite na zemljevid za točke po meri",
"losShowDisplayNodes": "Pokaži prikazna vozlišča",
"losCustomPoints": "Točke po meri",
"losCustomPointLabel": "Po meri {index}",
"@losCustomPointLabel": {
"placeholders": {
"index": {
"type": "int"
}
}
},
"losPointA": "Točka A",
"losPointB": "Točka B",
"losAntennaA": "Antena A: {value} {unit}",
"@losAntennaA": {
"placeholders": {
"value": {
"type": "String"
},
"unit": {
"type": "String"
}
}
},
"losAntennaB": "Antena B: {value} {unit}",
"@losAntennaB": {
"placeholders": {
"value": {
"type": "String"
},
"unit": {
"type": "String"
}
}
},
"losRun": "Zaženi LOS",
"losNoElevationData": "Ni podatkov o višini",
"losProfileClear": "{distance} {distanceUnit}, čisti LOS, najmanjša razdalja {clearance} {heightUnit}",
"@losProfileClear": {
"placeholders": {
"distance": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"clearance": {
"type": "String"
},
"heightUnit": {
"type": "String"
}
}
},
"losProfileBlocked": "{distance} {distanceUnit}, blokiral {obstruction} {heightUnit}",
"@losProfileBlocked": {
"placeholders": {
"distance": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"obstruction": {
"type": "String"
},
"heightUnit": {
"type": "String"
}
}
},
"losStatusChecking": "LOS: preverjam ...",
"losStatusNoData": "LOS: ni podatkov",
"losStatusSummary": "LOS: {clear}/{total} jasno, {blocked} blokirano, {unknown} neznano",
"@losStatusSummary": {
"placeholders": {
"clear": {
"type": "int"
},
"total": {
"type": "int"
},
"blocked": {
"type": "int"
},
"unknown": {
"type": "int"
}
}
},
"losErrorElevationUnavailable": "Podatki o nadmorski višini niso na voljo za enega ali več vzorcev.",
"losErrorInvalidInput": "Neveljavni podatki o točkah/višini za izračun LOS.",
"losRenameCustomPoint": "Preimenujte točko po meri",
"losPointName": "Ime točke",
"losShowPanelTooltip": "Pokaži ploščo LOS",
"losHidePanelTooltip": "Skrij ploščo LOS",
"losElevationAttribution": "Podatki o višini: Open-Meteo (CC BY 4.0)"
} }
+124 -4
View File
@@ -334,6 +334,8 @@
"channels_publicChannel": "Allmänt kanal", "channels_publicChannel": "Allmänt kanal",
"channels_privateChannel": "Privat kanal", "channels_privateChannel": "Privat kanal",
"channels_editChannel": "Redigera kanal", "channels_editChannel": "Redigera kanal",
"channels_muteChannel": "Tysta kanal",
"channels_unmuteChannel": "Slå på ljud för kanal",
"channels_deleteChannel": "Ta bort kanal", "channels_deleteChannel": "Ta bort kanal",
"channels_deleteChannelConfirm": "Radera \"{name}\"? Detta kan inte ångras.", "channels_deleteChannelConfirm": "Radera \"{name}\"? Detta kan inte ångras.",
"@channels_deleteChannelConfirm": { "@channels_deleteChannelConfirm": {
@@ -1351,12 +1353,12 @@
} }
} }
}, },
"repeater_neighbours": "Grannar", "repeater_neighbors": "Grannar",
"repeater_neighboursSubtitle": "Visa noll hoppgrannar.", "repeater_neighborsSubtitle": "Visa noll hoppgrannar.",
"neighbors_receivedData": "Mottagna grannars data", "neighbors_receivedData": "Mottagna grannars data",
"neighbors_requestTimedOut": "Grannar begär tidsinställd utskick.", "neighbors_requestTimedOut": "Grannar begär tidsinställd utskick.",
"neighbors_errorLoading": "Fel vid inläsning av grannar: {error}", "neighbors_errorLoading": "Fel vid inläsning av grannar: {error}",
"neighbors_repeatersNeighbours": "Upprepar grannar", "neighbors_repeatersNeighbors": "Upprepar grannar",
"neighbors_noData": "Inga grannuppgifter finns tillgängliga.", "neighbors_noData": "Inga grannuppgifter finns tillgängliga.",
"channels_createPrivateChannel": "Skapa en privat kanal", "channels_createPrivateChannel": "Skapa en privat kanal",
"channels_joinPrivateChannel": "Gå med i en Privat Kanal", "channels_joinPrivateChannel": "Gå med i en Privat Kanal",
@@ -1594,7 +1596,125 @@
"scanner_enableBluetooth": "Aktivera Bluetooth", "scanner_enableBluetooth": "Aktivera Bluetooth",
"scanner_bluetoothOffMessage": "Vänligen aktivera Bluetooth för att söka efter enheter.", "scanner_bluetoothOffMessage": "Vänligen aktivera Bluetooth för att söka efter enheter.",
"scanner_bluetoothOff": "Bluetooth är avstängt", "scanner_bluetoothOff": "Bluetooth är avstängt",
"snrIndicator_lastSeen": "Senast sedd",
"snrIndicator_nearByRepeaters": "Närliggande uppreparstationer",
"chat_ShowAllPaths": "Visa alla vägar",
"settings_clientRepeatSubtitle": "Låt enheten repetera nätpaket för andra användare.", "settings_clientRepeatSubtitle": "Låt enheten repetera nätpaket för andra användare.",
"settings_clientRepeat": "Upprepa utan elnät", "settings_clientRepeat": "Upprepa utan elnät",
"settings_clientRepeatFreqWarning": "För att kunna kommunicera utanför elnätet krävs frekvenserna 433, 869 eller 918 MHz." "settings_clientRepeatFreqWarning": "För att kunna kommunicera utanför elnätet krävs frekvenserna 433, 869 eller 918 MHz.",
"settings_aboutOpenMeteoAttribution": "LOS-höjddata: Open-Meteo (CC BY 4.0)",
"appSettings_unitsTitle": "Enheter",
"appSettings_unitsMetric": "Metriskt (m/km)",
"appSettings_unitsImperial": "Imperialt (ft / mi)",
"map_lineOfSight": "Synlinje",
"map_losScreenTitle": "Synlinje",
"losSelectStartEnd": "Välj start- och slutnoder för LOS.",
"losRunFailed": "Synlinjekontroll misslyckades: {error}",
"@losRunFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"losClearAllPoints": "Rensa alla punkter",
"losRunToViewElevationProfile": "Kör LOS för att se höjdprofil",
"losMenuTitle": "LOS-menyn",
"losMenuSubtitle": "Tryck på noder eller tryck länge på kartan för anpassade punkter",
"losShowDisplayNodes": "Visa displaynoder",
"losCustomPoints": "Anpassade poäng",
"losCustomPointLabel": "Anpassad {index}",
"@losCustomPointLabel": {
"placeholders": {
"index": {
"type": "int"
}
}
},
"losPointA": "Punkt A",
"losPointB": "Punkt B",
"losAntennaA": "Antenn A: {value} {unit}",
"@losAntennaA": {
"placeholders": {
"value": {
"type": "String"
},
"unit": {
"type": "String"
}
}
},
"losAntennaB": "Antenn B: {value} {unit}",
"@losAntennaB": {
"placeholders": {
"value": {
"type": "String"
},
"unit": {
"type": "String"
}
}
},
"losRun": "Kör LOS",
"losNoElevationData": "Inga höjddata",
"losProfileClear": "{distance} {distanceUnit}, rensa LOS, min clearance {clearance} {heightUnit}",
"@losProfileClear": {
"placeholders": {
"distance": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"clearance": {
"type": "String"
},
"heightUnit": {
"type": "String"
}
}
},
"losProfileBlocked": "{distance} {distanceUnit}, blockerad av {obstruction} {heightUnit}",
"@losProfileBlocked": {
"placeholders": {
"distance": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"obstruction": {
"type": "String"
},
"heightUnit": {
"type": "String"
}
}
},
"losStatusChecking": "LOS: kollar...",
"losStatusNoData": "LOS: inga data",
"losStatusSummary": "LOS: {clear}/{total} rensa, {blocked} blockerad, {unknown} okänd",
"@losStatusSummary": {
"placeholders": {
"clear": {
"type": "int"
},
"total": {
"type": "int"
},
"blocked": {
"type": "int"
},
"unknown": {
"type": "int"
}
}
},
"losErrorElevationUnavailable": "Höjddata är inte tillgänglig för ett eller flera prover.",
"losErrorInvalidInput": "Ogiltiga poäng/höjddata för LOS-beräkning.",
"losRenameCustomPoint": "Byt namn på anpassad punkt",
"losPointName": "Punktnamn",
"losShowPanelTooltip": "Visa LOS-panelen",
"losHidePanelTooltip": "Dölj LOS-panelen",
"losElevationAttribution": "Höjddata: Open-Meteo (CC BY 4.0)"
} }
+124 -4
View File
@@ -335,6 +335,8 @@
"channels_publicChannel": "Публічний канал", "channels_publicChannel": "Публічний канал",
"channels_privateChannel": "Приватний канал", "channels_privateChannel": "Приватний канал",
"channels_editChannel": "Редагувати канал", "channels_editChannel": "Редагувати канал",
"channels_muteChannel": "Вимкнути сповіщення каналу",
"channels_unmuteChannel": "Увімкнути сповіщення каналу",
"channels_deleteChannel": "Видалити канал", "channels_deleteChannel": "Видалити канал",
"channels_deleteChannelConfirm": "Видалити {name}? Це не можна скасувати.", "channels_deleteChannelConfirm": "Видалити {name}? Це не можна скасувати.",
"@channels_deleteChannelConfirm": { "@channels_deleteChannelConfirm": {
@@ -1352,12 +1354,12 @@
} }
} }
}, },
"repeater_neighbours": "Сусіди", "repeater_neighbors": "Сусіди",
"repeater_neighboursSubtitle": "Показати сусідів нульового стрибка.", "repeater_neighborsSubtitle": "Показати сусідів нульового стрибка.",
"neighbors_receivedData": "Дані сусідів отримано", "neighbors_receivedData": "Дані сусідів отримано",
"neighbors_requestTimedOut": "Час запиту сусідів вичерпано.", "neighbors_requestTimedOut": "Час запиту сусідів вичерпано.",
"neighbors_errorLoading": "Помилка завантаження сусідів: {error}", "neighbors_errorLoading": "Помилка завантаження сусідів: {error}",
"neighbors_repeatersNeighbours": "Ретранслятори-сусіди", "neighbors_repeatersNeighbors": "Ретранслятори-сусіди",
"neighbors_noData": "Дані про сусідів недоступні.", "neighbors_noData": "Дані про сусідів недоступні.",
"channels_createPrivateChannelDesc": "Захищено секретним ключем.", "channels_createPrivateChannelDesc": "Захищено секретним ключем.",
"channels_joinPrivateChannel": "Приєднатися до приватного каналу", "channels_joinPrivateChannel": "Приєднатися до приватного каналу",
@@ -1594,7 +1596,125 @@
"scanner_enableBluetooth": "Увімкніть Bluetooth", "scanner_enableBluetooth": "Увімкніть Bluetooth",
"scanner_bluetoothOffMessage": "Будь ласка, увімкніть Bluetooth, щоб сканувати пристрої.", "scanner_bluetoothOffMessage": "Будь ласка, увімкніть Bluetooth, щоб сканувати пристрої.",
"scanner_bluetoothOff": "Bluetooth вимкнено", "scanner_bluetoothOff": "Bluetooth вимкнено",
"snrIndicator_lastSeen": "Останній раз бачили",
"snrIndicator_nearByRepeaters": "Ближні ретранслятори",
"chat_ShowAllPaths": "Показати всі шляхи",
"settings_clientRepeatFreqWarning": "Повтор без підключення до мережі вимагає частоти 433, 869 або 918 МГц.", "settings_clientRepeatFreqWarning": "Повтор без підключення до мережі вимагає частоти 433, 869 або 918 МГц.",
"settings_clientRepeatSubtitle": "Дозвольте цьому пристрою повторювати пакети даних для інших пристроїв.", "settings_clientRepeatSubtitle": "Дозвольте цьому пристрою повторювати пакети даних для інших пристроїв.",
"settings_clientRepeat": "Автономна система" "settings_clientRepeat": "Автономна система",
"settings_aboutOpenMeteoAttribution": "Дані про висоту LOS: Open-Meteo (CC BY 4.0)",
"appSettings_unitsTitle": "одиниці",
"appSettings_unitsMetric": "Метричний (м / км)",
"appSettings_unitsImperial": "Імперська (ft / mi)",
"map_lineOfSight": "Пряма видимість",
"map_losScreenTitle": "Пряма видимість",
"losSelectStartEnd": "Виберіть початковий і кінцевий вузли для LOS.",
"losRunFailed": "Помилка перевірки прямої видимості: {error}",
"@losRunFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"losClearAllPoints": "Очистити всі пункти",
"losRunToViewElevationProfile": "Запустіть LOS, щоб переглянути профіль висоти",
"losMenuTitle": "Меню LOS",
"losMenuSubtitle": "Торкніться вузлів або утримуйте карту, щоб отримати власні точки",
"losShowDisplayNodes": "Показати вузли відображення",
"losCustomPoints": "Користувальницькі точки",
"losCustomPointLabel": "Спеціальний {index}",
"@losCustomPointLabel": {
"placeholders": {
"index": {
"type": "int"
}
}
},
"losPointA": "Точка А",
"losPointB": "Точка Б",
"losAntennaA": "Антена A: {value} {unit}",
"@losAntennaA": {
"placeholders": {
"value": {
"type": "String"
},
"unit": {
"type": "String"
}
}
},
"losAntennaB": "Антена B: {value} {unit}",
"@losAntennaB": {
"placeholders": {
"value": {
"type": "String"
},
"unit": {
"type": "String"
}
}
},
"losRun": "Запустіть LOS",
"losNoElevationData": "Немає даних про висоту",
"losProfileClear": "{distance} {distanceUnit}, чистий LOS, мінімальний зазор {clearance} {heightUnit}",
"@losProfileClear": {
"placeholders": {
"distance": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"clearance": {
"type": "String"
},
"heightUnit": {
"type": "String"
}
}
},
"losProfileBlocked": "{distance} {distanceUnit}, заблоковано {obstruction} {heightUnit}",
"@losProfileBlocked": {
"placeholders": {
"distance": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"obstruction": {
"type": "String"
},
"heightUnit": {
"type": "String"
}
}
},
"losStatusChecking": "LOS: перевірка...",
"losStatusNoData": "LOS: немає даних",
"losStatusSummary": "LOS: {clear}/{total} очищено, {blocked} заблоковано, {unknown} невідомо",
"@losStatusSummary": {
"placeholders": {
"clear": {
"type": "int"
},
"total": {
"type": "int"
},
"blocked": {
"type": "int"
},
"unknown": {
"type": "int"
}
}
},
"losErrorElevationUnavailable": "Дані про висоту недоступні для одного чи кількох зразків.",
"losErrorInvalidInput": "Недійсні дані про точки/висоту для розрахунку LOS.",
"losRenameCustomPoint": "Перейменуйте спеціальну точку",
"losPointName": "Назва точки",
"losShowPanelTooltip": "Показати панель LOS",
"losHidePanelTooltip": "Приховати панель LOS",
"losElevationAttribution": "Дані про висоту: Open-Meteo (CC BY 4.0)"
} }
+124 -4
View File
@@ -342,6 +342,8 @@
"channels_publicChannel": "公共频道", "channels_publicChannel": "公共频道",
"channels_privateChannel": "私密频道", "channels_privateChannel": "私密频道",
"channels_editChannel": "编辑频道", "channels_editChannel": "编辑频道",
"channels_muteChannel": "静音频道",
"channels_unmuteChannel": "取消静音频道",
"channels_deleteChannel": "删除频道", "channels_deleteChannel": "删除频道",
"channels_deleteChannelConfirm": "Delete \"{name}\"? This cannot be undone.", "channels_deleteChannelConfirm": "Delete \"{name}\"? This cannot be undone.",
"@channels_deleteChannelConfirm": { "@channels_deleteChannelConfirm": {
@@ -895,8 +897,8 @@
"repeater_telemetrySubtitle": "查看传感器和系统状态的数据。", "repeater_telemetrySubtitle": "查看传感器和系统状态的数据。",
"repeater_cli": "命令行界面", "repeater_cli": "命令行界面",
"repeater_cliSubtitle": "向复用器发送指令", "repeater_cliSubtitle": "向复用器发送指令",
"repeater_neighbours": "邻居", "repeater_neighbors": "邻居",
"repeater_neighboursSubtitle": "查看邻居节点(无需中间节点)。", "repeater_neighborsSubtitle": "查看邻居节点(无需中间节点)。",
"repeater_settings": "设置", "repeater_settings": "设置",
"repeater_settingsSubtitle": "配置重复器参数", "repeater_settingsSubtitle": "配置重复器参数",
"repeater_statusTitle": "重复器状态", "repeater_statusTitle": "重复器状态",
@@ -1266,7 +1268,7 @@
} }
} }
}, },
"neighbors_repeatersNeighbours": "重复使用的邻居", "neighbors_repeatersNeighbors": "重复使用的邻居",
"neighbors_noData": "没有可用的邻居信息。", "neighbors_noData": "没有可用的邻居信息。",
"neighbors_unknownContact": "Unknown {pubkey}", "neighbors_unknownContact": "Unknown {pubkey}",
"@neighbors_unknownContact": { "@neighbors_unknownContact": {
@@ -1594,7 +1596,125 @@
"scanner_bluetoothOffMessage": "请打开蓝牙功能,以便搜索设备。", "scanner_bluetoothOffMessage": "请打开蓝牙功能,以便搜索设备。",
"scanner_bluetoothOff": "蓝牙已关闭", "scanner_bluetoothOff": "蓝牙已关闭",
"scanner_enableBluetooth": "启用蓝牙", "scanner_enableBluetooth": "启用蓝牙",
"snrIndicator_lastSeen": "最近访问",
"snrIndicator_nearByRepeaters": "附近的重复器",
"chat_ShowAllPaths": "显示所有路径",
"settings_clientRepeat": "离网重复", "settings_clientRepeat": "离网重复",
"settings_clientRepeatSubtitle": "允许此设备重复发送网状数据包给其他设备", "settings_clientRepeatSubtitle": "允许此设备重复发送网状数据包给其他设备",
"settings_clientRepeatFreqWarning": "离网重复通信需要使用 433、869 或 918 兆赫兹的频率。" "settings_clientRepeatFreqWarning": "离网重复通信需要使用 433、869 或 918 兆赫兹的频率。",
"settings_aboutOpenMeteoAttribution": "LOS 高程数据:Open-Meteo (CC BY 4.0)",
"appSettings_unitsTitle": "单位",
"appSettings_unitsMetric": "公制(米/公里)",
"appSettings_unitsImperial": "英制 (ft / mi)",
"map_lineOfSight": "视线",
"map_losScreenTitle": "视线",
"losSelectStartEnd": "选择 LOS 的起始节点和结束节点。",
"losRunFailed": "视线检查失败:{error}",
"@losRunFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"losClearAllPoints": "清除所有点",
"losRunToViewElevationProfile": "运行 LOS 查看高程剖面",
"losMenuTitle": "服务水平菜单",
"losMenuSubtitle": "点击节点或长按地图以获取自定义点",
"losShowDisplayNodes": "显示显示节点",
"losCustomPoints": "自定义积分",
"losCustomPointLabel": "自定义 {index}",
"@losCustomPointLabel": {
"placeholders": {
"index": {
"type": "int"
}
}
},
"losPointA": "A点",
"losPointB": "B点",
"losAntennaA": "天线 A {value} {unit}",
"@losAntennaA": {
"placeholders": {
"value": {
"type": "String"
},
"unit": {
"type": "String"
}
}
},
"losAntennaB": "天线 B{value} {unit}",
"@losAntennaB": {
"placeholders": {
"value": {
"type": "String"
},
"unit": {
"type": "String"
}
}
},
"losRun": "运行视距",
"losNoElevationData": "无海拔数据",
"losProfileClear": "{distance} {distanceUnit},清除 LOS,最小间隙 {clearance} {heightUnit}",
"@losProfileClear": {
"placeholders": {
"distance": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"clearance": {
"type": "String"
},
"heightUnit": {
"type": "String"
}
}
},
"losProfileBlocked": "{distance} {distanceUnit},被 {obstruction} {heightUnit} 阻止",
"@losProfileBlocked": {
"placeholders": {
"distance": {
"type": "String"
},
"distanceUnit": {
"type": "String"
},
"obstruction": {
"type": "String"
},
"heightUnit": {
"type": "String"
}
}
},
"losStatusChecking": "洛斯:正在检查...",
"losStatusNoData": "LOS:无数据",
"losStatusSummary": "LOS{clear}/{total} 清除,{blocked} 阻塞,{unknown} 未知",
"@losStatusSummary": {
"placeholders": {
"clear": {
"type": "int"
},
"total": {
"type": "int"
},
"blocked": {
"type": "int"
},
"unknown": {
"type": "int"
}
}
},
"losErrorElevationUnavailable": "一个或多个样本的海拔数据不可用。",
"losErrorInvalidInput": "用于 LOS 计算的点/高程数据无效。",
"losRenameCustomPoint": "重命名自定义点",
"losPointName": "点名称",
"losShowPanelTooltip": "显示 LOS 面板",
"losHidePanelTooltip": "隐藏 LOS 面板",
"losElevationAttribution": "高程数据:Open-Meteo (CC BY 4.0)"
} }
+23
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter/foundation.dart';
import 'l10n/app_localizations.dart'; import 'l10n/app_localizations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -47,6 +48,7 @@ void main() async {
final notificationService = NotificationService(); final notificationService = NotificationService();
await notificationService.initialize(); await notificationService.initialize();
await backgroundService.initialize(); await backgroundService.initialize();
_registerThirdPartyLicenses();
// Wire up connector with services // Wire up connector with services
connector.initialize( connector.initialize(
@@ -80,6 +82,27 @@ void main() async {
); );
} }
void _registerThirdPartyLicenses() {
LicenseRegistry.addLicense(() async* {
yield const LicenseEntryWithLineBreaks(
<String>['Open-Meteo Elevation API Data'],
'''
Data used by LOS elevation lookups is provided by Open-Meteo.
Open-Meteo terms and attribution:
https://open-meteo.com/en/terms
Elevation API:
https://open-meteo.com/en/docs/elevation-api
Attribution license reference:
Creative Commons Attribution 4.0 International (CC BY 4.0)
https://creativecommons.org/licenses/by/4.0/
''',
);
});
}
class MeshCoreApp extends StatelessWidget { class MeshCoreApp extends StatelessWidget {
final MeshCoreConnector connector; final MeshCoreConnector connector;
final MessageRetryService retryService; final MessageRetryService retryService;
+50 -1
View File
@@ -1,3 +1,16 @@
enum UnitSystem { metric, imperial }
extension UnitSystemValue on UnitSystem {
String get value {
switch (this) {
case UnitSystem.imperial:
return 'imperial';
case UnitSystem.metric:
return 'metric';
}
}
}
class AppSettings { class AppSettings {
static const Object _unset = Object(); static const Object _unset = Object();
@@ -21,6 +34,9 @@ class AppSettings {
final String? languageOverride; // null = system default final String? languageOverride; // null = system default
final bool appDebugLogEnabled; final bool appDebugLogEnabled;
final Map<String, String> batteryChemistryByDeviceId; final Map<String, String> batteryChemistryByDeviceId;
final Map<String, String> batteryChemistryByRepeaterId;
final UnitSystem unitSystem;
final Set<String> mutedChannels;
AppSettings({ AppSettings({
this.clearPathOnMaxRetry = false, this.clearPathOnMaxRetry = false,
@@ -43,7 +59,12 @@ class AppSettings {
this.languageOverride, this.languageOverride,
this.appDebugLogEnabled = false, this.appDebugLogEnabled = false,
Map<String, String>? batteryChemistryByDeviceId, Map<String, String>? batteryChemistryByDeviceId,
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {}; Map<String, String>? batteryChemistryByRepeaterId,
this.unitSystem = UnitSystem.metric,
Set<String>? mutedChannels,
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {},
batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {},
mutedChannels = mutedChannels ?? {};
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
@@ -67,10 +88,20 @@ class AppSettings {
'language_override': languageOverride, 'language_override': languageOverride,
'app_debug_log_enabled': appDebugLogEnabled, 'app_debug_log_enabled': appDebugLogEnabled,
'battery_chemistry_by_device_id': batteryChemistryByDeviceId, 'battery_chemistry_by_device_id': batteryChemistryByDeviceId,
'battery_chemistry_by_repeater_id': batteryChemistryByRepeaterId,
'unit_system': unitSystem.value,
'muted_channels': mutedChannels.toList(),
}; };
} }
factory AppSettings.fromJson(Map<String, dynamic> json) { factory AppSettings.fromJson(Map<String, dynamic> json) {
UnitSystem parseUnitSystem(dynamic value) {
if (value is String && value.toLowerCase() == 'imperial') {
return UnitSystem.imperial;
}
return UnitSystem.metric;
}
return AppSettings( return AppSettings(
clearPathOnMaxRetry: json['clear_path_on_max_retry'] as bool? ?? false, clearPathOnMaxRetry: json['clear_path_on_max_retry'] as bool? ?? false,
mapShowRepeaters: json['map_show_repeaters'] as bool? ?? true, mapShowRepeaters: json['map_show_repeaters'] as bool? ?? true,
@@ -101,6 +132,17 @@ class AppSettings {
(key, value) => MapEntry(key.toString(), value.toString()), (key, value) => MapEntry(key.toString(), value.toString()),
) ?? ) ??
{}, {},
batteryChemistryByRepeaterId:
(json['battery_chemistry_by_repeater_id'] as Map?)?.map(
(key, value) => MapEntry(key.toString(), value.toString()),
) ??
{},
unitSystem: parseUnitSystem(json['unit_system']),
mutedChannels:
((json['muted_channels'] as List?)
?.map((e) => e.toString())
.toSet()) ??
{},
); );
} }
@@ -125,6 +167,9 @@ class AppSettings {
Object? languageOverride = _unset, Object? languageOverride = _unset,
bool? appDebugLogEnabled, bool? appDebugLogEnabled,
Map<String, String>? batteryChemistryByDeviceId, Map<String, String>? batteryChemistryByDeviceId,
Map<String, String>? batteryChemistryByRepeaterId,
UnitSystem? unitSystem,
Set<String>? mutedChannels,
}) { }) {
return AppSettings( return AppSettings(
clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry, clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry,
@@ -154,6 +199,10 @@ class AppSettings {
appDebugLogEnabled: appDebugLogEnabled ?? this.appDebugLogEnabled, appDebugLogEnabled: appDebugLogEnabled ?? this.appDebugLogEnabled,
batteryChemistryByDeviceId: batteryChemistryByDeviceId:
batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId, batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId,
batteryChemistryByRepeaterId:
batteryChemistryByRepeaterId ?? this.batteryChemistryByRepeaterId,
unitSystem: unitSystem ?? this.unitSystem,
mutedChannels: mutedChannels ?? this.mutedChannels,
); );
} }
} }
+38 -34
View File
@@ -119,7 +119,7 @@ class Contact {
final pathBytes = _pathBytesForDisplay; final pathBytes = _pathBytesForDisplay;
Uint8List? traceBytes; Uint8List? traceBytes;
if (pathLength <= 0) { if (pathBytes.isEmpty) {
traceBytes = Uint8List(1); traceBytes = Uint8List(1);
traceBytes[0] = publicKey[0]; traceBytes[0] = publicKey[0];
return traceBytes; return traceBytes;
@@ -160,43 +160,47 @@ class Contact {
} }
static Contact? fromFrame(Uint8List data) { static Contact? fromFrame(Uint8List data) {
if (data.length < contactFrameSize) return null; if (data.isEmpty) return null;
if (data[0] != respCodeContact) return null; if (data[0] != respCodeContact) return null;
try {
final pubKey = Uint8List.fromList(
data.sublist(contactPubKeyOffset, contactPubKeyOffset + pubKeySize),
);
final type = data[contactTypeOffset];
final pathLen = data[contactPathLenOffset].toSigned(8);
final safePathLen = pathLen > 0
? (pathLen > maxPathSize ? maxPathSize : pathLen)
: 0;
final pathBytes = safePathLen > 0
? Uint8List.fromList(
data.sublist(contactPathOffset, contactPathOffset + safePathLen),
)
: Uint8List(0);
final name = readCString(data, contactNameOffset, maxNameSize);
final lastmod = readUint32LE(data, contactLastmodOffset);
final pubKey = Uint8List.fromList( double? lat, lon;
data.sublist(contactPubKeyOffset, contactPubKeyOffset + pubKeySize), final latRaw = readInt32LE(data, contactLatOffset);
); final lonRaw = readInt32LE(data, contactLonOffset);
final type = data[contactTypeOffset]; if (latRaw != 0 || lonRaw != 0) {
final pathLen = data[contactPathLenOffset].toSigned(8); lat = latRaw / 1e6;
final safePathLen = pathLen > 0 lon = lonRaw / 1e6;
? (pathLen > maxPathSize ? maxPathSize : pathLen) }
: 0;
final pathBytes = safePathLen > 0
? Uint8List.fromList(
data.sublist(contactPathOffset, contactPathOffset + safePathLen),
)
: Uint8List(0);
final name = readCString(data, contactNameOffset, maxNameSize);
final lastmod = readUint32LE(data, contactLastmodOffset);
double? lat, lon; return Contact(
final latRaw = readInt32LE(data, contactLatOffset); publicKey: pubKey,
final lonRaw = readInt32LE(data, contactLonOffset); name: name.isEmpty ? 'Unknown' : name,
if (latRaw != 0 || lonRaw != 0) { type: type,
lat = latRaw / 1e6; pathLength: pathLen,
lon = lonRaw / 1e6; path: pathBytes,
latitude: lat,
longitude: lon,
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastmod * 1000),
);
} catch (e) {
// If parsing fails, return null
return null;
} }
return Contact(
publicKey: pubKey,
name: name.isEmpty ? 'Unknown' : name,
type: type,
pathLength: pathLen,
path: pathBytes,
latitude: lat,
longitude: lon,
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastmod * 1000),
);
} }
@override @override
+2 -1
View File
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../services/app_debug_log_service.dart'; import '../services/app_debug_log_service.dart';
import '../widgets/adaptive_app_bar_title.dart';
class AppDebugLogScreen extends StatelessWidget { class AppDebugLogScreen extends StatelessWidget {
const AppDebugLogScreen({super.key}); const AppDebugLogScreen({super.key});
@@ -17,7 +18,7 @@ class AppDebugLogScreen extends StatelessWidget {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(context.l10n.debugLog_appTitle), title: AdaptiveAppBarTitle(context.l10n.debugLog_appTitle),
centerTitle: true, centerTitle: true,
actions: [ actions: [
IconButton( IconButton(
+55 -1
View File
@@ -3,8 +3,10 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../models/app_settings.dart';
import '../services/app_settings_service.dart'; import '../services/app_settings_service.dart';
import '../services/notification_service.dart'; import '../services/notification_service.dart';
import '../widgets/adaptive_app_bar_title.dart';
import 'map_cache_screen.dart'; import 'map_cache_screen.dart';
class AppSettingsScreen extends StatelessWidget { class AppSettingsScreen extends StatelessWidget {
@@ -14,7 +16,7 @@ class AppSettingsScreen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(context.l10n.appSettings_title), title: AdaptiveAppBarTitle(context.l10n.appSettings_title),
centerTitle: true, centerTitle: true,
), ),
body: SafeArea( body: SafeArea(
@@ -360,6 +362,18 @@ class AppSettingsScreen extends StatelessWidget {
onTap: () => _showTimeFilterDialog(context, settingsService), onTap: () => _showTimeFilterDialog(context, settingsService),
), ),
const Divider(height: 1), const Divider(height: 1),
ListTile(
leading: const Icon(Icons.straighten),
title: Text(context.l10n.appSettings_unitsTitle),
subtitle: Text(
settingsService.settings.unitSystem == UnitSystem.imperial
? context.l10n.appSettings_unitsImperial
: context.l10n.appSettings_unitsMetric,
),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showUnitsDialog(context, settingsService),
),
const Divider(height: 1),
ListTile( ListTile(
leading: const Icon(Icons.download_outlined), leading: const Icon(Icons.download_outlined),
title: Text(context.l10n.appSettings_offlineMapCache), title: Text(context.l10n.appSettings_offlineMapCache),
@@ -706,6 +720,46 @@ class AppSettingsScreen extends StatelessWidget {
); );
} }
void _showUnitsDialog(
BuildContext context,
AppSettingsService settingsService,
) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.appSettings_unitsTitle),
content: RadioGroup<UnitSystem>(
groupValue: settingsService.settings.unitSystem,
onChanged: (value) {
if (value != null) {
settingsService.setUnitSystem(value);
Navigator.pop(context);
}
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: Text(context.l10n.appSettings_unitsMetric),
leading: const Radio<UnitSystem>(value: UnitSystem.metric),
),
ListTile(
title: Text(context.l10n.appSettings_unitsImperial),
leading: const Radio<UnitSystem>(value: UnitSystem.imperial),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.common_close),
),
],
),
);
}
Widget _buildDebugCard( Widget _buildDebugCard(
BuildContext context, BuildContext context,
AppSettingsService settingsService, AppSettingsService settingsService,
+2 -1
View File
@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../services/ble_debug_log_service.dart'; import '../services/ble_debug_log_service.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../widgets/adaptive_app_bar_title.dart';
enum _BleLogView { frames, rawLogRx } enum _BleLogView { frames, rawLogRx }
@@ -29,7 +30,7 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
: rawEntries.isNotEmpty; : rawEntries.isNotEmpty;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(context.l10n.debugLog_bleTitle), title: AdaptiveAppBarTitle(context.l10n.debugLog_bleTitle),
actions: [ actions: [
IconButton( IconButton(
tooltip: context.l10n.debugLog_copyLog, tooltip: context.l10n.debugLog_copyLog,
+369 -166
View File
@@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
@@ -271,193 +272,243 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
? message.pathVariants.first ? message.pathVariants.first
: Uint8List(0)); : Uint8List(0));
return Padding( const maxSwipeOffset = 64.0;
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), const replySwipeThreshold = 64.0;
child: Column( final messageBody = Column(
crossAxisAlignment: isOutgoing crossAxisAlignment: isOutgoing
? CrossAxisAlignment.end ? CrossAxisAlignment.end
: CrossAxisAlignment.start, : CrossAxisAlignment.start,
children: [ children: [
Row( Row(
mainAxisAlignment: isOutgoing mainAxisAlignment: isOutgoing
? MainAxisAlignment.end ? MainAxisAlignment.end
: MainAxisAlignment.start, : MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (!isOutgoing) ...[ if (!isOutgoing) ...[
_buildAvatar(message.senderName), _buildAvatar(message.senderName),
const SizedBox(width: 8), const SizedBox(width: 8),
], ],
Flexible( Flexible(
child: GestureDetector( child: GestureDetector(
onTap: () => _showMessagePathInfo(message), onTap: () => _showMessagePathInfo(message),
onLongPress: () => _showMessageActions(message), onLongPress: () => _showMessageActions(message),
child: Container( child: Container(
padding: gifId != null padding: gifId != null
? const EdgeInsets.all(4) ? const EdgeInsets.all(4)
: const EdgeInsets.symmetric( : const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
horizontal: 12, constraints: BoxConstraints(
vertical: 8, maxWidth: MediaQuery.of(context).size.width * 0.65,
), ),
constraints: BoxConstraints( decoration: BoxDecoration(
maxWidth: MediaQuery.of(context).size.width * 0.65, color: isOutgoing
), ? Theme.of(context).colorScheme.primaryContainer
decoration: BoxDecoration( : Theme.of(context).colorScheme.surfaceContainerHighest,
color: isOutgoing borderRadius: BorderRadius.circular(12),
? Theme.of(context).colorScheme.primaryContainer ),
: Theme.of( child: Column(
context, crossAxisAlignment: CrossAxisAlignment.start,
).colorScheme.surfaceContainerHighest, children: [
borderRadius: BorderRadius.circular(12), if (!isOutgoing) ...[
), Padding(
child: Column( padding: gifId != null
crossAxisAlignment: CrossAxisAlignment.start, ? const EdgeInsets.only(
children: [ left: 8,
if (!isOutgoing) ...[ top: 4,
Padding( bottom: 4,
padding: gifId != null )
? const EdgeInsets.only( : EdgeInsets.zero,
left: 8, child: Text(
top: 4, message.senderName,
bottom: 4, style: TextStyle(
) fontSize: 12,
: EdgeInsets.zero, fontWeight: FontWeight.bold,
child: Text( color: Theme.of(context).colorScheme.primary,
message.senderName,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
), ),
), ),
if (gifId == null) const SizedBox(height: 4), ),
], if (gifId == null) const SizedBox(height: 4),
if (message.replyToMessageId != null) ...[ ],
_buildReplyPreview(message), if (message.replyToMessageId != null) ...[
const SizedBox(height: 8), _buildReplyPreview(message),
], const SizedBox(height: 8),
if (poi != null) ],
_buildPoiMessage(context, poi, isOutgoing) if (poi != null)
else if (gifId != null) _buildPoiMessage(context, poi, isOutgoing)
ClipRRect( else if (gifId != null)
borderRadius: BorderRadius.circular(8), ClipRRect(
child: GifMessage( borderRadius: BorderRadius.circular(8),
url: child: GifMessage(
'https://media.giphy.com/media/$gifId/giphy.gif', url:
backgroundColor: Colors.transparent, 'https://media.giphy.com/media/$gifId/giphy.gif',
fallbackTextColor: isOutgoing backgroundColor: Colors.transparent,
? Theme.of(context) fallbackTextColor: isOutgoing
.colorScheme ? Theme.of(context)
.onPrimaryContainer .colorScheme
.withValues(alpha: 0.7) .onPrimaryContainer
: Theme.of(context).colorScheme.onSurface .withValues(alpha: 0.7)
.withValues(alpha: 0.6), : Theme.of(context).colorScheme.onSurface
), .withValues(alpha: 0.6),
)
else
Linkify(
text: message.text,
style: const TextStyle(fontSize: 14),
linkStyle: const TextStyle(
fontSize: 14,
color: Colors.green,
decoration: TextDecoration.underline,
),
options: const LinkifyOptions(
humanize: false,
defaultToHttps: false,
),
linkifiers: const [UrlLinkifier()],
onOpen: (link) =>
LinkHandler.handleLinkTap(context, link.url),
), ),
if (displayPath.isNotEmpty) ...[ )
const SizedBox(height: 4), else
Padding( Linkify(
padding: gifId != null text: message.text,
? const EdgeInsets.symmetric(horizontal: 8) style: const TextStyle(fontSize: 14),
: EdgeInsets.zero, linkStyle: const TextStyle(
child: Text( fontSize: 14,
'via ${_formatPathPrefixes(displayPath)}', color: Colors.green,
decoration: TextDecoration.underline,
),
options: const LinkifyOptions(
humanize: false,
defaultToHttps: false,
),
linkifiers: const [UrlLinkifier()],
onOpen: (link) =>
LinkHandler.handleLinkTap(context, link.url),
),
if (displayPath.isNotEmpty) ...[
const SizedBox(height: 4),
Padding(
padding: gifId != null
? const EdgeInsets.symmetric(horizontal: 8)
: EdgeInsets.zero,
child: Text(
'via ${_formatPathPrefixes(displayPath)}',
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
),
),
],
const SizedBox(height: 4),
Padding(
padding: gifId != null
? const EdgeInsets.only(
left: 8,
right: 8,
bottom: 4,
)
: EdgeInsets.zero,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(message.timestamp),
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
color: Colors.grey[600], color: Colors.grey[600],
), ),
), ),
), if (message.repeatCount > 0) ...[
], const SizedBox(width: 6),
const SizedBox(height: 4), Icon(
Padding( Icons.repeat,
padding: gifId != null size: 12,
? const EdgeInsets.only( color: Colors.grey[600],
left: 8, ),
right: 8, const SizedBox(width: 2),
bottom: 4,
)
: EdgeInsets.zero,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text( Text(
_formatTime(message.timestamp), '${message.repeatCount}',
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
color: Colors.grey[600], color: Colors.grey[600],
), ),
), ),
if (message.repeatCount > 0) ...[
const SizedBox(width: 6),
Icon(
Icons.repeat,
size: 12,
color: Colors.grey[600],
),
const SizedBox(width: 2),
Text(
'${message.repeatCount}',
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
),
],
if (isOutgoing) ...[
const SizedBox(width: 4),
Icon(
message.status == ChannelMessageStatus.sent
? Icons.check
: message.status ==
ChannelMessageStatus.pending
? Icons.schedule
: Icons.error_outline,
size: 14,
color:
message.status ==
ChannelMessageStatus.failed
? Colors.red
: Colors.grey[600],
),
],
], ],
), if (isOutgoing) ...[
const SizedBox(width: 4),
Icon(
message.status == ChannelMessageStatus.sent
? Icons.check
: message.status ==
ChannelMessageStatus.pending
? Icons.schedule
: Icons.error_outline,
size: 14,
color:
message.status ==
ChannelMessageStatus.failed
? Colors.red
: Colors.grey[600],
),
],
],
), ),
], ),
), ],
), ),
), ),
), ),
],
),
if (message.reactions.isNotEmpty) ...[
const SizedBox(height: 4),
Padding(
padding: EdgeInsets.only(left: isOutgoing ? 0 : 48),
child: _buildReactionsDisplay(message),
), ),
], ],
),
if (message.reactions.isNotEmpty) ...[
const SizedBox(height: 4),
Padding(
padding: EdgeInsets.only(left: isOutgoing ? 0 : 48),
child: _buildReactionsDisplay(message),
),
], ],
), ],
);
if (!isOutgoing) {
return _SwipeReplyBubble(
maxSwipeOffset: maxSwipeOffset,
replySwipeThreshold: replySwipeThreshold,
onReplyTriggered: () => _setReplyingTo(message),
hintBuilder: ({required isStart}) =>
_buildReplySwipeHint(isStart: isStart),
child: messageBody,
);
} else {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: messageBody,
);
}
}
Widget _buildReplySwipeHint({required bool isStart}) {
final colorScheme = Theme.of(context).colorScheme;
final content = Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.reply, color: colorScheme.primary),
const SizedBox(width: 6),
Text(
context.l10n.chat_reply,
style: TextStyle(
color: colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
],
);
return Container(
alignment: isStart ? Alignment.centerLeft : Alignment.centerRight,
padding: const EdgeInsets.symmetric(horizontal: 16),
color: colorScheme.primary.withValues(alpha: 0.08),
child: isStart
? content
: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.l10n.chat_reply,
style: TextStyle(
color: colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 6),
Icon(Icons.reply, color: colorScheme.primary),
],
),
); );
} }
@@ -901,7 +952,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => ChannelMessagePathScreen(message: message), builder: (context) =>
ChannelMessagePathScreen(message: message, channelMessage: true),
), ),
); );
} }
@@ -1006,6 +1058,157 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
} }
} }
class _SwipeReplyBubble extends StatefulWidget {
final double maxSwipeOffset;
final double replySwipeThreshold;
final VoidCallback onReplyTriggered;
final Widget Function({required bool isStart}) hintBuilder;
final Widget child;
const _SwipeReplyBubble({
required this.maxSwipeOffset,
required this.replySwipeThreshold,
required this.onReplyTriggered,
required this.hintBuilder,
required this.child,
});
@override
State<_SwipeReplyBubble> createState() => _SwipeReplyBubbleState();
}
class _SwipeReplyBubbleState extends State<_SwipeReplyBubble> {
Offset? _swipeStartPosition;
double _swipeOffset = 0;
double _maxSwipeDistance = 0;
int? _swipePointerId;
bool _swipeLockedToHorizontal = false;
void _handleSwipeStart(Offset position) {
_swipeStartPosition = position;
_maxSwipeDistance = 0;
if (_swipeOffset != 0) {
setState(() => _swipeOffset = 0);
}
}
void _handleSwipePointerDown(PointerDownEvent event) {
_swipePointerId = event.pointer;
_swipeLockedToHorizontal = false;
_handleSwipeStart(event.position);
}
void _handleSwipePointerMove(PointerMoveEvent event) {
if (_swipePointerId != event.pointer || _swipeStartPosition == null) {
return;
}
final dx = event.position.dx - _swipeStartPosition!.dx;
const axisLockThreshold = 12.0;
if (!_swipeLockedToHorizontal) {
if (-dx < axisLockThreshold) {
return;
}
_swipeLockedToHorizontal = true;
}
_handleSwipeUpdate(event.position);
}
void _handleSwipeUpdate(Offset position) {
if (_swipeStartPosition == null) return;
final dx = position.dx - _swipeStartPosition!.dx;
if (dx >= 0) return;
if (-dx < 6) return;
if (-dx > _maxSwipeDistance) {
_maxSwipeDistance = -dx;
}
final double clamped = dx.clamp(-widget.maxSwipeOffset, 0.0).toDouble();
final adjusted = _applySwipeResistance(clamped, widget.maxSwipeOffset);
if (adjusted != _swipeOffset) {
setState(() => _swipeOffset = adjusted);
}
}
void _handleSwipePointerUp(Offset position) {
if (_swipeLockedToHorizontal && _swipeStartPosition != null) {
final dx = position.dx - _swipeStartPosition!.dx;
final peak = math.max(
_maxSwipeDistance,
(-dx).clamp(0.0, double.infinity),
);
if (peak >= widget.replySwipeThreshold) {
widget.onReplyTriggered();
HapticFeedback.selectionClick();
}
}
_resetSwipe();
}
void _resetSwipe() {
if (_swipeOffset != 0) {
setState(() => _swipeOffset = 0);
}
_swipeStartPosition = null;
_maxSwipeDistance = 0;
_swipePointerId = null;
_swipeLockedToHorizontal = false;
}
double _applySwipeResistance(double rawOffset, double maxOffset) {
final abs = rawOffset.abs();
if (abs <= 0) return 0;
final norm = (abs / maxOffset).clamp(0.0, 1.0);
const deadZone = 0.18;
if (norm <= deadZone) {
return rawOffset.sign * maxOffset * (norm * 0.08);
}
final t = ((norm - deadZone) / (1 - deadZone)).clamp(0.0, 1.0);
final curved = t < 0.5
? 16 * math.pow(t, 5)
: 1 - math.pow(-2 * t + 2, 5) / 2;
const deadZoneEnd = 0.0144;
return rawOffset.sign *
maxOffset *
(deadZoneEnd + curved * (1 - deadZoneEnd));
}
@override
Widget build(BuildContext context) {
return Listener(
onPointerDown: _handleSwipePointerDown,
onPointerMove: _handleSwipePointerMove,
onPointerUp: (event) => _handleSwipePointerUp(event.position),
onPointerCancel: (_) => _resetSwipe(),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Stack(
alignment: Alignment.center,
children: [
Positioned.fill(
child: Opacity(
opacity: _swipeOffset.abs() / widget.maxSwipeOffset,
child: widget.hintBuilder(isStart: false),
),
),
AnimatedContainer(
duration: const Duration(milliseconds: 150),
transform: Matrix4.translationValues(_swipeOffset, 0, 0),
curve: Curves.easeOut,
child: widget.child,
),
],
),
),
);
}
}
class _PoiInfo { class _PoiInfo {
final double lat; final double lat;
final double lon; final double lon;
+184 -52
View File
@@ -9,26 +9,38 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../services/map_tile_cache_service.dart'; import '../services/map_tile_cache_service.dart';
import '../services/app_settings_service.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../models/channel_message.dart'; import '../models/channel_message.dart';
import '../models/app_settings.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../widgets/adaptive_app_bar_title.dart';
class ChannelMessagePathScreen extends StatelessWidget { class ChannelMessagePathScreen extends StatelessWidget {
final ChannelMessage message; final ChannelMessage message;
final bool channelMessage;
const ChannelMessagePathScreen({super.key, required this.message}); const ChannelMessagePathScreen({
super.key,
required this.message,
this.channelMessage = false,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<MeshCoreConnector>( return Consumer<MeshCoreConnector>(
builder: (context, connector, _) { builder: (context, connector, _) {
final l10n = context.l10n; final l10n = context.l10n;
final primaryPath = _selectPrimaryPath( final primaryPathTmp = _selectPrimaryPath(
message.pathBytes, message.pathBytes,
message.pathVariants, message.pathVariants,
); );
final primaryPath = !channelMessage && !message.isOutgoing
? Uint8List.fromList(primaryPathTmp.reversed.toList())
: primaryPathTmp;
final hops = _buildPathHops(primaryPath, connector.contacts, l10n); final hops = _buildPathHops(primaryPath, connector.contacts, l10n);
final hasHopDetails = primaryPath.isNotEmpty; final hasHopDetails = primaryPath.isNotEmpty;
final observedLabel = _formatObservedHops( final observedLabel = _formatObservedHops(
@@ -37,10 +49,9 @@ class ChannelMessagePathScreen extends StatelessWidget {
l10n, l10n,
); );
final extraPaths = _otherPaths(primaryPath, message.pathVariants); final extraPaths = _otherPaths(primaryPath, message.pathVariants);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(l10n.channelPath_title), title: AdaptiveAppBarTitle(l10n.channelPath_title),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.radar_outlined), icon: const Icon(Icons.radar_outlined),
@@ -50,9 +61,9 @@ class ChannelMessagePathScreen extends StatelessWidget {
MaterialPageRoute( MaterialPageRoute(
builder: (context) => PathTraceMapScreen( builder: (context) => PathTraceMapScreen(
title: context.l10n.contacts_repeaterPathTrace, title: context.l10n.contacts_repeaterPathTrace,
path: Uint8List.fromList(primaryPath), path: primaryPath,
flipPathRound: true, flipPathRound: true,
reversePathRound: true, reversePathRound: !message.isOutgoing && !channelMessage,
), ),
), ),
), ),
@@ -62,7 +73,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
tooltip: l10n.channelPath_viewMap, tooltip: l10n.channelPath_viewMap,
onPressed: hasHopDetails onPressed: hasHopDetails
? () { ? () {
_openPathMap(context); _openPathMap(context, channelMessage: channelMessage);
} }
: null, : null,
), ),
@@ -157,7 +168,11 @@ class ChannelMessagePathScreen extends StatelessWidget {
), ),
subtitle: Text(_formatPathPrefixes(variants[i])), subtitle: Text(_formatPathPrefixes(variants[i])),
trailing: const Icon(Icons.map_outlined, size: 20), trailing: const Icon(Icons.map_outlined, size: 20),
onTap: () => _openPathMap(context, initialPath: variants[i]), onTap: () => _openPathMap(
context,
initialPath: variants[i],
channelMessage: channelMessage,
),
), ),
), ),
], ],
@@ -248,13 +263,18 @@ class ChannelMessagePathScreen extends StatelessWidget {
); );
} }
void _openPathMap(BuildContext context, {Uint8List? initialPath}) { void _openPathMap(
BuildContext context, {
Uint8List? initialPath,
bool channelMessage = false,
}) {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => ChannelMessagePathMapScreen( builder: (context) => ChannelMessagePathMapScreen(
message: message, message: message,
initialPath: initialPath, initialPath: initialPath,
channelMessage: channelMessage,
), ),
), ),
); );
@@ -264,11 +284,13 @@ class ChannelMessagePathScreen extends StatelessWidget {
class ChannelMessagePathMapScreen extends StatefulWidget { class ChannelMessagePathMapScreen extends StatefulWidget {
final ChannelMessage message; final ChannelMessage message;
final Uint8List? initialPath; final Uint8List? initialPath;
final bool channelMessage;
const ChannelMessagePathMapScreen({ const ChannelMessagePathMapScreen({
super.key, super.key,
required this.message, required this.message,
this.initialPath, this.initialPath,
this.channelMessage = false,
}); });
@override @override
@@ -278,8 +300,12 @@ class ChannelMessagePathMapScreen extends StatefulWidget {
class _ChannelMessagePathMapScreenState class _ChannelMessagePathMapScreenState
extends State<ChannelMessagePathMapScreen> { extends State<ChannelMessagePathMapScreen> {
static const double _labelZoomThreshold = 8.5;
Uint8List? _selectedPath; Uint8List? _selectedPath;
double _pathDistance = 0.0; double _pathDistance = 0.0;
bool _showNodeLabels = true;
bool _didReceivePositionUpdate = false;
@override @override
void initState() { void initState() {
@@ -314,6 +340,8 @@ class _ChannelMessagePathMapScreenState
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<MeshCoreConnector>( return Consumer<MeshCoreConnector>(
builder: (context, connector, _) { builder: (context, connector, _) {
final settings = context.watch<AppSettingsService>().settings;
final isImperial = settings.unitSystem == UnitSystem.imperial;
final tileCache = context.read<MapTileCacheService>(); final tileCache = context.read<MapTileCacheService>();
final primaryPath = _selectPrimaryPath( final primaryPath = _selectPrimaryPath(
widget.message.pathBytes, widget.message.pathBytes,
@@ -323,11 +351,18 @@ class _ChannelMessagePathMapScreenState
primaryPath, primaryPath,
widget.message.pathVariants, widget.message.pathVariants,
); );
final selectedPath = _resolveSelectedPath( final selectedPathTmp = _resolveSelectedPath(
_selectedPath, _selectedPath,
observedPaths, observedPaths,
primaryPath, primaryPath,
); );
final selectedPath =
((!widget.message.isOutgoing && !widget.channelMessage) ||
(widget.message.isOutgoing && widget.channelMessage))
? Uint8List.fromList(selectedPathTmp.reversed.toList())
: selectedPathTmp;
final selectedIndex = _indexForPath(selectedPath, observedPaths); final selectedIndex = _indexForPath(selectedPath, observedPaths);
final hops = _buildPathHops( final hops = _buildPathHops(
selectedPath, selectedPath,
@@ -336,12 +371,22 @@ class _ChannelMessagePathMapScreenState
); );
final points = <LatLng>[]; final points = <LatLng>[];
if ((widget.message.isOutgoing && !widget.channelMessage) ||
(widget.message.isOutgoing && widget.channelMessage)) {
points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!));
}
for (final hop in hops) { for (final hop in hops) {
if (hop.hasLocation) { if (hop.hasLocation) {
points.add(hop.position!); points.add(hop.position!);
} }
} }
points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!));
if ((!widget.message.isOutgoing && !widget.channelMessage) ||
(!widget.message.isOutgoing && widget.channelMessage)) {
points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!));
}
final polylines = points.length > 1 final polylines = points.length > 1
? [ ? [
@@ -357,6 +402,9 @@ class _ChannelMessagePathMapScreenState
? points.first ? points.first
: const LatLng(0, 0); : const LatLng(0, 0);
final initialZoom = points.isNotEmpty ? 13.0 : 2.0; final initialZoom = points.isNotEmpty ? 13.0 : 2.0;
if (!_didReceivePositionUpdate) {
_showNodeLabels = initialZoom >= _labelZoomThreshold;
}
final bounds = points.length > 1 final bounds = points.length > 1
? LatLngBounds.fromPoints(points) ? LatLngBounds.fromPoints(points)
: null; : null;
@@ -366,7 +414,9 @@ class _ChannelMessagePathMapScreenState
_pathDistance = _getPathDistance(points); _pathDistance = _getPathDistance(points);
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(context.l10n.channelPath_mapTitle)), appBar: AppBar(
title: AdaptiveAppBarTitle(context.l10n.channelPath_mapTitle),
),
body: SafeArea( body: SafeArea(
top: false, top: false,
child: Stack( child: Stack(
@@ -388,6 +438,17 @@ class _ChannelMessagePathMapScreenState
interactionOptions: InteractionOptions( interactionOptions: InteractionOptions(
flags: ~InteractiveFlag.rotate, flags: ~InteractiveFlag.rotate,
), ),
onPositionChanged: (camera, hasGesture) {
final shouldShow = camera.zoom >= _labelZoomThreshold;
if (!_didReceivePositionUpdate ||
shouldShow != _showNodeLabels) {
if (!mounted) return;
setState(() {
_didReceivePositionUpdate = true;
_showNodeLabels = shouldShow;
});
}
},
), ),
children: [ children: [
TileLayer( TileLayer(
@@ -399,7 +460,12 @@ class _ChannelMessagePathMapScreenState
), ),
if (polylines.isNotEmpty) if (polylines.isNotEmpty)
PolylineLayer(polylines: polylines), PolylineLayer(polylines: polylines),
MarkerLayer(markers: _buildHopMarkers(hops)), MarkerLayer(
markers: _buildHopMarkers(
hops,
showLabels: _showNodeLabels,
),
),
], ],
), ),
if (observedPaths.length > 1) if (observedPaths.length > 1)
@@ -422,7 +488,7 @@ class _ChannelMessagePathMapScreenState
), ),
), ),
), ),
_buildLegendCard(context, hops), _buildLegendCard(context, hops, isImperial),
], ],
), ),
), ),
@@ -494,45 +560,61 @@ class _ChannelMessagePathMapScreenState
); );
} }
List<Marker> _buildHopMarkers(List<_PathHop> hops) { List<Marker> _buildHopMarkers(
return [ List<_PathHop> hops, {
for (final hop in hops) required bool showLabels,
if (hop.hasLocation) }) {
Marker( final markers = <Marker>[];
point: hop.position!, for (final hop in hops) {
width: 35, if (!hop.hasLocation) continue;
height: 35, final point = hop.position!;
child: Container( markers.add(
decoration: BoxDecoration( Marker(
color: Colors.green, point: point,
shape: BoxShape.circle, width: 35,
border: Border.all(color: Colors.white, width: 2), height: 35,
boxShadow: [ child: Container(
BoxShadow( decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.3), color: Colors.green,
blurRadius: 4, shape: BoxShape.circle,
offset: const Offset(0, 2), border: Border.all(color: Colors.white, width: 2),
), boxShadow: [
], BoxShadow(
), color: Colors.black.withValues(alpha: 0.3),
alignment: Alignment.center, blurRadius: 4,
child: Text( offset: const Offset(0, 2),
hop.index.toString(),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
), ),
],
),
alignment: Alignment.center,
child: Text(
hop.index.toString(),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
), ),
), ),
), ),
if (context.read<MeshCoreConnector>().selfLatitude != null && ),
context.read<MeshCoreConnector>().selfLongitude != null) );
Marker( if (showLabels) {
point: LatLng( markers.add(
context.read<MeshCoreConnector>().selfLatitude!, _buildNodeLabelMarker(
context.read<MeshCoreConnector>().selfLongitude!, point: point,
label: hop.contact?.name ?? _formatPrefix(hop.prefix),
), ),
);
}
}
final selfLat = context.read<MeshCoreConnector>().selfLatitude;
final selfLon = context.read<MeshCoreConnector>().selfLongitude;
if (selfLat != null && selfLon != null) {
final selfPoint = LatLng(selfLat, selfLon);
markers.add(
Marker(
point: selfPoint,
width: 35, width: 35,
height: 35, height: 35,
child: Container( child: Container(
@@ -559,10 +641,60 @@ class _ChannelMessagePathMapScreenState
), ),
), ),
), ),
]; );
if (showLabels) {
markers.add(
_buildNodeLabelMarker(
point: selfPoint,
label: context.l10n.pathTrace_you,
),
);
}
}
return markers;
} }
Widget _buildLegendCard(BuildContext context, List<_PathHop> hops) { Marker _buildNodeLabelMarker({required LatLng point, required String label}) {
return Marker(
point: point,
width: 120,
height: 24,
alignment: Alignment.topCenter,
child: IgnorePointer(
child: Transform.translate(
offset: const Offset(0, -20),
child: FittedBox(
fit: BoxFit.contain,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
),
),
),
),
);
}
Widget _buildLegendCard(
BuildContext context,
List<_PathHop> hops,
bool isImperial,
) {
final l10n = context.l10n; final l10n = context.l10n;
final maxHeight = MediaQuery.of(context).size.height * 0.35; final maxHeight = MediaQuery.of(context).size.height * 0.35;
final estimatedHeight = 72.0 + (hops.length * 56.0); final estimatedHeight = 72.0 + (hops.length * 56.0);
@@ -581,7 +713,7 @@ class _ChannelMessagePathMapScreenState
Padding( Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: Text( child: Text(
'${l10n.channelPath_repeaterHops} (${(_pathDistance / 1609.34).toStringAsFixed(2)} Miles / ${(_pathDistance / 1000).toStringAsFixed(2)} Km)', '${l10n.channelPath_repeaterHops} ${formatDistance(_pathDistance, isImperial: isImperial)}',
style: const TextStyle(fontWeight: FontWeight.w600), style: const TextStyle(fontWeight: FontWeight.w600),
), ),
), ),
+26 -3
View File
@@ -4,18 +4,19 @@ import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:meshcore_open/storage/channel_message_store.dart'; import 'package:meshcore_open/storage/channel_message_store.dart';
import 'package:meshcore_open/widgets/app_bar.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../services/app_settings_service.dart';
import '../models/channel.dart'; import '../models/channel.dart';
import '../models/community.dart'; import '../models/community.dart';
import '../storage/community_store.dart'; import '../storage/community_store.dart';
import '../utils/dialog_utils.dart'; import '../utils/dialog_utils.dart';
import '../utils/disconnect_navigation_mixin.dart'; import '../utils/disconnect_navigation_mixin.dart';
import '../utils/route_transitions.dart'; import '../utils/route_transitions.dart';
import '../widgets/battery_indicator.dart';
import '../widgets/list_filter_widget.dart'; import '../widgets/list_filter_widget.dart';
import '../widgets/empty_state.dart'; import '../widgets/empty_state.dart';
import '../widgets/qr_code_display.dart'; import '../widgets/qr_code_display.dart';
@@ -118,8 +119,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
canPop: allowBack, canPop: allowBack,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
leading: BatteryIndicator(connector: connector), title: AppBarTitle(context.l10n.channels_title),
title: Text(context.l10n.channels_title),
centerTitle: true, centerTitle: true,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
actions: [ actions: [
@@ -489,6 +489,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
ChannelMessageStore channelMessageStore, ChannelMessageStore channelMessageStore,
Channel channel, Channel channel,
) { ) {
final settingsService = context.read<AppSettingsService>();
final isMuted = settingsService.isChannelMuted(channel.name);
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: (context) => SafeArea( builder: (context) => SafeArea(
@@ -506,6 +509,26 @@ class _ChannelsScreenState extends State<ChannelsScreen>
} }
}, },
), ),
ListTile(
leading: Icon(
isMuted
? Icons.notifications_outlined
: Icons.notifications_off_outlined,
),
title: Text(
isMuted
? context.l10n.channels_unmuteChannel
: context.l10n.channels_muteChannel,
),
onTap: () async {
Navigator.pop(context);
if (isMuted) {
await settingsService.unmuteChannel(channel.name);
} else {
await settingsService.muteChannel(channel.name);
}
},
),
ListTile( ListTile(
leading: const Icon(Icons.delete_outline, color: Colors.red), leading: const Icon(Icons.delete_outline, color: Colors.red),
title: Text( title: Text(
+299 -222
View File
@@ -19,7 +19,9 @@ import '../helpers/utf8_length_limiter.dart';
import '../models/channel_message.dart'; import '../models/channel_message.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/message.dart'; import '../models/message.dart';
import '../models/path_history.dart';
import '../services/path_history_service.dart'; import '../services/path_history_service.dart';
import '../widgets/elements_ui.dart';
import 'channel_message_path_screen.dart'; import 'channel_message_path_screen.dart';
import 'map_screen.dart'; import 'map_screen.dart';
import '../utils/emoji_utils.dart'; import '../utils/emoji_utils.dart';
@@ -431,242 +433,317 @@ class _ChatScreenState extends State<ChatScreen> {
void _showPathHistory(BuildContext context) { void _showPathHistory(BuildContext context) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
bool showAllPaths = false;
showDialog( showDialog(
context: context, context: context,
builder: (context) => Consumer<PathHistoryService>( builder: (context) => StatefulBuilder(
builder: (context, pathService, _) { builder: (context, setDialogState) => Consumer<PathHistoryService>(
final paths = pathService.getRecentPaths(widget.contact.publicKeyHex); builder: (context, pathService, _) {
return AlertDialog( final paths = pathService.getRecentPaths(
title: Row( widget.contact.publicKeyHex,
children: [ );
const Icon(Icons.timeline),
const SizedBox(width: 8), final repeatersList = List.of(connector.directRepeaters)
Text(context.l10n.chat_pathManagement), ..sort((a, b) => b.ranking.compareTo(a.ranking));
],
), if (repeatersList.isEmpty) {
content: SingleChildScrollView( showAllPaths = true;
child: Column( }
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, final directRepeater = repeatersList.isEmpty
? null
: repeatersList.first;
final secondDirectRepeater = repeatersList.length < 2
? null
: repeatersList.elementAt(1);
final thirdDirectRepeater = repeatersList.length < 3
? null
: repeatersList.elementAt(2);
List<MapEntry<int, MapEntry<Color, PathRecord>>>
pathsWithRepeaters = paths.map((path) {
final isDirectRepeater =
directRepeater != null &&
path.pathBytes.isNotEmpty &&
directRepeater.pubkeyFirstByte == path.pathBytes.first;
final isSecondDirectRepeater =
secondDirectRepeater != null &&
path.pathBytes.isNotEmpty &&
secondDirectRepeater.pubkeyFirstByte == path.pathBytes.first;
final isThirdDirectRepeater =
thirdDirectRepeater != null &&
path.pathBytes.isNotEmpty &&
thirdDirectRepeater.pubkeyFirstByte == path.pathBytes.first;
int ranking = -1;
Color color = Colors.grey;
if (isDirectRepeater) {
color = Colors.green;
ranking = 3;
} else if (isSecondDirectRepeater) {
color = Colors.yellow;
ranking = 2;
} else if (isThirdDirectRepeater) {
color = Colors.red;
ranking = 1;
} else if (path.wasFloodDiscovery) {
color = Colors.blue;
ranking = 0;
}
return MapEntry(ranking, MapEntry(color, path));
}).toList();
pathsWithRepeaters.sort((a, b) => b.key.compareTo(a.key));
return AlertDialog(
title: Row(
children: [ children: [
if (paths.isNotEmpty) ...[ const Icon(Icons.timeline),
const SizedBox(width: 8),
Text(context.l10n.chat_pathManagement),
],
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (pathsWithRepeaters.isNotEmpty) ...[
if (repeatersList.isNotEmpty)
FeatureToggleRow(
title: context.l10n.chat_ShowAllPaths,
subtitle: "",
value: showAllPaths,
onChanged: (val) {
setDialogState(() {
showAllPaths = val;
});
},
),
Text(
context.l10n.chat_recentAckPaths,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
if (pathsWithRepeaters.length >= 100) ...[
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: Colors.amber[100],
borderRadius: BorderRadius.circular(8),
),
child: Text(
context.l10n.chat_pathHistoryFull,
style: const TextStyle(fontSize: 12),
),
),
],
const SizedBox(height: 8),
...pathsWithRepeaters.map((entry) {
final path = entry.value.value;
final color = entry.value.key;
if (!showAllPaths && entry.key < 1) {
return const SizedBox.shrink();
} else {
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
dense: true,
leading: CircleAvatar(
radius: 16,
backgroundColor: color,
child: Text(
'${path.hopCount}',
style: const TextStyle(fontSize: 12),
),
),
title: Text(
'${path.hopCount} ${path.hopCount == 1 ? context.l10n.chat_hopSingular : context.l10n.chat_hopPlural}',
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
'${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(path.timestamp)}${path.successCount} ${context.l10n.chat_successes}',
style: const TextStyle(fontSize: 11),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.close, size: 16),
tooltip: context.l10n.chat_removePath,
onPressed: () async {
await pathService.removePathRecord(
widget.contact.publicKeyHex,
path.pathBytes,
);
},
),
path.wasFloodDiscovery
? const Icon(
Icons.waves,
size: 16,
color: Colors.grey,
)
: const Icon(
Icons.route,
size: 16,
color: Colors.grey,
),
],
),
onLongPress: () =>
_showFullPathDialog(context, path.pathBytes),
onTap: () async {
if (path.pathBytes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context
.l10n
.chat_pathDetailsNotAvailable,
),
duration: const Duration(seconds: 2),
),
);
return;
}
final pathBytes = Uint8List.fromList(
path.pathBytes,
);
final pathLength = path.pathBytes.length;
// Set the path override to persist user's choice
await connector.setPathOverride(
widget.contact,
pathLen: pathLength,
pathBytes: pathBytes,
);
if (!context.mounted) return;
Navigator.pop(context);
await _notifyPathSet(
connector,
widget.contact,
pathBytes,
path.hopCount,
);
},
),
);
}
}),
const Divider(),
] else ...[
Text(context.l10n.chat_noPathHistoryYet),
const Divider(),
],
const SizedBox(height: 8),
Text( Text(
context.l10n.chat_recentAckPaths, context.l10n.chat_pathActions,
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 12, fontSize: 12,
), ),
), ),
if (paths.length >= 100) ...[
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: Colors.amber[100],
borderRadius: BorderRadius.circular(8),
),
child: Text(
context.l10n.chat_pathHistoryFull,
style: const TextStyle(fontSize: 12),
),
),
],
const SizedBox(height: 8), const SizedBox(height: 8),
...paths.map((path) { ListTile(
return Card( dense: true,
margin: const EdgeInsets.symmetric(vertical: 4), leading: const CircleAvatar(
child: ListTile( radius: 16,
dense: true, backgroundColor: Colors.purple,
leading: CircleAvatar( child: Icon(Icons.edit_road, size: 16),
radius: 16, ),
backgroundColor: path.wasFloodDiscovery title: Text(
? Colors.blue context.l10n.chat_setCustomPath,
: Colors.green, style: const TextStyle(fontSize: 14),
child: Text( ),
'${path.hopCount}', subtitle: Text(
style: const TextStyle(fontSize: 12), context.l10n.chat_setCustomPathSubtitle,
), style: const TextStyle(fontSize: 11),
),
onTap: () {
Navigator.pop(context);
_showCustomPathDialog(context);
},
),
ListTile(
dense: true,
leading: const CircleAvatar(
radius: 16,
backgroundColor: Colors.orange,
child: Icon(Icons.clear_all, size: 16),
),
title: Text(
context.l10n.chat_clearPath,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
context.l10n.chat_clearPathSubtitle,
style: const TextStyle(fontSize: 11),
),
onTap: () async {
await connector.clearContactPath(widget.contact);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.chat_pathCleared),
duration: const Duration(seconds: 2),
), ),
title: Text( );
'${path.hopCount} ${path.hopCount == 1 ? context.l10n.chat_hopSingular : context.l10n.chat_hopPlural}', Navigator.pop(context);
style: const TextStyle(fontSize: 14), },
),
ListTile(
dense: true,
leading: const CircleAvatar(
radius: 16,
backgroundColor: Colors.blue,
child: Icon(Icons.waves, size: 16),
),
title: Text(
context.l10n.chat_forceFloodMode,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
context.l10n.chat_floodModeSubtitle,
style: const TextStyle(fontSize: 11),
),
onTap: () async {
await connector.setPathOverride(
widget.contact,
pathLen: -1,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.chat_floodModeEnabled),
duration: const Duration(seconds: 2),
), ),
subtitle: Text( );
'${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(path.timestamp)}${path.successCount} ${context.l10n.chat_successes}', Navigator.pop(context);
style: const TextStyle(fontSize: 11), },
), ),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.close, size: 16),
tooltip: context.l10n.chat_removePath,
onPressed: () async {
await pathService.removePathRecord(
widget.contact.publicKeyHex,
path.pathBytes,
);
},
),
path.wasFloodDiscovery
? const Icon(
Icons.waves,
size: 16,
color: Colors.grey,
)
: const Icon(
Icons.route,
size: 16,
color: Colors.grey,
),
],
),
onLongPress: () =>
_showFullPathDialog(context, path.pathBytes),
onTap: () async {
if (path.pathBytes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.chat_pathDetailsNotAvailable,
),
duration: const Duration(seconds: 2),
),
);
return;
}
final pathBytes = Uint8List.fromList(
path.pathBytes,
);
final pathLength = path.pathBytes.length;
// Set the path override to persist user's choice
await connector.setPathOverride(
widget.contact,
pathLen: pathLength,
pathBytes: pathBytes,
);
if (!context.mounted) return;
Navigator.pop(context);
await _notifyPathSet(
connector,
widget.contact,
pathBytes,
path.hopCount,
);
},
),
);
}),
const Divider(),
] else ...[
Text(context.l10n.chat_noPathHistoryYet),
const Divider(),
], ],
const SizedBox(height: 8), ),
Text(
context.l10n.chat_pathActions,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
const SizedBox(height: 8),
ListTile(
dense: true,
leading: const CircleAvatar(
radius: 16,
backgroundColor: Colors.purple,
child: Icon(Icons.edit_road, size: 16),
),
title: Text(
context.l10n.chat_setCustomPath,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
context.l10n.chat_setCustomPathSubtitle,
style: const TextStyle(fontSize: 11),
),
onTap: () {
Navigator.pop(context);
_showCustomPathDialog(context);
},
),
ListTile(
dense: true,
leading: const CircleAvatar(
radius: 16,
backgroundColor: Colors.orange,
child: Icon(Icons.clear_all, size: 16),
),
title: Text(
context.l10n.chat_clearPath,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
context.l10n.chat_clearPathSubtitle,
style: const TextStyle(fontSize: 11),
),
onTap: () async {
await connector.clearContactPath(widget.contact);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.chat_pathCleared),
duration: const Duration(seconds: 2),
),
);
Navigator.pop(context);
},
),
ListTile(
dense: true,
leading: const CircleAvatar(
radius: 16,
backgroundColor: Colors.blue,
child: Icon(Icons.waves, size: 16),
),
title: Text(
context.l10n.chat_forceFloodMode,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
context.l10n.chat_floodModeSubtitle,
style: const TextStyle(fontSize: 11),
),
onTap: () async {
await connector.setPathOverride(
widget.contact,
pathLen: -1,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.chat_floodModeEnabled),
duration: const Duration(seconds: 2),
),
);
Navigator.pop(context);
},
),
],
), ),
), actions: [
actions: [ TextButton(
TextButton( onPressed: () => Navigator.pop(context),
onPressed: () => Navigator.pop(context), child: Text(context.l10n.common_close),
child: Text(context.l10n.common_close), ),
), ],
], );
); },
}, ),
), ),
); );
} }
+2 -1
View File
@@ -6,6 +6,7 @@ import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../models/community.dart'; import '../models/community.dart';
import '../storage/community_store.dart'; import '../storage/community_store.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/qr_scanner_widget.dart'; import '../widgets/qr_scanner_widget.dart';
/// Screen for scanning community QR codes to join communities. /// Screen for scanning community QR codes to join communities.
@@ -29,7 +30,7 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(context.l10n.community_scanQr), title: AdaptiveAppBarTitle(context.l10n.community_scanQr),
centerTitle: true, centerTitle: true,
), ),
body: _isProcessing body: _isProcessing
+115 -91
View File
@@ -3,6 +3,8 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:meshcore_open/screens/path_trace_map.dart'; import 'package:meshcore_open/screens/path_trace_map.dart';
import 'package:meshcore_open/utils/app_logger.dart';
import 'package:meshcore_open/widgets/app_bar.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
@@ -16,7 +18,6 @@ import '../utils/dialog_utils.dart';
import '../utils/disconnect_navigation_mixin.dart'; import '../utils/disconnect_navigation_mixin.dart';
import '../utils/emoji_utils.dart'; import '../utils/emoji_utils.dart';
import '../utils/route_transitions.dart'; import '../utils/route_transitions.dart';
import '../widgets/battery_indicator.dart';
import '../widgets/list_filter_widget.dart'; import '../widgets/list_filter_widget.dart';
import '../widgets/empty_state.dart'; import '../widgets/empty_state.dart';
import '../widgets/quick_switch_bar.dart'; import '../widgets/quick_switch_bar.dart';
@@ -90,79 +91,90 @@ class _ContactsScreenState extends State<ContactsScreen>
_frameSubscription = connector.receivedFrames.listen((frame) { _frameSubscription = connector.receivedFrames.listen((frame) {
if (frame.isEmpty) return; if (frame.isEmpty) return;
final frameBuffer = BufferReader(frame); final frameBuffer = BufferReader(frame);
final code = frameBuffer.readUInt8(); try {
final code = frameBuffer.readUInt8();
if (code == respCodeExportContact) { if (code == respCodeExportContact) {
final advertPacket = frameBuffer.readRemainingBytes(); final advertPacket = frameBuffer.readRemainingBytes();
// Validate packet has expected minimum size (98+ bytes per protocol) // Validate packet has expected minimum size (98+ bytes per protocol)
if (advertPacket.length < 98) { if (advertPacket.length < 98) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.contacts_invalidAdvertFormat),
),
);
}
_pendingOperations.remove(ContactOperationType.export);
return;
}
final hexString = pubKeyToHex(advertPacket);
Clipboard.setData(ClipboardData(text: "meshcore://$hexString"));
}
if (code == respCodeOk) {
// Show a snackbar indicating success
if (!mounted) return;
if (_pendingOperations.contains(ContactOperationType.import)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.contacts_contactImported)),
);
}
if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(context.l10n.contacts_invalidAdvertFormat), content: Text(context.l10n.contacts_zeroHopContactAdvertSent),
), ),
); );
} }
_pendingOperations.remove(ContactOperationType.export);
return;
}
final hexString = pubKeyToHex(advertPacket);
Clipboard.setData(ClipboardData(text: "meshcore://$hexString"));
}
if (code == respCodeOk) { if (_pendingOperations.contains(ContactOperationType.export)) {
// Show a snackbar indicating success ScaffoldMessenger.of(context).showSnackBar(
if (!mounted) return; SnackBar(
content: Text(context.l10n.contacts_contactAdvertCopied),
),
);
}
if (_pendingOperations.contains(ContactOperationType.import)) { _pendingOperations.clear();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.contacts_contactImported)),
);
} }
if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) { if (code == respCodeErr) {
ScaffoldMessenger.of(context).showSnackBar( // Show a snackbar indicating failure
SnackBar( if (!mounted) return;
content: Text(context.l10n.contacts_zeroHopContactAdvertSent),
), if (_pendingOperations.contains(ContactOperationType.import)) {
); ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.contacts_contactImportFailed),
),
);
}
if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.contacts_zeroHopContactAdvertFailed),
),
);
}
if (_pendingOperations.contains(ContactOperationType.export)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.contacts_contactAdvertCopyFailed),
),
);
}
_pendingOperations.clear();
} }
} catch (e) {
if (_pendingOperations.contains(ContactOperationType.export)) { appLogger.error(
ScaffoldMessenger.of(context).showSnackBar( 'Error processing received frame: $e',
SnackBar(content: Text(context.l10n.contacts_contactAdvertCopied)), tag: 'ContactsScreen',
); );
}
_pendingOperations.clear();
}
if (code == respCodeErr) {
// Show a snackbar indicating failure
if (!mounted) return;
if (_pendingOperations.contains(ContactOperationType.import)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.contacts_contactImportFailed)),
);
}
if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.contacts_zeroHopContactAdvertFailed),
),
);
}
if (_pendingOperations.contains(ContactOperationType.export)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.contacts_contactAdvertCopyFailed),
),
);
}
_pendingOperations.clear();
} }
}); });
} }
@@ -171,14 +183,17 @@ class _ContactsScreenState extends State<ContactsScreen>
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final exportContactFrame = buildExportContactFrame(pubKey); final exportContactFrame = buildExportContactFrame(pubKey);
_pendingOperations.add(ContactOperationType.export); _pendingOperations.add(ContactOperationType.export);
await connector.sendFrame(exportContactFrame); await connector.sendFrame(exportContactFrame, expectsGenericAck: true);
} }
Future<void> _contactZeroHop(Uint8List pubKey) async { Future<void> _contactZeroHop(Uint8List pubKey) async {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final exportContactZeroHopFrame = buildZeroHopContact(pubKey); final exportContactZeroHopFrame = buildZeroHopContact(pubKey);
_pendingOperations.add(ContactOperationType.zeroHopShare); _pendingOperations.add(ContactOperationType.zeroHopShare);
await connector.sendFrame(exportContactZeroHopFrame); await connector.sendFrame(
exportContactZeroHopFrame,
expectsGenericAck: true,
);
} }
Future<void> _contactImport() async { Future<void> _contactImport() async {
@@ -205,7 +220,7 @@ class _ContactsScreenState extends State<ContactsScreen>
try { try {
final importContactFrame = buildImportContactFrame(hexString); final importContactFrame = buildImportContactFrame(hexString);
_pendingOperations.add(ContactOperationType.import); _pendingOperations.add(ContactOperationType.import);
await connector.sendFrame(importContactFrame); await connector.sendFrame(importContactFrame, expectsGenericAck: true);
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -229,9 +244,7 @@ class _ContactsScreenState extends State<ContactsScreen>
canPop: allowBack, canPop: allowBack,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
leading: BatteryIndicator(connector: connector), title: AppBarTitle(context.l10n.contacts_title),
title: Text(context.l10n.contacts_title),
centerTitle: true,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
actions: [ actions: [
PopupMenuButton( PopupMenuButton(
@@ -1160,12 +1173,17 @@ class _ContactTile extends StatelessWidget {
backgroundColor: _getTypeColor(contact.type), backgroundColor: _getTypeColor(contact.type),
child: _buildContactAvatar(contact), child: _buildContactAvatar(contact),
), ),
title: Text(contact.name), title: Text(contact.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Column( subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(contact.pathLabel), Text(contact.pathLabel, maxLines: 1, overflow: TextOverflow.ellipsis),
Text(contact.shortPubKeyHex, style: TextStyle(fontSize: 12)), Text(
contact.shortPubKeyHex,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
),
], ],
), ),
// Clamp text scaling in trailing section to prevent overflow while // Clamp text scaling in trailing section to prevent overflow while
@@ -1176,26 +1194,32 @@ class _ContactTile extends StatelessWidget {
MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3), MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3),
), ),
), ),
child: Column( child: SizedBox(
mainAxisAlignment: MainAxisAlignment.center, width: 120,
crossAxisAlignment: CrossAxisAlignment.end, child: Column(
children: [ mainAxisAlignment: MainAxisAlignment.center,
if (unreadCount > 0) ...[ crossAxisAlignment: CrossAxisAlignment.end,
UnreadBadge(count: unreadCount), children: [
const SizedBox(height: 4), if (unreadCount > 0) ...[
], UnreadBadge(count: unreadCount),
Text( const SizedBox(height: 4),
_formatLastSeen(context, lastSeen),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (contact.hasLocation)
Icon(Icons.location_on, size: 14, color: Colors.grey[400]),
], ],
), Text(
], _formatLastSeen(context, lastSeen),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (contact.hasLocation)
Icon(Icons.location_on, size: 14, color: Colors.grey[400]),
],
),
],
),
), ),
), ),
onTap: onTap, onTap: onTap,
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -7,6 +7,7 @@ import '../l10n/app_localizations.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../services/app_settings_service.dart'; import '../services/app_settings_service.dart';
import '../services/map_tile_cache_service.dart'; import '../services/map_tile_cache_service.dart';
import '../widgets/adaptive_app_bar_title.dart';
class MapCacheScreen extends StatefulWidget { class MapCacheScreen extends StatefulWidget {
const MapCacheScreen({super.key}); const MapCacheScreen({super.key});
@@ -224,7 +225,10 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
: (_completedTiles / _estimatedTiles).clamp(0.0, 1.0).toDouble(); : (_completedTiles / _estimatedTiles).clamp(0.0, 1.0).toDouble();
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(l10n.mapCache_title), centerTitle: true), appBar: AppBar(
title: AdaptiveAppBarTitle(l10n.mapCache_title),
centerTitle: true,
),
body: Column( body: Column(
children: [ children: [
Expanded( Expanded(
+166 -25
View File
@@ -5,11 +5,13 @@ import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:meshcore_open/screens/path_trace_map.dart'; import 'package:meshcore_open/screens/path_trace_map.dart';
import 'package:meshcore_open/widgets/app_bar.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../models/app_settings.dart';
import '../models/channel.dart'; import '../models/channel.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../services/app_settings_service.dart'; import '../services/app_settings_service.dart';
@@ -17,7 +19,6 @@ import '../services/map_marker_service.dart';
import '../services/map_tile_cache_service.dart'; import '../services/map_tile_cache_service.dart';
import '../utils/contact_search.dart'; import '../utils/contact_search.dart';
import '../utils/route_transitions.dart'; import '../utils/route_transitions.dart';
import '../widgets/battery_indicator.dart';
import '../widgets/quick_switch_bar.dart'; import '../widgets/quick_switch_bar.dart';
import 'channels_screen.dart'; import 'channels_screen.dart';
import 'chat_screen.dart'; import 'chat_screen.dart';
@@ -26,6 +27,7 @@ import '../widgets/repeater_login_dialog.dart';
import '../widgets/room_login_dialog.dart'; import '../widgets/room_login_dialog.dart';
import 'repeater_hub_screen.dart'; import 'repeater_hub_screen.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
import 'line_of_sight_map_screen.dart';
class MapScreen extends StatefulWidget { class MapScreen extends StatefulWidget {
final LatLng? highlightPosition; final LatLng? highlightPosition;
@@ -46,6 +48,8 @@ class MapScreen extends StatefulWidget {
} }
class _MapScreenState extends State<MapScreen> { class _MapScreenState extends State<MapScreen> {
static const double _labelZoomThreshold = 8.5;
final MapController _mapController = MapController(); final MapController _mapController = MapController();
final MapMarkerService _markerService = MapMarkerService(); final MapMarkerService _markerService = MapMarkerService();
final Set<String> _hiddenMarkerIds = {}; final Set<String> _hiddenMarkerIds = {};
@@ -58,6 +62,7 @@ class _MapScreenState extends State<MapScreen> {
final List<LatLng> _points = []; final List<LatLng> _points = [];
final List<Polyline> _polylines = []; final List<Polyline> _polylines = [];
bool _legendExpanded = false; bool _legendExpanded = false;
bool _showNodeLabels = true;
@override @override
void initState() { void initState() {
@@ -105,7 +110,7 @@ class _MapScreenState extends State<MapScreen> {
double _zoomFromStdDev(double latStdDev, double lonStdDev) { double _zoomFromStdDev(double latStdDev, double lonStdDev) {
final maxSpread = max(latStdDev, lonStdDev); final maxSpread = max(latStdDev, lonStdDev);
if (maxSpread <= 0) return 13.0; if (maxSpread <= 0) return 13.0;
// Approzimate: each zoom level halves the visible area // Approximate: each zoom level halves the visible area
// ~0.01 degrees spread -> zoom 13, ~0.1 -> zoom 10, ~1.0 -> zoom 7 // ~0.01 degrees spread -> zoom 13, ~0.1 -> zoom 10, ~1.0 -> zoom 7
final zoom = 10.0 - log(maxSpread * 10 + 1) / ln10 * 3; final zoom = 10.0 - log(maxSpread * 10 + 1) / ln10 * 3;
return zoom.clamp(4.0, 15.0); return zoom.clamp(4.0, 15.0);
@@ -247,6 +252,7 @@ class _MapScreenState extends State<MapScreen> {
// Re center map after removed markers have loaded // Re center map after removed markers have loaded
if (!_hasInitializedMap && _removedMarkersLoaded) { if (!_hasInitializedMap && _removedMarkersLoaded) {
_hasInitializedMap = true; _hasInitializedMap = true;
_showNodeLabels = initialZoom >= _labelZoomThreshold;
if (hasMapContent) { if (hasMapContent) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) { if (mounted) {
@@ -262,8 +268,7 @@ class _MapScreenState extends State<MapScreen> {
canPop: allowBack, canPop: allowBack,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
leading: BatteryIndicator(connector: connector), title: AppBarTitle(context.l10n.map_title),
title: Text(context.l10n.map_title),
centerTitle: true, centerTitle: true,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
actions: [ actions: [
@@ -273,6 +278,47 @@ class _MapScreenState extends State<MapScreen> {
onPressed: () => _startPath(), onPressed: () => _startPath(),
tooltip: context.l10n.contacts_pathTrace, tooltip: context.l10n.contacts_pathTrace,
), ),
if (!_isBuildingPathTrace)
IconButton(
icon: const Icon(Icons.visibility),
onPressed: () {
final candidates = <LineOfSightEndpoint>[];
if (connector.selfLatitude != null &&
connector.selfLongitude != null) {
candidates.add(
LineOfSightEndpoint(
label: context.l10n.pathTrace_you,
point: LatLng(
connector.selfLatitude!,
connector.selfLongitude!,
),
color: Colors.teal,
icon: Icons.person_pin_circle,
),
);
}
for (final c in contactsWithLocation) {
candidates.add(
LineOfSightEndpoint(
label: c.name,
point: LatLng(c.latitude!, c.longitude!),
color: _getNodeColor(c.type),
icon: _getNodeIcon(c.type),
),
);
}
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LineOfSightMapScreen(
title: context.l10n.map_losScreenTitle,
candidates: candidates,
),
),
);
},
tooltip: context.l10n.map_lineOfSight,
),
PopupMenuButton( PopupMenuButton(
itemBuilder: (context) => [ itemBuilder: (context) => [
PopupMenuItem( PopupMenuItem(
@@ -351,6 +397,14 @@ class _MapScreenState extends State<MapScreen> {
position: latLng, position: latLng,
); );
}, },
onPositionChanged: (camera, hasGesture) {
final shouldShow = camera.zoom >= _labelZoomThreshold;
if (shouldShow != _showNodeLabels && mounted) {
setState(() {
_showNodeLabels = shouldShow;
});
}
},
), ),
children: [ children: [
TileLayer( TileLayer(
@@ -375,7 +429,11 @@ class _MapScreenState extends State<MapScreen> {
size: 34, size: 34,
), ),
), ),
..._buildMarkers(contactsWithLocation, settings), ..._buildMarkers(
contactsWithLocation,
settings,
showLabels: _showNodeLabels,
),
...sharedMarkers.map(_buildSharedMarker), ...sharedMarkers.map(_buildSharedMarker),
if (connector.selfLatitude != null && if (connector.selfLatitude != null &&
connector.selfLongitude != null) connector.selfLongitude != null)
@@ -384,8 +442,8 @@ class _MapScreenState extends State<MapScreen> {
connector.selfLatitude!, connector.selfLatitude!,
connector.selfLongitude!, connector.selfLongitude!,
), ),
width: 35, width: 40,
height: 35, height: 40,
child: Container( child: Container(
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -404,23 +462,31 @@ class _MapScreenState extends State<MapScreen> {
], ],
), ),
alignment: Alignment.center, alignment: Alignment.center,
child: Text( child: const Icon(
context.l10n.pathTrace_you, Icons.person_pin_circle,
style: const TextStyle( color: Colors.white,
color: Colors.black, size: 20,
fontWeight: FontWeight.bold,
fontSize: 12,
),
), ),
), ),
), ),
if (_showNodeLabels &&
connector.selfLatitude != null &&
connector.selfLongitude != null)
_buildNodeLabelMarker(
point: LatLng(
connector.selfLatitude!,
connector.selfLongitude!,
),
label: context.l10n.pathTrace_you,
),
], ],
), ),
], ],
), ),
if (!_isBuildingPathTrace) if (!_isBuildingPathTrace)
_buildLegend( _buildLegend(
contactsWithLocation.length, contactsWithLocation,
settings,
sharedMarkers.length, sharedMarkers.length,
), ),
if (_isBuildingPathTrace) _buildPathTraceOverlay(), if (_isBuildingPathTrace) _buildPathTraceOverlay(),
@@ -445,20 +511,28 @@ class _MapScreenState extends State<MapScreen> {
); );
} }
List<Marker> _buildMarkers(List<Contact> contacts, settings) { List<Marker> _buildMarkers(
List<Contact> contacts,
settings, {
required bool showLabels,
}) {
final markers = <Marker>[]; final markers = <Marker>[];
for (final contact in contacts) { for (final contact in contacts) {
if (!contact.hasLocation) continue; if (!contact.hasLocation) continue;
// Apply node type filters // Apply node type filters
if (contact.type == advTypeRepeater && !settings.mapShowRepeaters) { if (contact.type == advTypeRepeater &&
(!settings.mapShowRepeaters && !_isBuildingPathTrace)) {
continue;
}
if (contact.type == advTypeChat &&
!(settings.mapShowChatNodes && !_isBuildingPathTrace)) {
continue; continue;
} }
if (contact.type == advTypeChat && !settings.mapShowChatNodes) continue;
if (contact.type != advTypeChat && if (contact.type != advTypeChat &&
contact.type != advTypeRepeater && contact.type != advTypeRepeater &&
!settings.mapShowOtherNodes) { (!settings.mapShowOtherNodes && !_isBuildingPathTrace)) {
continue; continue;
} }
@@ -500,11 +574,54 @@ class _MapScreenState extends State<MapScreen> {
); );
markers.add(marker); markers.add(marker);
if (showLabels) {
markers.add(
_buildNodeLabelMarker(
point: LatLng(contact.latitude!, contact.longitude!),
label: contact.name,
),
);
}
} }
return markers; return markers;
} }
Marker _buildNodeLabelMarker({required LatLng point, required String label}) {
return Marker(
point: point,
width: 120,
height: 24,
alignment: Alignment.topCenter,
child: IgnorePointer(
child: Transform.translate(
offset: const Offset(0, -20),
child: FittedBox(
fit: BoxFit.contain,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
),
),
),
),
);
}
Color _getNodeColor(int type) { Color _getNodeColor(int type) {
switch (type) { switch (type) {
case advTypeChat: case advTypeChat:
@@ -535,7 +652,26 @@ class _MapScreenState extends State<MapScreen> {
} }
} }
Widget _buildLegend(int nodeCount, int markerCount) { Widget _buildLegend(
List<Contact> contactsWithLocation,
settings,
int markerCount,
) {
int nodeCount = 0;
for (final contact in contactsWithLocation) {
// Apply node type filters
if (contact.type == advTypeRepeater && !settings.mapShowRepeaters) {
continue;
}
if (contact.type == advTypeChat && !settings.mapShowChatNodes) continue;
if (contact.type != advTypeChat &&
contact.type != advTypeRepeater &&
!settings.mapShowOtherNodes) {
continue;
}
nodeCount++;
}
return Positioned( return Positioned(
top: 16, top: 16,
right: 16, right: 16,
@@ -826,7 +962,7 @@ class _MapScreenState extends State<MapScreen> {
color: _getNodeColor(contact.type), color: _getNodeColor(contact.type),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded(child: Text(contact.name)), Expanded(child: SelectableText(contact.name)),
], ],
), ),
content: Column( content: Column(
@@ -997,7 +1133,7 @@ class _MapScreenState extends State<MapScreen> {
), ),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text(value, style: const TextStyle(fontSize: 14)), SelectableText(value, style: const TextStyle(fontSize: 14)),
], ],
), ),
); );
@@ -1520,6 +1656,9 @@ class _MapScreenState extends State<MapScreen> {
Widget _buildPathTraceOverlay() { Widget _buildPathTraceOverlay() {
final l10n = context.l10n; final l10n = context.l10n;
final isImperial =
context.read<AppSettingsService>().settings.unitSystem ==
UnitSystem.imperial;
return Positioned( return Positioned(
top: 16, top: 16,
left: 16, left: 16,
@@ -1540,7 +1679,7 @@ class _MapScreenState extends State<MapScreen> {
const SizedBox(height: 6), const SizedBox(height: 6),
if (_pathTrace.isNotEmpty) if (_pathTrace.isNotEmpty)
Text( Text(
"${l10n.path_currentPathLabel} ${formatDistance(getPathDistanceMeters(_points))}", "${l10n.path_currentPathLabel} ${formatDistance(getPathDistanceMeters(_points), isImperial: isImperial)}",
style: TextStyle(fontSize: 12, color: Colors.grey[700]), style: TextStyle(fontSize: 12, color: Colors.grey[700]),
), ),
SelectableText( SelectableText(
@@ -1550,8 +1689,10 @@ class _MapScreenState extends State<MapScreen> {
style: TextStyle(fontSize: 18), style: TextStyle(fontSize: 18),
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Row( Wrap(
mainAxisSize: MainAxisSize.min, alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: [ children: [
if (_pathTrace.isNotEmpty) if (_pathTrace.isNotEmpty)
ElevatedButton( ElevatedButton(
@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:meshcore_open/utils/app_logger.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../models/contact.dart'; import '../models/contact.dart';
@@ -11,28 +12,28 @@ import '../services/repeater_command_service.dart';
import '../widgets/path_management_dialog.dart'; import '../widgets/path_management_dialog.dart';
import '../widgets/snr_indicator.dart'; import '../widgets/snr_indicator.dart';
class NeighboursScreen extends StatefulWidget { class NeighborsScreen extends StatefulWidget {
final Contact repeater; final Contact repeater;
final String password; final String password;
const NeighboursScreen({ const NeighborsScreen({
super.key, super.key,
required this.repeater, required this.repeater,
required this.password, required this.password,
}); });
@override @override
State<NeighboursScreen> createState() => _NeighboursScreenState(); State<NeighborsScreen> createState() => _NeighborsScreenState();
} }
class _NeighboursScreenState extends State<NeighboursScreen> { class _NeighborsScreenState extends State<NeighborsScreen> {
static const int _reqNeighboursKeyLen = 4; static const int _reqNeighborsKeyLen = 4;
static const int _statusPayloadOffset = 8; static const int _statusPayloadOffset = 8;
static const int _statusStatsSize = 52; static const int _statusStatsSize = 52;
static const int _statusResponseBytes = static const int _statusResponseBytes =
_statusPayloadOffset + _statusStatsSize; _statusPayloadOffset + _statusStatsSize;
Uint8List _tagData = Uint8List(4); Uint8List _tagData = Uint8List(4);
int _neighbourCount = 0; int _neighborCount = 0;
bool _isLoading = false; bool _isLoading = false;
bool _isLoaded = false; bool _isLoaded = false;
@@ -41,7 +42,7 @@ class _NeighboursScreenState extends State<NeighboursScreen> {
StreamSubscription<Uint8List>? _frameSubscription; StreamSubscription<Uint8List>? _frameSubscription;
RepeaterCommandService? _commandService; RepeaterCommandService? _commandService;
PathSelection? _pendingStatusSelection; PathSelection? _pendingStatusSelection;
List<Map<String, dynamic>>? _parsedNeighbours; List<Map<String, dynamic>>? _parsedNeighbors;
@override @override
void initState() { void initState() {
@@ -49,7 +50,7 @@ class _NeighboursScreenState extends State<NeighboursScreen> {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
_commandService = RepeaterCommandService(connector); _commandService = RepeaterCommandService(connector);
_setupMessageListener(); _setupMessageListener();
_loadNeighbours(); _loadNeighbors();
_hasData = false; _hasData = false;
} }
@@ -62,13 +63,12 @@ class _NeighboursScreenState extends State<NeighboursScreen> {
if (frame[0] == respCodeSent) { if (frame[0] == respCodeSent) {
_tagData = frame.sublist(2, 6); _tagData = frame.sublist(2, 6);
//_timeEstment = frame.buffer.asByteData().getUint32(6, Endian.little);
} }
// Check if it's a binary response // Check if it's a binary response
if (frame[0] == pushCodeBinaryResponse && if (frame[0] == pushCodeBinaryResponse &&
listEquals(frame.sublist(2, 6), _tagData)) { listEquals(frame.sublist(2, 6), _tagData)) {
_handleNeighboursResponse(connector, frame.sublist(6)); _handleNeighborsResponse(connector, frame.sublist(6));
} }
}); });
} }
@@ -91,65 +91,77 @@ class _NeighboursScreenState extends State<NeighboursScreen> {
return '${h}h ${m2}m'; return '${h}h ${m2}m';
} }
static List<Map<String, dynamic>> parseNeighboursData( static List<Map<String, dynamic>> parseNeighborsData(
BufferReader buffer, BufferReader buffer,
int resultsCount, int resultsCount,
) { ) {
final Map<int, Map<String, dynamic>> neighbours = {}; final Map<int, Map<String, dynamic>> neighbors = {};
for (var i = 0; i < resultsCount; i++) { try {
final neighbourData = neighbours.putIfAbsent( for (var i = 0; i < resultsCount; i++) {
i, final neighborData = neighbors.putIfAbsent(
() => { i,
'contact': null, () => {
'publicKey': <Uint8List>{}, 'contact': null,
'lastHeard': <int>{}, 'publicKey': <Uint8List>{},
'snr': <double>{}, 'lastHeard': <int>{},
}, 'snr': <double>{},
); },
neighbourData['publicKey'] = buffer.readBytes(_reqNeighboursKeyLen); );
neighbourData['lastHeard'] = buffer.readUInt32LE(); neighborData['publicKey'] = buffer.readBytes(_reqNeighborsKeyLen);
neighbourData['snr'] = buffer.readInt8() / 4.0; neighborData['lastHeard'] = buffer.readUInt32LE();
} neighborData['snr'] = buffer.readInt8() / 4.0;
}
return neighbours.values.toList(); return neighbors.values.toList();
} catch (e) {
appLogger.error(
'Error parsing neighbors data: $e',
tag: 'NeighborsScreen',
);
return [];
}
} }
void _handleNeighboursResponse(MeshCoreConnector connector, Uint8List frame) { void _handleNeighborsResponse(MeshCoreConnector connector, Uint8List frame) {
final buffer = BufferReader(frame); final buffer = BufferReader(frame);
final neighbourCount = buffer.readUInt16LE(); try {
final parsedNeighbours = parseNeighboursData(buffer, buffer.readUInt16LE()); final neighborCount = buffer.readUInt16LE();
connector.contacts.where((c) => c.type == advTypeRepeater).forEach(( final parsedNeighbors = parseNeighborsData(buffer, buffer.readUInt16LE());
repeater, connector.contacts.where((c) => c.type == advTypeRepeater).forEach((
) { repeater,
for (var neighbourData in parsedNeighbours) { ) {
final publicKey = neighbourData['publicKey']; for (var neighborData in parsedNeighbors) {
if (listEquals( final publicKey = neighborData['publicKey'];
repeater.publicKey.sublist(0, _reqNeighboursKeyLen), if (listEquals(
publicKey, repeater.publicKey.sublist(0, _reqNeighborsKeyLen),
)) { publicKey,
neighbourData['contact'] = repeater; )) {
neighborData['contact'] = repeater;
}
} }
} });
});
setState(() { setState(() {
_parsedNeighbours = parsedNeighbours; _parsedNeighbors = parsedNeighbors;
_neighbourCount = neighbourCount; _neighborCount = neighborCount;
}); });
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(context.l10n.neighbors_receivedData), content: Text(context.l10n.neighbors_receivedData),
backgroundColor: Colors.green, backgroundColor: Colors.green,
), ),
); );
_statusTimeout?.cancel(); _statusTimeout?.cancel();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_isLoading = false; _isLoading = false;
_isLoaded = true; _isLoaded = true;
_hasData = true; _hasData = true;
}); });
} catch (e) {
appLogger.error('Error handling neighbors response: $e');
}
} }
Contact _resolveRepeater(MeshCoreConnector connector) { Contact _resolveRepeater(MeshCoreConnector connector) {
@@ -159,7 +171,7 @@ class _NeighboursScreenState extends State<NeighboursScreen> {
); );
} }
Future<void> _loadNeighbours() async { Future<void> _loadNeighbors() async {
if (_commandService == null) return; if (_commandService == null) return;
setState(() { setState(() {
@@ -172,17 +184,17 @@ class _NeighboursScreenState extends State<NeighboursScreen> {
final selection = await connector.preparePathForContactSend(repeater); final selection = await connector.preparePathForContactSend(repeater);
_pendingStatusSelection = selection; _pendingStatusSelection = selection;
//[version][number of requested neighbours][offset_16bit][order by][len of public key] //[version][number of requested neighbors][offset_16bit][order by][len of public key]
final frame = buildSendBinaryReq( final frame = buildSendBinaryReq(
repeater.publicKey, repeater.publicKey,
payload: Uint8List.fromList([ payload: Uint8List.fromList([
reqTypeGetNeighbours, reqTypeGetNeighbors,
0x00, 0x00,
0x0F, 0x0F,
0x00, 0x00,
0x00, 0x00,
0x00, 0x00,
_reqNeighboursKeyLen, _reqNeighborsKeyLen,
]), ]),
); );
await connector.sendFrame(frame); await connector.sendFrame(frame);
@@ -258,7 +270,7 @@ class _NeighboursScreenState extends State<NeighboursScreen> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
l10n.neighbors_repeatersNeighbours, l10n.neighbors_repeatersNeighbors,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
), ),
Text( Text(
@@ -345,7 +357,7 @@ class _NeighboursScreenState extends State<NeighboursScreen> {
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
: const Icon(Icons.refresh), : const Icon(Icons.refresh),
onPressed: _isLoading ? null : _loadNeighbours, onPressed: _isLoading ? null : _loadNeighbors,
tooltip: l10n.repeater_refresh, tooltip: l10n.repeater_refresh,
), ),
], ],
@@ -353,13 +365,13 @@ class _NeighboursScreenState extends State<NeighboursScreen> {
body: SafeArea( body: SafeArea(
top: false, top: false,
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: _loadNeighbours, onRefresh: _loadNeighbors,
child: ListView( child: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
if (!_isLoaded && if (!_isLoaded &&
!_hasData && !_hasData &&
(_parsedNeighbours == null || _parsedNeighbours!.isEmpty)) (_parsedNeighbors == null || _parsedNeighbors!.isEmpty))
Center( Center(
child: Text( child: Text(
l10n.neighbors_noData, l10n.neighbors_noData,
@@ -368,10 +380,9 @@ class _NeighboursScreenState extends State<NeighboursScreen> {
), ),
if (_isLoaded || if (_isLoaded ||
_hasData && _hasData &&
!(_parsedNeighbours == null || !(_parsedNeighbors == null || _parsedNeighbors!.isEmpty))
_parsedNeighbours!.isEmpty)) _buildNeighborsInfoCard(
_buildNeighboursInfoCard( "${l10n.repeater_neighbors} - $_neighborCount",
"${l10n.repeater_neighbours} - $_neighbourCount",
), ),
], ],
), ),
@@ -380,7 +391,7 @@ class _NeighboursScreenState extends State<NeighboursScreen> {
); );
} }
Widget _buildNeighboursInfoCard(String title) { Widget _buildNeighborsInfoCard(String title) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
return Card( return Card(
child: Padding( child: Padding(
@@ -405,7 +416,7 @@ class _NeighboursScreenState extends State<NeighboursScreen> {
], ],
), ),
const Divider(), const Divider(),
for (final entry in _parsedNeighbours!.asMap().entries) for (final entry in _parsedNeighbors!.asMap().entries)
_buildInfoRow( _buildInfoRow(
entry.value['contact'] != null entry.value['contact'] != null
? entry.value['contact'].name ? entry.value['contact'].name
@@ -430,6 +441,7 @@ class _NeighboursScreenState extends State<NeighboursScreen> {
double snr, double snr,
int spreadingFactor, int spreadingFactor,
) { ) {
final snrUi = snrUiFromSNR(snr, spreadingFactor);
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 3), padding: const EdgeInsets.symmetric(vertical: 3),
child: Row( child: Row(
@@ -443,9 +455,15 @@ class _NeighboursScreenState extends State<NeighboursScreen> {
style: const TextStyle(fontWeight: FontWeight.w500), style: const TextStyle(fontWeight: FontWeight.w500),
), ),
subtitle: Text(value), subtitle: Text(value),
trailing: SNRIcon( trailing: Column(
snr: snr, mainAxisAlignment: MainAxisAlignment.center,
snrLevels: getSNRfromSF(spreadingFactor), children: [
Icon(snrUi.icon, color: snrUi.color, size: 18.0),
Text(
snrUi.text,
style: TextStyle(fontSize: 10, color: snrUi.color),
),
],
), ),
), ),
), ),
+269 -135
View File
@@ -8,8 +8,11 @@ import 'package:latlong2/latlong.dart';
import 'package:meshcore_open/connector/meshcore_connector.dart'; import 'package:meshcore_open/connector/meshcore_connector.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart'; import 'package:meshcore_open/connector/meshcore_protocol.dart';
import 'package:meshcore_open/l10n/l10n.dart'; import 'package:meshcore_open/l10n/l10n.dart';
import 'package:meshcore_open/models/app_settings.dart';
import 'package:meshcore_open/models/contact.dart'; import 'package:meshcore_open/models/contact.dart';
import 'package:meshcore_open/services/app_settings_service.dart';
import 'package:meshcore_open/services/map_tile_cache_service.dart'; import 'package:meshcore_open/services/map_tile_cache_service.dart';
import 'package:meshcore_open/utils/app_logger.dart';
import 'package:meshcore_open/widgets/snr_indicator.dart'; import 'package:meshcore_open/widgets/snr_indicator.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -26,13 +29,16 @@ double getPathDistanceMeters(List<LatLng> points) {
return distanceMeters; return distanceMeters;
} }
String formatDistance(double distanceMeters) { String formatDistance(double distanceMeters, {required bool isImperial}) {
return '(${(distanceMeters / 1609.34).toStringAsFixed(2)} Miles / ${(distanceMeters / 1000).toStringAsFixed(2)} Km)'; if (isImperial) {
return '(${(distanceMeters / 1609.34).toStringAsFixed(2)} mi)';
}
return '(${(distanceMeters / 1000).toStringAsFixed(2)} km)';
} }
class PathTraceData { class PathTraceData {
final Uint8List pathData; final Uint8List pathData;
final Uint8List snrData; final List<double> snrData;
final Map<int, Contact> pathContacts; final Map<int, Contact> pathContacts;
PathTraceData({ PathTraceData({
@@ -45,6 +51,7 @@ class PathTraceData {
class PathTraceMapScreen extends StatefulWidget { class PathTraceMapScreen extends StatefulWidget {
final String title; final String title;
final Uint8List path; final Uint8List path;
final int? repeaterId;
final bool flipPathRound; final bool flipPathRound;
final bool reversePathRound; final bool reversePathRound;
@@ -52,6 +59,7 @@ class PathTraceMapScreen extends StatefulWidget {
super.key, super.key,
required this.title, required this.title,
required this.path, required this.path,
this.repeaterId,
this.flipPathRound = false, this.flipPathRound = false,
this.reversePathRound = false, this.reversePathRound = false,
}); });
@@ -61,6 +69,8 @@ class PathTraceMapScreen extends StatefulWidget {
} }
class _PathTraceMapScreenState extends State<PathTraceMapScreen> { class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
static const double _labelZoomThreshold = 8.5;
StreamSubscription<Uint8List>? _frameSubscription; StreamSubscription<Uint8List>? _frameSubscription;
Timer? _timeoutTimer; Timer? _timeoutTimer;
@@ -75,6 +85,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
LatLngBounds? _bounds; LatLngBounds? _bounds;
ValueKey<String> _mapKey = const ValueKey('initial'); ValueKey<String> _mapKey = const ValueKey('initial');
double _pathDistanceMeters = 0.0; double _pathDistanceMeters = 0.0;
bool _showNodeLabels = true;
String _formatPathPrefixes(Uint8List pathBytes) { String _formatPathPrefixes(Uint8List pathBytes) {
return pathBytes return pathBytes
@@ -96,7 +107,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
super.dispose(); super.dispose();
} }
Uint8List addReturnpath(Uint8List pathBytes) { Uint8List addReturnPath(Uint8List pathBytes) {
Uint8List? traceBytes; Uint8List? traceBytes;
final len = (pathBytes.length + pathBytes.length - 1); final len = (pathBytes.length + pathBytes.length - 1);
traceBytes = Uint8List(len); traceBytes = Uint8List(len);
@@ -124,7 +135,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
: widget.path; : widget.path;
if (widget.flipPathRound) { if (widget.flipPathRound) {
path = addReturnpath(pathTmp); path = addReturnPath(pathTmp);
} else { } else {
path = pathTmp; path = pathTmp;
} }
@@ -146,42 +157,57 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
_frameSubscription = connector.receivedFrames.listen((frame) { _frameSubscription = connector.receivedFrames.listen((frame) {
if (frame.isEmpty) return; if (frame.isEmpty) return;
final frameBuffer = BufferReader(frame); final frameBuffer = BufferReader(frame);
final code = frameBuffer.readUInt8(); try {
final code = frameBuffer.readUInt8();
if (code == respCodeSent) { if (code == respCodeSent) {
frameBuffer.skipBytes(1); //reserved frameBuffer.skipBytes(1); //reserved
tagData = frameBuffer.readBytes(4); tagData = frameBuffer.readBytes(4);
final timeoutSeconds = frameBuffer.readUInt32LE(); final timeoutMilliseconds = frameBuffer.readUInt32LE();
// Start timeout timer for trace response // Start timeout timer for trace response
_timeoutTimer?.cancel(); _timeoutTimer?.cancel();
_timeoutTimer = Timer(Duration(milliseconds: timeoutSeconds), () { _timeoutTimer = Timer(
Duration(milliseconds: timeoutMilliseconds),
() {
if (!mounted) return;
setState(() {
_isLoading = false;
_failed2Loaded = true;
});
},
);
}
if (code == respCodeErr) {
_timeoutTimer?.cancel();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_isLoading = false; _isLoading = false;
_failed2Loaded = true; _failed2Loaded = true;
}); });
}); }
}
if (code == respCodeErr) { // Check if it's a binary response
if (frame.length > 8 &&
code == pushCodeTraceData &&
listEquals(frame.sublist(4, 8), tagData)) {
_timeoutTimer?.cancel();
if (!mounted) return;
frameBuffer.skipBytes(3); //reserved + path length + flag
if (listEquals(frameBuffer.readBytes(4), tagData)) {
_handleTraceResponse(frame);
}
}
} catch (e) {
_timeoutTimer?.cancel(); _timeoutTimer?.cancel();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_isLoading = false; _isLoading = false;
_failed2Loaded = true; _failed2Loaded = true;
}); });
} // Handle any parsing errors gracefully
// Check if it's a binary response appLogger.error('Error parsing frame: $e', tag: 'PathTraceMapScreen');
if (frame.length > 8 &&
code == pushCodeTraceData &&
listEquals(frame.sublist(4, 8), tagData)) {
_timeoutTimer?.cancel();
if (!mounted) return;
frameBuffer.skipBytes(3); //reserved + path length + flag
if (listEquals(frameBuffer.readBytes(4), tagData)) {
_handleTraceResponse(frame);
}
} }
}); });
} }
@@ -190,69 +216,91 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final buffer = BufferReader(frame); final buffer = BufferReader(frame);
buffer.skipBytes(2); // Skip push code and reserved byte try {
int pathLength = buffer.readUInt8(); buffer.skipBytes(2); // Skip push code and reserved byte
buffer.skipBytes(5); // Skip Flag byte and tag data int pathLength = buffer.readUInt8();
buffer.skipBytes(4); // Skip auth code buffer.skipBytes(5); // Skip Flag byte and tag data
Uint8List pathData = buffer.readBytes(pathLength); buffer.skipBytes(4); // Skip auth code
Uint8List snrData = buffer.readRemainingBytes(); Uint8List pathData = buffer.readBytes(pathLength);
List<double> snrData = buffer
.readRemainingBytes()
.map((snr) => snr.toSigned(8).toDouble() / 4)
.toList();
Map<int, Contact> pathContacts = {}; Map<int, Contact> pathContacts = {};
connector.contacts.where((c) => c.type != advTypeChat).forEach((repeater) { connector.contacts.where((c) => c.type != advTypeChat).forEach((
for (var repeaterData in pathData) { repeater,
if (listEquals( ) {
repeater.publicKey.sublist(0, 1), for (var repeaterData in pathData) {
Uint8List.fromList([repeaterData]), if (listEquals(
)) { repeater.publicKey.sublist(0, 1),
pathContacts[repeaterData] = repeater; Uint8List.fromList([repeaterData]),
)) {
pathContacts[repeaterData] = repeater;
}
} }
} });
});
setState(() { setState(() {
_isLoading = false; _isLoading = false;
_hasData = true; _hasData = true;
_traceData = PathTraceData( _traceData = PathTraceData(
pathData: pathData, pathData: pathData,
snrData: snrData, snrData: snrData,
pathContacts: pathContacts, pathContacts: pathContacts,
); );
_points = <LatLng>[]; _points = <LatLng>[];
_points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!)); _points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!));
for (final hop in _traceData!.pathData) { for (final hop in _traceData!.pathData) {
final contact = _traceData!.pathContacts[hop]; final contact = _traceData!.pathContacts[hop];
if (contact != null && if (contact != null &&
contact.hasLocation && contact.hasLocation &&
contact.latitude != null && contact.latitude != null &&
contact.longitude != null) { contact.longitude != null) {
_points.add(LatLng(contact.latitude!, contact.longitude!)); _points.add(LatLng(contact.latitude!, contact.longitude!));
}
} }
} _polylines = _points.length > 1
_polylines = _points.length > 1 ? [
? [ Polyline(
Polyline( points: _points,
points: _points, strokeWidth: 4,
strokeWidth: 4, color: Colors.blueAccent,
color: Colors.blueAccent, ),
), ]
] : <Polyline>[];
: <Polyline>[];
_initialCenter = _points.isNotEmpty ? _points.first : const LatLng(0, 0); _initialCenter = _points.isNotEmpty
_initialZoom = _points.isNotEmpty ? 13.0 : 2.0; ? _points.first
_bounds = _points.length > 1 ? LatLngBounds.fromPoints(_points) : null; : const LatLng(0, 0);
_mapKey = ValueKey( _initialZoom = _points.isNotEmpty ? 13.0 : 2.0;
'${context.l10n.pathTrace_you},${_formatPathPrefixes(_traceData!.pathData)}', _bounds = _points.length > 1 ? LatLngBounds.fromPoints(_points) : null;
_mapKey = ValueKey(
'${context.l10n.pathTrace_you},${_formatPathPrefixes(_traceData!.pathData)}',
);
_pathDistanceMeters = getPathDistanceMeters(_points);
});
} catch (e) {
appLogger.error(
'Error handling trace response: $e',
tag: 'PathTraceMapScreen',
); );
_pathDistanceMeters = getPathDistanceMeters(_points); if (mounted) {
}); setState(() {
_isLoading = false;
_failed2Loaded = true;
});
}
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<MeshCoreConnector>( return Consumer<MeshCoreConnector>(
builder: (context, connector, _) { builder: (context, connector, _) {
final settings = context.watch<AppSettingsService>().settings;
final isImperial = settings.unitSystem == UnitSystem.imperial;
final tileCache = context.read<MapTileCacheService>(); final tileCache = context.read<MapTileCacheService>();
return Scaffold( return Scaffold(
@@ -317,7 +365,8 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
), ),
), ),
), ),
if (_hasData) _buildLegendCard(context, _traceData!), if (_hasData)
_buildLegendCard(context, _traceData!, isImperial),
], ],
), ),
), ),
@@ -326,55 +375,61 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
); );
} }
List<Marker> _buildHopMarkers(List<int> pathData) { List<Marker> _buildHopMarkers(
return [ List<int> pathData, {
for (final hop in pathData) required bool showLabels,
if (_traceData!.pathContacts[hop] != null && }) {
_traceData!.pathContacts[hop]!.hasLocation) final markers = <Marker>[];
Marker( for (final hop in pathData) {
point: LatLng( final contact = _traceData!.pathContacts[hop];
_traceData!.pathContacts[hop]!.latitude!, if (contact == null || !contact.hasLocation) continue;
_traceData!.pathContacts[hop]!.longitude!, final point = LatLng(contact.latitude!, contact.longitude!);
), markers.add(
width: 35,
height: 35,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
alignment: Alignment.center,
child: Text(
_traceData!.pathContacts[hop]!.publicKey
.sublist(0, 1)
.map(
(b) => b.toRadixString(16).padLeft(2, '0').toUpperCase(),
)
.join(),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
),
if (context.read<MeshCoreConnector>().selfLatitude != null &&
context.read<MeshCoreConnector>().selfLongitude != null)
Marker( Marker(
point: LatLng( point: point,
context.read<MeshCoreConnector>().selfLatitude!, width: 35,
context.read<MeshCoreConnector>().selfLongitude!, height: 35,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
alignment: Alignment.center,
child: Text(
contact.publicKey
.sublist(0, 1)
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
), ),
),
);
if (showLabels) {
markers.add(_buildNodeLabelMarker(point: point, label: contact.name));
}
}
final selfLat = context.read<MeshCoreConnector>().selfLatitude;
final selfLon = context.read<MeshCoreConnector>().selfLongitude;
if (selfLat != null && selfLon != null) {
final selfPoint = LatLng(selfLat, selfLon);
markers.add(
Marker(
point: selfPoint,
width: 35, width: 35,
height: 35, height: 35,
child: Container( child: Container(
@@ -402,7 +457,53 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
), ),
), ),
), ),
]; );
if (showLabels) {
markers.add(
_buildNodeLabelMarker(
point: selfPoint,
label: context.l10n.pathTrace_you,
),
);
}
}
return markers;
}
Marker _buildNodeLabelMarker({required LatLng point, required String label}) {
return Marker(
point: point,
width: 120,
height: 24,
alignment: Alignment.topCenter,
child: IgnorePointer(
child: Transform.translate(
offset: const Offset(0, -20),
child: FittedBox(
fit: BoxFit.contain,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
),
),
),
),
);
} }
String formatDirectionText(PathTraceData pathTraceData, int index) { String formatDirectionText(PathTraceData pathTraceData, int index) {
@@ -482,6 +583,14 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
), ),
minZoom: 2.0, minZoom: 2.0,
maxZoom: 18.0, maxZoom: 18.0,
onPositionChanged: (camera, hasGesture) {
final shouldShow = camera.zoom >= _labelZoomThreshold;
if (shouldShow != _showNodeLabels && mounted) {
setState(() {
_showNodeLabels = shouldShow;
});
}
},
), ),
children: [ children: [
TileLayer( TileLayer(
@@ -492,12 +601,21 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
), ),
if (_polylines.isNotEmpty) PolylineLayer(polylines: _polylines), if (_polylines.isNotEmpty) PolylineLayer(polylines: _polylines),
if (_traceData!.pathData.isNotEmpty) if (_traceData!.pathData.isNotEmpty)
MarkerLayer(markers: _buildHopMarkers(_traceData!.pathData)), MarkerLayer(
markers: _buildHopMarkers(
_traceData!.pathData,
showLabels: _showNodeLabels,
),
),
], ],
); );
} }
Widget _buildLegendCard(BuildContext context, PathTraceData pathTraceData) { Widget _buildLegendCard(
BuildContext context,
PathTraceData pathTraceData,
bool isImperial,
) {
final l10n = context.l10n; final l10n = context.l10n;
final maxHeight = MediaQuery.of(context).size.height * 0.35; final maxHeight = MediaQuery.of(context).size.height * 0.35;
final estimatedHeight = 72.0 + (pathTraceData.pathData.length * 56.0); final estimatedHeight = 72.0 + (pathTraceData.pathData.length * 56.0);
@@ -516,7 +634,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
Padding( Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: Text( child: Text(
'${l10n.channelPath_repeaterHops} ${formatDistance(_pathDistanceMeters)}', '${l10n.channelPath_repeaterHops} ${formatDistance(_pathDistanceMeters, isImperial: isImperial)}',
style: const TextStyle(fontWeight: FontWeight.w600), style: const TextStyle(fontWeight: FontWeight.w600),
), ),
), ),
@@ -532,6 +650,12 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
itemCount: pathTraceData.pathData.length + 1, itemCount: pathTraceData.pathData.length + 1,
separatorBuilder: (_, _) => const Divider(height: 1), separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final snrUi = snrUiFromSNR(
index < pathTraceData.snrData.length
? pathTraceData.snrData[index]
: null,
context.read<MeshCoreConnector>().currentSf,
);
return Column( return Column(
children: [ children: [
ListTile( ListTile(
@@ -550,12 +674,22 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
), ),
style: const TextStyle(fontSize: 14), style: const TextStyle(fontSize: 14),
), ),
trailing: SNRIcon( trailing: Column(
snr: mainAxisAlignment: MainAxisAlignment.center,
pathTraceData.snrData[index].toSigned( children: [
8, Icon(
) / snrUi.icon,
4.0, color: snrUi.color,
size: 18.0,
),
Text(
snrUi.text,
style: TextStyle(
fontSize: 10,
color: snrUi.color,
),
),
],
), ),
onTap: () { onTap: () {
// Handle item tap // Handle item tap
+67 -7
View File
@@ -1,12 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart'; import 'package:meshcore_open/connector/meshcore_protocol.dart';
import 'package:provider/provider.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../services/app_settings_service.dart';
import 'repeater_status_screen.dart'; import 'repeater_status_screen.dart';
import 'repeater_cli_screen.dart'; import 'repeater_cli_screen.dart';
import 'repeater_settings_screen.dart'; import 'repeater_settings_screen.dart';
import 'telemetry_screen.dart'; import 'telemetry_screen.dart';
import 'neighbours_screen.dart'; import 'neighbors_screen.dart';
class RepeaterHubScreen extends StatelessWidget { class RepeaterHubScreen extends StatelessWidget {
final Contact repeater; final Contact repeater;
@@ -21,6 +23,10 @@ class RepeaterHubScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
final settingsService = context.watch<AppSettingsService>();
final chemistry = settingsService.batteryChemistryForRepeater(
repeater.publicKeyHex,
);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Column( title: Column(
@@ -107,6 +113,62 @@ class RepeaterHubScreen extends StatelessWidget {
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.battery_full),
const SizedBox(width: 10),
Expanded(
child: Text(
l10n.appSettings_batteryChemistry,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: chemistry,
isExpanded: true,
decoration: const InputDecoration(
border: UnderlineInputBorder(),
isDense: true,
),
onChanged: (value) {
if (value == null) return;
settingsService.setBatteryChemistryForRepeater(
repeater.publicKeyHex,
value,
);
},
items: [
DropdownMenuItem(
value: 'nmc',
child: Text(l10n.appSettings_batteryNmc),
),
DropdownMenuItem(
value: 'lifepo4',
child: Text(l10n.appSettings_batteryLifepo4),
),
DropdownMenuItem(
value: 'lipo',
child: Text(l10n.appSettings_batteryLipo),
),
],
),
],
),
),
),
const SizedBox(height: 24),
Text( Text(
l10n.repeater_managementTools, l10n.repeater_managementTools,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
@@ -174,17 +236,15 @@ class RepeaterHubScreen extends StatelessWidget {
_buildManagementCard( _buildManagementCard(
context, context,
icon: Icons.group, icon: Icons.group,
title: l10n.repeater_neighbours, title: l10n.repeater_neighbors,
subtitle: l10n.repeater_neighboursSubtitle, subtitle: l10n.repeater_neighborsSubtitle,
color: Colors.orange, color: Colors.orange,
onTap: () { onTap: () {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => NeighboursScreen( builder: (context) =>
repeater: repeater, NeighborsScreen(repeater: repeater, password: password),
password: password,
),
), ),
); );
}, },
+35 -9
View File
@@ -8,7 +8,9 @@ import '../models/contact.dart';
import '../models/path_selection.dart'; import '../models/path_selection.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../services/app_settings_service.dart';
import '../services/repeater_command_service.dart'; import '../services/repeater_command_service.dart';
import '../utils/battery_utils.dart';
import '../widgets/path_management_dialog.dart'; import '../widgets/path_management_dialog.dart';
class RepeaterStatusScreen extends StatefulWidget { class RepeaterStatusScreen extends StatefulWidget {
@@ -179,6 +181,12 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
_dupDirect = directDups; _dupDirect = directDups;
_dupFlood = floodDups; _dupFlood = floodDups;
}); });
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
connector.updateRepeaterBatterySnapshot(
widget.repeater.publicKeyHex,
batteryMv,
source: 'status_binary',
);
_recordStatusResult(true); _recordStatusResult(true);
} }
@@ -201,6 +209,18 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
_uptimeSecs = _asInt(data['uptime_secs']); _uptimeSecs = _asInt(data['uptime_secs']);
_queueLen = _asInt(data['queue_len']); _queueLen = _asInt(data['queue_len']);
_debugFlags = _asInt(data['errors']); _debugFlags = _asInt(data['errors']);
final batteryMv = _batteryMv;
if (batteryMv != null) {
final connector = Provider.of<MeshCoreConnector>(
context,
listen: false,
);
connector.updateRepeaterBatterySnapshot(
widget.repeater.publicKeyHex,
batteryMv,
source: 'status_text',
);
}
} else if (data.containsKey('noise_floor')) { } else if (data.containsKey('noise_floor')) {
_noiseFloor = _asInt(data['noise_floor']); _noiseFloor = _asInt(data['noise_floor']);
_lastRssi = _asInt(data['last_rssi']); _lastRssi = _asInt(data['last_rssi']);
@@ -590,18 +610,24 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
} }
String _batteryText() { String _batteryText() {
if (_batteryMv == null) return ''; final connector = context.watch<MeshCoreConnector>();
final percent = _batteryPercentFromMv(_batteryMv!); final batteryMv =
final volts = (_batteryMv! / 1000.0).toStringAsFixed(2); connector.getRepeaterBatteryMillivolts(widget.repeater.publicKeyHex) ??
_batteryMv;
if (batteryMv == null) return '';
final percent = estimateBatteryPercentFromMillivolts(
batteryMv,
_batteryChemistry(),
);
final volts = (batteryMv / 1000.0).toStringAsFixed(2);
return '$percent% / ${volts}V'; return '$percent% / ${volts}V';
} }
int _batteryPercentFromMv(int millivolts) { String _batteryChemistry() {
const minMv = 3000; final settingsService = context.read<AppSettingsService>();
const maxMv = 4200; return settingsService.batteryChemistryForRepeater(
if (millivolts <= minMv) return 0; widget.repeater.publicKeyHex,
if (millivolts >= maxMv) return 100; );
return (((millivolts - minMv) * 100) / (maxMv - minMv)).round();
} }
String _clockText() { String _clockText() {
+2 -1
View File
@@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/device_tile.dart'; import '../widgets/device_tile.dart';
import 'contacts_screen.dart'; import 'contacts_screen.dart';
@@ -70,7 +71,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(context.l10n.scanner_title), title: AdaptiveAppBarTitle(context.l10n.scanner_title),
centerTitle: true, centerTitle: true,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
), ),
+5 -1
View File
@@ -8,6 +8,7 @@ import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../models/radio_settings.dart'; import '../models/radio_settings.dart';
import '../widgets/adaptive_app_bar_title.dart';
import 'app_settings_screen.dart'; import 'app_settings_screen.dart';
import 'app_debug_log_screen.dart'; import 'app_debug_log_screen.dart';
import 'ble_debug_log_screen.dart'; import 'ble_debug_log_screen.dart';
@@ -41,7 +42,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(l10n.settings_title), centerTitle: true), appBar: AppBar(
title: AdaptiveAppBarTitle(l10n.settings_title),
centerTitle: true,
),
body: SafeArea( body: SafeArea(
top: false, top: false,
child: Consumer<MeshCoreConnector>( child: Consumer<MeshCoreConnector>(
+49 -17
View File
@@ -5,11 +5,14 @@ import 'package:provider/provider.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/path_selection.dart'; import '../models/path_selection.dart';
import '../models/app_settings.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../services/app_settings_service.dart';
import '../services/repeater_command_service.dart'; import '../services/repeater_command_service.dart';
import '../widgets/path_management_dialog.dart'; import '../widgets/path_management_dialog.dart';
import '../helpers/cayenne_lpp.dart'; import '../helpers/cayenne_lpp.dart';
import '../utils/battery_utils.dart';
class TelemetryScreen extends StatefulWidget { class TelemetryScreen extends StatefulWidget {
final Contact repeater; final Contact repeater;
@@ -72,9 +75,19 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
} }
void _handleStatusResponse(Uint8List frame) { void _handleStatusResponse(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,
batteryMv,
source: 'telemetry',
);
}
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_parsedTelemetry = CayenneLpp.parseByChannel(frame); _parsedTelemetry = parsedTelemetry;
}); });
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -181,6 +194,8 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
final connector = context.watch<MeshCoreConnector>(); final connector = context.watch<MeshCoreConnector>();
final settings = context.watch<AppSettingsService>().settings;
final isImperialUnits = settings.unitSystem == UnitSystem.imperial;
final repeater = _resolveRepeater(connector); final repeater = _resolveRepeater(connector);
final isFloodMode = repeater.pathOverride == -1; final isFloodMode = repeater.pathOverride == -1;
@@ -307,6 +322,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
entry['values'], entry['values'],
l10n.telemetry_channelTitle(entry['channel']), l10n.telemetry_channelTitle(entry['channel']),
entry['channel'], entry['channel'],
isImperialUnits,
), ),
], ],
), ),
@@ -319,6 +335,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
Map<String, dynamic> channelData, Map<String, dynamic> channelData,
String title, String title,
int channel, int channel,
bool isImperialUnits,
) { ) {
final l10n = context.l10n; final l10n = context.l10n;
return Card( return Card(
@@ -358,12 +375,12 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
else if (entry.key == 'temperature' && channel == 1) else if (entry.key == 'temperature' && channel == 1)
_buildInfoRow( _buildInfoRow(
l10n.telemetry_mcuTemperatureLabel, l10n.telemetry_mcuTemperatureLabel,
_temperatureText(entry.value), _temperatureText(entry.value, isImperialUnits),
) )
else if (entry.key == 'temperature') else if (entry.key == 'temperature')
_buildInfoRow( _buildInfoRow(
l10n.telemetry_temperatureLabel, l10n.telemetry_temperatureLabel,
_temperatureText(entry.value), _temperatureText(entry.value, isImperialUnits),
) )
else if (entry.key == 'current' && channel == 1) else if (entry.key == 'current' && channel == 1)
_buildInfoRow( _buildInfoRow(
@@ -405,29 +422,44 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
); );
} }
String _batteryText(double? batteryMv) { int? _extractTelemetryBatteryMillivolts(List<Map<String, dynamic>> entries) {
for (final entry in entries) {
if (entry['channel'] != 1) continue;
final values = entry['values'];
if (values is! Map<String, dynamic>) continue;
final voltage = values['voltage'];
if (voltage is num) return (voltage.toDouble() * 1000).round();
}
return null;
}
String _batteryText(double? telemetryVolts) {
final l10n = context.l10n; final l10n = context.l10n;
final connector = context.watch<MeshCoreConnector>();
final batteryMv =
connector.getRepeaterBatteryMillivolts(widget.repeater.publicKeyHex) ??
(telemetryVolts == null ? null : (telemetryVolts * 1000).round());
if (batteryMv == null) return l10n.common_notAvailable; if (batteryMv == null) return l10n.common_notAvailable;
final percent = _batteryPercentFromMv(batteryMv); final chemistry = _batteryChemistry();
final volts = batteryMv.toStringAsFixed(2); final percent = estimateBatteryPercentFromMillivolts(batteryMv, chemistry);
final volts = (batteryMv / 1000).toStringAsFixed(2);
return l10n.telemetry_batteryValue(percent, volts); return l10n.telemetry_batteryValue(percent, volts);
} }
int _batteryPercentFromMv(double millivolts) { String _batteryChemistry() {
const minMv = 2.800; final settingsService = context.read<AppSettingsService>();
const maxMv = 4.200; return settingsService.batteryChemistryForRepeater(
if (millivolts <= minMv) return 0; widget.repeater.publicKeyHex,
if (millivolts >= maxMv) return 100; );
return (((millivolts - minMv) * 100) / (maxMv - minMv)).round();
} }
String _temperatureText(double? tempC) { String _temperatureText(double? tempC, bool isImperialUnits) {
final l10n = context.l10n; final l10n = context.l10n;
if (tempC == null) return l10n.common_notAvailable; if (tempC == null) return l10n.common_notAvailable;
final tempF = (tempC * 9 / 5) + 32; final tempF = (tempC * 9 / 5) + 32;
return l10n.telemetry_temperatureValue( if (isImperialUnits) {
tempC.toStringAsFixed(1), return '${tempF.toStringAsFixed(1)}°F';
tempF.toStringAsFixed(1), }
); return '${tempC.toStringAsFixed(1)}°C';
} }
} }
+38
View File
@@ -17,6 +17,12 @@ class AppSettingsService extends ChangeNotifier {
return stored ?? 'nmc'; return stored ?? 'nmc';
} }
String batteryChemistryForRepeater(String repeaterPubKeyHex) {
final stored = _settings.batteryChemistryByRepeaterId[repeaterPubKeyHex];
if (stored == 'liion') return 'nmc';
return stored ?? 'nmc';
}
Future<void> loadSettings() async { Future<void> loadSettings() async {
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_settingsKey); final jsonStr = prefs.getString(_settingsKey);
@@ -132,4 +138,36 @@ class AppSettingsService extends ChangeNotifier {
_settings.copyWith(batteryChemistryByDeviceId: updated), _settings.copyWith(batteryChemistryByDeviceId: updated),
); );
} }
Future<void> setBatteryChemistryForRepeater(
String repeaterPubKeyHex,
String chemistry,
) async {
final updated = Map<String, String>.from(
_settings.batteryChemistryByRepeaterId,
);
updated[repeaterPubKeyHex] = chemistry;
await updateSettings(
_settings.copyWith(batteryChemistryByRepeaterId: updated),
);
}
Future<void> setUnitSystem(UnitSystem value) async {
await updateSettings(_settings.copyWith(unitSystem: value));
}
bool isChannelMuted(String channelName) {
return _settings.mutedChannels.contains(channelName);
}
Future<void> muteChannel(String channelName) async {
final updated = Set<String>.from(_settings.mutedChannels)..add(channelName);
await updateSettings(_settings.copyWith(mutedChannels: updated));
}
Future<void> unmuteChannel(String channelName) async {
final updated = Set<String>.from(_settings.mutedChannels)
..remove(channelName);
await updateSettings(_settings.copyWith(mutedChannels: updated));
}
} }
+22 -2
View File
@@ -1,4 +1,5 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
class BleDebugLogEntry { class BleDebugLogEntry {
@@ -44,6 +45,7 @@ class BleDebugLogService extends ChangeNotifier {
static const int maxEntries = 500; static const int maxEntries = 500;
final List<BleDebugLogEntry> _entries = []; final List<BleDebugLogEntry> _entries = [];
final List<BleRawLogRxEntry> _rawLogRxEntries = []; final List<BleRawLogRxEntry> _rawLogRxEntries = [];
bool _notifyScheduled = false;
List<BleDebugLogEntry> get entries => List.unmodifiable(_entries); List<BleDebugLogEntry> get entries => List.unmodifiable(_entries);
List<BleRawLogRxEntry> get rawLogRxEntries => List<BleRawLogRxEntry> get rawLogRxEntries =>
@@ -78,13 +80,31 @@ class BleDebugLogService extends ChangeNotifier {
} }
} }
notifyListeners(); _notifyListenersSafely();
} }
void clear() { void clear() {
_entries.clear(); _entries.clear();
_rawLogRxEntries.clear(); _rawLogRxEntries.clear();
notifyListeners(); _notifyListenersSafely();
}
void _notifyListenersSafely() {
final phase = SchedulerBinding.instance.schedulerPhase;
final canNotifyNow =
phase == SchedulerPhase.idle ||
phase == SchedulerPhase.postFrameCallbacks;
if (canNotifyNow) {
notifyListeners();
return;
}
if (_notifyScheduled) return;
_notifyScheduled = true;
SchedulerBinding.instance.addPostFrameCallback((_) {
_notifyScheduled = false;
notifyListeners();
});
} }
String _describeFrame( String _describeFrame(
+406
View File
@@ -0,0 +1,406 @@
import 'dart:convert';
import 'dart:async';
import 'dart:math';
import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart';
typedef ElevationDataSource =
Future<List<double?>> Function(List<LatLng> points);
class LineOfSightSample {
final double distanceMeters;
final double terrainMeters;
final double lineHeightMeters;
final double clearanceMeters;
const LineOfSightSample({
required this.distanceMeters,
required this.terrainMeters,
required this.lineHeightMeters,
required this.clearanceMeters,
});
}
class LineOfSightResult {
final bool hasData;
final bool isClear;
final double totalDistanceMeters;
final double maxObstructionMeters;
final double? firstObstructionDistanceMeters;
final List<LineOfSightSample> samples;
final String? errorMessage;
const LineOfSightResult({
required this.hasData,
required this.isClear,
required this.totalDistanceMeters,
required this.maxObstructionMeters,
required this.firstObstructionDistanceMeters,
required this.samples,
this.errorMessage,
});
const LineOfSightResult.error({
required this.totalDistanceMeters,
required this.errorMessage,
}) : hasData = false,
isClear = false,
maxObstructionMeters = 0,
firstObstructionDistanceMeters = null,
samples = const [];
}
class LineOfSightPathSegment {
final int index;
final LatLng start;
final LatLng end;
final LineOfSightResult result;
const LineOfSightPathSegment({
required this.index,
required this.start,
required this.end,
required this.result,
});
}
class LineOfSightPathResult {
final List<LineOfSightPathSegment> segments;
final int clearSegments;
final int blockedSegments;
final int unknownSegments;
const LineOfSightPathResult({
required this.segments,
required this.clearSegments,
required this.blockedSegments,
required this.unknownSegments,
});
}
class LineOfSightService {
static const String errorElevationUnavailable =
'los_error_elevation_unavailable';
static const String errorInvalidInput = 'los_error_invalid_input';
static const double _earthRadiusMeters = 6371000.0;
static const Distance _distance = Distance();
static const Duration _cacheTtl = Duration(hours: 24);
static const int _maxFetchAttempts = 4; // initial try + 3 retries
static const Duration _initialBackoff = Duration(milliseconds: 300);
final http.Client _httpClient;
final bool _ownsHttpClient;
final ElevationDataSource? _elevationDataSource;
final Map<String, _CachedElevation> _elevationCache = {};
LineOfSightService({
http.Client? httpClient,
ElevationDataSource? elevationDataSource,
}) : _httpClient = httpClient ?? http.Client(),
_ownsHttpClient = httpClient == null,
_elevationDataSource = elevationDataSource;
Future<LineOfSightPathResult> analyzePath(
List<LatLng> points, {
double startAntennaHeightMeters = 1.5,
double endAntennaHeightMeters = 1.5,
double kFactor = 4.0 / 3.0,
double obstructionToleranceMeters = 0.0,
}) async {
if (points.length < 2) {
return const LineOfSightPathResult(
segments: [],
clearSegments: 0,
blockedSegments: 0,
unknownSegments: 0,
);
}
final segments = <LineOfSightPathSegment>[];
var clearSegments = 0;
var blockedSegments = 0;
var unknownSegments = 0;
for (int i = 0; i < points.length - 1; i++) {
final result = await analyzeLink(
points[i],
points[i + 1],
startAntennaHeightMeters: startAntennaHeightMeters,
endAntennaHeightMeters: endAntennaHeightMeters,
kFactor: kFactor,
obstructionToleranceMeters: obstructionToleranceMeters,
);
segments.add(
LineOfSightPathSegment(
index: i,
start: points[i],
end: points[i + 1],
result: result,
),
);
if (!result.hasData) {
unknownSegments++;
} else if (result.isClear) {
clearSegments++;
} else {
blockedSegments++;
}
}
return LineOfSightPathResult(
segments: segments,
clearSegments: clearSegments,
blockedSegments: blockedSegments,
unknownSegments: unknownSegments,
);
}
Future<LineOfSightResult> analyzeLink(
LatLng start,
LatLng end, {
double startAntennaHeightMeters = 1.5,
double endAntennaHeightMeters = 1.5,
double kFactor = 4.0 / 3.0,
double obstructionToleranceMeters = 0.0,
}) async {
final totalDistanceMeters = _distance.as(LengthUnit.Meter, start, end);
if (totalDistanceMeters <= 1) {
return LineOfSightResult(
hasData: true,
isClear: true,
totalDistanceMeters: totalDistanceMeters,
maxObstructionMeters: 0,
firstObstructionDistanceMeters: null,
samples: const [],
);
}
final samplePoints = _buildSamplePoints(start, end, totalDistanceMeters);
final elevations = await _getElevations(samplePoints);
if (elevations.any((e) => e == null)) {
return LineOfSightResult.error(
totalDistanceMeters: totalDistanceMeters,
errorMessage: errorElevationUnavailable,
);
}
return computeFromElevations(
points: samplePoints,
elevations: elevations.cast<double>(),
startAntennaHeightMeters: startAntennaHeightMeters,
endAntennaHeightMeters: endAntennaHeightMeters,
kFactor: kFactor,
obstructionToleranceMeters: obstructionToleranceMeters,
);
}
static LineOfSightResult computeFromElevations({
required List<LatLng> points,
required List<double> elevations,
double startAntennaHeightMeters = 1.5,
double endAntennaHeightMeters = 1.5,
double kFactor = 4.0 / 3.0,
double obstructionToleranceMeters = 0.0,
}) {
if (points.length < 2 || elevations.length != points.length) {
return const LineOfSightResult.error(
totalDistanceMeters: 0,
errorMessage: errorInvalidInput,
);
}
final totalDistanceMeters = _distance.as(
LengthUnit.Meter,
points.first,
points.last,
);
final effectiveEarthRadius = _earthRadiusMeters * kFactor;
final startLineHeight = elevations.first + startAntennaHeightMeters;
final endLineHeight = elevations.last + endAntennaHeightMeters;
var maxObstructionMeters = 0.0;
double? firstObstructionDistanceMeters;
final samples = <LineOfSightSample>[];
var isClear = true;
for (int i = 0; i < points.length; i++) {
final fraction = points.length == 1 ? 0.0 : i / (points.length - 1);
final distanceFromStart = totalDistanceMeters * fraction;
final lineHeight =
startLineHeight + (endLineHeight - startLineHeight) * fraction;
final earthBulge =
(distanceFromStart * (totalDistanceMeters - distanceFromStart)) /
(2 * effectiveEarthRadius);
final terrainHeight = elevations[i] + earthBulge;
final clearance = lineHeight - terrainHeight;
if (clearance < -obstructionToleranceMeters) {
isClear = false;
final obstruction = -clearance;
if (obstruction > maxObstructionMeters) {
maxObstructionMeters = obstruction;
}
firstObstructionDistanceMeters ??= distanceFromStart;
}
samples.add(
LineOfSightSample(
distanceMeters: distanceFromStart,
terrainMeters: terrainHeight,
lineHeightMeters: lineHeight,
clearanceMeters: clearance,
),
);
}
return LineOfSightResult(
hasData: true,
isClear: isClear,
totalDistanceMeters: totalDistanceMeters,
maxObstructionMeters: maxObstructionMeters,
firstObstructionDistanceMeters: firstObstructionDistanceMeters,
samples: samples,
);
}
List<LatLng> _buildSamplePoints(
LatLng start,
LatLng end,
double distanceMeters,
) {
final sampleCount = distanceMeters < 2000
? 21
: distanceMeters < 10000
? 41
: 81;
final points = <LatLng>[];
for (int i = 0; i < sampleCount; i++) {
final t = i / (sampleCount - 1);
points.add(
LatLng(
start.latitude + (end.latitude - start.latitude) * t,
start.longitude + (end.longitude - start.longitude) * t,
),
);
}
return points;
}
Future<List<double?>> _getElevations(List<LatLng> points) async {
final dataSource = _elevationDataSource;
if (dataSource != null) {
return dataSource(points);
}
final uncached = <int, LatLng>{};
final values = List<double?>.filled(points.length, null);
for (int i = 0; i < points.length; i++) {
final key = _cacheKey(points[i]);
final cached = _readCachedValue(key);
if (cached != null) {
values[i] = cached;
} else {
uncached[i] = points[i];
}
}
if (uncached.isEmpty) return values;
final latCsv = uncached.values
.map((p) => p.latitude.toStringAsFixed(6))
.join(',');
final lonCsv = uncached.values
.map((p) => p.longitude.toStringAsFixed(6))
.join(',');
final uri = Uri.parse(
'https://api.open-meteo.com/v1/elevation?latitude=$latCsv&longitude=$lonCsv',
);
final response = await _getWithBackoff(uri);
if (response.statusCode != 200) {
return values;
}
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
return values;
}
final elevations = decoded['elevation'];
if (elevations is! List) {
return values;
}
final indices = uncached.keys.toList();
for (int i = 0; i < min(indices.length, elevations.length); i++) {
final value = elevations[i];
if (value is! num) continue;
final index = indices[i];
final elevation = value.toDouble();
values[index] = elevation;
_elevationCache[_cacheKey(points[index])] = _CachedElevation(
value: elevation,
expiresAt: DateTime.now().add(_cacheTtl),
);
}
return values;
}
Future<http.Response> _getWithBackoff(Uri uri) async {
var attempt = 0;
Duration backoff = _initialBackoff;
while (true) {
attempt++;
try {
final response = await _httpClient.get(uri);
if (!_shouldRetryStatus(response.statusCode) ||
attempt >= _maxFetchAttempts) {
return response;
}
} catch (_) {
if (attempt >= _maxFetchAttempts) rethrow;
}
await Future.delayed(backoff);
backoff *= 2;
}
}
bool _shouldRetryStatus(int statusCode) {
return statusCode == 429 || statusCode >= 500;
}
double? _readCachedValue(String key) {
final cached = _elevationCache[key];
if (cached == null) return null;
if (DateTime.now().isAfter(cached.expiresAt)) {
_elevationCache.remove(key);
return null;
}
return cached.value;
}
String _cacheKey(LatLng point) {
return '${point.latitude.toStringAsFixed(5)},${point.longitude.toStringAsFixed(5)}';
}
void dispose() {
if (_ownsHttpClient) {
_httpClient.close();
}
}
}
class _CachedElevation {
final double value;
final DateTime expiresAt;
const _CachedElevation({required this.value, required this.expiresAt});
}
+11 -4
View File
@@ -234,7 +234,11 @@ class MessageRetryService extends ChangeNotifier {
} }
} }
void updateMessageFromSent(Uint8List ackHash, int timeoutMs) { bool updateMessageFromSent(
Uint8List ackHash,
int timeoutMs, {
bool allowQueueFallback = true,
}) {
final ackHashHex = ackHash final ackHashHex = ackHash
.map((b) => b.toRadixString(16).padLeft(2, '0')) .map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(); .join();
@@ -277,7 +281,7 @@ class MessageRetryService extends ChangeNotifier {
} }
// FALLBACK: Old queue-based matching (for messages sent before hash computation was added) // FALLBACK: Old queue-based matching (for messages sent before hash computation was added)
if (messageId == null) { if (messageId == null && allowQueueFallback) {
_debugLogService?.warn( _debugLogService?.warn(
'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue', 'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue',
tag: 'AckHash', tag: 'AckHash',
@@ -320,7 +324,7 @@ class MessageRetryService extends ChangeNotifier {
if (messageId == null || contact == null) { if (messageId == null || contact == null) {
debugPrint('No pending message found for ACK hash: $ackHashHex'); debugPrint('No pending message found for ACK hash: $ackHashHex');
return; return false;
} }
// Store the mapping for future lookups (e.g., when ACK arrives) // Store the mapping for future lookups (e.g., when ACK arrives)
@@ -339,7 +343,7 @@ class MessageRetryService extends ChangeNotifier {
'Message $messageId no longer pending for ACK hash: $ackHashHex', 'Message $messageId no longer pending for ACK hash: $ackHashHex',
); );
_ackHashToMessageId.remove(ackHashHex); _ackHashToMessageId.remove(ackHashHex);
return; return false;
} }
// Add this ACK hash to the list of expected ACKs for this message (for history) // Add this ACK hash to the list of expected ACKs for this message (for history)
@@ -389,8 +393,11 @@ class MessageRetryService extends ChangeNotifier {
_startTimeoutTimer(messageId, actualTimeout); _startTimeoutTimer(messageId, actualTimeout);
debugPrint('Updated message $messageId with ACK hash: $ackHashHex'); debugPrint('Updated message $messageId with ACK hash: $ackHashHex');
return true;
} }
bool get hasPendingMessages => _pendingMessages.isNotEmpty;
void _startTimeoutTimer(String messageId, int timeoutMs) { void _startTimeoutTimer(String messageId, int timeoutMs) {
_timeoutTimers[messageId]?.cancel(); _timeoutTimers[messageId]?.cancel();
_timeoutTimers[messageId] = Timer(Duration(milliseconds: timeoutMs), () { _timeoutTimers[messageId] = Timer(Duration(milliseconds: timeoutMs), () {
+89 -62
View File
@@ -58,11 +58,17 @@ class NotificationService {
requestBadgePermission: true, requestBadgePermission: true,
requestSoundPermission: true, requestSoundPermission: true,
); );
const windowsSettings = WindowsInitializationSettings(
appName: 'MeshCore Open',
appUserModelId: 'org.meshcore.open.app',
guid: 'e7ea8f85-72f5-4f36-91f6-038f740ccf86',
);
const initSettings = InitializationSettings( const initSettings = InitializationSettings(
android: androidSettings, android: androidSettings,
iOS: iosSettings, iOS: iosSettings,
macOS: macSettings, macOS: macSettings,
windows: windowsSettings,
); );
try { try {
@@ -76,6 +82,13 @@ class NotificationService {
} }
} }
Future<bool> _ensureInitialized() async {
if (!_isInitialized) {
await initialize();
}
return _isInitialized;
}
Future<bool> requestPermissions() async { Future<bool> requestPermissions() async {
if (!_isInitialized) { if (!_isInitialized) {
await initialize(); await initialize();
@@ -114,9 +127,7 @@ class NotificationService {
String? contactId, String? contactId,
int? badgeCount, int? badgeCount,
}) async { }) async {
if (!_isInitialized) { if (!await _ensureInitialized()) return;
await initialize();
}
final androidDetails = AndroidNotificationDetails( final androidDetails = AndroidNotificationDetails(
'messages', 'messages',
@@ -148,13 +159,17 @@ class NotificationService {
macOS: macDetails, macOS: macDetails,
); );
await _notifications.show( try {
id: contactId?.hashCode ?? 0, await _notifications.show(
title: contactName, id: contactId?.hashCode ?? 0,
body: message, title: contactName,
notificationDetails: notificationDetails, body: message,
payload: 'message:$contactId', notificationDetails: notificationDetails,
); payload: 'message:$contactId',
);
} catch (e) {
debugPrint('Failed to show message notification: $e');
}
} }
Future<void> _showAdvertNotificationImpl({ Future<void> _showAdvertNotificationImpl({
@@ -162,9 +177,7 @@ class NotificationService {
required String contactType, required String contactType,
String? contactId, String? contactId,
}) async { }) async {
if (!_isInitialized) { if (!await _ensureInitialized()) return;
await initialize();
}
const androidDetails = AndroidNotificationDetails( const androidDetails = AndroidNotificationDetails(
'adverts', 'adverts',
@@ -193,13 +206,17 @@ class NotificationService {
macOS: macDetails, macOS: macDetails,
); );
await _notifications.show( try {
id: contactId?.hashCode ?? DateTime.now().millisecondsSinceEpoch, await _notifications.show(
title: _l10n.notification_newTypeDiscovered(contactType), id: contactId?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
body: contactName, title: _l10n.notification_newTypeDiscovered(contactType),
notificationDetails: notificationDetails, body: contactName,
payload: 'advert:$contactId', notificationDetails: notificationDetails,
); payload: 'advert:$contactId',
);
} catch (e) {
debugPrint('Failed to show advert notification: $e');
}
} }
Future<void> _showChannelMessageNotificationImpl({ Future<void> _showChannelMessageNotificationImpl({
@@ -208,9 +225,7 @@ class NotificationService {
int? channelIndex, int? channelIndex,
int? badgeCount, int? badgeCount,
}) async { }) async {
if (!_isInitialized) { if (!await _ensureInitialized()) return;
await initialize();
}
final androidDetails = AndroidNotificationDetails( final androidDetails = AndroidNotificationDetails(
'channel_messages', 'channel_messages',
@@ -247,13 +262,17 @@ class NotificationService {
? _l10n.notification_receivedNewMessage ? _l10n.notification_receivedNewMessage
: preview; : preview;
await _notifications.show( try {
id: channelIndex?.hashCode ?? DateTime.now().millisecondsSinceEpoch, await _notifications.show(
title: channelName, id: channelIndex?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
body: body, title: channelName,
notificationDetails: notificationDetails, body: body,
payload: 'channel:$channelIndex', notificationDetails: notificationDetails,
); payload: 'channel:$channelIndex',
);
} catch (e) {
debugPrint('Failed to show channel notification: $e');
}
} }
/// Returns a privacy-safe identifier for debug logging. /// Returns a privacy-safe identifier for debug logging.
@@ -396,35 +415,39 @@ class NotificationService {
Future<void> _showNotificationImmediately( Future<void> _showNotificationImmediately(
_PendingNotification notification, _PendingNotification notification,
) async { ) async {
switch (notification.type) { try {
case _NotificationType.message: switch (notification.type) {
await _showMessageNotificationImpl( case _NotificationType.message:
contactName: notification.title, await _showMessageNotificationImpl(
message: notification.body, contactName: notification.title,
contactId: notification.id, message: notification.body,
badgeCount: notification.badgeCount, contactId: notification.id,
); badgeCount: notification.badgeCount,
break; );
case _NotificationType.advert: break;
await _showAdvertNotificationImpl( case _NotificationType.advert:
contactName: notification.body, await _showAdvertNotificationImpl(
contactType: notification.title, contactName: notification.body,
contactId: notification.id, contactType: notification.title,
); contactId: notification.id,
break; );
case _NotificationType.channelMessage: break;
await _showChannelMessageNotificationImpl( case _NotificationType.channelMessage:
channelName: notification.title, await _showChannelMessageNotificationImpl(
message: notification.body, channelName: notification.title,
channelIndex: int.tryParse(notification.id ?? ''), message: notification.body,
badgeCount: notification.badgeCount, channelIndex: int.tryParse(notification.id ?? ''),
); badgeCount: notification.badgeCount,
break; );
break;
}
} catch (e) {
debugPrint('Failed to show immediate notification: $e');
} }
} }
Future<void> _showBatchSummary(List<_PendingNotification> batch) async { Future<void> _showBatchSummary(List<_PendingNotification> batch) async {
if (!_isInitialized) await initialize(); if (!await _ensureInitialized()) return;
// Group by type // Group by type
final messages = batch final messages = batch
@@ -468,13 +491,17 @@ class NotificationService {
const notificationDetails = NotificationDetails(android: androidDetails); const notificationDetails = NotificationDetails(android: androidDetails);
await _notifications.show( try {
id: 'batch_summary'.hashCode, await _notifications.show(
title: _l10n.notification_activityTitle, id: 'batch_summary'.hashCode,
body: parts.join(', '), title: _l10n.notification_activityTitle,
notificationDetails: notificationDetails, body: parts.join(', '),
payload: 'batch', notificationDetails: notificationDetails,
); payload: 'batch',
);
} catch (e) {
debugPrint('Failed to show batch summary notification: $e');
}
} }
} }
+26
View File
@@ -0,0 +1,26 @@
typedef BatteryVoltageRange = ({int minMv, int maxMv});
BatteryVoltageRange batteryVoltageRange(String chemistry) {
switch (chemistry) {
case 'lifepo4':
return (minMv: 2600, maxMv: 3650);
case 'lipo':
return (minMv: 3000, maxMv: 4200);
case 'nmc':
default:
return (minMv: 3000, maxMv: 4200);
}
}
int estimateBatteryPercentFromMillivolts(int millivolts, String chemistry) {
final range = batteryVoltageRange(chemistry);
if (millivolts <= range.minMv) return 0;
if (millivolts >= range.maxMv) return 100;
return (((millivolts - range.minMv) * 100) / (range.maxMv - range.minMv))
.round();
}
int estimateBatteryPercentFromVolts(double volts, String chemistry) {
final millivolts = (volts * 1000).round();
return estimateBatteryPercentFromMillivolts(millivolts, chemistry);
}
+17
View File
@@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
class AdaptiveAppBarTitle extends StatelessWidget {
final String text;
const AdaptiveAppBarTitle(this.text, {super.key});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) => SizedBox(
width: constraints.maxWidth,
child: FittedBox(fit: BoxFit.scaleDown, child: Text(text, maxLines: 1)),
),
);
}
}
+67
View File
@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:meshcore_open/connector/meshcore_connector.dart';
import 'package:meshcore_open/widgets/battery_indicator.dart';
import 'package:provider/provider.dart';
import 'snr_indicator.dart';
class AppBarTitle extends StatelessWidget {
final String title;
final Widget? leading;
final Widget? trailing;
const AppBarTitle(this.title, {this.leading, this.trailing, super.key});
@override
Widget build(BuildContext context) {
final connector = context.watch<MeshCoreConnector>();
final selfName = connector.selfName;
return LayoutBuilder(
builder: (context, constraints) {
final availableWidth = constraints.hasBoundedWidth
? constraints.maxWidth
: MediaQuery.sizeOf(context).width;
final compact = availableWidth < 240;
final showSubtitle =
!compact && connector.isConnected && selfName != null;
final showBattery = availableWidth >= 60;
final showSnr = availableWidth >= 110;
final showIndicators = showBattery || showSnr;
return Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
leading ?? const SizedBox.shrink(),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
if (showSubtitle)
Text(
'($selfName)',
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
if (showIndicators) const SizedBox(width: 6),
if (showIndicators)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (showBattery) BatteryIndicator(connector: connector),
if (showSnr) SNRIndicator(connector: connector),
],
),
trailing ?? const SizedBox.shrink(),
],
);
},
);
}
}
+17 -13
View File
@@ -68,20 +68,24 @@ class _BatteryIndicatorState extends State<BatteryIndicator> {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(batteryUi.icon, size: 18, color: batteryUi.color), Column(
const SizedBox(width: 2), mainAxisSize: MainAxisSize.min,
Flexible( children: [
child: Text( Icon(batteryUi.icon, size: 18, color: batteryUi.color),
displayText, const SizedBox(height: 2),
style: TextStyle( Flexible(
fontSize: 12, child: Text(
fontWeight: FontWeight.w500, displayText,
color: batteryUi.color, style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: batteryUi.color,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
), ),
overflow: TextOverflow.visible, ],
maxLines: 1,
softWrap: false,
),
), ),
], ],
), ),
+161 -81
View File
@@ -1,7 +1,9 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:meshcore_open/models/path_history.dart';
import 'package:meshcore_open/screens/path_trace_map.dart'; import 'package:meshcore_open/screens/path_trace_map.dart';
import 'package:meshcore_open/widgets/elements_ui.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
@@ -19,15 +21,22 @@ class PathManagementDialog {
} }
} }
class _PathManagementDialog extends StatelessWidget { class _PathManagementDialog extends StatefulWidget {
final Contact contact; final Contact contact;
const _PathManagementDialog({required this.contact}); const _PathManagementDialog({required this.contact});
@override
State<_PathManagementDialog> createState() => _PathManagementDialogState();
}
class _PathManagementDialogState extends State<_PathManagementDialog> {
bool _showAllPaths = false;
Contact _resolveContact(MeshCoreConnector connector) { Contact _resolveContact(MeshCoreConnector connector) {
return connector.contacts.firstWhere( return connector.contacts.firstWhere(
(c) => c.publicKeyHex == contact.publicKeyHex, (c) => c.publicKeyHex == widget.contact.publicKeyHex,
orElse: () => contact, orElse: () => widget.contact,
); );
} }
@@ -134,6 +143,59 @@ class _PathManagementDialog extends StatelessWidget {
final currentContact = _resolveContact(connector); final currentContact = _resolveContact(connector);
final paths = pathService.getRecentPaths(currentContact.publicKeyHex); final paths = pathService.getRecentPaths(currentContact.publicKeyHex);
final repeatersList = List.of(connector.directRepeaters)
..sort((a, b) => b.ranking.compareTo(a.ranking));
if (repeatersList.isEmpty) {
_showAllPaths = true;
}
final directRepeater = repeatersList.isEmpty
? null
: repeatersList.first;
final secondDirectRepeater = repeatersList.length < 2
? null
: repeatersList.elementAt(1);
final thirdDirectRepeater = repeatersList.length < 3
? null
: repeatersList.elementAt(2);
List<MapEntry<int, MapEntry<Color, PathRecord>>> pathsWithRepeaters =
paths.map((path) {
final isDirectRepeater =
directRepeater != null &&
path.pathBytes.isNotEmpty &&
directRepeater.pubkeyFirstByte == path.pathBytes.first;
final isSecondDirectRepeater =
secondDirectRepeater != null &&
path.pathBytes.isNotEmpty &&
secondDirectRepeater.pubkeyFirstByte == path.pathBytes.first;
final isThirdDirectRepeater =
thirdDirectRepeater != null &&
path.pathBytes.isNotEmpty &&
thirdDirectRepeater.pubkeyFirstByte == path.pathBytes.first;
int ranking = -1;
Color color = Colors.grey;
if (isDirectRepeater) {
color = Colors.green;
ranking = 3;
} else if (isSecondDirectRepeater) {
color = Colors.yellow;
ranking = 2;
} else if (isThirdDirectRepeater) {
color = Colors.red;
ranking = 1;
} else if (path.wasFloodDiscovery) {
color = Colors.blue;
ranking = 0;
}
return MapEntry(ranking, MapEntry(color, path));
}).toList();
pathsWithRepeaters.sort((a, b) => b.key.compareTo(a.key));
return AlertDialog( return AlertDialog(
title: Text(l10n.chat_pathManagement), title: Text(l10n.chat_pathManagement),
content: SingleChildScrollView( content: SingleChildScrollView(
@@ -147,6 +209,17 @@ class _PathManagementDialog extends StatelessWidget {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
if (paths.isNotEmpty) ...[ if (paths.isNotEmpty) ...[
if (repeatersList.isNotEmpty)
FeatureToggleRow(
title: l10n.chat_ShowAllPaths,
subtitle: "",
value: _showAllPaths,
onChanged: (val) {
setState(() {
_showAllPaths = val;
});
},
),
Text( Text(
l10n.chat_recentAckPaths, l10n.chat_recentAckPaths,
style: const TextStyle( style: const TextStyle(
@@ -154,7 +227,7 @@ class _PathManagementDialog extends StatelessWidget {
fontSize: 12, fontSize: 12,
), ),
), ),
if (paths.length >= 100) ...[ if (pathsWithRepeaters.length >= 100) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
width: double.infinity, width: double.infinity,
@@ -173,92 +246,99 @@ class _PathManagementDialog extends StatelessWidget {
), ),
], ],
const SizedBox(height: 8), const SizedBox(height: 8),
...paths.map((path) { ...pathsWithRepeaters.map((entry) {
return Card( final path = entry.value.value;
margin: const EdgeInsets.symmetric(vertical: 4), final color = entry.value.key;
child: ListTile(
dense: true, if (!_showAllPaths && entry.key < 1) {
leading: CircleAvatar( return const SizedBox.shrink();
radius: 16, } else {
backgroundColor: path.wasFloodDiscovery return Card(
? Colors.blue margin: const EdgeInsets.symmetric(vertical: 4),
: Colors.green, child: ListTile(
child: Text( dense: true,
'${path.hopCount}', leading: CircleAvatar(
style: const TextStyle(fontSize: 12), radius: 16,
), backgroundColor: color,
), child: Text(
title: Text( '${path.hopCount}',
l10n.chat_hopsCount(path.hopCount), style: const TextStyle(fontSize: 12),
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
'${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(context, path.timestamp)}${path.successCount} ${l10n.chat_successes}',
style: const TextStyle(fontSize: 11),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.close, size: 16),
tooltip: l10n.chat_removePath,
onPressed: () async {
await pathService.removePathRecord(
currentContact.publicKeyHex,
path.pathBytes,
);
},
), ),
path.wasFloodDiscovery ),
? const Icon( title: Text(
Icons.waves, l10n.chat_hopsCount(path.hopCount),
size: 16, style: const TextStyle(fontSize: 14),
color: Colors.grey, ),
) subtitle: Text(
: const Icon( '${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(context, path.timestamp)}${path.successCount} ${l10n.chat_successes}',
Icons.route, style: const TextStyle(fontSize: 11),
size: 16, ),
color: Colors.grey, trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.close, size: 16),
tooltip: l10n.chat_removePath,
onPressed: () async {
await pathService.removePathRecord(
currentContact.publicKeyHex,
path.pathBytes,
);
},
),
path.wasFloodDiscovery
? const Icon(
Icons.waves,
size: 16,
color: Colors.grey,
)
: const Icon(
Icons.route,
size: 16,
color: Colors.grey,
),
],
),
onLongPress: () =>
_showFullPathDialog(context, path.pathBytes),
onTap: () async {
if (path.pathBytes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
l10n.chat_pathDetailsNotAvailable,
), ),
], duration: const Duration(seconds: 2),
), ),
onLongPress: () => );
_showFullPathDialog(context, path.pathBytes), return;
onTap: () async { }
if (path.pathBytes.isEmpty) {
final pathBytes = Uint8List.fromList(
path.pathBytes,
);
final pathLength = path.pathBytes.length;
await connector.setPathOverride(
currentContact,
pathLen: pathLength,
pathBytes: pathBytes,
);
if (!context.mounted) return;
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
l10n.chat_pathDetailsNotAvailable, l10n.path_usingHopsPath(path.hopCount),
), ),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
return; },
} ),
);
final pathBytes = Uint8List.fromList(path.pathBytes); }
final pathLength = path.pathBytes.length;
await connector.setPathOverride(
currentContact,
pathLen: pathLength,
pathBytes: pathBytes,
);
if (!context.mounted) return;
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
l10n.path_usingHopsPath(path.hopCount),
),
duration: const Duration(seconds: 2),
),
);
},
),
);
}), }),
const Divider(), const Divider(),
] else ...[ ] else ...[
+174 -31
View File
@@ -1,4 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
class SNRUi {
final IconData icon;
final Color color;
final String text;
const SNRUi(this.icon, this.color, this.text);
}
List<double> getSNRfromSF(int spreadingFactor) { List<double> getSNRfromSF(int spreadingFactor) {
switch (spreadingFactor) { switch (spreadingFactor) {
@@ -19,44 +28,178 @@ List<double> getSNRfromSF(int spreadingFactor) {
} }
} }
class SNRIcon extends StatelessWidget { SNRUi snrUiFromSNR(double? snr, int? spreadingFactor) {
final double snr; if (snr == null ||
final List<double> snrLevels; spreadingFactor == null ||
spreadingFactor < 7 ||
spreadingFactor > 12) {
return const SNRUi(Icons.signal_cellular_off, Colors.grey, '');
}
const SNRIcon({ final snrLevels = getSNRfromSF(spreadingFactor);
super.key,
required this.snr, IconData icon;
this.snrLevels = const [4.0, -2.0, -4.0, -6.0], Color color;
}); String text = '${snr.toStringAsFixed(1)} dB';
if (snr >= snrLevels[0]) {
icon = Icons.signal_cellular_alt;
color = Colors.green;
} else if (snr >= snrLevels[1]) {
icon = Icons.signal_cellular_alt;
color = Colors.lightGreen;
} else if (snr >= snrLevels[2]) {
icon = Icons.signal_cellular_alt;
color = Colors.yellow;
} else if (snr >= snrLevels[3]) {
icon = Icons.signal_cellular_alt_2_bar;
color = Colors.orange;
} else {
icon = Icons.signal_cellular_alt_1_bar;
color = Colors.red;
}
return SNRUi(icon, color, text);
}
class SNRIndicator extends StatefulWidget {
final MeshCoreConnector connector;
const SNRIndicator({super.key, required this.connector});
@override
State<SNRIndicator> createState() => _SNRIndicatorState();
}
class _SNRIndicatorState extends State<SNRIndicator> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
IconData icon; final directRepeaters = widget.connector.directRepeaters;
Color color; final directBestRepeaters = List.of(directRepeaters)
..sort((a, b) => (b.ranking).compareTo(a.ranking));
final directRepeater = directBestRepeaters.isEmpty
? null
: directBestRepeaters.first;
if (snr >= snrLevels[0]) { final snrUi = snrUiFromSNR(
icon = Icons.signal_cellular_alt; directBestRepeaters.isNotEmpty ? directRepeater!.snr : null,
color = Colors.green; widget.connector.currentSf,
} else if (snr >= snrLevels[1]) { );
icon = Icons.signal_cellular_alt;
color = Colors.lightGreen; return InkWell(
} else if (snr >= snrLevels[2]) { onTap: () {
icon = Icons.signal_cellular_alt; if (directRepeater != null) {
color = Colors.yellow; _showFullPathDialog(context, directBestRepeaters);
} else if (snr >= snrLevels[3]) { }
icon = Icons.signal_cellular_alt_2_bar; },
color = Colors.orange; borderRadius: BorderRadius.circular(8),
} else { child: Padding(
icon = Icons.signal_cellular_alt_1_bar; padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
color = Colors.red; child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(snrUi.icon, size: 18, color: snrUi.color),
Text(
snrUi.text,
style: TextStyle(fontSize: 12, color: snrUi.color),
),
],
),
if (directRepeater != null)
Text(
'${directRepeaters.length}: ${directRepeater.pubkeyFirstByte.toRadixString(16).padLeft(2, '0')}: ${_formatLastUpdated(directRepeater.lastUpdated)}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.grey,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
);
}
String _formatLastUpdated(DateTime lastSeen) {
final now = DateTime.now();
final diff = now.difference(lastSeen);
if (diff.isNegative) {
return "0s";
} }
if (diff.inMinutes < 1) {
return "${diff.inSeconds}s";
}
if (diff.inMinutes < 60) {
return "${diff.inMinutes}m";
}
if (diff.inHours < 24) {
final hours = diff.inHours;
return "${hours}h";
}
final days = diff.inDays;
return "${days}d";
}
return Column( void _showFullPathDialog(
mainAxisAlignment: MainAxisAlignment.center, BuildContext context,
children: [ List<DirectRepeater> directBestRepeaters,
Icon(icon, color: color), ) {
Text('$snr dB', style: TextStyle(fontSize: 10, color: color)), final l10n = context.l10n;
],
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.snrIndicator_nearByRepeaters),
content: SizedBox(
child: Scrollbar(
child: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: directBestRepeaters.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final repeater = directBestRepeaters[index];
final snrUi = snrUiFromSNR(
repeater.snr,
widget.connector.currentSf,
);
final name = widget.connector.contacts
.where((c) => c.publicKey.first == repeater.pubkeyFirstByte)
.map((c) => c.name)
.firstOrNull;
return Column(
children: [
ListTile(
leading: Icon(snrUi.icon, color: snrUi.color),
title: Text(
name ??
repeater.pubkeyFirstByte
.toRadixString(16)
.padLeft(2, '0'),
),
subtitle: Text(
'SNR: ${repeater.snr.toStringAsFixed(1)} dB\n${l10n.snrIndicator_lastSeen}: ${_formatLastUpdated(repeater.lastUpdated)}',
),
),
],
);
},
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.common_close),
),
],
),
); );
} }
} }
@@ -0,0 +1,72 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:meshcore_open/services/line_of_sight_service.dart';
void main() {
List<LatLng> makePoints(int count) {
return List<LatLng>.generate(count, (i) => LatLng(0, i * 0.00001));
}
test('computeFromElevations reports clear LOS on flat terrain', () {
final points = makePoints(21);
final elevations = List<double>.filled(points.length, 100);
final result = LineOfSightService.computeFromElevations(
points: points,
elevations: elevations,
startAntennaHeightMeters: 2,
endAntennaHeightMeters: 2,
);
expect(result.hasData, isTrue);
expect(result.isClear, isTrue);
expect(result.maxObstructionMeters, equals(0));
expect(result.firstObstructionDistanceMeters, isNull);
});
test(
'computeFromElevations reports blocked LOS with central obstruction',
() {
final points = makePoints(21);
final elevations = List<double>.filled(points.length, 100);
elevations[10] = 300;
final result = LineOfSightService.computeFromElevations(
points: points,
elevations: elevations,
startAntennaHeightMeters: 1.5,
endAntennaHeightMeters: 1.5,
);
expect(result.hasData, isTrue);
expect(result.isClear, isFalse);
expect(result.maxObstructionMeters, greaterThan(0));
expect(result.firstObstructionDistanceMeters, isNotNull);
},
);
test('analyzePath summarizes clear and blocked segments', () async {
final service = LineOfSightService(
elevationDataSource: (points) async {
final elevations = List<double?>.filled(points.length, 100);
if (points.first.longitude > 0.00005) {
elevations[elevations.length ~/ 2] = 300;
}
return elevations;
},
);
final path = [
const LatLng(0, 0),
const LatLng(0, 0.0001),
const LatLng(0, 0.0002),
];
final result = await service.analyzePath(path);
expect(result.segments.length, 2);
expect(result.clearSegments, 1);
expect(result.blockedSegments, 1);
expect(result.unknownSegments, 0);
});
}
+23
View File
@@ -0,0 +1,23 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/utils/battery_utils.dart';
void main() {
group('battery utils', () {
test('nmc range maps 3.0V to 0% and 4.2V to 100%', () {
expect(estimateBatteryPercentFromVolts(3.0, 'nmc'), 0);
expect(estimateBatteryPercentFromVolts(4.2, 'nmc'), 100);
});
test('lifepo4 range maps 2.6V to 0% and 3.65V to 100%', () {
expect(estimateBatteryPercentFromVolts(2.6, 'lifepo4'), 0);
expect(estimateBatteryPercentFromVolts(3.65, 'lifepo4'), 100);
});
test('unknown chemistry falls back to nmc mapping', () {
expect(
estimateBatteryPercentFromMillivolts(3600, 'unknown'),
estimateBatteryPercentFromMillivolts(3600, 'nmc'),
);
});
});
}