From 19edeab9d53c3479dc93d6bf6073725e4be56b9c Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Thu, 19 Feb 2026 11:17:58 -0800 Subject: [PATCH 01/14] add rbenv support --- .ruby-version | 1 + 1 file changed, 1 insertion(+) create mode 100644 .ruby-version diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 00000000..fcdb2e10 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +4.0.0 From d2b693e5ce7021c27b7061c7408a4278e82064cb Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Fri, 20 Feb 2026 20:27:38 -0800 Subject: [PATCH 02/14] Add a signal readout for the nearest repeater. With improvements to app bar and other UI polish. (#200) * Refactor Cayenne LPP parsing with error handling and logging - Added error handling and logging to the Cayenne LPP parsing methods to manage malformed data gracefully. - Improved the structure of the parsing logic for better readability and maintainability. - Updated the Contact model to include error handling during frame parsing. - Refactored Channels, Contacts, Map, and Neighbours screens to utilize a new AppBarTitle widget for consistent app bar design. - Enhanced the BatteryIndicator widget to display SNR information for direct repeaters. - Introduced SNRUi class for better management of SNR icon and text representation. - Improved error handling in PathTraceMap and Neighbours screens to log errors appropriately. * Fix trace route bytes generation logic in Contact model * Ignore advertisements from self in MeshCoreConnector * Refactor PathTraceData to use List for snrData and adjust data mapping in PathTraceMapScreen * Add SNRIndicator to AppBar and refactor BatteryIndicator layout * Enhance path management dialog to display direct repeaters with color coding based on signal strength * Remove unused import from SNR indicator widget * Update lib/models/contact.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update lib/connector/meshcore_connector.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update lib/connector/meshcore_connector.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update lib/screens/path_trace_map.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update lib/widgets/battery_indicator.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update lib/helpers/cayenne_lpp.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Refactor packet handling to skip only the RSSI byte for improved reliability * Add SNR indicator localization and update UI references for nearby repeaters * Handle loading state and error parsing in PathTraceMapScreen; update SNR indicator dialog content layout * Throw an exception for unsupported LPP types in CayenneLpp class * Refactor AppBarTitle widget to remove unused style parameter; update related screens to reflect changes Improve SNR handling by adding validation for spreading factor range in snrUiFromSNR function Update contact handling in MeshCoreConnector to fix variable naming and improve readability Stop parsing unsupported LPP types in CayenneLpp to avoid misalignment * Sort direct repeaters by last updated time and SNR; limit to top three for improved path management dialog * Prevent notifications for chat and sensor adverts without a valid path * Implement ranking system for direct repeaters based on SNR and recency; update related UI components to reflect changes * Refactor localization keys for "neighbors" terminology across multiple languages - Updated localization keys from "neighbours" to "neighbors" in the following files: - app_localizations_bg.dart - app_localizations_de.dart - app_localizations_en.dart - app_localizations_es.dart - app_localizations_fr.dart - app_localizations_it.dart - app_localizations_nl.dart - app_localizations_pl.dart - app_localizations_pt.dart - app_localizations_ru.dart - app_localizations_sk.dart - app_localizations_sl.dart - app_localizations_sv.dart - app_localizations_uk.dart - app_localizations_zh.dart - Updated corresponding ARB files to reflect the changes in keys. - Renamed the NeighboursScreen to NeighborsScreen in the chat and repeater hub screens for consistency. * Adjust ranking calculation for direct repeaters by adding offset to SNR for improved accuracy * Fix typo in variable name for second direct repeater in path management dialog * Refactor ranking calculation for direct repeaters and update path handling in channel message screens * Refactor path handling in ChannelMessagePathScreen to improve logic for outgoing messages and channel messages * Fix AppBarTitle horizontal overflow with long titles (#187) * Initial plan * Wrap title Column in Expanded to prevent horizontal overflow in AppBarTitle Co-authored-by: wel97459 <12990640+wel97459@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: wel97459 <12990640+wel97459@users.noreply.github.com> * Refactor AppBarTitle widget to simplify Text widget initialization * Add "Show All Paths" feature to chat path management - Implemented localization for "Show All Paths" in multiple languages (DE, EN, ES, FR, IT, NL, PL, PT, RU, SK, SL, SV, UK, ZH). - Updated path management dialog to include a toggle for showing all paths. - Refactored path history display logic to conditionally show paths based on the toggle state. - Cleaned up unused print statements and improved code readability in path tracing and chat screens. * Refactor FeatureToggleRow visibility in chat and path management dialogs based on repeaters list * Remove unused import of 'dart:ffi' in path_trace_map.dart * Refactor repeater management logic and update UI state handling in chat and path management dialogs * Refactor RX data handling and improve repeater management logic in chat and path management dialogs --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: wel97459 <12990640+wel97459@users.noreply.github.com> --- lib/connector/meshcore_connector.dart | 309 ++++++++++- lib/connector/meshcore_protocol.dart | 56 +- lib/helpers/cayenne_lpp.dart | 336 +++++------ lib/l10n/app_bg.arb | 9 +- lib/l10n/app_de.arb | 9 +- lib/l10n/app_en.arb | 17 +- lib/l10n/app_es.arb | 9 +- lib/l10n/app_fr.arb | 9 +- lib/l10n/app_it.arb | 9 +- lib/l10n/app_localizations.dart | 38 +- lib/l10n/app_localizations_bg.dart | 15 +- lib/l10n/app_localizations_de.dart | 15 +- lib/l10n/app_localizations_en.dart | 21 +- lib/l10n/app_localizations_es.dart | 15 +- lib/l10n/app_localizations_fr.dart | 16 +- lib/l10n/app_localizations_it.dart | 15 +- lib/l10n/app_localizations_nl.dart | 15 +- lib/l10n/app_localizations_pl.dart | 15 +- lib/l10n/app_localizations_pt.dart | 16 +- lib/l10n/app_localizations_ru.dart | 15 +- lib/l10n/app_localizations_sk.dart | 15 +- lib/l10n/app_localizations_sl.dart | 15 +- lib/l10n/app_localizations_sv.dart | 15 +- lib/l10n/app_localizations_uk.dart | 15 +- lib/l10n/app_localizations_zh.dart | 15 +- lib/l10n/app_nl.arb | 9 +- lib/l10n/app_pl.arb | 9 +- lib/l10n/app_pt.arb | 9 +- lib/l10n/app_ru.arb | 9 +- lib/l10n/app_sk.arb | 9 +- lib/l10n/app_sl.arb | 9 +- lib/l10n/app_sv.arb | 9 +- lib/l10n/app_uk.arb | 9 +- lib/l10n/app_zh.arb | 9 +- lib/models/contact.dart | 72 +-- lib/screens/channel_chat_screen.dart | 3 +- lib/screens/channel_message_path_screen.dart | 58 +- lib/screens/channels_screen.dart | 5 +- lib/screens/chat_screen.dart | 521 ++++++++++-------- lib/screens/contacts_screen.dart | 142 ++--- lib/screens/map_screen.dart | 15 +- ...ours_screen.dart => neighbors_screen.dart} | 170 +++--- lib/screens/path_trace_map.dart | 216 +++++--- lib/screens/repeater_hub_screen.dart | 12 +- lib/widgets/app_bar.dart | 48 ++ lib/widgets/battery_indicator.dart | 30 +- lib/widgets/path_management_dialog.dart | 242 +++++--- lib/widgets/snr_indicator.dart | 205 +++++-- pubspec.lock | 16 +- 49 files changed, 1956 insertions(+), 914 deletions(-) rename lib/screens/{neighbours_screen.dart => neighbors_screen.dart} (75%) create mode 100644 lib/widgets/app_bar.dart diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index f74d5241..9b256e29 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -37,6 +37,42 @@ class MeshCoreUuids { static const String txCharacteristic = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"; } +class DirectRepeater { + static const int maxAgeMinutes = 30; // Max age for direct repeater info + final int pubkeyFirstByte; + double snr; + DateTime lastUpdated; + + DirectRepeater({ + required this.pubkeyFirstByte, + required this.snr, + DateTime? lastUpdated, + }) : lastUpdated = lastUpdated ?? DateTime.now(); + + void update(double newSNR) { + snr = newSNR; + lastUpdated = DateTime.now(); + } + + int get ranking { + if (isStale()) { + return -1; // Stale repeaters get lowest rank + } + // Higher SNR gets higher rank and recency within maxAgeMinutes breaks ties. + final ageMs = + DateTime.now().millisecondsSinceEpoch - + lastUpdated.millisecondsSinceEpoch; + final maxAgeMs = maxAgeMinutes * 60 * 1000; + final recencyScore = (maxAgeMs - ageMs).clamp(0, maxAgeMs); + return ((snr - 31.75) * 1000).round() + recencyScore; + } + + bool isStale() { + return DateTime.now().difference(lastUpdated) > + const Duration(minutes: maxAgeMinutes); + } +} + enum MeshCoreConnectionState { disconnected, scanning, @@ -95,6 +131,7 @@ class MeshCoreConnector extends ChangeNotifier { int? _batteryMillivolts; double? _selfLatitude; double? _selfLongitude; + final List _directRepeaters = List.empty(growable: true); bool _isLoadingContacts = false; bool _isLoadingChannels = false; bool _hasLoadedChannels = false; @@ -196,6 +233,7 @@ class MeshCoreConnector extends ChangeNotifier { String? get selfName => _selfName; double? get selfLatitude => _selfLatitude; double? get selfLongitude => _selfLongitude; + List get directRepeaters => _directRepeaters; int? get currentTxPower => _currentTxPower; int? get maxTxPower => _maxTxPower; int? get currentFreqHz => _currentFreqHz; @@ -1696,6 +1734,11 @@ class MeshCoreConnector extends ChangeNotifier { _isLoadingContacts = true; notifyListeners(); break; + case pushCodeNewAdvert: + debugPrint('Got New CONTACT'); + // It's the same format as respCodeContact, so we can reuse the handler + _handleContact(frame); + break; case respCodeContact: debugPrint('Got CONTACT'); _handleContact(frame); @@ -1740,6 +1783,7 @@ class MeshCoreConnector extends ChangeNotifier { case pushCodeStatusResponse: break; case pushCodeLogRxData: + _handleRxData(frame); _handleLogRxData(frame); break; case respCodeChannelInfo: @@ -2028,6 +2072,80 @@ class MeshCoreConnector extends ChangeNotifier { } } + void _handleContactAdvert(Contact contact) { + if (listEquals(contact.publicKey, _selfPublicKey)) { + return; + } + + if (contact.type == advTypeRepeater) { + _contactUnreadCount.remove(contact.publicKeyHex); + _unreadStore.saveContactUnreadCount( + Map.from(_contactUnreadCount), + ); + } + // Check if this is a new contact + final isNewContact = !_knownContactKeys.contains(contact.publicKeyHex); + final existingIndex = _contacts.indexWhere( + (c) => c.publicKeyHex == contact.publicKeyHex, + ); + + if (existingIndex >= 0) { + final existing = _contacts[existingIndex]; + final mergedLastMessageAt = + existing.lastMessageAt.isAfter(contact.lastMessageAt) + ? existing.lastMessageAt + : contact.lastMessageAt; + + appLogger.info( + 'Refreshing contact ${contact.name}: devicePath=${contact.pathLength}, existingOverride=${existing.pathOverride}', + tag: 'Connector', + ); + + // CRITICAL: Preserve user's path override when contact is refreshed from device + _contacts[existingIndex] = contact.copyWith( + lastMessageAt: mergedLastMessageAt, + pathOverride: existing.pathOverride, // Preserve user's path choice + pathOverrideBytes: existing.pathOverrideBytes, + ); + + appLogger.info( + 'After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_contacts[existingIndex].pathLength}', + tag: 'Connector', + ); + } else { + _contacts.add(contact); + appLogger.info( + 'Added new contact ${contact.name}: pathLen=${contact.pathLength}', + tag: 'Connector', + ); + } + _knownContactKeys.add(contact.publicKeyHex); + _loadMessagesForContact(contact.publicKeyHex); + + // Add path to history if we have a valid path + if (_pathHistoryService != null && contact.pathLength >= 0) { + _pathHistoryService!.handlePathUpdated(contact); + } + + notifyListeners(); + + // Show notification for new contact (advertisement) + if (isNewContact && _appSettingsService != null) { + final settings = _appSettingsService!.settings; + if (settings.notificationsEnabled && settings.notifyOnNewAdvert) { + _notificationService.showAdvertNotification( + contactName: contact.name, + contactType: contact.typeLabel, + contactId: contact.publicKeyHex, + ); + } + } + + if (!_isLoadingContacts) { + unawaited(_persistContacts()); + } + } + Future _persistContacts() async { await _contactStore.saveContacts(_contacts); } @@ -3287,7 +3405,11 @@ class MeshCoreConnector extends ChangeNotifier { void _handleCustomVars(Uint8List frame) { final buf = BufferReader(frame.sublist(1)); - _currentCustomVars = _parseKeyValueString(buf.readString()); + try { + _currentCustomVars = _parseKeyValueString(buf.readString()); + } catch (e) { + appLogger.warn('Malformed custom vars frame: $e', tag: 'Connector'); + } } void _setState(MeshCoreConnectionState newState) { @@ -3311,6 +3433,191 @@ class MeshCoreConnector extends ChangeNotifier { super.dispose(); } + + void _handleRxData(Uint8List frame) { + final packet = BufferReader(frame); + double snr = 0.0; + int routeType = 0; + int payloadType = 0; + Uint8List pathBytes = Uint8List(0); + Uint8List payload = Uint8List(0); + try { + packet.skipBytes(1); // Skip frame type byte + snr = packet.readInt8() / 4.0; + packet.skipBytes(1); // Skip RSSI byte + //final rssi = packet.readByte(); + final header = packet.readByte(); + routeType = header & 0x03; + payloadType = (header >> 2) & 0x0F; + //final payloadVer = (header >> 6) & 0x03; + final pathLen = packet.readByte(); + pathBytes = packet.readBytes(pathLen); + payload = packet.readBytes(packet.remaining); + } catch (e) { + appLogger.warn('Malformed RX frame: $e', tag: 'Connector'); + return; + } + + switch (payloadType) { + case payloadTypeADVERT: + _handlePayloadAdvertReceived(payload, pathBytes, routeType, snr); + break; + default: + } + } + + void _handlePayloadAdvertReceived( + Uint8List frame, + Uint8List path, + int routeType, + double snr, + ) { + final advert = BufferReader(frame); + double latitude = 0.0; + double longitude = 0.0; + String name = ''; + String contactKeyHex = ''; + Uint8List publicKey = Uint8List(0); + int type = 0; + int timestamp = 0; + bool hasLocation = false; + bool hasName = false; + try { + publicKey = advert.readBytes(32); + contactKeyHex = publicKey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + + timestamp = advert.readInt32LE(); + //TODO add signature verification + advert.skipBytes(64); // Skip signature for now + final flags = advert.readByte(); + type = flags & 0x0F; + hasLocation = (flags & 0x10) != 0; + // For future use: + //final hasFeature1 = (flags & 0x20) != 0; + //final hasFeature2 = (flags & 0x40) != 0; + hasName = (flags & 0x80) != 0; + if (hasLocation && advert.remaining >= 8) { + latitude = advert.readInt32LE() / 1e6; + longitude = advert.readInt32LE() / 1e6; + } + if (hasName && advert.remaining > 0) { + name = advert.readString(); + } + } catch (e) { + appLogger.warn('Malformed advert frame: $e', tag: 'Connector'); + return; + } + + if (listEquals(publicKey, _selfPublicKey)) { + return; + } + + // Check if this is a new contact + final isNewContact = !_knownContactKeys.contains(contactKeyHex); + + if (isNewContact) { + final newContact = Contact( + publicKey: publicKey, + name: name, + type: type, + pathLength: path.length, + path: Uint8List.fromList( + path.reversed.toList(), + ), // Store path in reverse for easier use in outgoing messages + latitude: latitude, + longitude: longitude, + lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), + ); + _handleContactAdvert(newContact); + _updateDirectRepeater(newContact, snr, path); + return; + } + + final existingIndex = _contacts.indexWhere( + (c) => c.publicKeyHex == contactKeyHex, + ); + + if (existingIndex >= 0) { + final existing = _contacts[existingIndex]; + final mergedLastMessageAt = existing.lastMessageAt.isAfter(DateTime.now()) + ? DateTime.now() + : existing.lastMessageAt; + + appLogger.info( + 'Refreshing contact ${existing.name}: devicePath=${existing.pathLength}, existingOverride=${existing.pathOverride}', + tag: 'Connector', + ); + + // CRITICAL: Preserve user's path override when contact is refreshed from device + _contacts[existingIndex] = existing.copyWith( + latitude: hasLocation ? latitude : existing.latitude, + longitude: hasLocation ? longitude : existing.longitude, + name: hasName ? name : existing.name, + path: Uint8List.fromList(path.reversed.toList()), + pathLength: path.length, + lastMessageAt: mergedLastMessageAt, + lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), + pathOverride: existing.pathOverride, // Preserve user's path choice + pathOverrideBytes: existing.pathOverrideBytes, + ); + + // Add path to history if we have a valid path + if (_pathHistoryService != null && + _contacts[existingIndex].pathLength >= 0) { + _pathHistoryService!.handlePathUpdated(_contacts[existingIndex]); + } + + _updateDirectRepeater(_contacts[existingIndex], snr, path); + + appLogger.info( + 'After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_contacts[existingIndex].pathLength}', + tag: 'Connector', + ); + } + } + + void _updateDirectRepeater(Contact contact, double snr, Uint8List path) { + final pubkeyFirstByte = path.isNotEmpty + ? path.last + : contact.publicKey.first; + + _directRepeaters.removeWhere((r) => r.isStale()); + + //We can use adverts from chat and sensor nodes, but only if the advert has a path to get the last hop. + if ((contact.type == advTypeChat || contact.type == advTypeSensor) && + path.isEmpty) { + notifyListeners(); + return; + } + + final isTracked = _directRepeaters.where( + (r) => r.pubkeyFirstByte == pubkeyFirstByte, + ); + + final sortedRepeaters = List.from(_directRepeaters) + ..sort((a, b) => b.snr.compareTo(a.snr)); + final weakestRepeater = sortedRepeaters.isNotEmpty + ? sortedRepeaters.last + : null; + + if (_directRepeaters.length >= 5 && + weakestRepeater != null && + isTracked.isEmpty) { + _directRepeaters.remove(weakestRepeater); + } + + if (isTracked.isNotEmpty) { + final repeater = isTracked.first; + repeater.update(snr); + } else if (_directRepeaters.length < 5) { + _directRepeaters.add( + DirectRepeater(pubkeyFirstByte: pubkeyFirstByte, snr: snr), + ); + } + notifyListeners(); + } } const int _phRouteMask = 0x03; diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index ee835781..2933e802 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -13,12 +13,22 @@ class BufferReader { int readByte() => readBytes(1)[0]; Uint8List readBytes(int count) { + if (_pointer + count > _buffer.length) { + throw RangeError( + 'Attempted to read $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}', + ); + } final data = _buffer.sublist(_pointer, _pointer + count); _pointer += count; return data; } void skipBytes(int count) { + if (_pointer + count > _buffer.length) { + throw RangeError( + 'Attempted to skip $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}', + ); + } _pointer += count; } @@ -151,6 +161,7 @@ const int cmdGetContactByKey = 30; const int cmdGetChannel = 31; const int cmdSetChannel = 32; const int cmdSendTracePath = 36; +const int cmdSetOtherParams = 38; const int cmdGetRadioSettings = 57; const int cmdGetTelemetryReq = 39; const int cmdGetCustomVar = 40; @@ -166,7 +177,7 @@ const int reqTypeGetStatus = 0x01; const int reqTypeKeepAlive = 0x02; const int reqTypeGetTelemetry = 0x03; const int reqTypeGetAccessList = 0x05; -const int reqTypeGetNeighbours = 0x06; +const int reqTypeGetNeighbors = 0x06; // Repeater response codes const int respServerLoginOk = 0; @@ -212,6 +223,30 @@ const int advTypeRepeater = 2; const int advTypeRoom = 3; const int advTypeSensor = 4; +// Payload Types +const int payloadTypeREQ = + 0x00; // request (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob) +const int payloadTypeRESPONSE = + 0x01; // response to REQ or ANON_REQ (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob) +const int payloadTypeTXTMSG = + 0x02; // a plain text message (prefixed with dest/src hashes, MAC) (enc data: timestamp, text) +const int payloadTypeACK = 0x03; // a simple ack +const int payloadTypeADVERT = 0x04; // a node advertising its Identity +const int payloadTypeGRPTXT = + 0x05; // an (unverified) group text message (prefixed with channel hash, MAC) (enc data: timestamp, "name: msg") +const int payloadTypeGRPDATA = + 0x06; // an (unverified) group datagram (prefixed with channel hash, MAC) (enc data: timestamp, blob) +const int payloadTypeANONREQ = + 0x07; // generic request (prefixed with dest_hash, ephemeral pub_key, MAC) (enc data: ...) +const int payloadTypePATH = + 0x08; // returned path (prefixed with dest/src hashes, MAC) (enc data: path, extra) +const int payloadTypeTRACE = 0x09; // trace a path, collecting SNI for each hop +const int payloadTypeMULTIPART = 0x0A; // packet is one of a set of packets +const int payloadTypeCONTROL = 0x0B; // a control/discovery packet +//... +const int payloadTypeRawCustom = + 0x0F; // custom packet as raw bytes, for applications with custom encryption, payloads, etc + // Sizes const int pubKeySize = 32; const int maxPathSize = 64; @@ -788,3 +823,22 @@ Uint8List buildZeroHopContact(Uint8List pubKey) { writer.writeBytes(pubKey); return writer.toBytes(); } + +// Build CMD_SET_OTHER_PARAMS frame +// Format: [cmd][allowAutoAddContacts][allowTelemetryFlags][advertLocationPolicy][multiAcks] +Uint8List buildSetOtherParamsFrame( + bool allowAutoAddContacts, + int allowTelemetryFlags, + int advertLocationPolicy, + int multiAcks, +) { + final writer = BufferWriter(); + writer.writeByte(cmdSetOtherParams); + writer.writeByte( + allowAutoAddContacts ? 0x00 : 0x01, + ); // Allow Auto Add Contacts + writer.writeByte(allowTelemetryFlags); // Allow Telemetry Flags + writer.writeByte(advertLocationPolicy); // Advertisement Location Policy + writer.writeByte(multiAcks); // Multi Acknowledgements + return writer.toBytes(); +} diff --git a/lib/helpers/cayenne_lpp.dart b/lib/helpers/cayenne_lpp.dart index bf9b8e77..07909e63 100644 --- a/lib/helpers/cayenne_lpp.dart +++ b/lib/helpers/cayenne_lpp.dart @@ -1,4 +1,6 @@ import 'dart:typed_data'; +import 'package:meshcore_open/utils/app_logger.dart'; + import '../connector/meshcore_protocol.dart'; class CayenneLpp { @@ -84,180 +86,192 @@ class CayenneLpp { static List> parse(Uint8List bytes) { final buffer = BufferReader(bytes); final telemetry = >[]; + try { + while (buffer.remaining >= 2) { + final channel = buffer.readUInt8(); + final type = buffer.readUInt8(); - while (buffer.remaining >= 2) { - final channel = buffer.readUInt8(); - final type = buffer.readUInt8(); + if (channel == 0 && type == 0) { + break; + } - if (channel == 0 && type == 0) { - break; - } - - switch (type) { - case lppGenericSensor: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readUInt32BE(), - }); - break; - case lppLuminosity: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readUInt16BE(), - }); - break; - case lppPresence: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readUInt8(), - }); - break; - case lppTemperature: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readInt16BE() / 10, - }); - break; - case lppRelativeHumidity: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readUInt8() / 2, - }); - break; - case lppBarometricPressure: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readUInt16BE() / 10, - }); - break; - case lppVoltage: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readInt16BE() / 100, - }); - break; - case lppCurrent: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readInt16BE() / 1000, - }); - break; - case lppPercentage: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readUInt8(), - }); - break; - case lppConcentration: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readUInt16BE(), - }); - break; - case lppPower: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readUInt16BE(), - }); - break; - case lppGps: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': { - 'latitude': buffer.readInt24BE() / 10000, - 'longitude': buffer.readInt24BE() / 10000, - 'altitude': buffer.readInt24BE() / 100, - }, - }); - break; - default: - return telemetry; + switch (type) { + case lppGenericSensor: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readUInt32BE(), + }); + break; + case lppLuminosity: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readUInt16BE(), + }); + break; + case lppPresence: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readUInt8(), + }); + break; + case lppTemperature: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readInt16BE() / 10, + }); + break; + case lppRelativeHumidity: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readUInt8() / 2, + }); + break; + case lppBarometricPressure: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readUInt16BE() / 10, + }); + break; + case lppVoltage: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readInt16BE() / 100, + }); + break; + case lppCurrent: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readInt16BE() / 1000, + }); + break; + case lppPercentage: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readUInt8(), + }); + break; + case lppConcentration: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readUInt16BE(), + }); + break; + case lppPower: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readUInt16BE(), + }); + break; + case lppGps: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': { + 'latitude': buffer.readInt24BE() / 10000, + 'longitude': buffer.readInt24BE() / 10000, + 'altitude': buffer.readInt24BE() / 100, + }, + }); + break; + default: + return telemetry; + } } + return telemetry; + } catch (e) { + // Handle parsing errors, possibly due to malformed data + appLogger.error('Error parsing Cayenne LPP data: $e'); + // Return any telemetry parsed so far to preserve partial data + return telemetry; } - - return telemetry; } static List> parseByChannel(Uint8List bytes) { final buffer = BufferReader(bytes); final Map> channels = {}; + try { + while (buffer.remaining >= 2) { + final channel = buffer.readUInt8(); + final type = buffer.readUInt8(); - while (buffer.remaining >= 2) { - final channel = buffer.readUInt8(); - final type = buffer.readUInt8(); + // Optional: stop on padding (00 00) + if (channel == 0 && type == 0) { + break; + } - // Optional: stop on padding (00 00) - if (channel == 0 && type == 0) { - break; + final channelData = channels.putIfAbsent( + channel, + () => {'channel': channel, 'values': {}}, + ); + + switch (type) { + case lppGenericSensor: + channelData['values']['generic'] = buffer.readUInt32BE(); + break; + case lppLuminosity: + channelData['values']['luminosity'] = buffer.readUInt16BE(); + break; + case lppPresence: + channelData['values']['presence'] = buffer.readUInt8() != 0; + break; + case lppTemperature: + channelData['values']['temperature'] = buffer.readInt16BE() / 10.0; + break; + case lppRelativeHumidity: + channelData['values']['humidity'] = buffer.readUInt8() / 2.0; + break; + case lppBarometricPressure: + channelData['values']['pressure'] = buffer.readUInt16BE() / 10.0; + break; + case lppVoltage: + channelData['values']['voltage'] = buffer.readInt16BE() / 100.0; + break; + case lppCurrent: + channelData['values']['current'] = buffer.readInt16BE() / 1000.0; + break; + case lppPercentage: + channelData['values']['percentage'] = buffer.readUInt8(); + break; + case lppConcentration: + channelData['values']['concentration'] = buffer.readUInt16BE(); + break; + case lppPower: + channelData['values']['power'] = buffer.readUInt16BE(); + break; + case lppGps: + channelData['values']['gps'] = { + 'latitude': buffer.readInt24BE() / 10000.0, + 'longitude': buffer.readInt24BE() / 10000.0, + 'altitude': buffer.readInt24BE() / 100.0, + }; + break; + // Add more types as needed... + default: + //Stopped parsing to avoid misalignment + return channels.values.toList(); + } } - final channelData = channels.putIfAbsent( - channel, - () => {'channel': channel, 'values': {}}, - ); - - switch (type) { - case lppGenericSensor: - channelData['values']['generic'] = buffer.readUInt32BE(); - break; - case lppLuminosity: - channelData['values']['luminosity'] = buffer.readUInt16BE(); - break; - case lppPresence: - channelData['values']['presence'] = buffer.readUInt8() != 0; - break; - case lppTemperature: - channelData['values']['temperature'] = buffer.readInt16BE() / 10.0; - break; - case lppRelativeHumidity: - channelData['values']['humidity'] = buffer.readUInt8() / 2.0; - break; - case lppBarometricPressure: - channelData['values']['pressure'] = buffer.readUInt16BE() / 10.0; - break; - case lppVoltage: - channelData['values']['voltage'] = buffer.readInt16BE() / 100.0; - break; - case lppCurrent: - channelData['values']['current'] = buffer.readInt16BE() / 1000.0; - break; - case lppPercentage: - channelData['values']['percentage'] = buffer.readUInt8(); - break; - case lppConcentration: - channelData['values']['concentration'] = buffer.readUInt16BE(); - break; - case lppPower: - channelData['values']['power'] = buffer.readUInt16BE(); - break; - case lppGps: - channelData['values']['gps'] = { - 'latitude': buffer.readInt24BE() / 10000.0, - 'longitude': buffer.readInt24BE() / 10000.0, - 'altitude': buffer.readInt24BE() / 100.0, - }; - break; - // Add more types as needed... - default: - // Unknown type: skip or handle error? - continue; - } + final List> channelsOut = channels.values.toList(); + channelsOut.sort((a, b) => a['channel'].compareTo(b['channel'])); + return channelsOut; + } catch (e) { + // Handle parsing errors, possibly due to malformed data + appLogger.error('Error parsing Cayenne LPP data: $e'); + return < + Map + >[]; // Return an empty list on error to avoid crashing the app } - - final List> channelsOut = channels.values.toList(); - channelsOut.sort((a, b) => a['channel'].compareTo(b['channel'])); - return channelsOut; } } diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index 567c74c6..b6f4301e 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -1351,12 +1351,12 @@ } } }, - "repeater_neighboursSubtitle": "Преглед на съседни възли с нулев скок.", - "repeater_neighbours": "Съседи", + "repeater_neighborsSubtitle": "Преглед на съседни възли с нулев скок.", + "repeater_neighbors": "Съседи", "neighbors_receivedData": "Получени данни за съседи", "neighbors_requestTimedOut": "Съседите поискат изтичане на време.", "neighbors_errorLoading": "Грешка при зареждане на съседи: {error}", - "neighbors_repeatersNeighbours": "Повторители Съседи", + "neighbors_repeatersNeighbors": "Повторители Съседи", "neighbors_noData": "Няма налични данни за съседи.", "channels_createPrivateChannel": "Създай Частен Канал", "channels_joinPrivateChannel": "Присъедини се към Частен Канал", @@ -1594,6 +1594,9 @@ "scanner_bluetoothOff": "Bluetooth е изключен.", "scanner_enableBluetooth": "Активирайте Bluetooth", "scanner_bluetoothOffMessage": "Моля, активирайте Bluetooth, за да сканирате за устройства.", + "snrIndicator_lastSeen": "Последно видян", + "snrIndicator_nearByRepeaters": "Близки повтарящи се устройства", + "chat_ShowAllPaths": "Покажи всички пътища", "settings_clientRepeatSubtitle": "Позволете на това устройство да предава пакети към мрежата за други устройства.", "settings_clientRepeatFreqWarning": "За повторение извън мрежата са необходими честоти от 433, 869 или 918 MHz.", "settings_clientRepeat": "Без електричество – повторение" diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 7aba89b1..077c3989 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1351,12 +1351,12 @@ } } }, - "repeater_neighbours": "Nachbarn", - "repeater_neighboursSubtitle": "Anzahl der Hop-Nachbarn anzeigen.", + "repeater_neighbors": "Nachbarn", + "repeater_neighborsSubtitle": "Anzahl der Hop-Nachbarn anzeigen.", "neighbors_receivedData": "Empfangene Nachbarsdaten", "neighbors_requestTimedOut": "Anfrage durch Timeout fehlgeschlagen.", "neighbors_errorLoading": "Fehler beim Laden der Nachbarn: {error}", - "neighbors_repeatersNeighbours": "Nachbarn", + "neighbors_repeatersNeighbors": "Nachbarn", "neighbors_noData": "Keine Nachbarsdaten verfügbar.", "channels_joinPrivateChannel": "Treten Sie einem privaten Kanal bei", "channels_joinPrivateChannelDesc": "Manuelle Eingabe eines geheimen Schlüssels.", @@ -1622,6 +1622,9 @@ "scanner_bluetoothOffMessage": "Bitte aktivieren Sie Bluetooth, um nach Geräten zu suchen.", "scanner_bluetoothOff": "Bluetooth ist deaktiviert.", "scanner_enableBluetooth": "Bluetooth aktivieren", + "snrIndicator_lastSeen": "Zuletzt gesehen", + "snrIndicator_nearByRepeaters": "In der Nähe befindliche Repeater", + "chat_ShowAllPaths": "Alle Pfade anzeigen", "settings_clientRepeat": "Wiederholung, ohne Stromanschluss", "settings_clientRepeatFreqWarning": "Die Kommunikation ohne Stromversorgung erfordert Frequenzen von 433, 869 oder 918 MHz.", "settings_clientRepeatSubtitle": "Ermöglichen Sie diesem Gerät, Mesh-Pakete für andere zu wiederholen." diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index cfd63300..bf49d7ef 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -558,6 +558,7 @@ }, "debugFrame_hexDump": "Hex Dump:", "chat_pathManagement": "Path Management", + "chat_ShowAllPaths": "Show all paths", "chat_routingMode": "Routing mode", "chat_autoUseSavedPath": "Auto (use saved path)", "chat_forceFloodMode": "Force Flood Mode", @@ -905,8 +906,8 @@ "repeater_telemetrySubtitle": "View telemetry of sensors and system stats", "repeater_cli": "CLI", "repeater_cliSubtitle": "Send commands to the repeater", - "repeater_neighbours": "Neighbors", - "repeater_neighboursSubtitle": "View zero hop neighbors.", + "repeater_neighbors": "Neighbors", + "repeater_neighborsSubtitle": "View zero hop neighbors.", "repeater_settings": "Settings", "repeater_settingsSubtitle": "Configure repeater parameters", "repeater_statusTitle": "Repeater Status", @@ -1266,8 +1267,8 @@ } } }, - "neighbors_receivedData": "Received Neighbours Data", - "neighbors_requestTimedOut": "Neighbours request timed out.", + "neighbors_receivedData": "Received Neighbors Data", + "neighbors_requestTimedOut": "Neighbors request timed out.", "neighbors_errorLoading": "Error loading neighbors: {error}", "@neighbors_errorLoading": { "placeholders": { @@ -1276,8 +1277,8 @@ } } }, - "neighbors_repeatersNeighbours": "Repeaters Neighbours", - "neighbors_noData": "No neighbours data available.", + "neighbors_repeatersNeighbors": "Repeaters Neighbors", + "neighbors_noData": "No neighbors data available.", "neighbors_unknownContact": "Unknown {pubkey}", "@neighbors_unknownContact": { "placeholders": { @@ -1624,5 +1625,7 @@ "settings_gpxExportChat": "Companion locations", "settings_gpxExportAllContacts": "All contacts locations", "settings_gpxExportShareText": "Map data exported from meshcore-open", - "settings_gpxExportShareSubject": "meshcore-open GPX map data export" + "settings_gpxExportShareSubject": "meshcore-open GPX map data export", + "snrIndicator_nearByRepeaters": "Nearby Repeaters", + "snrIndicator_lastSeen": "Last seen" } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 4250a6f6..1896b4f3 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1351,12 +1351,12 @@ } } }, - "repeater_neighbours": "Vecinos", - "repeater_neighboursSubtitle": "Ver vecinos de salto cero.", + "repeater_neighbors": "Vecinos", + "repeater_neighborsSubtitle": "Ver vecinos de salto cero.", "neighbors_receivedData": "Recibidas Datos de Vecinos", "neighbors_requestTimedOut": "Los vecinos solicitan que se desconecte.", "neighbors_errorLoading": "Error al cargar vecinos: {error}", - "neighbors_repeatersNeighbours": "Repetidores Vecinos", + "neighbors_repeatersNeighbors": "Repetidores Vecinos", "neighbors_noData": "No hay datos de vecinos disponibles.", "channels_joinPrivateChannel": "Únete a un Canal Privado", "channels_createPrivateChannel": "Crear un Canal Privado", @@ -1622,6 +1622,9 @@ "scanner_bluetoothOffMessage": "Por favor, active el Bluetooth para escanear dispositivos.", "scanner_bluetoothOff": "Bluetooth está desactivado.", "scanner_enableBluetooth": "Habilitar Bluetooth", + "snrIndicator_nearByRepeaters": "Repetidores cercanos", + "snrIndicator_lastSeen": "Visto por última vez", + "chat_ShowAllPaths": "Mostrar todos los caminos", "settings_clientRepeatFreqWarning": "Para la comunicación fuera de la red, se requiere una frecuencia de 433, 869 o 918 MHz.", "settings_clientRepeat": "Repetir sin conexión", "settings_clientRepeatSubtitle": "Permita que este dispositivo repita los paquetes de red para otros usuarios." diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 56d4a410..d1befced 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1351,12 +1351,12 @@ } } }, - "repeater_neighbours": "Voisins", - "repeater_neighboursSubtitle": "Afficher les voisins de saut nuls.", + "repeater_neighbors": "Voisins", + "repeater_neighborsSubtitle": "Afficher les voisins de saut nuls.", "neighbors_receivedData": "Données des voisins reçues", "neighbors_requestTimedOut": "Les voisins demandent un délai.", "neighbors_errorLoading": "Erreur lors du chargement des voisins : {error}", - "neighbors_repeatersNeighbours": "Répéteurs Voisins", + "neighbors_repeatersNeighbors": "Répéteurs Voisins", "neighbors_noData": "Aucune donnée concernant les voisins disponible.", "channels_createPrivateChannelDesc": "Sécurisé avec une clé secrète.", "channels_joinPrivateChannel": "Rejoindre un Canal Privé", @@ -1594,6 +1594,9 @@ "scanner_bluetoothOffMessage": "Veuillez activer le Bluetooth pour rechercher des appareils.", "scanner_bluetoothOff": "Le Bluetooth est désactivé.", "scanner_enableBluetooth": "Activer le Bluetooth", + "snrIndicator_lastSeen": "Dernière fois vu", + "snrIndicator_nearByRepeaters": "Répéteurs à proximité", + "chat_ShowAllPaths": "Afficher tous les chemins", "settings_clientRepeatFreqWarning": "Pour les transmissions hors réseau, il est nécessaire d'utiliser les fréquences de 433, 869 ou 918 MHz.", "settings_clientRepeatSubtitle": "Permettez à cet appareil de répéter les paquets de données pour les autres.", "settings_clientRepeat": "Répétition hors réseau" diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 239c7659..22371baf 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1351,12 +1351,12 @@ } } }, - "repeater_neighbours": "Vicini", - "repeater_neighboursSubtitle": "Visualizza vicini di salto pari a zero.", + "repeater_neighbors": "Vicini", + "repeater_neighborsSubtitle": "Visualizza vicini di salto pari a zero.", "neighbors_receivedData": "Ricevute dati vicini", "neighbors_requestTimedOut": "I vicini richiedono un timeout.", "neighbors_errorLoading": "Errore nel caricamento dei vicini: {error}", - "neighbors_repeatersNeighbours": "Ripetitori Vicini", + "neighbors_repeatersNeighbors": "Ripetitori Vicini", "neighbors_noData": "Nessun dato sugli vicini disponibile.", "channels_createPrivateChannel": "Crea un Canale Privato", "channels_createPrivateChannelDesc": "Protetta con una chiave segreta.", @@ -1594,6 +1594,9 @@ "scanner_bluetoothOff": "Il Bluetooth è disattivato.", "scanner_bluetoothOffMessage": "Si prega di attivare il Bluetooth per effettuare la scansione dei dispositivi.", "scanner_enableBluetooth": "Abilita il Bluetooth", + "snrIndicator_nearByRepeaters": "Ripetitori vicini", + "snrIndicator_lastSeen": "Ultimo accesso", + "chat_ShowAllPaths": "Mostra tutti i percorsi", "settings_clientRepeat": "Ripetizione \"fuori dalla rete\"", "settings_clientRepeatFreqWarning": "Per la comunicazione fuori rete, è necessario utilizzare frequenze di 433, 869 o 918 MHz.", "settings_clientRepeatSubtitle": "Permetti a questo dispositivo di ripetere i pacchetti di rete per gli altri." diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 7235e90b..2bcda786 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2032,6 +2032,12 @@ abstract class AppLocalizations { /// **'Path Management'** String get chat_pathManagement; + /// No description provided for @chat_ShowAllPaths. + /// + /// In en, this message translates to: + /// **'Show all paths'** + String get chat_ShowAllPaths; + /// No description provided for @chat_routingMode. /// /// In en, this message translates to: @@ -3027,17 +3033,17 @@ abstract class AppLocalizations { /// **'Send commands to the repeater'** String get repeater_cliSubtitle; - /// No description provided for @repeater_neighbours. + /// No description provided for @repeater_neighbors. /// /// In en, this message translates to: /// **'Neighbors'** - String get repeater_neighbours; + String get repeater_neighbors; - /// No description provided for @repeater_neighboursSubtitle. + /// No description provided for @repeater_neighborsSubtitle. /// /// In en, this message translates to: /// **'View zero hop neighbors.'** - String get repeater_neighboursSubtitle; + String get repeater_neighborsSubtitle; /// No description provided for @repeater_settings. /// @@ -4181,13 +4187,13 @@ abstract class AppLocalizations { /// No description provided for @neighbors_receivedData. /// /// In en, this message translates to: - /// **'Received Neighbours Data'** + /// **'Received Neighbors Data'** String get neighbors_receivedData; /// No description provided for @neighbors_requestTimedOut. /// /// In en, this message translates to: - /// **'Neighbours request timed out.'** + /// **'Neighbors request timed out.'** String get neighbors_requestTimedOut; /// No description provided for @neighbors_errorLoading. @@ -4196,16 +4202,16 @@ abstract class AppLocalizations { /// **'Error loading neighbors: {error}'** String neighbors_errorLoading(String error); - /// No description provided for @neighbors_repeatersNeighbours. + /// No description provided for @neighbors_repeatersNeighbors. /// /// In en, this message translates to: - /// **'Repeaters Neighbours'** - String get neighbors_repeatersNeighbours; + /// **'Repeaters Neighbors'** + String get neighbors_repeatersNeighbors; /// No description provided for @neighbors_noData. /// /// In en, this message translates to: - /// **'No neighbours data available.'** + /// **'No neighbors data available.'** String get neighbors_noData; /// No description provided for @neighbors_unknownContact. @@ -5023,6 +5029,18 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'meshcore-open GPX map data export'** String get settings_gpxExportShareSubject; + + /// No description provided for @snrIndicator_nearByRepeaters. + /// + /// In en, this message translates to: + /// **'Nearby Repeaters'** + String get snrIndicator_nearByRepeaters; + + /// No description provided for @snrIndicator_lastSeen. + /// + /// In en, this message translates to: + /// **'Last seen'** + String get snrIndicator_lastSeen; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index fdf9ec7e..137d48a5 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -1080,6 +1080,9 @@ class AppLocalizationsBg extends AppLocalizations { @override String get chat_pathManagement => 'Управление на пътища'; + @override + String get chat_ShowAllPaths => 'Покажи всички пътища'; + @override String get chat_routingMode => 'Режим на маршрутизиране'; @@ -1677,10 +1680,10 @@ class AppLocalizationsBg extends AppLocalizations { String get repeater_cliSubtitle => 'Изпрати команди към ретранслатора'; @override - String get repeater_neighbours => 'Съседи'; + String get repeater_neighbors => 'Съседи'; @override - String get repeater_neighboursSubtitle => + String get repeater_neighborsSubtitle => 'Преглед на съседни възли с нулев скок.'; @override @@ -2380,7 +2383,7 @@ class AppLocalizationsBg extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => 'Повторители Съседи'; + String get neighbors_repeatersNeighbors => 'Повторители Съседи'; @override String get neighbors_noData => 'Няма налични данни за съседи.'; @@ -2890,4 +2893,10 @@ class AppLocalizationsBg extends AppLocalizations { @override String get settings_gpxExportShareSubject => 'meshcore-open износ на данни за карта в формат GPX'; + + @override + String get snrIndicator_nearByRepeaters => 'Близки повтарящи се устройства'; + + @override + String get snrIndicator_lastSeen => 'Последно видян'; } diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index c0fc4c86..927ac488 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1080,6 +1080,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get chat_pathManagement => 'Pfadverwaltung'; + @override + String get chat_ShowAllPaths => 'Alle Pfade anzeigen'; + @override String get chat_routingMode => 'Routenmodus'; @@ -1676,10 +1679,10 @@ class AppLocalizationsDe extends AppLocalizations { String get repeater_cliSubtitle => 'Sende Befehle an den Repeater'; @override - String get repeater_neighbours => 'Nachbarn'; + String get repeater_neighbors => 'Nachbarn'; @override - String get repeater_neighboursSubtitle => 'Anzahl der Hop-Nachbarn anzeigen.'; + String get repeater_neighborsSubtitle => 'Anzahl der Hop-Nachbarn anzeigen.'; @override String get repeater_settings => 'Einstellungen'; @@ -2382,7 +2385,7 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => 'Nachbarn'; + String get neighbors_repeatersNeighbors => 'Nachbarn'; @override String get neighbors_noData => 'Keine Nachbarsdaten verfügbar.'; @@ -2898,4 +2901,10 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settings_gpxExportShareSubject => 'GPX-Kartendaten aus meshcore-open exportieren'; + + @override + String get snrIndicator_nearByRepeaters => 'In der Nähe befindliche Repeater'; + + @override + String get snrIndicator_lastSeen => 'Zuletzt gesehen'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 4f0bed12..ef7c0c34 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1065,6 +1065,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get chat_pathManagement => 'Path Management'; + @override + String get chat_ShowAllPaths => 'Show all paths'; + @override String get chat_routingMode => 'Routing mode'; @@ -1650,10 +1653,10 @@ class AppLocalizationsEn extends AppLocalizations { String get repeater_cliSubtitle => 'Send commands to the repeater'; @override - String get repeater_neighbours => 'Neighbors'; + String get repeater_neighbors => 'Neighbors'; @override - String get repeater_neighboursSubtitle => 'View zero hop neighbors.'; + String get repeater_neighborsSubtitle => 'View zero hop neighbors.'; @override String get repeater_settings => 'Settings'; @@ -2329,10 +2332,10 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String get neighbors_receivedData => 'Received Neighbours Data'; + String get neighbors_receivedData => 'Received Neighbors Data'; @override - String get neighbors_requestTimedOut => 'Neighbours request timed out.'; + String get neighbors_requestTimedOut => 'Neighbors request timed out.'; @override String neighbors_errorLoading(String error) { @@ -2340,10 +2343,10 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => 'Repeaters Neighbours'; + String get neighbors_repeatersNeighbors => 'Repeaters Neighbors'; @override - String get neighbors_noData => 'No neighbours data available.'; + String get neighbors_noData => 'No neighbors data available.'; @override String neighbors_unknownContact(String pubkey) { @@ -2845,4 +2848,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settings_gpxExportShareSubject => 'meshcore-open GPX map data export'; + + @override + String get snrIndicator_nearByRepeaters => 'Nearby Repeaters'; + + @override + String get snrIndicator_lastSeen => 'Last seen'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index f56e4e4a..f72196db 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -1079,6 +1079,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get chat_pathManagement => 'Gestión de Rutas'; + @override + String get chat_ShowAllPaths => 'Mostrar todos los caminos'; + @override String get chat_routingMode => 'Modo de enrutamiento'; @@ -1674,10 +1677,10 @@ class AppLocalizationsEs extends AppLocalizations { String get repeater_cliSubtitle => 'Enviar comandos al repetidor'; @override - String get repeater_neighbours => 'Vecinos'; + String get repeater_neighbors => 'Vecinos'; @override - String get repeater_neighboursSubtitle => 'Ver vecinos de salto cero.'; + String get repeater_neighborsSubtitle => 'Ver vecinos de salto cero.'; @override String get repeater_settings => 'Configuración'; @@ -2376,7 +2379,7 @@ class AppLocalizationsEs extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => 'Repetidores Vecinos'; + String get neighbors_repeatersNeighbors => 'Repetidores Vecinos'; @override String get neighbors_noData => 'No hay datos de vecinos disponibles.'; @@ -2889,4 +2892,10 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settings_gpxExportShareSubject => 'meshcore-open exportación de datos de mapa GPX'; + + @override + String get snrIndicator_nearByRepeaters => 'Repetidores cercanos'; + + @override + String get snrIndicator_lastSeen => 'Visto por última vez'; } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index e1325dae..89785685 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1082,6 +1082,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get chat_pathManagement => 'Gestion des chemins'; + @override + String get chat_ShowAllPaths => 'Afficher tous les chemins'; + @override String get chat_routingMode => 'Mode de routage'; @@ -1682,11 +1685,10 @@ class AppLocalizationsFr extends AppLocalizations { String get repeater_cliSubtitle => 'Envoyer des commandes au répéteur'; @override - String get repeater_neighbours => 'Voisins'; + String get repeater_neighbors => 'Voisins'; @override - String get repeater_neighboursSubtitle => - 'Afficher les voisins de saut nuls.'; + String get repeater_neighborsSubtitle => 'Afficher les voisins de saut nuls.'; @override String get repeater_settings => 'Paramètres'; @@ -2391,7 +2393,7 @@ class AppLocalizationsFr extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => 'Répéteurs Voisins'; + String get neighbors_repeatersNeighbors => 'Répéteurs Voisins'; @override String get neighbors_noData => @@ -2913,4 +2915,10 @@ class AppLocalizationsFr extends AppLocalizations { @override String get settings_gpxExportShareSubject => 'meshcore-open exporter les données de carte GPX'; + + @override + String get snrIndicator_nearByRepeaters => 'Répéteurs à proximité'; + + @override + String get snrIndicator_lastSeen => 'Dernière fois vu'; } diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 15d5354d..a2b790f6 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -1077,6 +1077,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get chat_pathManagement => 'Gestione Percorsi'; + @override + String get chat_ShowAllPaths => 'Mostra tutti i percorsi'; + @override String get chat_routingMode => 'Modalità di routing'; @@ -1672,10 +1675,10 @@ class AppLocalizationsIt extends AppLocalizations { String get repeater_cliSubtitle => 'Invia comandi al ripetitore'; @override - String get repeater_neighbours => 'Vicini'; + String get repeater_neighbors => 'Vicini'; @override - String get repeater_neighboursSubtitle => + String get repeater_neighborsSubtitle => 'Visualizza vicini di salto pari a zero.'; @override @@ -2376,7 +2379,7 @@ class AppLocalizationsIt extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => 'Ripetitori Vicini'; + String get neighbors_repeatersNeighbors => 'Ripetitori Vicini'; @override String get neighbors_noData => 'Nessun dato sugli vicini disponibile.'; @@ -2893,4 +2896,10 @@ class AppLocalizationsIt extends AppLocalizations { @override String get settings_gpxExportShareSubject => 'meshcore-open esportazione dati mappa GPX'; + + @override + String get snrIndicator_nearByRepeaters => 'Ripetitori vicini'; + + @override + String get snrIndicator_lastSeen => 'Ultimo accesso'; } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 17b3bce1..a958e795 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -1074,6 +1074,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get chat_pathManagement => 'Beheer van Paden'; + @override + String get chat_ShowAllPaths => 'Toon alle paden'; + @override String get chat_routingMode => 'Routeerwijze'; @@ -1668,10 +1671,10 @@ class AppLocalizationsNl extends AppLocalizations { String get repeater_cliSubtitle => 'Verzend commando\'s naar de repeater'; @override - String get repeater_neighbours => 'Buren'; + String get repeater_neighbors => 'Buren'; @override - String get repeater_neighboursSubtitle => 'Bekijk nul hops buren.'; + String get repeater_neighborsSubtitle => 'Bekijk nul hops buren.'; @override String get repeater_settings => 'Instellingen'; @@ -2367,7 +2370,7 @@ class AppLocalizationsNl extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => 'Herhalingen Buren'; + String get neighbors_repeatersNeighbors => 'Herhalingen Buren'; @override String get neighbors_noData => 'Geen gegevens van buren beschikbaar.'; @@ -2881,4 +2884,10 @@ class AppLocalizationsNl extends AppLocalizations { @override String get settings_gpxExportShareSubject => 'meshcore-open GPX kaartgegevens exporteren'; + + @override + String get snrIndicator_nearByRepeaters => 'Nabije herhalingseenheden'; + + @override + String get snrIndicator_lastSeen => 'Laatst gezien'; } diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 147e1606..55bc6ec7 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -1079,6 +1079,9 @@ class AppLocalizationsPl extends AppLocalizations { @override String get chat_pathManagement => 'Zarządzanie ścieżkami'; + @override + String get chat_ShowAllPaths => 'Pokaż wszystkie ścieżki'; + @override String get chat_routingMode => 'Tryb routingu'; @@ -1676,10 +1679,10 @@ class AppLocalizationsPl extends AppLocalizations { String get repeater_cliSubtitle => 'Wyślij polecenia do powielacza'; @override - String get repeater_neighbours => 'Sąsiedzi'; + String get repeater_neighbors => 'Sąsiedzi'; @override - String get repeater_neighboursSubtitle => + String get repeater_neighborsSubtitle => 'Wyświetl sąsiedztwo zerowych hopów.'; @override @@ -2375,7 +2378,7 @@ class AppLocalizationsPl extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => 'Powtarzacze Sąsiedzi'; + String get neighbors_repeatersNeighbors => 'Powtarzacze Sąsiedzi'; @override String get neighbors_noData => 'Brak danych dotyczących sąsiadów.'; @@ -2895,4 +2898,10 @@ class AppLocalizationsPl extends AppLocalizations { @override String get settings_gpxExportShareSubject => 'Eksport danych mapy GPX meshcore-open'; + + @override + String get snrIndicator_nearByRepeaters => 'Nadajniki w pobliżu'; + + @override + String get snrIndicator_lastSeen => 'Ostatnio widziany'; } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index b4817757..596d268b 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -1079,6 +1079,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get chat_pathManagement => 'Gerenciamento de Caminhos'; + @override + String get chat_ShowAllPaths => 'Mostrar todos os caminhos'; + @override String get chat_routingMode => 'Modo de roteamento'; @@ -1674,11 +1677,10 @@ class AppLocalizationsPt extends AppLocalizations { String get repeater_cliSubtitle => 'Enviar comandos ao repetidor'; @override - String get repeater_neighbours => 'Vizinhos'; + String get repeater_neighbors => 'Vizinhos'; @override - String get repeater_neighboursSubtitle => - 'Visualizar vizinhos de salto zero.'; + String get repeater_neighborsSubtitle => 'Visualizar vizinhos de salto zero.'; @override String get repeater_settings => 'Configurações'; @@ -2377,7 +2379,7 @@ class AppLocalizationsPt extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => 'Repetidores Vizinhos'; + String get neighbors_repeatersNeighbors => 'Repetidores Vizinhos'; @override String get neighbors_noData => 'Não estão disponíveis dados de vizinhos.'; @@ -2890,4 +2892,10 @@ class AppLocalizationsPt extends AppLocalizations { @override String get settings_gpxExportShareSubject => 'meshcore-open exportação de dados de mapa GPX'; + + @override + String get snrIndicator_nearByRepeaters => 'Repetidores Próximos'; + + @override + String get snrIndicator_lastSeen => 'Visto pela última vez'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index e5875b13..4647746a 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1077,6 +1077,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get chat_pathManagement => 'Управление маршрутами'; + @override + String get chat_ShowAllPaths => 'Показать все пути'; + @override String get chat_routingMode => 'Режим маршрутизации'; @@ -1676,10 +1679,10 @@ class AppLocalizationsRu extends AppLocalizations { String get repeater_cliSubtitle => 'Отправка команд репитеру'; @override - String get repeater_neighbours => 'Соседи'; + String get repeater_neighbors => 'Соседи'; @override - String get repeater_neighboursSubtitle => 'Просмотр соседей на нулевом хопе.'; + String get repeater_neighborsSubtitle => 'Просмотр соседей на нулевом хопе.'; @override String get repeater_settings => 'Настройки'; @@ -2379,7 +2382,7 @@ class AppLocalizationsRu extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => 'Соседи репитеров'; + String get neighbors_repeatersNeighbors => 'Соседи репитеров'; @override String get neighbors_noData => 'Данные о соседях недоступны.'; @@ -2901,4 +2904,10 @@ class AppLocalizationsRu extends AppLocalizations { @override String get settings_gpxExportShareSubject => 'meshcore-open экспорт данных карты GPX'; + + @override + String get snrIndicator_nearByRepeaters => 'Ближайшие ретрансляторы'; + + @override + String get snrIndicator_lastSeen => 'Последний раз видели'; } diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 4e8b4cb7..8e186631 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -1074,6 +1074,9 @@ class AppLocalizationsSk extends AppLocalizations { @override String get chat_pathManagement => 'Správa ciest'; + @override + String get chat_ShowAllPaths => 'Zobraziť všetky cesty'; + @override String get chat_routingMode => 'Režim trasy'; @@ -1669,10 +1672,10 @@ class AppLocalizationsSk extends AppLocalizations { String get repeater_cliSubtitle => 'Pošlite príkazy opakovaču'; @override - String get repeater_neighbours => 'Súsezný'; + String get repeater_neighbors => 'Súsezný'; @override - String get repeater_neighboursSubtitle => 'Zobraziť susedné body bez skokov.'; + String get repeater_neighborsSubtitle => 'Zobraziť susedné body bez skokov.'; @override String get repeater_settings => 'Nastavenia'; @@ -2363,7 +2366,7 @@ class AppLocalizationsSk extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => 'Opakovadlá Súsezná'; + String get neighbors_repeatersNeighbors => 'Opakovadlá Súsezná'; @override String get neighbors_noData => @@ -2877,4 +2880,10 @@ class AppLocalizationsSk extends AppLocalizations { @override String get settings_gpxExportShareSubject => 'meshcore-open export dát GPX mapových údajov'; + + @override + String get snrIndicator_nearByRepeaters => 'Miestne opakovače'; + + @override + String get snrIndicator_lastSeen => 'Naposledy videný'; } diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index e01151e3..b95e7115 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -1072,6 +1072,9 @@ class AppLocalizationsSl extends AppLocalizations { @override String get chat_pathManagement => 'Upravljanje poti'; + @override + String get chat_ShowAllPaths => 'Prikaži vse poti'; + @override String get chat_routingMode => 'Navodilo za usmerjevalni način'; @@ -1668,10 +1671,10 @@ class AppLocalizationsSl extends AppLocalizations { 'Pošlji ukazne povelje na ponovitveno enoto.'; @override - String get repeater_neighbours => 'Sosedi'; + String get repeater_neighbors => 'Sosedi'; @override - String get repeater_neighboursSubtitle => 'Pogledati nič sosednjih hopjev.'; + String get repeater_neighborsSubtitle => 'Pogledati nič sosednjih hopjev.'; @override String get repeater_settings => 'Nastavitve'; @@ -2367,7 +2370,7 @@ class AppLocalizationsSl extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => 'Ponovitve Sosedi'; + String get neighbors_repeatersNeighbors => 'Ponovitve Sosedi'; @override String get neighbors_noData => 'Niso na voljo podatki o sosedih.'; @@ -2882,4 +2885,10 @@ class AppLocalizationsSl extends AppLocalizations { @override String get settings_gpxExportShareSubject => 'meshcore-open izvoz podatkov GPX karte'; + + @override + String get snrIndicator_nearByRepeaters => 'Bližnji ponovitelji'; + + @override + String get snrIndicator_lastSeen => 'Zadnjič videno'; } diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index f0817114..10047ca8 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -1069,6 +1069,9 @@ class AppLocalizationsSv extends AppLocalizations { @override String get chat_pathManagement => 'Stigarhantering'; + @override + String get chat_ShowAllPaths => 'Visa alla vägar'; + @override String get chat_routingMode => 'Ruttläge'; @@ -1658,10 +1661,10 @@ class AppLocalizationsSv extends AppLocalizations { String get repeater_cliSubtitle => 'Skicka kommandon till repetitorn'; @override - String get repeater_neighbours => 'Grannar'; + String get repeater_neighbors => 'Grannar'; @override - String get repeater_neighboursSubtitle => 'Visa noll hoppgrannar.'; + String get repeater_neighborsSubtitle => 'Visa noll hoppgrannar.'; @override String get repeater_settings => 'Inställningar'; @@ -2352,7 +2355,7 @@ class AppLocalizationsSv extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => 'Upprepar grannar'; + String get neighbors_repeatersNeighbors => 'Upprepar grannar'; @override String get neighbors_noData => 'Inga grannuppgifter finns tillgängliga.'; @@ -2862,4 +2865,10 @@ class AppLocalizationsSv extends AppLocalizations { @override String get settings_gpxExportShareSubject => 'meshcore-open export av GPX-kartdata'; + + @override + String get snrIndicator_nearByRepeaters => 'Närliggande uppreparstationer'; + + @override + String get snrIndicator_lastSeen => 'Senast sedd'; } diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 847c3e5d..9edc64a6 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -1075,6 +1075,9 @@ class AppLocalizationsUk extends AppLocalizations { @override String get chat_pathManagement => 'Керування шляхами'; + @override + String get chat_ShowAllPaths => 'Показати всі шляхи'; + @override String get chat_routingMode => 'Режим маршрутизації'; @@ -1675,10 +1678,10 @@ class AppLocalizationsUk extends AppLocalizations { String get repeater_cliSubtitle => 'Надіслати команди ретранслятору'; @override - String get repeater_neighbours => 'Сусіди'; + String get repeater_neighbors => 'Сусіди'; @override - String get repeater_neighboursSubtitle => + String get repeater_neighborsSubtitle => 'Показати сусідів нульового стрибка.'; @override @@ -2380,7 +2383,7 @@ class AppLocalizationsUk extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => 'Ретранслятори-сусіди'; + String get neighbors_repeatersNeighbors => 'Ретранслятори-сусіди'; @override String get neighbors_noData => 'Дані про сусідів недоступні.'; @@ -2907,4 +2910,10 @@ class AppLocalizationsUk extends AppLocalizations { @override String get settings_gpxExportShareSubject => 'експорт даних карти meshcore-open у форматі GPX'; + + @override + String get snrIndicator_nearByRepeaters => 'Ближні ретранслятори'; + + @override + String get snrIndicator_lastSeen => 'Останній раз бачили'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index fdc45312..9753da6e 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1031,6 +1031,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get chat_pathManagement => '路径管理'; + @override + String get chat_ShowAllPaths => '显示所有路径'; + @override String get chat_routingMode => '路由模式'; @@ -1596,10 +1599,10 @@ class AppLocalizationsZh extends AppLocalizations { String get repeater_cliSubtitle => '向复用器发送指令'; @override - String get repeater_neighbours => '邻居'; + String get repeater_neighbors => '邻居'; @override - String get repeater_neighboursSubtitle => '查看邻居节点(无需中间节点)。'; + String get repeater_neighborsSubtitle => '查看邻居节点(无需中间节点)。'; @override String get repeater_settings => '设置'; @@ -2246,7 +2249,7 @@ class AppLocalizationsZh extends AppLocalizations { } @override - String get neighbors_repeatersNeighbours => '重复使用的邻居'; + String get neighbors_repeatersNeighbors => '重复使用的邻居'; @override String get neighbors_noData => '没有可用的邻居信息。'; @@ -2714,4 +2717,10 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settings_gpxExportShareSubject => 'meshcore-open GPX 地图数据导出'; + + @override + String get snrIndicator_nearByRepeaters => '附近的重复器'; + + @override + String get snrIndicator_lastSeen => '最近访问'; } diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 7c397b46..859e48d9 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1351,12 +1351,12 @@ } } }, - "repeater_neighbours": "Buren", - "repeater_neighboursSubtitle": "Bekijk nul hops buren.", + "repeater_neighbors": "Buren", + "repeater_neighborsSubtitle": "Bekijk nul hops buren.", "neighbors_receivedData": "Ontvangen Buurdata", "neighbors_requestTimedOut": "Buren vragen om tijdelijk uitgeschakeld.", "neighbors_errorLoading": "Fout bij het laden van buren: {error}", - "neighbors_repeatersNeighbours": "Herhalingen Buren", + "neighbors_repeatersNeighbors": "Herhalingen Buren", "neighbors_noData": "Geen gegevens van buren beschikbaar.", "channels_createPrivateChannelDesc": "Beveiligd met een geheime sleutel.", "channels_createPrivateChannel": "Maak een Privé Kanaal", @@ -1594,6 +1594,9 @@ "scanner_enableBluetooth": "Activeer Bluetooth", "scanner_bluetoothOffMessage": "Zorg ervoor dat Bluetooth is ingeschakeld om naar apparaten te zoeken.", "scanner_bluetoothOff": "Bluetooth is uitgeschakeld", + "snrIndicator_lastSeen": "Laatst gezien", + "snrIndicator_nearByRepeaters": "Nabije herhalingseenheden", + "chat_ShowAllPaths": "Toon alle paden", "settings_clientRepeat": "Herhalen: Afgekoppeld", "settings_clientRepeatSubtitle": "Laat dit apparaat de mesh-pakketten opnieuw verzenden voor andere apparaten.", "settings_clientRepeatFreqWarning": "Om een signaal buiten het netwerk te versturen, zijn frequenties van 433, 869 of 918 MHz vereist." diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 5ebeebff..d03b9119 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1351,12 +1351,12 @@ } } }, - "repeater_neighbours": "Sąsiedzi", - "repeater_neighboursSubtitle": "Wyświetl sąsiedztwo zerowych hopów.", + "repeater_neighbors": "Sąsiedzi", + "repeater_neighborsSubtitle": "Wyświetl sąsiedztwo zerowych hopów.", "neighbors_receivedData": "Otrzymano dane sąsiedztwa", "neighbors_requestTimedOut": "Sąsiedzi proszą o wyłączenie timingu.", "neighbors_errorLoading": "Błąd podczas ładowania sąsiadów: {error}", - "neighbors_repeatersNeighbours": "Powtarzacze Sąsiedzi", + "neighbors_repeatersNeighbors": "Powtarzacze Sąsiedzi", "neighbors_noData": "Brak danych dotyczących sąsiadów.", "channels_joinPrivateChannelDesc": "Ręcznie wprowadź klucz tajny.", "channels_createPrivateChannel": "Utwórz Prywatny Kanał", @@ -1594,6 +1594,9 @@ "scanner_bluetoothOffMessage": "Prosimy włączyć Bluetooth, aby przeskanować urządzenia.", "scanner_bluetoothOff": "Bluetooth jest wyłączony", "scanner_enableBluetooth": "Włącz Bluetooth", + "snrIndicator_lastSeen": "Ostatnio widziany", + "snrIndicator_nearByRepeaters": "Nadajniki w pobliżu", + "chat_ShowAllPaths": "Pokaż wszystkie ścieżki", "settings_clientRepeatSubtitle": "Pozwól temu urządzeniu powtarzać pakiety danych dla innych urządzeń.", "settings_clientRepeat": "Powtórzenie: Niezależne od sieci", "settings_clientRepeatFreqWarning": "Powtórka poza siecią wymaga częstotliwości 433, 869 lub 918 MHz." diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index a88e0388..83a77197 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1351,12 +1351,12 @@ } } }, - "repeater_neighbours": "Vizinhos", + "repeater_neighbors": "Vizinhos", "neighbors_receivedData": "Dados dos Vizinhos Recebidos", - "repeater_neighboursSubtitle": "Visualizar vizinhos de salto zero.", + "repeater_neighborsSubtitle": "Visualizar vizinhos de salto zero.", "neighbors_requestTimedOut": "Vizinhos solicitam tempo limite esgotado.", "neighbors_errorLoading": "Erro ao carregar vizinhos: {error}", - "neighbors_repeatersNeighbours": "Repetidores Vizinhos", + "neighbors_repeatersNeighbors": "Repetidores Vizinhos", "neighbors_noData": "Não estão disponíveis dados de vizinhos.", "channels_createPrivateChannelDesc": "Protegido com uma chave secreta.", "channels_joinPrivateChannelDesc": "Inserir uma chave secreta manualmente.", @@ -1594,6 +1594,9 @@ "scanner_enableBluetooth": "Ative o Bluetooth", "scanner_bluetoothOff": "Bluetooth está desativado", "scanner_bluetoothOffMessage": "Por favor, ative o Bluetooth para escanear por dispositivos.", + "snrIndicator_nearByRepeaters": "Repetidores Próximos", + "snrIndicator_lastSeen": "Visto pela última vez", + "chat_ShowAllPaths": "Mostrar todos os caminhos", "settings_clientRepeatFreqWarning": "A repetição fora da rede requer frequências de 433, 869 ou 918 MHz.", "settings_clientRepeat": "Repetição sem rede", "settings_clientRepeatSubtitle": "Permita que este dispositivo repita pacotes de rede para outros dispositivos." diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index fc17eee1..380ba10b 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -467,8 +467,8 @@ "repeater_telemetrySubtitle": "Просмотр телеметрии датчиков и системной статистики", "repeater_cli": "CLI", "repeater_cliSubtitle": "Отправка команд репитеру", - "repeater_neighbours": "Соседи", - "repeater_neighboursSubtitle": "Просмотр соседей на нулевом хопе.", + "repeater_neighbors": "Соседи", + "repeater_neighborsSubtitle": "Просмотр соседей на нулевом хопе.", "repeater_settings": "Настройки", "repeater_settingsSubtitle": "Настройка параметров репитера", "repeater_statusTitle": "Статус репитера", @@ -661,7 +661,7 @@ "neighbors_receivedData": "Полученные данные о соседях", "neighbors_requestTimedOut": "Время ожидания данных о соседях истекло.", "neighbors_errorLoading": "Ошибка загрузки соседей: {error}", - "neighbors_repeatersNeighbours": "Соседи репитеров", + "neighbors_repeatersNeighbors": "Соседи репитеров", "neighbors_noData": "Данные о соседях недоступны.", "neighbors_unknownContact": "Неизвестный {pubkey}", "neighbors_heardA ago": "Слышали: {time} назад", @@ -834,6 +834,9 @@ "scanner_enableBluetooth": "Включите Bluetooth", "scanner_bluetoothOff": "Bluetooth выключен", "scanner_bluetoothOffMessage": "Пожалуйста, включите Bluetooth, чтобы найти устройства.", + "snrIndicator_nearByRepeaters": "Ближайшие ретрансляторы", + "snrIndicator_lastSeen": "Последний раз видели", + "chat_ShowAllPaths": "Показать все пути", "settings_clientRepeatFreqWarning": "Для работы в режиме \"без подключения к сети\" требуется частота 433, 869 или 918 МГц.", "settings_clientRepeatSubtitle": "Позвольте этому устройству повторять пакеты данных для других устройств.", "settings_clientRepeat": "Повторение \"вне сети\"" diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index 14cd3eca..aca4a29b 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -1351,12 +1351,12 @@ } } }, - "repeater_neighboursSubtitle": "Zobraziť susedné body bez skokov.", + "repeater_neighborsSubtitle": "Zobraziť susedné body bez skokov.", "neighbors_requestTimedOut": "Súďia žiadajú o časové ukončenie.", "neighbors_receivedData": "Obdielo dáta suseda", - "repeater_neighbours": "Súsezný", + "repeater_neighbors": "Súsezný", "neighbors_errorLoading": "Chyba pri načítaní susedov: {error}", - "neighbors_repeatersNeighbours": "Opakovadlá Súsezná", + "neighbors_repeatersNeighbors": "Opakovadlá Súsezná", "neighbors_noData": "Nie je dostupná žiadna informácia o susedoch.", "channels_createPrivateChannel": "Vytvorte súkromný kanál", "channels_joinPrivateChannel": "Pripojiť sa k súkromnému kanálu", @@ -1594,6 +1594,9 @@ "scanner_bluetoothOffMessage": "Prosím, zapnite Bluetooth, aby ste mohli skenovať pre zariadenia.", "scanner_bluetoothOff": "Bluetooth je vypnutý", "scanner_enableBluetooth": "Povolte Bluetooth", + "snrIndicator_lastSeen": "Naposledy videný", + "snrIndicator_nearByRepeaters": "Miestne opakovače", + "chat_ShowAllPaths": "Zobraziť všetky cesty", "settings_clientRepeat": "Opätovné použitie bez elektrickej siete", "settings_clientRepeatFreqWarning": "Použitie off-grid systému vyžaduje frekvencie 433, 869 alebo 918 MHz.", "settings_clientRepeatSubtitle": "Umožnite, aby toto zariadenie opakovávalo siete pre ostatných." diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index e633965e..59b84342 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -1351,12 +1351,12 @@ } } }, - "repeater_neighboursSubtitle": "Pogledati nič sosednjih hopjev.", - "repeater_neighbours": "Sosedi", + "repeater_neighborsSubtitle": "Pogledati nič sosednjih hopjev.", + "repeater_neighbors": "Sosedi", "neighbors_receivedData": "Prejeto podatke o sosedih", "neighbors_requestTimedOut": "Sosedi zahtevajo izklop po dogovoru.", "neighbors_errorLoading": "Napaka pri obnašanju sosedov: {error}", - "neighbors_repeatersNeighbours": "Ponovitve Sosedi", + "neighbors_repeatersNeighbors": "Ponovitve Sosedi", "neighbors_noData": "Niso na voljo podatki o sosedih.", "channels_joinPrivateChannel": "Pridružite se zasebni skupini", "channels_createPrivateChannelDesc": "Varno zaklenjeno s skrivnim ključem.", @@ -1594,6 +1594,9 @@ "scanner_enableBluetooth": "Omogočite Bluetooth", "scanner_bluetoothOffMessage": "Prosimo, vklopite Bluetooth, da lahko poiščete naprave.", "scanner_bluetoothOff": "Bluetooth je izklopljen", + "snrIndicator_lastSeen": "Zadnjič videno", + "snrIndicator_nearByRepeaters": "Bližnji ponovitelji", + "chat_ShowAllPaths": "Prikaži vse poti", "settings_clientRepeatFreqWarning": "Za ponovni prenos na brezžični način so potrebne frekvence 433, 869 ali 918 MHz.", "settings_clientRepeatSubtitle": "Omogočite temu naprave, da ponavlja paketne sporočila za druge.", "settings_clientRepeat": "Neovadno ponavljanje" diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index 4e50409a..fa786f70 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -1351,12 +1351,12 @@ } } }, - "repeater_neighbours": "Grannar", - "repeater_neighboursSubtitle": "Visa noll hoppgrannar.", + "repeater_neighbors": "Grannar", + "repeater_neighborsSubtitle": "Visa noll hoppgrannar.", "neighbors_receivedData": "Mottagna grannars data", "neighbors_requestTimedOut": "Grannar begär tidsinställd utskick.", "neighbors_errorLoading": "Fel vid inläsning av grannar: {error}", - "neighbors_repeatersNeighbours": "Upprepar grannar", + "neighbors_repeatersNeighbors": "Upprepar grannar", "neighbors_noData": "Inga grannuppgifter finns tillgängliga.", "channels_createPrivateChannel": "Skapa en privat kanal", "channels_joinPrivateChannel": "Gå med i en Privat Kanal", @@ -1594,6 +1594,9 @@ "scanner_enableBluetooth": "Aktivera Bluetooth", "scanner_bluetoothOffMessage": "Vänligen aktivera Bluetooth för att söka efter enheter.", "scanner_bluetoothOff": "Bluetooth är avstängt", + "snrIndicator_lastSeen": "Senast sedd", + "snrIndicator_nearByRepeaters": "Närliggande uppreparstationer", + "chat_ShowAllPaths": "Visa alla vägar", "settings_clientRepeatSubtitle": "Låt enheten repetera nätpaket för andra användare.", "settings_clientRepeat": "Upprepa utan elnät", "settings_clientRepeatFreqWarning": "För att kunna kommunicera utanför elnätet krävs frekvenserna 433, 869 eller 918 MHz." diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index afa11793..3f7b276f 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -1352,12 +1352,12 @@ } } }, - "repeater_neighbours": "Сусіди", - "repeater_neighboursSubtitle": "Показати сусідів нульового стрибка.", + "repeater_neighbors": "Сусіди", + "repeater_neighborsSubtitle": "Показати сусідів нульового стрибка.", "neighbors_receivedData": "Дані сусідів отримано", "neighbors_requestTimedOut": "Час запиту сусідів вичерпано.", "neighbors_errorLoading": "Помилка завантаження сусідів: {error}", - "neighbors_repeatersNeighbours": "Ретранслятори-сусіди", + "neighbors_repeatersNeighbors": "Ретранслятори-сусіди", "neighbors_noData": "Дані про сусідів недоступні.", "channels_createPrivateChannelDesc": "Захищено секретним ключем.", "channels_joinPrivateChannel": "Приєднатися до приватного каналу", @@ -1594,6 +1594,9 @@ "scanner_enableBluetooth": "Увімкніть Bluetooth", "scanner_bluetoothOffMessage": "Будь ласка, увімкніть Bluetooth, щоб сканувати пристрої.", "scanner_bluetoothOff": "Bluetooth вимкнено", + "snrIndicator_lastSeen": "Останній раз бачили", + "snrIndicator_nearByRepeaters": "Ближні ретранслятори", + "chat_ShowAllPaths": "Показати всі шляхи", "settings_clientRepeatFreqWarning": "Повтор без підключення до мережі вимагає частоти 433, 869 або 918 МГц.", "settings_clientRepeatSubtitle": "Дозвольте цьому пристрою повторювати пакети даних для інших пристроїв.", "settings_clientRepeat": "Автономна система" diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 312ed1a1..bc433927 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -895,8 +895,8 @@ "repeater_telemetrySubtitle": "查看传感器和系统状态的数据。", "repeater_cli": "命令行界面", "repeater_cliSubtitle": "向复用器发送指令", - "repeater_neighbours": "邻居", - "repeater_neighboursSubtitle": "查看邻居节点(无需中间节点)。", + "repeater_neighbors": "邻居", + "repeater_neighborsSubtitle": "查看邻居节点(无需中间节点)。", "repeater_settings": "设置", "repeater_settingsSubtitle": "配置重复器参数", "repeater_statusTitle": "重复器状态", @@ -1266,7 +1266,7 @@ } } }, - "neighbors_repeatersNeighbours": "重复使用的邻居", + "neighbors_repeatersNeighbors": "重复使用的邻居", "neighbors_noData": "没有可用的邻居信息。", "neighbors_unknownContact": "Unknown {pubkey}", "@neighbors_unknownContact": { @@ -1594,6 +1594,9 @@ "scanner_bluetoothOffMessage": "请打开蓝牙功能,以便搜索设备。", "scanner_bluetoothOff": "蓝牙已关闭", "scanner_enableBluetooth": "启用蓝牙", + "snrIndicator_lastSeen": "最近访问", + "snrIndicator_nearByRepeaters": "附近的重复器", + "chat_ShowAllPaths": "显示所有路径", "settings_clientRepeat": "离网重复", "settings_clientRepeatSubtitle": "允许此设备重复发送网状数据包给其他设备", "settings_clientRepeatFreqWarning": "离网重复通信需要使用 433、869 或 918 兆赫兹的频率。" diff --git a/lib/models/contact.dart b/lib/models/contact.dart index a98580f3..143a62a2 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -119,7 +119,7 @@ class Contact { final pathBytes = _pathBytesForDisplay; Uint8List? traceBytes; - if (pathLength <= 0) { + if (pathBytes.isEmpty) { traceBytes = Uint8List(1); traceBytes[0] = publicKey[0]; return traceBytes; @@ -160,43 +160,47 @@ class Contact { } static Contact? fromFrame(Uint8List data) { - if (data.length < contactFrameSize) return null; + if (data.isEmpty) return null; if (data[0] != respCodeContact) return null; + try { + final pubKey = Uint8List.fromList( + data.sublist(contactPubKeyOffset, contactPubKeyOffset + pubKeySize), + ); + final type = data[contactTypeOffset]; + final pathLen = data[contactPathLenOffset].toSigned(8); + final safePathLen = pathLen > 0 + ? (pathLen > maxPathSize ? maxPathSize : pathLen) + : 0; + final pathBytes = safePathLen > 0 + ? Uint8List.fromList( + data.sublist(contactPathOffset, contactPathOffset + safePathLen), + ) + : Uint8List(0); + final name = readCString(data, contactNameOffset, maxNameSize); + final lastmod = readUint32LE(data, contactLastmodOffset); - final pubKey = Uint8List.fromList( - data.sublist(contactPubKeyOffset, contactPubKeyOffset + pubKeySize), - ); - final type = data[contactTypeOffset]; - final pathLen = data[contactPathLenOffset].toSigned(8); - final safePathLen = pathLen > 0 - ? (pathLen > maxPathSize ? maxPathSize : pathLen) - : 0; - final pathBytes = safePathLen > 0 - ? Uint8List.fromList( - data.sublist(contactPathOffset, contactPathOffset + safePathLen), - ) - : Uint8List(0); - final name = readCString(data, contactNameOffset, maxNameSize); - final lastmod = readUint32LE(data, contactLastmodOffset); + double? lat, lon; + final latRaw = readInt32LE(data, contactLatOffset); + final lonRaw = readInt32LE(data, contactLonOffset); + if (latRaw != 0 || lonRaw != 0) { + lat = latRaw / 1e6; + lon = lonRaw / 1e6; + } - double? lat, lon; - final latRaw = readInt32LE(data, contactLatOffset); - final lonRaw = readInt32LE(data, contactLonOffset); - if (latRaw != 0 || lonRaw != 0) { - lat = latRaw / 1e6; - lon = lonRaw / 1e6; + return Contact( + publicKey: pubKey, + name: name.isEmpty ? 'Unknown' : name, + type: type, + pathLength: pathLen, + path: pathBytes, + latitude: lat, + longitude: lon, + lastSeen: DateTime.fromMillisecondsSinceEpoch(lastmod * 1000), + ); + } catch (e) { + // If parsing fails, return null + return null; } - - return Contact( - publicKey: pubKey, - name: name.isEmpty ? 'Unknown' : name, - type: type, - pathLength: pathLen, - path: pathBytes, - latitude: lat, - longitude: lon, - lastSeen: DateTime.fromMillisecondsSinceEpoch(lastmod * 1000), - ); } @override diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index c82356d2..021ad7d8 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -901,7 +901,8 @@ class _ChannelChatScreenState extends State { Navigator.push( context, MaterialPageRoute( - builder: (context) => ChannelMessagePathScreen(message: message), + builder: (context) => + ChannelMessagePathScreen(message: message, channelMessage: true), ), ); } diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index 1b0544ca..e6fcacce 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -17,18 +17,27 @@ import '../models/contact.dart'; class ChannelMessagePathScreen extends StatelessWidget { final ChannelMessage message; - - const ChannelMessagePathScreen({super.key, required this.message}); + final bool channelMessage; + const ChannelMessagePathScreen({ + super.key, + required this.message, + this.channelMessage = false, + }); @override Widget build(BuildContext context) { return Consumer( builder: (context, connector, _) { final l10n = context.l10n; - final primaryPath = _selectPrimaryPath( + final primaryPathTmp = _selectPrimaryPath( message.pathBytes, message.pathVariants, ); + + final primaryPath = !channelMessage && !message.isOutgoing + ? Uint8List.fromList(primaryPathTmp.reversed.toList()) + : primaryPathTmp; + final hops = _buildPathHops(primaryPath, connector.contacts, l10n); final hasHopDetails = primaryPath.isNotEmpty; final observedLabel = _formatObservedHops( @@ -37,7 +46,6 @@ class ChannelMessagePathScreen extends StatelessWidget { l10n, ); final extraPaths = _otherPaths(primaryPath, message.pathVariants); - return Scaffold( appBar: AppBar( title: Text(l10n.channelPath_title), @@ -50,9 +58,9 @@ class ChannelMessagePathScreen extends StatelessWidget { MaterialPageRoute( builder: (context) => PathTraceMapScreen( title: context.l10n.contacts_repeaterPathTrace, - path: Uint8List.fromList(primaryPath), + path: primaryPath, flipPathRound: true, - reversePathRound: true, + reversePathRound: !message.isOutgoing && !channelMessage, ), ), ), @@ -62,7 +70,7 @@ class ChannelMessagePathScreen extends StatelessWidget { tooltip: l10n.channelPath_viewMap, onPressed: hasHopDetails ? () { - _openPathMap(context); + _openPathMap(context, channelMessage: channelMessage); } : null, ), @@ -157,7 +165,11 @@ class ChannelMessagePathScreen extends StatelessWidget { ), subtitle: Text(_formatPathPrefixes(variants[i])), trailing: const Icon(Icons.map_outlined, size: 20), - onTap: () => _openPathMap(context, initialPath: variants[i]), + onTap: () => _openPathMap( + context, + initialPath: variants[i], + channelMessage: channelMessage, + ), ), ), ], @@ -248,13 +260,18 @@ class ChannelMessagePathScreen extends StatelessWidget { ); } - void _openPathMap(BuildContext context, {Uint8List? initialPath}) { + void _openPathMap( + BuildContext context, { + Uint8List? initialPath, + bool channelMessage = false, + }) { Navigator.push( context, MaterialPageRoute( builder: (context) => ChannelMessagePathMapScreen( message: message, initialPath: initialPath, + channelMessage: channelMessage, ), ), ); @@ -264,11 +281,13 @@ class ChannelMessagePathScreen extends StatelessWidget { class ChannelMessagePathMapScreen extends StatefulWidget { final ChannelMessage message; final Uint8List? initialPath; + final bool channelMessage; const ChannelMessagePathMapScreen({ super.key, required this.message, this.initialPath, + this.channelMessage = false, }); @override @@ -323,11 +342,18 @@ class _ChannelMessagePathMapScreenState primaryPath, widget.message.pathVariants, ); - final selectedPath = _resolveSelectedPath( + final selectedPathTmp = _resolveSelectedPath( _selectedPath, observedPaths, primaryPath, ); + + final selectedPath = + ((!widget.message.isOutgoing && !widget.channelMessage) || + (widget.message.isOutgoing && widget.channelMessage)) + ? Uint8List.fromList(selectedPathTmp.reversed.toList()) + : selectedPathTmp; + final selectedIndex = _indexForPath(selectedPath, observedPaths); final hops = _buildPathHops( selectedPath, @@ -336,12 +362,22 @@ class _ChannelMessagePathMapScreenState ); final points = []; + + if ((widget.message.isOutgoing && !widget.channelMessage) || + (widget.message.isOutgoing && widget.channelMessage)) { + points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!)); + } + for (final hop in hops) { if (hop.hasLocation) { points.add(hop.position!); } } - points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!)); + + if ((!widget.message.isOutgoing && !widget.channelMessage) || + (!widget.message.isOutgoing && widget.channelMessage)) { + points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!)); + } final polylines = points.length > 1 ? [ diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 6b8b92d4..26062dea 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:meshcore_open/widgets/app_bar.dart'; import 'package:provider/provider.dart'; import 'package:uuid/uuid.dart'; @@ -14,7 +15,6 @@ import '../storage/community_store.dart'; import '../utils/dialog_utils.dart'; import '../utils/disconnect_navigation_mixin.dart'; import '../utils/route_transitions.dart'; -import '../widgets/battery_indicator.dart'; import '../widgets/list_filter_widget.dart'; import '../widgets/empty_state.dart'; import '../widgets/qr_code_display.dart'; @@ -116,8 +116,7 @@ class _ChannelsScreenState extends State canPop: allowBack, child: Scaffold( appBar: AppBar( - leading: BatteryIndicator(connector: connector), - title: Text(context.l10n.channels_title), + title: AppBarTitle(context.l10n.channels_title), centerTitle: true, automaticallyImplyLeading: false, actions: [ diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index f00f242a..ad897a0b 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -19,7 +19,9 @@ import '../helpers/utf8_length_limiter.dart'; import '../models/channel_message.dart'; import '../models/contact.dart'; import '../models/message.dart'; +import '../models/path_history.dart'; import '../services/path_history_service.dart'; +import '../widgets/elements_ui.dart'; import 'channel_message_path_screen.dart'; import 'map_screen.dart'; import '../utils/emoji_utils.dart'; @@ -431,242 +433,317 @@ class _ChatScreenState extends State { void _showPathHistory(BuildContext context) { final connector = Provider.of(context, listen: false); - + bool showAllPaths = false; showDialog( context: context, - builder: (context) => Consumer( - builder: (context, pathService, _) { - final paths = pathService.getRecentPaths(widget.contact.publicKeyHex); - return AlertDialog( - title: Row( - children: [ - const Icon(Icons.timeline), - const SizedBox(width: 8), - Text(context.l10n.chat_pathManagement), - ], - ), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + builder: (context) => StatefulBuilder( + builder: (context, setDialogState) => Consumer( + builder: (context, pathService, _) { + final paths = pathService.getRecentPaths( + widget.contact.publicKeyHex, + ); + + final repeatersList = List.of(connector.directRepeaters) + ..sort((a, b) => b.ranking.compareTo(a.ranking)); + + if (repeatersList.isEmpty) { + showAllPaths = true; + } + + final directRepeater = repeatersList.isEmpty + ? null + : repeatersList.first; + final secondDirectRepeater = repeatersList.length < 2 + ? null + : repeatersList.elementAt(1); + final thirdDirectRepeater = repeatersList.length < 3 + ? null + : repeatersList.elementAt(2); + + List>> + pathsWithRepeaters = paths.map((path) { + final isDirectRepeater = + directRepeater != null && + path.pathBytes.isNotEmpty && + directRepeater.pubkeyFirstByte == path.pathBytes.first; + final isSecondDirectRepeater = + secondDirectRepeater != null && + path.pathBytes.isNotEmpty && + secondDirectRepeater.pubkeyFirstByte == path.pathBytes.first; + final isThirdDirectRepeater = + thirdDirectRepeater != null && + path.pathBytes.isNotEmpty && + thirdDirectRepeater.pubkeyFirstByte == path.pathBytes.first; + + int ranking = -1; + Color color = Colors.grey; + if (isDirectRepeater) { + color = Colors.green; + ranking = 3; + } else if (isSecondDirectRepeater) { + color = Colors.yellow; + ranking = 2; + } else if (isThirdDirectRepeater) { + color = Colors.red; + ranking = 1; + } else if (path.wasFloodDiscovery) { + color = Colors.blue; + ranking = 0; + } + + return MapEntry(ranking, MapEntry(color, path)); + }).toList(); + + pathsWithRepeaters.sort((a, b) => b.key.compareTo(a.key)); + + return AlertDialog( + title: Row( children: [ - if (paths.isNotEmpty) ...[ + const Icon(Icons.timeline), + const SizedBox(width: 8), + Text(context.l10n.chat_pathManagement), + ], + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (pathsWithRepeaters.isNotEmpty) ...[ + if (repeatersList.isNotEmpty) + FeatureToggleRow( + title: context.l10n.chat_ShowAllPaths, + subtitle: "", + value: showAllPaths, + onChanged: (val) { + setDialogState(() { + showAllPaths = val; + }); + }, + ), + Text( + context.l10n.chat_recentAckPaths, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + if (pathsWithRepeaters.length >= 100) ...[ + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: Colors.amber[100], + borderRadius: BorderRadius.circular(8), + ), + child: Text( + context.l10n.chat_pathHistoryFull, + style: const TextStyle(fontSize: 12), + ), + ), + ], + const SizedBox(height: 8), + ...pathsWithRepeaters.map((entry) { + final path = entry.value.value; + final color = entry.value.key; + if (!showAllPaths && entry.key < 1) { + return const SizedBox.shrink(); + } else { + return Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: ListTile( + dense: true, + leading: CircleAvatar( + radius: 16, + backgroundColor: color, + child: Text( + '${path.hopCount}', + style: const TextStyle(fontSize: 12), + ), + ), + title: Text( + '${path.hopCount} ${path.hopCount == 1 ? context.l10n.chat_hopSingular : context.l10n.chat_hopPlural}', + style: const TextStyle(fontSize: 14), + ), + subtitle: Text( + '${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(path.timestamp)} • ${path.successCount} ${context.l10n.chat_successes}', + style: const TextStyle(fontSize: 11), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.close, size: 16), + tooltip: context.l10n.chat_removePath, + onPressed: () async { + await pathService.removePathRecord( + widget.contact.publicKeyHex, + path.pathBytes, + ); + }, + ), + path.wasFloodDiscovery + ? const Icon( + Icons.waves, + size: 16, + color: Colors.grey, + ) + : const Icon( + Icons.route, + size: 16, + color: Colors.grey, + ), + ], + ), + onLongPress: () => + _showFullPathDialog(context, path.pathBytes), + onTap: () async { + if (path.pathBytes.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context + .l10n + .chat_pathDetailsNotAvailable, + ), + duration: const Duration(seconds: 2), + ), + ); + return; + } + + final pathBytes = Uint8List.fromList( + path.pathBytes, + ); + final pathLength = path.pathBytes.length; + + // Set the path override to persist user's choice + await connector.setPathOverride( + widget.contact, + pathLen: pathLength, + pathBytes: pathBytes, + ); + + if (!context.mounted) return; + Navigator.pop(context); + await _notifyPathSet( + connector, + widget.contact, + pathBytes, + path.hopCount, + ); + }, + ), + ); + } + }), + const Divider(), + ] else ...[ + Text(context.l10n.chat_noPathHistoryYet), + const Divider(), + ], + const SizedBox(height: 8), Text( - context.l10n.chat_recentAckPaths, + context.l10n.chat_pathActions, style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 12, ), ), - if (paths.length >= 100) ...[ - const SizedBox(height: 8), - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - decoration: BoxDecoration( - color: Colors.amber[100], - borderRadius: BorderRadius.circular(8), - ), - child: Text( - context.l10n.chat_pathHistoryFull, - style: const TextStyle(fontSize: 12), - ), - ), - ], const SizedBox(height: 8), - ...paths.map((path) { - return Card( - margin: const EdgeInsets.symmetric(vertical: 4), - child: ListTile( - dense: true, - leading: CircleAvatar( - radius: 16, - backgroundColor: path.wasFloodDiscovery - ? Colors.blue - : Colors.green, - child: Text( - '${path.hopCount}', - style: const TextStyle(fontSize: 12), - ), + ListTile( + dense: true, + leading: const CircleAvatar( + radius: 16, + backgroundColor: Colors.purple, + child: Icon(Icons.edit_road, size: 16), + ), + title: Text( + context.l10n.chat_setCustomPath, + style: const TextStyle(fontSize: 14), + ), + subtitle: Text( + context.l10n.chat_setCustomPathSubtitle, + style: const TextStyle(fontSize: 11), + ), + onTap: () { + Navigator.pop(context); + _showCustomPathDialog(context); + }, + ), + ListTile( + dense: true, + leading: const CircleAvatar( + radius: 16, + backgroundColor: Colors.orange, + child: Icon(Icons.clear_all, size: 16), + ), + title: Text( + context.l10n.chat_clearPath, + style: const TextStyle(fontSize: 14), + ), + subtitle: Text( + context.l10n.chat_clearPathSubtitle, + style: const TextStyle(fontSize: 11), + ), + onTap: () async { + await connector.clearContactPath(widget.contact); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.chat_pathCleared), + duration: const Duration(seconds: 2), ), - title: Text( - '${path.hopCount} ${path.hopCount == 1 ? context.l10n.chat_hopSingular : context.l10n.chat_hopPlural}', - style: const TextStyle(fontSize: 14), + ); + Navigator.pop(context); + }, + ), + ListTile( + dense: true, + leading: const CircleAvatar( + radius: 16, + backgroundColor: Colors.blue, + child: Icon(Icons.waves, size: 16), + ), + title: Text( + context.l10n.chat_forceFloodMode, + style: const TextStyle(fontSize: 14), + ), + subtitle: Text( + context.l10n.chat_floodModeSubtitle, + style: const TextStyle(fontSize: 11), + ), + onTap: () async { + await connector.setPathOverride( + widget.contact, + pathLen: -1, + ); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.chat_floodModeEnabled), + duration: const Duration(seconds: 2), ), - subtitle: Text( - '${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(path.timestamp)} • ${path.successCount} ${context.l10n.chat_successes}', - style: const TextStyle(fontSize: 11), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.close, size: 16), - tooltip: context.l10n.chat_removePath, - onPressed: () async { - await pathService.removePathRecord( - widget.contact.publicKeyHex, - path.pathBytes, - ); - }, - ), - path.wasFloodDiscovery - ? const Icon( - Icons.waves, - size: 16, - color: Colors.grey, - ) - : const Icon( - Icons.route, - size: 16, - color: Colors.grey, - ), - ], - ), - onLongPress: () => - _showFullPathDialog(context, path.pathBytes), - onTap: () async { - if (path.pathBytes.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.chat_pathDetailsNotAvailable, - ), - duration: const Duration(seconds: 2), - ), - ); - return; - } - - final pathBytes = Uint8List.fromList( - path.pathBytes, - ); - final pathLength = path.pathBytes.length; - - // Set the path override to persist user's choice - await connector.setPathOverride( - widget.contact, - pathLen: pathLength, - pathBytes: pathBytes, - ); - - if (!context.mounted) return; - Navigator.pop(context); - await _notifyPathSet( - connector, - widget.contact, - pathBytes, - path.hopCount, - ); - }, - ), - ); - }), - const Divider(), - ] else ...[ - Text(context.l10n.chat_noPathHistoryYet), - const Divider(), + ); + Navigator.pop(context); + }, + ), ], - const SizedBox(height: 8), - Text( - context.l10n.chat_pathActions, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 12, - ), - ), - const SizedBox(height: 8), - ListTile( - dense: true, - leading: const CircleAvatar( - radius: 16, - backgroundColor: Colors.purple, - child: Icon(Icons.edit_road, size: 16), - ), - title: Text( - context.l10n.chat_setCustomPath, - style: const TextStyle(fontSize: 14), - ), - subtitle: Text( - context.l10n.chat_setCustomPathSubtitle, - style: const TextStyle(fontSize: 11), - ), - onTap: () { - Navigator.pop(context); - _showCustomPathDialog(context); - }, - ), - ListTile( - dense: true, - leading: const CircleAvatar( - radius: 16, - backgroundColor: Colors.orange, - child: Icon(Icons.clear_all, size: 16), - ), - title: Text( - context.l10n.chat_clearPath, - style: const TextStyle(fontSize: 14), - ), - subtitle: Text( - context.l10n.chat_clearPathSubtitle, - style: const TextStyle(fontSize: 11), - ), - onTap: () async { - await connector.clearContactPath(widget.contact); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.chat_pathCleared), - duration: const Duration(seconds: 2), - ), - ); - Navigator.pop(context); - }, - ), - ListTile( - dense: true, - leading: const CircleAvatar( - radius: 16, - backgroundColor: Colors.blue, - child: Icon(Icons.waves, size: 16), - ), - title: Text( - context.l10n.chat_forceFloodMode, - style: const TextStyle(fontSize: 14), - ), - subtitle: Text( - context.l10n.chat_floodModeSubtitle, - style: const TextStyle(fontSize: 11), - ), - onTap: () async { - await connector.setPathOverride( - widget.contact, - pathLen: -1, - ); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.chat_floodModeEnabled), - duration: const Duration(seconds: 2), - ), - ); - Navigator.pop(context); - }, - ), - ], + ), ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(context.l10n.common_close), - ), - ], - ); - }, + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(context.l10n.common_close), + ), + ], + ); + }, + ), ), ); } diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 6799d695..d4701075 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:meshcore_open/screens/path_trace_map.dart'; +import 'package:meshcore_open/utils/app_logger.dart'; +import 'package:meshcore_open/widgets/app_bar.dart'; import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; @@ -16,7 +18,6 @@ import '../utils/dialog_utils.dart'; import '../utils/disconnect_navigation_mixin.dart'; import '../utils/emoji_utils.dart'; import '../utils/route_transitions.dart'; -import '../widgets/battery_indicator.dart'; import '../widgets/list_filter_widget.dart'; import '../widgets/empty_state.dart'; import '../widgets/quick_switch_bar.dart'; @@ -90,79 +91,90 @@ class _ContactsScreenState extends State _frameSubscription = connector.receivedFrames.listen((frame) { if (frame.isEmpty) return; final frameBuffer = BufferReader(frame); - final code = frameBuffer.readUInt8(); + try { + final code = frameBuffer.readUInt8(); - if (code == respCodeExportContact) { - final advertPacket = frameBuffer.readRemainingBytes(); - // Validate packet has expected minimum size (98+ bytes per protocol) - if (advertPacket.length < 98) { - if (mounted) { + if (code == respCodeExportContact) { + final advertPacket = frameBuffer.readRemainingBytes(); + // Validate packet has expected minimum size (98+ bytes per protocol) + if (advertPacket.length < 98) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.contacts_invalidAdvertFormat), + ), + ); + } + _pendingOperations.remove(ContactOperationType.export); + return; + } + final hexString = pubKeyToHex(advertPacket); + Clipboard.setData(ClipboardData(text: "meshcore://$hexString")); + } + + if (code == respCodeOk) { + // Show a snackbar indicating success + if (!mounted) return; + + if (_pendingOperations.contains(ContactOperationType.import)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.contacts_contactImported)), + ); + } + + if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(context.l10n.contacts_invalidAdvertFormat), + content: Text(context.l10n.contacts_zeroHopContactAdvertSent), ), ); } - _pendingOperations.remove(ContactOperationType.export); - return; - } - final hexString = pubKeyToHex(advertPacket); - Clipboard.setData(ClipboardData(text: "meshcore://$hexString")); - } - if (code == respCodeOk) { - // Show a snackbar indicating success - if (!mounted) return; + if (_pendingOperations.contains(ContactOperationType.export)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.contacts_contactAdvertCopied), + ), + ); + } - if (_pendingOperations.contains(ContactOperationType.import)) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.contacts_contactImported)), - ); + _pendingOperations.clear(); } - if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.contacts_zeroHopContactAdvertSent), - ), - ); + if (code == respCodeErr) { + // Show a snackbar indicating failure + if (!mounted) return; + + if (_pendingOperations.contains(ContactOperationType.import)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.contacts_contactImportFailed), + ), + ); + } + + if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.contacts_zeroHopContactAdvertFailed), + ), + ); + } + if (_pendingOperations.contains(ContactOperationType.export)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.contacts_contactAdvertCopyFailed), + ), + ); + } + + _pendingOperations.clear(); } - - if (_pendingOperations.contains(ContactOperationType.export)) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.contacts_contactAdvertCopied)), - ); - } - - _pendingOperations.clear(); - } - - if (code == respCodeErr) { - // Show a snackbar indicating failure - if (!mounted) return; - - if (_pendingOperations.contains(ContactOperationType.import)) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.contacts_contactImportFailed)), - ); - } - - if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.contacts_zeroHopContactAdvertFailed), - ), - ); - } - if (_pendingOperations.contains(ContactOperationType.export)) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.contacts_contactAdvertCopyFailed), - ), - ); - } - - _pendingOperations.clear(); + } catch (e) { + appLogger.error( + 'Error processing received frame: $e', + tag: 'ContactsScreen', + ); } }); } @@ -229,9 +241,7 @@ class _ContactsScreenState extends State canPop: allowBack, child: Scaffold( appBar: AppBar( - leading: BatteryIndicator(connector: connector), - title: Text(context.l10n.contacts_title), - centerTitle: true, + title: AppBarTitle(context.l10n.contacts_title), automaticallyImplyLeading: false, actions: [ PopupMenuButton( diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 552f579e..1fad04b6 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:meshcore_open/screens/path_trace_map.dart'; +import 'package:meshcore_open/widgets/app_bar.dart'; import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; @@ -17,7 +18,6 @@ import '../services/map_marker_service.dart'; import '../services/map_tile_cache_service.dart'; import '../utils/contact_search.dart'; import '../utils/route_transitions.dart'; -import '../widgets/battery_indicator.dart'; import '../widgets/quick_switch_bar.dart'; import 'channels_screen.dart'; import 'chat_screen.dart'; @@ -105,7 +105,7 @@ class _MapScreenState extends State { double _zoomFromStdDev(double latStdDev, double lonStdDev) { final maxSpread = max(latStdDev, lonStdDev); if (maxSpread <= 0) return 13.0; - // Approzimate: each zoom level halves the visible area + // Approximate: each zoom level halves the visible area // ~0.01 degrees spread -> zoom 13, ~0.1 -> zoom 10, ~1.0 -> zoom 7 final zoom = 10.0 - log(maxSpread * 10 + 1) / ln10 * 3; return zoom.clamp(4.0, 15.0); @@ -262,8 +262,7 @@ class _MapScreenState extends State { canPop: allowBack, child: Scaffold( appBar: AppBar( - leading: BatteryIndicator(connector: connector), - title: Text(context.l10n.map_title), + title: AppBarTitle(context.l10n.map_title), centerTitle: true, automaticallyImplyLeading: false, actions: [ @@ -384,8 +383,8 @@ class _MapScreenState extends State { connector.selfLatitude!, connector.selfLongitude!, ), - width: 35, - height: 35, + width: 40, + height: 40, child: Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( @@ -826,7 +825,7 @@ class _MapScreenState extends State { color: _getNodeColor(contact.type), ), const SizedBox(width: 8), - Expanded(child: Text(contact.name)), + Expanded(child: SelectableText(contact.name)), ], ), content: Column( @@ -997,7 +996,7 @@ class _MapScreenState extends State { ), ), const SizedBox(height: 2), - Text(value, style: const TextStyle(fontSize: 14)), + SelectableText(value, style: const TextStyle(fontSize: 14)), ], ), ); diff --git a/lib/screens/neighbours_screen.dart b/lib/screens/neighbors_screen.dart similarity index 75% rename from lib/screens/neighbours_screen.dart rename to lib/screens/neighbors_screen.dart index b6061883..3dee3391 100644 --- a/lib/screens/neighbours_screen.dart +++ b/lib/screens/neighbors_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:meshcore_open/utils/app_logger.dart'; import 'package:provider/provider.dart'; import '../l10n/l10n.dart'; import '../models/contact.dart'; @@ -11,28 +12,28 @@ import '../services/repeater_command_service.dart'; import '../widgets/path_management_dialog.dart'; import '../widgets/snr_indicator.dart'; -class NeighboursScreen extends StatefulWidget { +class NeighborsScreen extends StatefulWidget { final Contact repeater; final String password; - const NeighboursScreen({ + const NeighborsScreen({ super.key, required this.repeater, required this.password, }); @override - State createState() => _NeighboursScreenState(); + State createState() => _NeighborsScreenState(); } -class _NeighboursScreenState extends State { - static const int _reqNeighboursKeyLen = 4; +class _NeighborsScreenState extends State { + static const int _reqNeighborsKeyLen = 4; static const int _statusPayloadOffset = 8; static const int _statusStatsSize = 52; static const int _statusResponseBytes = _statusPayloadOffset + _statusStatsSize; Uint8List _tagData = Uint8List(4); - int _neighbourCount = 0; + int _neighborCount = 0; bool _isLoading = false; bool _isLoaded = false; @@ -41,7 +42,7 @@ class _NeighboursScreenState extends State { StreamSubscription? _frameSubscription; RepeaterCommandService? _commandService; PathSelection? _pendingStatusSelection; - List>? _parsedNeighbours; + List>? _parsedNeighbors; @override void initState() { @@ -49,7 +50,7 @@ class _NeighboursScreenState extends State { final connector = Provider.of(context, listen: false); _commandService = RepeaterCommandService(connector); _setupMessageListener(); - _loadNeighbours(); + _loadNeighbors(); _hasData = false; } @@ -62,13 +63,12 @@ class _NeighboursScreenState extends State { if (frame[0] == respCodeSent) { _tagData = frame.sublist(2, 6); - //_timeEstment = frame.buffer.asByteData().getUint32(6, Endian.little); } // Check if it's a binary response if (frame[0] == pushCodeBinaryResponse && listEquals(frame.sublist(2, 6), _tagData)) { - _handleNeighboursResponse(connector, frame.sublist(6)); + _handleNeighborsResponse(connector, frame.sublist(6)); } }); } @@ -91,65 +91,77 @@ class _NeighboursScreenState extends State { return '${h}h ${m2}m'; } - static List> parseNeighboursData( + static List> parseNeighborsData( BufferReader buffer, int resultsCount, ) { - final Map> neighbours = {}; - for (var i = 0; i < resultsCount; i++) { - final neighbourData = neighbours.putIfAbsent( - i, - () => { - 'contact': null, - 'publicKey': {}, - 'lastHeard': {}, - 'snr': {}, - }, - ); - neighbourData['publicKey'] = buffer.readBytes(_reqNeighboursKeyLen); - neighbourData['lastHeard'] = buffer.readUInt32LE(); - neighbourData['snr'] = buffer.readInt8() / 4.0; - } + final Map> neighbors = {}; + try { + for (var i = 0; i < resultsCount; i++) { + final neighborData = neighbors.putIfAbsent( + i, + () => { + 'contact': null, + 'publicKey': {}, + 'lastHeard': {}, + 'snr': {}, + }, + ); + neighborData['publicKey'] = buffer.readBytes(_reqNeighborsKeyLen); + neighborData['lastHeard'] = buffer.readUInt32LE(); + neighborData['snr'] = buffer.readInt8() / 4.0; + } - return neighbours.values.toList(); + return neighbors.values.toList(); + } catch (e) { + appLogger.error( + 'Error parsing neighbors data: $e', + tag: 'NeighborsScreen', + ); + return []; + } } - void _handleNeighboursResponse(MeshCoreConnector connector, Uint8List frame) { + void _handleNeighborsResponse(MeshCoreConnector connector, Uint8List frame) { final buffer = BufferReader(frame); - final neighbourCount = buffer.readUInt16LE(); - final parsedNeighbours = parseNeighboursData(buffer, buffer.readUInt16LE()); - connector.contacts.where((c) => c.type == advTypeRepeater).forEach(( - repeater, - ) { - for (var neighbourData in parsedNeighbours) { - final publicKey = neighbourData['publicKey']; - if (listEquals( - repeater.publicKey.sublist(0, _reqNeighboursKeyLen), - publicKey, - )) { - neighbourData['contact'] = repeater; + try { + final neighborCount = buffer.readUInt16LE(); + final parsedNeighbors = parseNeighborsData(buffer, buffer.readUInt16LE()); + connector.contacts.where((c) => c.type == advTypeRepeater).forEach(( + repeater, + ) { + for (var neighborData in parsedNeighbors) { + final publicKey = neighborData['publicKey']; + if (listEquals( + repeater.publicKey.sublist(0, _reqNeighborsKeyLen), + publicKey, + )) { + neighborData['contact'] = repeater; + } } - } - }); + }); - setState(() { - _parsedNeighbours = parsedNeighbours; - _neighbourCount = neighbourCount; - }); + setState(() { + _parsedNeighbors = parsedNeighbors; + _neighborCount = neighborCount; + }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.neighbors_receivedData), - backgroundColor: Colors.green, - ), - ); - _statusTimeout?.cancel(); - if (!mounted) return; - setState(() { - _isLoading = false; - _isLoaded = true; - _hasData = true; - }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.neighbors_receivedData), + backgroundColor: Colors.green, + ), + ); + _statusTimeout?.cancel(); + if (!mounted) return; + setState(() { + _isLoading = false; + _isLoaded = true; + _hasData = true; + }); + } catch (e) { + appLogger.error('Error handling neighbors response: $e'); + } } Contact _resolveRepeater(MeshCoreConnector connector) { @@ -159,7 +171,7 @@ class _NeighboursScreenState extends State { ); } - Future _loadNeighbours() async { + Future _loadNeighbors() async { if (_commandService == null) return; setState(() { @@ -172,17 +184,17 @@ class _NeighboursScreenState extends State { final selection = await connector.preparePathForContactSend(repeater); _pendingStatusSelection = selection; - //[version][number of requested neighbours][offset_16bit][order by][len of public key] + //[version][number of requested neighbors][offset_16bit][order by][len of public key] final frame = buildSendBinaryReq( repeater.publicKey, payload: Uint8List.fromList([ - reqTypeGetNeighbours, + reqTypeGetNeighbors, 0x00, 0x0F, 0x00, 0x00, 0x00, - _reqNeighboursKeyLen, + _reqNeighborsKeyLen, ]), ); await connector.sendFrame(frame); @@ -258,7 +270,7 @@ class _NeighboursScreenState extends State { mainAxisSize: MainAxisSize.min, children: [ Text( - l10n.neighbors_repeatersNeighbours, + l10n.neighbors_repeatersNeighbors, style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), Text( @@ -345,7 +357,7 @@ class _NeighboursScreenState extends State { child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.refresh), - onPressed: _isLoading ? null : _loadNeighbours, + onPressed: _isLoading ? null : _loadNeighbors, tooltip: l10n.repeater_refresh, ), ], @@ -353,13 +365,13 @@ class _NeighboursScreenState extends State { body: SafeArea( top: false, child: RefreshIndicator( - onRefresh: _loadNeighbours, + onRefresh: _loadNeighbors, child: ListView( padding: const EdgeInsets.all(16), children: [ if (!_isLoaded && !_hasData && - (_parsedNeighbours == null || _parsedNeighbours!.isEmpty)) + (_parsedNeighbors == null || _parsedNeighbors!.isEmpty)) Center( child: Text( l10n.neighbors_noData, @@ -368,10 +380,9 @@ class _NeighboursScreenState extends State { ), if (_isLoaded || _hasData && - !(_parsedNeighbours == null || - _parsedNeighbours!.isEmpty)) - _buildNeighboursInfoCard( - "${l10n.repeater_neighbours} - $_neighbourCount", + !(_parsedNeighbors == null || _parsedNeighbors!.isEmpty)) + _buildNeighborsInfoCard( + "${l10n.repeater_neighbors} - $_neighborCount", ), ], ), @@ -380,7 +391,7 @@ class _NeighboursScreenState extends State { ); } - Widget _buildNeighboursInfoCard(String title) { + Widget _buildNeighborsInfoCard(String title) { final connector = Provider.of(context, listen: false); return Card( child: Padding( @@ -405,7 +416,7 @@ class _NeighboursScreenState extends State { ], ), const Divider(), - for (final entry in _parsedNeighbours!.asMap().entries) + for (final entry in _parsedNeighbors!.asMap().entries) _buildInfoRow( entry.value['contact'] != null ? entry.value['contact'].name @@ -430,6 +441,7 @@ class _NeighboursScreenState extends State { double snr, int spreadingFactor, ) { + final snrUi = snrUiFromSNR(snr, spreadingFactor); return Padding( padding: const EdgeInsets.symmetric(vertical: 3), child: Row( @@ -443,9 +455,15 @@ class _NeighboursScreenState extends State { style: const TextStyle(fontWeight: FontWeight.w500), ), subtitle: Text(value), - trailing: SNRIcon( - snr: snr, - snrLevels: getSNRfromSF(spreadingFactor), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(snrUi.icon, color: snrUi.color, size: 18.0), + Text( + snrUi.text, + style: TextStyle(fontSize: 10, color: snrUi.color), + ), + ], ), ), ), diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index 7677d0df..8e24bee1 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -10,6 +10,7 @@ import 'package:meshcore_open/connector/meshcore_protocol.dart'; import 'package:meshcore_open/l10n/l10n.dart'; import 'package:meshcore_open/models/contact.dart'; import 'package:meshcore_open/services/map_tile_cache_service.dart'; +import 'package:meshcore_open/utils/app_logger.dart'; import 'package:meshcore_open/widgets/snr_indicator.dart'; import 'package:provider/provider.dart'; @@ -32,7 +33,7 @@ String formatDistance(double distanceMeters) { class PathTraceData { final Uint8List pathData; - final Uint8List snrData; + final List snrData; final Map pathContacts; PathTraceData({ @@ -45,6 +46,7 @@ class PathTraceData { class PathTraceMapScreen extends StatefulWidget { final String title; final Uint8List path; + final int? repeaterId; final bool flipPathRound; final bool reversePathRound; @@ -52,6 +54,7 @@ class PathTraceMapScreen extends StatefulWidget { super.key, required this.title, required this.path, + this.repeaterId, this.flipPathRound = false, this.reversePathRound = false, }); @@ -96,7 +99,7 @@ class _PathTraceMapScreenState extends State { super.dispose(); } - Uint8List addReturnpath(Uint8List pathBytes) { + Uint8List addReturnPath(Uint8List pathBytes) { Uint8List? traceBytes; final len = (pathBytes.length + pathBytes.length - 1); traceBytes = Uint8List(len); @@ -124,7 +127,7 @@ class _PathTraceMapScreenState extends State { : widget.path; if (widget.flipPathRound) { - path = addReturnpath(pathTmp); + path = addReturnPath(pathTmp); } else { path = pathTmp; } @@ -146,42 +149,57 @@ class _PathTraceMapScreenState extends State { _frameSubscription = connector.receivedFrames.listen((frame) { if (frame.isEmpty) return; final frameBuffer = BufferReader(frame); - final code = frameBuffer.readUInt8(); + try { + final code = frameBuffer.readUInt8(); - if (code == respCodeSent) { - frameBuffer.skipBytes(1); //reserved - tagData = frameBuffer.readBytes(4); - final timeoutSeconds = frameBuffer.readUInt32LE(); + if (code == respCodeSent) { + frameBuffer.skipBytes(1); //reserved + tagData = frameBuffer.readBytes(4); + final timeoutMilliseconds = frameBuffer.readUInt32LE(); - // Start timeout timer for trace response - _timeoutTimer?.cancel(); - _timeoutTimer = Timer(Duration(milliseconds: timeoutSeconds), () { + // Start timeout timer for trace response + _timeoutTimer?.cancel(); + _timeoutTimer = Timer( + Duration(milliseconds: timeoutMilliseconds), + () { + if (!mounted) return; + setState(() { + _isLoading = false; + _failed2Loaded = true; + }); + }, + ); + } + + if (code == respCodeErr) { + _timeoutTimer?.cancel(); if (!mounted) return; setState(() { _isLoading = false; _failed2Loaded = true; }); - }); - } + } - 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(); if (!mounted) return; setState(() { _isLoading = false; _failed2Loaded = true; }); - } - // Check if it's a binary response - if (frame.length > 8 && - code == pushCodeTraceData && - listEquals(frame.sublist(4, 8), tagData)) { - _timeoutTimer?.cancel(); - if (!mounted) return; - frameBuffer.skipBytes(3); //reserved + path length + flag - if (listEquals(frameBuffer.readBytes(4), tagData)) { - _handleTraceResponse(frame); - } + // Handle any parsing errors gracefully + appLogger.error('Error parsing frame: $e', tag: 'PathTraceMapScreen'); } }); } @@ -190,63 +208,83 @@ class _PathTraceMapScreenState extends State { final connector = Provider.of(context, listen: false); final buffer = BufferReader(frame); - buffer.skipBytes(2); // Skip push code and reserved byte - int pathLength = buffer.readUInt8(); - buffer.skipBytes(5); // Skip Flag byte and tag data - buffer.skipBytes(4); // Skip auth code - Uint8List pathData = buffer.readBytes(pathLength); - Uint8List snrData = buffer.readRemainingBytes(); + try { + buffer.skipBytes(2); // Skip push code and reserved byte + int pathLength = buffer.readUInt8(); + buffer.skipBytes(5); // Skip Flag byte and tag data + buffer.skipBytes(4); // Skip auth code + Uint8List pathData = buffer.readBytes(pathLength); + List snrData = buffer + .readRemainingBytes() + .map((snr) => snr.toSigned(8).toDouble() / 4) + .toList(); - Map pathContacts = {}; + Map pathContacts = {}; - connector.contacts.where((c) => c.type != advTypeChat).forEach((repeater) { - for (var repeaterData in pathData) { - if (listEquals( - repeater.publicKey.sublist(0, 1), - Uint8List.fromList([repeaterData]), - )) { - pathContacts[repeaterData] = repeater; + connector.contacts.where((c) => c.type != advTypeChat).forEach(( + repeater, + ) { + for (var repeaterData in pathData) { + if (listEquals( + repeater.publicKey.sublist(0, 1), + Uint8List.fromList([repeaterData]), + )) { + pathContacts[repeaterData] = repeater; + } } - } - }); + }); - setState(() { - _isLoading = false; - _hasData = true; - _traceData = PathTraceData( - pathData: pathData, - snrData: snrData, - pathContacts: pathContacts, - ); - _points = []; - _points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!)); - for (final hop in _traceData!.pathData) { - final contact = _traceData!.pathContacts[hop]; - if (contact != null && - contact.hasLocation && - contact.latitude != null && - contact.longitude != null) { - _points.add(LatLng(contact.latitude!, contact.longitude!)); + setState(() { + _isLoading = false; + _hasData = true; + _traceData = PathTraceData( + pathData: pathData, + snrData: snrData, + pathContacts: pathContacts, + ); + _points = []; + _points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!)); + for (final hop in _traceData!.pathData) { + final contact = _traceData!.pathContacts[hop]; + if (contact != null && + contact.hasLocation && + contact.latitude != null && + contact.longitude != null) { + _points.add(LatLng(contact.latitude!, contact.longitude!)); + } } - } - _polylines = _points.length > 1 - ? [ - Polyline( - points: _points, - strokeWidth: 4, - color: Colors.blueAccent, - ), - ] - : []; + _polylines = _points.length > 1 + ? [ + Polyline( + points: _points, + strokeWidth: 4, + color: Colors.blueAccent, + ), + ] + : []; - _initialCenter = _points.isNotEmpty ? _points.first : const LatLng(0, 0); - _initialZoom = _points.isNotEmpty ? 13.0 : 2.0; - _bounds = _points.length > 1 ? LatLngBounds.fromPoints(_points) : null; - _mapKey = ValueKey( - '${context.l10n.pathTrace_you},${_formatPathPrefixes(_traceData!.pathData)}', + _initialCenter = _points.isNotEmpty + ? _points.first + : const LatLng(0, 0); + _initialZoom = _points.isNotEmpty ? 13.0 : 2.0; + _bounds = _points.length > 1 ? LatLngBounds.fromPoints(_points) : null; + _mapKey = ValueKey( + '${context.l10n.pathTrace_you},${_formatPathPrefixes(_traceData!.pathData)}', + ); + _pathDistanceMeters = getPathDistanceMeters(_points); + }); + } catch (e) { + appLogger.error( + 'Error handling trace response: $e', + tag: 'PathTraceMapScreen', ); - _pathDistanceMeters = getPathDistanceMeters(_points); - }); + if (mounted) { + setState(() { + _isLoading = false; + _failed2Loaded = true; + }); + } + } } @override @@ -532,6 +570,12 @@ class _PathTraceMapScreenState extends State { itemCount: pathTraceData.pathData.length + 1, separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, index) { + final snrUi = snrUiFromSNR( + index < pathTraceData.snrData.length + ? pathTraceData.snrData[index] + : null, + context.read().currentSf, + ); return Column( children: [ ListTile( @@ -550,12 +594,22 @@ class _PathTraceMapScreenState extends State { ), style: const TextStyle(fontSize: 14), ), - trailing: SNRIcon( - snr: - pathTraceData.snrData[index].toSigned( - 8, - ) / - 4.0, + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + snrUi.icon, + color: snrUi.color, + size: 18.0, + ), + Text( + snrUi.text, + style: TextStyle( + fontSize: 10, + color: snrUi.color, + ), + ), + ], ), onTap: () { // Handle item tap diff --git a/lib/screens/repeater_hub_screen.dart b/lib/screens/repeater_hub_screen.dart index 903f89e6..a5f503d5 100644 --- a/lib/screens/repeater_hub_screen.dart +++ b/lib/screens/repeater_hub_screen.dart @@ -6,7 +6,7 @@ import 'repeater_status_screen.dart'; import 'repeater_cli_screen.dart'; import 'repeater_settings_screen.dart'; import 'telemetry_screen.dart'; -import 'neighbours_screen.dart'; +import 'neighbors_screen.dart'; class RepeaterHubScreen extends StatelessWidget { final Contact repeater; @@ -174,17 +174,15 @@ class RepeaterHubScreen extends StatelessWidget { _buildManagementCard( context, icon: Icons.group, - title: l10n.repeater_neighbours, - subtitle: l10n.repeater_neighboursSubtitle, + title: l10n.repeater_neighbors, + subtitle: l10n.repeater_neighborsSubtitle, color: Colors.orange, onTap: () { Navigator.push( context, MaterialPageRoute( - builder: (context) => NeighboursScreen( - repeater: repeater, - password: password, - ), + builder: (context) => + NeighborsScreen(repeater: repeater, password: password), ), ); }, diff --git a/lib/widgets/app_bar.dart b/lib/widgets/app_bar.dart new file mode 100644 index 00000000..c88a5965 --- /dev/null +++ b/lib/widgets/app_bar.dart @@ -0,0 +1,48 @@ +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(); + + return Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + leading ?? const SizedBox.shrink(), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(title, overflow: TextOverflow.ellipsis), + if (connector.isConnected && connector.selfName != null) + Text( + '(${connector.selfName})', + style: TextStyle(fontSize: 14, color: Colors.grey[600]), + overflow: TextOverflow.ellipsis, + ), + ], + ), + const SizedBox(width: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + BatteryIndicator(connector: connector), + SNRIndicator(connector: connector), + ], + ), + trailing ?? const SizedBox.shrink(), + ], + ); + } +} diff --git a/lib/widgets/battery_indicator.dart b/lib/widgets/battery_indicator.dart index 78374150..ccea59dd 100644 --- a/lib/widgets/battery_indicator.dart +++ b/lib/widgets/battery_indicator.dart @@ -68,20 +68,24 @@ class _BatteryIndicatorState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(batteryUi.icon, size: 18, color: batteryUi.color), - const SizedBox(width: 2), - Flexible( - child: Text( - displayText, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: batteryUi.color, + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(batteryUi.icon, size: 18, color: batteryUi.color), + const SizedBox(height: 2), + Flexible( + child: Text( + displayText, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: batteryUi.color, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), - overflow: TextOverflow.visible, - maxLines: 1, - softWrap: false, - ), + ], ), ], ), diff --git a/lib/widgets/path_management_dialog.dart b/lib/widgets/path_management_dialog.dart index 483697f1..c2b6d12a 100644 --- a/lib/widgets/path_management_dialog.dart +++ b/lib/widgets/path_management_dialog.dart @@ -1,7 +1,9 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:meshcore_open/models/path_history.dart'; import 'package:meshcore_open/screens/path_trace_map.dart'; +import 'package:meshcore_open/widgets/elements_ui.dart'; import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; @@ -19,15 +21,22 @@ class PathManagementDialog { } } -class _PathManagementDialog extends StatelessWidget { +class _PathManagementDialog extends StatefulWidget { final Contact contact; const _PathManagementDialog({required this.contact}); + @override + State<_PathManagementDialog> createState() => _PathManagementDialogState(); +} + +class _PathManagementDialogState extends State<_PathManagementDialog> { + bool _showAllPaths = false; + Contact _resolveContact(MeshCoreConnector connector) { return connector.contacts.firstWhere( - (c) => c.publicKeyHex == contact.publicKeyHex, - orElse: () => contact, + (c) => c.publicKeyHex == widget.contact.publicKeyHex, + orElse: () => widget.contact, ); } @@ -134,6 +143,59 @@ class _PathManagementDialog extends StatelessWidget { final currentContact = _resolveContact(connector); final paths = pathService.getRecentPaths(currentContact.publicKeyHex); + final repeatersList = List.of(connector.directRepeaters) + ..sort((a, b) => b.ranking.compareTo(a.ranking)); + + if (repeatersList.isEmpty) { + _showAllPaths = true; + } + + final directRepeater = repeatersList.isEmpty + ? null + : repeatersList.first; + final secondDirectRepeater = repeatersList.length < 2 + ? null + : repeatersList.elementAt(1); + final thirdDirectRepeater = repeatersList.length < 3 + ? null + : repeatersList.elementAt(2); + + List>> pathsWithRepeaters = + paths.map((path) { + final isDirectRepeater = + directRepeater != null && + path.pathBytes.isNotEmpty && + directRepeater.pubkeyFirstByte == path.pathBytes.first; + final isSecondDirectRepeater = + secondDirectRepeater != null && + path.pathBytes.isNotEmpty && + secondDirectRepeater.pubkeyFirstByte == path.pathBytes.first; + final isThirdDirectRepeater = + thirdDirectRepeater != null && + path.pathBytes.isNotEmpty && + thirdDirectRepeater.pubkeyFirstByte == path.pathBytes.first; + + int ranking = -1; + Color color = Colors.grey; + if (isDirectRepeater) { + color = Colors.green; + ranking = 3; + } else if (isSecondDirectRepeater) { + color = Colors.yellow; + ranking = 2; + } else if (isThirdDirectRepeater) { + color = Colors.red; + ranking = 1; + } else if (path.wasFloodDiscovery) { + color = Colors.blue; + ranking = 0; + } + + return MapEntry(ranking, MapEntry(color, path)); + }).toList(); + + pathsWithRepeaters.sort((a, b) => b.key.compareTo(a.key)); + return AlertDialog( title: Text(l10n.chat_pathManagement), content: SingleChildScrollView( @@ -147,6 +209,17 @@ class _PathManagementDialog extends StatelessWidget { ), const SizedBox(height: 12), if (paths.isNotEmpty) ...[ + if (repeatersList.isNotEmpty) + FeatureToggleRow( + title: l10n.chat_ShowAllPaths, + subtitle: "", + value: _showAllPaths, + onChanged: (val) { + setState(() { + _showAllPaths = val; + }); + }, + ), Text( l10n.chat_recentAckPaths, style: const TextStyle( @@ -154,7 +227,7 @@ class _PathManagementDialog extends StatelessWidget { fontSize: 12, ), ), - if (paths.length >= 100) ...[ + if (pathsWithRepeaters.length >= 100) ...[ const SizedBox(height: 8), Container( width: double.infinity, @@ -173,92 +246,99 @@ class _PathManagementDialog extends StatelessWidget { ), ], const SizedBox(height: 8), - ...paths.map((path) { - return Card( - margin: const EdgeInsets.symmetric(vertical: 4), - child: ListTile( - dense: true, - leading: CircleAvatar( - radius: 16, - backgroundColor: path.wasFloodDiscovery - ? Colors.blue - : Colors.green, - child: Text( - '${path.hopCount}', - style: const TextStyle(fontSize: 12), - ), - ), - title: Text( - l10n.chat_hopsCount(path.hopCount), - style: const TextStyle(fontSize: 14), - ), - subtitle: Text( - '${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(context, path.timestamp)} • ${path.successCount} ${l10n.chat_successes}', - style: const TextStyle(fontSize: 11), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.close, size: 16), - tooltip: l10n.chat_removePath, - onPressed: () async { - await pathService.removePathRecord( - currentContact.publicKeyHex, - path.pathBytes, - ); - }, + ...pathsWithRepeaters.map((entry) { + final path = entry.value.value; + final color = entry.value.key; + + if (!_showAllPaths && entry.key < 1) { + return const SizedBox.shrink(); + } else { + return Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: ListTile( + dense: true, + leading: CircleAvatar( + radius: 16, + backgroundColor: color, + child: Text( + '${path.hopCount}', + style: const TextStyle(fontSize: 12), ), - path.wasFloodDiscovery - ? const Icon( - Icons.waves, - size: 16, - color: Colors.grey, - ) - : const Icon( - Icons.route, - size: 16, - color: Colors.grey, + ), + title: Text( + l10n.chat_hopsCount(path.hopCount), + style: const TextStyle(fontSize: 14), + ), + subtitle: Text( + '${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(context, path.timestamp)} • ${path.successCount} ${l10n.chat_successes}', + style: const TextStyle(fontSize: 11), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.close, size: 16), + tooltip: l10n.chat_removePath, + onPressed: () async { + await pathService.removePathRecord( + currentContact.publicKeyHex, + path.pathBytes, + ); + }, + ), + path.wasFloodDiscovery + ? const Icon( + Icons.waves, + size: 16, + color: Colors.grey, + ) + : const Icon( + Icons.route, + size: 16, + color: Colors.grey, + ), + ], + ), + onLongPress: () => + _showFullPathDialog(context, path.pathBytes), + onTap: () async { + if (path.pathBytes.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + l10n.chat_pathDetailsNotAvailable, ), - ], - ), - onLongPress: () => - _showFullPathDialog(context, path.pathBytes), - onTap: () async { - if (path.pathBytes.isEmpty) { + duration: const Duration(seconds: 2), + ), + ); + return; + } + + final pathBytes = Uint8List.fromList( + path.pathBytes, + ); + final pathLength = path.pathBytes.length; + + await connector.setPathOverride( + currentContact, + pathLen: pathLength, + pathBytes: pathBytes, + ); + + if (!context.mounted) return; + Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - l10n.chat_pathDetailsNotAvailable, + l10n.path_usingHopsPath(path.hopCount), ), duration: const Duration(seconds: 2), ), ); - return; - } - - final pathBytes = Uint8List.fromList(path.pathBytes); - final pathLength = path.pathBytes.length; - - await connector.setPathOverride( - currentContact, - pathLen: pathLength, - pathBytes: pathBytes, - ); - - if (!context.mounted) return; - Navigator.pop(context); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - l10n.path_usingHopsPath(path.hopCount), - ), - duration: const Duration(seconds: 2), - ), - ); - }, - ), - ); + }, + ), + ); + } }), const Divider(), ] else ...[ diff --git a/lib/widgets/snr_indicator.dart b/lib/widgets/snr_indicator.dart index da68a65a..db4fb8e1 100644 --- a/lib/widgets/snr_indicator.dart +++ b/lib/widgets/snr_indicator.dart @@ -1,4 +1,13 @@ import 'package:flutter/material.dart'; +import '../connector/meshcore_connector.dart'; +import '../l10n/l10n.dart'; + +class SNRUi { + final IconData icon; + final Color color; + final String text; + const SNRUi(this.icon, this.color, this.text); +} List getSNRfromSF(int spreadingFactor) { switch (spreadingFactor) { @@ -19,44 +28,178 @@ List getSNRfromSF(int spreadingFactor) { } } -class SNRIcon extends StatelessWidget { - final double snr; - final List snrLevels; +SNRUi snrUiFromSNR(double? snr, int? spreadingFactor) { + if (snr == null || + spreadingFactor == null || + spreadingFactor < 7 || + spreadingFactor > 12) { + return const SNRUi(Icons.signal_cellular_off, Colors.grey, '—'); + } - const SNRIcon({ - super.key, - required this.snr, - this.snrLevels = const [4.0, -2.0, -4.0, -6.0], - }); + final snrLevels = getSNRfromSF(spreadingFactor); + + IconData icon; + Color color; + String text = '${snr.toStringAsFixed(1)} dB'; + + if (snr >= snrLevels[0]) { + icon = Icons.signal_cellular_alt; + color = Colors.green; + } else if (snr >= snrLevels[1]) { + icon = Icons.signal_cellular_alt; + color = Colors.lightGreen; + } else if (snr >= snrLevels[2]) { + icon = Icons.signal_cellular_alt; + color = Colors.yellow; + } else if (snr >= snrLevels[3]) { + icon = Icons.signal_cellular_alt_2_bar; + color = Colors.orange; + } else { + icon = Icons.signal_cellular_alt_1_bar; + color = Colors.red; + } + + return SNRUi(icon, color, text); +} + +class SNRIndicator extends StatefulWidget { + final MeshCoreConnector connector; + + const SNRIndicator({super.key, required this.connector}); + @override + State createState() => _SNRIndicatorState(); +} + +class _SNRIndicatorState extends State { @override Widget build(BuildContext context) { - IconData icon; - Color color; + final directRepeaters = widget.connector.directRepeaters; + final directBestRepeaters = List.of(directRepeaters) + ..sort((a, b) => (b.ranking).compareTo(a.ranking)); + final directRepeater = directBestRepeaters.isEmpty + ? null + : directBestRepeaters.first; - if (snr >= snrLevels[0]) { - icon = Icons.signal_cellular_alt; - color = Colors.green; - } else if (snr >= snrLevels[1]) { - icon = Icons.signal_cellular_alt; - color = Colors.lightGreen; - } else if (snr >= snrLevels[2]) { - icon = Icons.signal_cellular_alt; - color = Colors.yellow; - } else if (snr >= snrLevels[3]) { - icon = Icons.signal_cellular_alt_2_bar; - color = Colors.orange; - } else { - icon = Icons.signal_cellular_alt_1_bar; - color = Colors.red; + final snrUi = snrUiFromSNR( + directBestRepeaters.isNotEmpty ? directRepeater!.snr : null, + widget.connector.currentSf, + ); + + return InkWell( + onTap: () { + if (directRepeater != null) { + _showFullPathDialog(context, directBestRepeaters); + } + }, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(snrUi.icon, size: 18, color: snrUi.color), + Text( + snrUi.text, + style: TextStyle(fontSize: 12, color: snrUi.color), + ), + ], + ), + if (directRepeater != null) + Text( + '${directRepeaters.length}: ${directRepeater.pubkeyFirstByte.toRadixString(16).padLeft(2, '0')}: ${_formatLastUpdated(directRepeater.lastUpdated)}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Colors.grey, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + } + + String _formatLastUpdated(DateTime lastSeen) { + final now = DateTime.now(); + final diff = now.difference(lastSeen); + if (diff.isNegative) { + return "0s"; } + if (diff.inMinutes < 1) { + return "${diff.inSeconds}s"; + } + if (diff.inMinutes < 60) { + return "${diff.inMinutes}m"; + } + if (diff.inHours < 24) { + final hours = diff.inHours; + return "${hours}h"; + } + final days = diff.inDays; + return "${days}d"; + } - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, color: color), - Text('$snr dB', style: TextStyle(fontSize: 10, color: color)), - ], + void _showFullPathDialog( + BuildContext context, + List directBestRepeaters, + ) { + final l10n = context.l10n; + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.snrIndicator_nearByRepeaters), + content: SizedBox( + child: Scrollbar( + child: ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: directBestRepeaters.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + final repeater = directBestRepeaters[index]; + final snrUi = snrUiFromSNR( + repeater.snr, + widget.connector.currentSf, + ); + + final name = widget.connector.contacts + .where((c) => c.publicKey.first == repeater.pubkeyFirstByte) + .map((c) => c.name) + .firstOrNull; + + return Column( + children: [ + ListTile( + leading: Icon(snrUi.icon, color: snrUi.color), + title: Text( + name ?? + repeater.pubkeyFirstByte + .toRadixString(16) + .padLeft(2, '0'), + ), + subtitle: Text( + 'SNR: ${repeater.snr.toStringAsFixed(1)} dB\n${l10n.snrIndicator_lastSeen}: ${_formatLastUpdated(repeater.lastUpdated)}', + ), + ), + ], + ); + }, + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.common_close), + ), + ], + ), ); } } diff --git a/pubspec.lock b/pubspec.lock index 09e93017..f6958384 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -69,10 +69,10 @@ packages: dependency: "direct main" description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -497,18 +497,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.18" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -910,10 +910,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.9" timezone: dependency: transitive description: From f4b18d97a12f1e44297fbc1a93895d19ee60fb14 Mon Sep 17 00:00:00 2001 From: just_stuff_tm <133525672+just-stuff-tm@users.noreply.github.com> Date: Sat, 21 Feb 2026 01:08:23 -0500 Subject: [PATCH 03/14] Added Line Of Sight Feature for repeater placement, Added app wide Units Setting (#198) * feat: add LOS workflow, global units, l10n cleanup, and mobile UI overflow fixes Squashes prior PR commits into one changeset including: LOS map/service/tests, global metric/imperial unit system adoption, notification/BLE safety fixes, app-wide localization backfill/mojibake cleanup, and responsive UI title/overflow hardening. * l10n: revert unrelated locale churn for LOS feature * feat: keep LOS with app-wide unit settings * fix: resolve post-merge app bar/import analyzer errors * style: format screen files for CI --- lib/l10n/app_bg.arb | 117 +- lib/l10n/app_de.arb | 117 +- lib/l10n/app_en.arb | 115 ++ lib/l10n/app_es.arb | 117 +- lib/l10n/app_fr.arb | 117 +- lib/l10n/app_it.arb | 117 +- lib/l10n/app_localizations.dart | 208 ++++ lib/l10n/app_localizations_bg.dart | 129 +++ lib/l10n/app_localizations_de.dart | 130 +++ lib/l10n/app_localizations_en.dart | 128 +++ lib/l10n/app_localizations_es.dart | 131 +++ lib/l10n/app_localizations_fr.dart | 130 +++ lib/l10n/app_localizations_it.dart | 130 +++ lib/l10n/app_localizations_nl.dart | 130 +++ lib/l10n/app_localizations_pl.dart | 129 +++ lib/l10n/app_localizations_pt.dart | 129 +++ lib/l10n/app_localizations_ru.dart | 129 +++ lib/l10n/app_localizations_sk.dart | 129 +++ lib/l10n/app_localizations_sl.dart | 129 +++ lib/l10n/app_localizations_sv.dart | 127 +++ lib/l10n/app_localizations_uk.dart | 130 +++ lib/l10n/app_localizations_zh.dart | 124 ++ lib/l10n/app_nl.arb | 117 +- lib/l10n/app_pl.arb | 117 +- lib/l10n/app_pt.arb | 117 +- lib/l10n/app_ru.arb | 117 +- lib/l10n/app_sk.arb | 117 +- lib/l10n/app_sl.arb | 117 +- lib/l10n/app_sv.arb | 117 +- lib/l10n/app_uk.arb | 117 +- lib/l10n/app_zh.arb | 117 +- lib/main.dart | 23 + lib/models/app_settings.dart | 28 + lib/screens/app_debug_log_screen.dart | 3 +- lib/screens/app_settings_screen.dart | 56 +- lib/screens/ble_debug_log_screen.dart | 3 +- lib/screens/channel_message_path_screen.dart | 181 ++- lib/screens/community_qr_scanner_screen.dart | 3 +- lib/screens/contacts_screen.dart | 55 +- lib/screens/line_of_sight_map_screen.dart | 1005 +++++++++++++++++ lib/screens/map_cache_screen.dart | 6 +- lib/screens/map_screen.dart | 134 ++- lib/screens/path_trace_map.dart | 191 +++- lib/screens/scanner_screen.dart | 3 +- lib/screens/settings_screen.dart | 6 +- lib/screens/telemetry_screen.dart | 20 +- lib/services/app_settings_service.dart | 10 + lib/services/ble_debug_log_service.dart | 24 +- lib/services/line_of_sight_service.dart | 406 +++++++ lib/services/notification_service.dart | 151 ++- lib/widgets/adaptive_app_bar_title.dart | 17 + test/services/line_of_sight_service_test.dart | 72 ++ 52 files changed, 6078 insertions(+), 214 deletions(-) create mode 100644 lib/screens/line_of_sight_map_screen.dart create mode 100644 lib/services/line_of_sight_service.dart create mode 100644 lib/widgets/adaptive_app_bar_title.dart create mode 100644 test/services/line_of_sight_service_test.dart diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index b6f4301e..5689f951 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -1599,5 +1599,120 @@ "chat_ShowAllPaths": "Покажи всички пътища", "settings_clientRepeatSubtitle": "Позволете на това устройство да предава пакети към мрежата за други устройства.", "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)" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 077c3989..22fdf6bd 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1627,5 +1627,120 @@ "chat_ShowAllPaths": "Alle Pfade anzeigen", "settings_clientRepeat": "Wiederholung, ohne Stromanschluss", "settings_clientRepeatFreqWarning": "Die Kommunikation ohne Stromversorgung erfordert Frequenzen von 433, 869 oder 918 MHz.", - "settings_clientRepeatSubtitle": "Ermöglichen Sie diesem Gerät, Mesh-Pakete für andere zu wiederholen." + "settings_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)" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bf49d7ef..ae245398 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -131,6 +131,7 @@ }, "settings_aboutLegalese": "2026 MeshCore Open Source Project", "settings_aboutDescription": "An open-source Flutter client for MeshCore LoRa mesh networking devices.", + "settings_aboutOpenMeteoAttribution": "LOS elevation data: Open-Meteo (CC BY 4.0)", "settings_infoName": "Name", "settings_infoId": "ID", "settings_infoStatus": "Status", @@ -242,6 +243,9 @@ "appSettings_last24Hours": "Last 24 hours", "appSettings_lastWeek": "Last week", "appSettings_offlineMapCache": "Offline Map Cache", + "appSettings_unitsTitle": "Units", + "appSettings_unitsMetric": "Metric (m / km)", + "appSettings_unitsImperial": "Imperial (ft / mi)", "appSettings_noAreaSelected": "No area selected", "appSettings_areaSelectedZoom": "Area selected (zoom {minZoom}-{maxZoom})", "@appSettings_areaSelectedZoom": { @@ -639,6 +643,8 @@ }, "chat_invalidLink": "Invalid link format", "map_title": "Node Map", + "map_lineOfSight": "Line of Sight", + "map_losScreenTitle": "Line of Sight", "map_noNodesWithLocation": "No nodes with location data", "map_nodesNeedGps": "Nodes need to share their GPS coordinates\nto appear on the map", "map_nodesCount": "Nodes: {count}", @@ -1548,6 +1554,115 @@ "pathTrace_refreshTooltip": "Refresh Path Trace.", "pathTrace_someHopsNoLocation": "One or more of the hops is missing a location!", "pathTrace_clearTooltip": "Clear path.", + "losSelectStartEnd": "Select start and end nodes for LOS.", + "losRunFailed": "Line-of-sight check failed: {error}", + "@losRunFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "losClearAllPoints": "Clear all points", + "losRunToViewElevationProfile": "Run LOS to view elevation profile", + "losMenuTitle": "LOS Menu", + "losMenuSubtitle": "Tap nodes or long-press map for custom points", + "losShowDisplayNodes": "Show display nodes", + "losCustomPoints": "Custom points", + "losCustomPointLabel": "Custom {index}", + "@losCustomPointLabel": { + "placeholders": { + "index": { + "type": "int" + } + } + }, + "losPointA": "Point A", + "losPointB": "Point B", + "losAntennaA": "Antenna A: {value} {unit}", + "@losAntennaA": { + "placeholders": { + "value": { + "type": "String" + }, + "unit": { + "type": "String" + } + } + }, + "losAntennaB": "Antenna B: {value} {unit}", + "@losAntennaB": { + "placeholders": { + "value": { + "type": "String" + }, + "unit": { + "type": "String" + } + } + }, + "losRun": "Run LOS", + "losNoElevationData": "No elevation data", + "losProfileClear": "{distance} {distanceUnit}, clear LOS, min clearance {clearance} {heightUnit}", + "@losProfileClear": { + "placeholders": { + "distance": { + "type": "String" + }, + "distanceUnit": { + "type": "String" + }, + "clearance": { + "type": "String" + }, + "heightUnit": { + "type": "String" + } + } + }, + "losProfileBlocked": "{distance} {distanceUnit}, blocked by {obstruction} {heightUnit}", + "@losProfileBlocked": { + "placeholders": { + "distance": { + "type": "String" + }, + "distanceUnit": { + "type": "String" + }, + "obstruction": { + "type": "String" + }, + "heightUnit": { + "type": "String" + } + } + }, + "losStatusChecking": "LOS: checking...", + "losStatusNoData": "LOS: no data", + "losStatusSummary": "LOS: {clear}/{total} clear, {blocked} blocked, {unknown} unknown", + "@losStatusSummary": { + "placeholders": { + "clear": { + "type": "int" + }, + "total": { + "type": "int" + }, + "blocked": { + "type": "int" + }, + "unknown": { + "type": "int" + } + } + }, + "losErrorElevationUnavailable": "Elevation data unavailable for one or more samples.", + "losErrorInvalidInput": "Invalid points/elevation data for LOS calculation.", + "losRenameCustomPoint": "Rename custom point", + "losPointName": "Point name", + "losShowPanelTooltip": "Show LOS panel", + "losHidePanelTooltip": "Hide LOS panel", + "losElevationAttribution": "Elevation data: Open-Meteo (CC BY 4.0)", "contacts_pathTrace": "Path Trace", "contacts_ping": "Ping", "contacts_repeaterPathTrace": "Path trace to repeater", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 1896b4f3..3a7fe537 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1627,5 +1627,120 @@ "chat_ShowAllPaths": "Mostrar todos los caminos", "settings_clientRepeatFreqWarning": "Para la comunicación fuera de la red, se requiere una frecuencia de 433, 869 o 918 MHz.", "settings_clientRepeat": "Repetir sin conexión", - "settings_clientRepeatSubtitle": "Permita que este dispositivo repita los paquetes de red para otros usuarios." + "settings_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)" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index d1befced..f962ee5c 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1599,5 +1599,120 @@ "chat_ShowAllPaths": "Afficher tous les chemins", "settings_clientRepeatFreqWarning": "Pour les transmissions hors réseau, il est nécessaire d'utiliser les fréquences de 433, 869 ou 918 MHz.", "settings_clientRepeatSubtitle": "Permettez à cet appareil de répéter les paquets de données pour les autres.", - "settings_clientRepeat": "Répétition hors réseau" + "settings_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)" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 22371baf..61110043 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1599,5 +1599,120 @@ "chat_ShowAllPaths": "Mostra tutti i percorsi", "settings_clientRepeat": "Ripetizione \"fuori dalla rete\"", "settings_clientRepeatFreqWarning": "Per la comunicazione fuori rete, è necessario utilizzare frequenze di 433, 869 o 918 MHz.", - "settings_clientRepeatSubtitle": "Permetti a questo dispositivo di ripetere i pacchetti di rete per gli altri." + "settings_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)" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 2bcda786..20d04228 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -700,6 +700,12 @@ abstract class AppLocalizations { /// **'An open-source Flutter client for MeshCore LoRa mesh networking devices.'** String get settings_aboutDescription; + /// No description provided for @settings_aboutOpenMeteoAttribution. + /// + /// In en, this message translates to: + /// **'LOS elevation data: Open-Meteo (CC BY 4.0)'** + String get settings_aboutOpenMeteoAttribution; + /// No description provided for @settings_infoName. /// /// In en, this message translates to: @@ -1240,6 +1246,24 @@ abstract class AppLocalizations { /// **'Offline Map Cache'** String get appSettings_offlineMapCache; + /// No description provided for @appSettings_unitsTitle. + /// + /// In en, this message translates to: + /// **'Units'** + String get appSettings_unitsTitle; + + /// No description provided for @appSettings_unitsMetric. + /// + /// In en, this message translates to: + /// **'Metric (m / km)'** + String get appSettings_unitsMetric; + + /// No description provided for @appSettings_unitsImperial. + /// + /// In en, this message translates to: + /// **'Imperial (ft / mi)'** + String get appSettings_unitsImperial; + /// No description provided for @appSettings_noAreaSelected. /// /// In en, this message translates to: @@ -2290,6 +2314,18 @@ abstract class AppLocalizations { /// **'Node Map'** String get map_title; + /// No description provided for @map_lineOfSight. + /// + /// In en, this message translates to: + /// **'Line of Sight'** + String get map_lineOfSight; + + /// No description provided for @map_losScreenTitle. + /// + /// In en, this message translates to: + /// **'Line of Sight'** + String get map_losScreenTitle; + /// No description provided for @map_noNodesWithLocation. /// /// In en, this message translates to: @@ -4772,6 +4808,178 @@ abstract class AppLocalizations { /// **'Clear path.'** String get pathTrace_clearTooltip; + /// No description provided for @losSelectStartEnd. + /// + /// In en, this message translates to: + /// **'Select start and end nodes for LOS.'** + String get losSelectStartEnd; + + /// No description provided for @losRunFailed. + /// + /// In en, this message translates to: + /// **'Line-of-sight check failed: {error}'** + String losRunFailed(String error); + + /// No description provided for @losClearAllPoints. + /// + /// In en, this message translates to: + /// **'Clear all points'** + String get losClearAllPoints; + + /// No description provided for @losRunToViewElevationProfile. + /// + /// In en, this message translates to: + /// **'Run LOS to view elevation profile'** + String get losRunToViewElevationProfile; + + /// No description provided for @losMenuTitle. + /// + /// In en, this message translates to: + /// **'LOS Menu'** + String get losMenuTitle; + + /// No description provided for @losMenuSubtitle. + /// + /// In en, this message translates to: + /// **'Tap nodes or long-press map for custom points'** + String get losMenuSubtitle; + + /// No description provided for @losShowDisplayNodes. + /// + /// In en, this message translates to: + /// **'Show display nodes'** + String get losShowDisplayNodes; + + /// No description provided for @losCustomPoints. + /// + /// In en, this message translates to: + /// **'Custom points'** + String get losCustomPoints; + + /// No description provided for @losCustomPointLabel. + /// + /// In en, this message translates to: + /// **'Custom {index}'** + String losCustomPointLabel(int index); + + /// No description provided for @losPointA. + /// + /// In en, this message translates to: + /// **'Point A'** + String get losPointA; + + /// No description provided for @losPointB. + /// + /// In en, this message translates to: + /// **'Point B'** + String get losPointB; + + /// No description provided for @losAntennaA. + /// + /// In en, this message translates to: + /// **'Antenna A: {value} {unit}'** + String losAntennaA(String value, String unit); + + /// No description provided for @losAntennaB. + /// + /// In en, this message translates to: + /// **'Antenna B: {value} {unit}'** + String losAntennaB(String value, String unit); + + /// No description provided for @losRun. + /// + /// In en, this message translates to: + /// **'Run LOS'** + String get losRun; + + /// No description provided for @losNoElevationData. + /// + /// In en, this message translates to: + /// **'No elevation data'** + String get losNoElevationData; + + /// No description provided for @losProfileClear. + /// + /// In en, this message translates to: + /// **'{distance} {distanceUnit}, clear LOS, min clearance {clearance} {heightUnit}'** + String losProfileClear( + String distance, + String distanceUnit, + String clearance, + String heightUnit, + ); + + /// No description provided for @losProfileBlocked. + /// + /// In en, this message translates to: + /// **'{distance} {distanceUnit}, blocked by {obstruction} {heightUnit}'** + String losProfileBlocked( + String distance, + String distanceUnit, + String obstruction, + String heightUnit, + ); + + /// No description provided for @losStatusChecking. + /// + /// In en, this message translates to: + /// **'LOS: checking...'** + String get losStatusChecking; + + /// No description provided for @losStatusNoData. + /// + /// In en, this message translates to: + /// **'LOS: no data'** + String get losStatusNoData; + + /// No description provided for @losStatusSummary. + /// + /// In en, this message translates to: + /// **'LOS: {clear}/{total} clear, {blocked} blocked, {unknown} unknown'** + String losStatusSummary(int clear, int total, int blocked, int unknown); + + /// No description provided for @losErrorElevationUnavailable. + /// + /// In en, this message translates to: + /// **'Elevation data unavailable for one or more samples.'** + String get losErrorElevationUnavailable; + + /// No description provided for @losErrorInvalidInput. + /// + /// In en, this message translates to: + /// **'Invalid points/elevation data for LOS calculation.'** + String get losErrorInvalidInput; + + /// No description provided for @losRenameCustomPoint. + /// + /// In en, this message translates to: + /// **'Rename custom point'** + String get losRenameCustomPoint; + + /// No description provided for @losPointName. + /// + /// In en, this message translates to: + /// **'Point name'** + String get losPointName; + + /// No description provided for @losShowPanelTooltip. + /// + /// In en, this message translates to: + /// **'Show LOS panel'** + String get losShowPanelTooltip; + + /// No description provided for @losHidePanelTooltip. + /// + /// In en, this message translates to: + /// **'Hide LOS panel'** + String get losHidePanelTooltip; + + /// No description provided for @losElevationAttribution. + /// + /// In en, this message translates to: + /// **'Elevation data: Open-Meteo (CC BY 4.0)'** + String get losElevationAttribution; + /// No description provided for @contacts_pathTrace. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 137d48a5..9c66ff22 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -326,6 +326,10 @@ class AppLocalizationsBg extends AppLocalizations { String get settings_aboutDescription => 'Отворен софтуер за Flutter клиент за MeshCore LoRa мрежови устройства.'; + @override + String get settings_aboutOpenMeteoAttribution => + 'Данни за надморска височина на LOS: Open-Meteo (CC BY 4.0)'; + @override String get settings_infoName => 'Име'; @@ -622,6 +626,15 @@ class AppLocalizationsBg extends AppLocalizations { @override String get appSettings_offlineMapCache => 'Кеш на офлайн карти'; + @override + String get appSettings_unitsTitle => 'единици'; + + @override + String get appSettings_unitsMetric => 'Метрика (m / km)'; + + @override + String get appSettings_unitsImperial => 'Имперска (ft / mi)'; + @override String get appSettings_noAreaSelected => 'Няма избрана област'; @@ -1243,6 +1256,12 @@ class AppLocalizationsBg extends AppLocalizations { @override String get map_title => 'Карта на възлите'; + @override + String get map_lineOfSight => 'Линия на видимост'; + + @override + String get map_losScreenTitle => 'Линия на видимост'; + @override String get map_noNodesWithLocation => 'Няма възли с данни за местоположение.'; @@ -2724,6 +2743,116 @@ class AppLocalizationsBg extends AppLocalizations { @override String get pathTrace_clearTooltip => 'Изчисти пътя'; + @override + String get losSelectStartEnd => 'Изберете начални и крайни възли за LOS.'; + + @override + String losRunFailed(String error) { + return 'Проверката на пряката видимост е неуспешна: $error'; + } + + @override + String get losClearAllPoints => 'Изчистете всички точки'; + + @override + String get losRunToViewElevationProfile => + 'Стартирайте LOS, за да видите профила на надморската височина'; + + @override + String get losMenuTitle => 'LOS меню'; + + @override + String get losMenuSubtitle => + 'Докоснете възли или натиснете продължително карта за персонализирани точки'; + + @override + String get losShowDisplayNodes => 'Показване на възли на дисплея'; + + @override + String get losCustomPoints => 'Персонализирани точки'; + + @override + String losCustomPointLabel(int index) { + return 'Персонализирано $index'; + } + + @override + String get losPointA => 'Точка А'; + + @override + String get losPointB => 'Точка Б'; + + @override + String losAntennaA(String value, String unit) { + return 'Антена A: $value $unit'; + } + + @override + String losAntennaB(String value, String unit) { + return 'Антена B: $value $unit'; + } + + @override + String get losRun => 'Стартирайте LOS'; + + @override + String get losNoElevationData => 'Няма данни за надморска височина'; + + @override + String losProfileClear( + String distance, + String distanceUnit, + String clearance, + String heightUnit, + ) { + return '$distance $distanceUnit, чист LOS, минимално разстояние $clearance $heightUnit'; + } + + @override + String losProfileBlocked( + String distance, + String distanceUnit, + String obstruction, + String heightUnit, + ) { + return '$distance $distanceUnit, блокиран от $obstruction $heightUnit'; + } + + @override + String get losStatusChecking => 'LOS: проверка...'; + + @override + String get losStatusNoData => 'LOS: няма данни'; + + @override + String losStatusSummary(int clear, int total, int blocked, int unknown) { + return 'LOS: $clear/$total ясно, $blocked блокирано, $unknown неизвестно'; + } + + @override + String get losErrorElevationUnavailable => + 'Няма налични данни за надморска височина за една или повече проби.'; + + @override + String get losErrorInvalidInput => + 'Невалидни данни за точки/надморска височина за изчисляване на LOS.'; + + @override + String get losRenameCustomPoint => 'Преименувайте персонализирана точка'; + + @override + String get losPointName => 'Име на точката'; + + @override + String get losShowPanelTooltip => 'Показване на LOS панел'; + + @override + String get losHidePanelTooltip => 'Скриване на LOS панела'; + + @override + String get losElevationAttribution => + 'Данни за надморска височина: Open-Meteo (CC BY 4.0)'; + @override String get contacts_pathTrace => 'Пътен проследяване'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 927ac488..ef7cd9d2 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -320,6 +320,10 @@ class AppLocalizationsDe extends AppLocalizations { String get settings_aboutDescription => 'Ein Open-Source-Flutter-Client für MeshCore LoRa-Meshnetzwerkgeräte.'; + @override + String get settings_aboutOpenMeteoAttribution => + 'LOS-Höhendaten: Open-Meteo (CC BY 4.0)'; + @override String get settings_infoName => 'Name'; @@ -619,6 +623,15 @@ class AppLocalizationsDe extends AppLocalizations { @override String get appSettings_offlineMapCache => 'Offline-Karten-Cache'; + @override + String get appSettings_unitsTitle => 'Einheiten'; + + @override + String get appSettings_unitsMetric => 'Metrisch (m/km)'; + + @override + String get appSettings_unitsImperial => 'Imperial (ft/mi)'; + @override String get appSettings_noAreaSelected => 'Kein Bereich ausgewählt'; @@ -1242,6 +1255,12 @@ class AppLocalizationsDe extends AppLocalizations { @override String get map_title => 'Karte'; + @override + String get map_lineOfSight => 'Sichtlinie'; + + @override + String get map_losScreenTitle => 'Sichtlinie'; + @override String get map_noNodesWithLocation => 'Keine Knoten mit Standortdaten'; @@ -2729,6 +2748,117 @@ class AppLocalizationsDe extends AppLocalizations { @override String get pathTrace_clearTooltip => 'Pfad löschen'; + @override + String get losSelectStartEnd => + 'Wählen Sie Start- und Endknoten für LOS aus.'; + + @override + String losRunFailed(String error) { + return 'Sichtlinienprüfung fehlgeschlagen: $error'; + } + + @override + String get losClearAllPoints => 'Löschen Sie alle Punkte'; + + @override + String get losRunToViewElevationProfile => + 'Führen Sie LOS aus, um das Höhenprofil anzuzeigen'; + + @override + String get losMenuTitle => 'LOS-Menü'; + + @override + String get losMenuSubtitle => + 'Tippen Sie auf Knoten oder drücken Sie lange auf die Karte, um benutzerdefinierte Punkte anzuzeigen'; + + @override + String get losShowDisplayNodes => 'Anzeigeknoten anzeigen'; + + @override + String get losCustomPoints => 'Benutzerdefinierte Punkte'; + + @override + String losCustomPointLabel(int index) { + return 'Benutzerdefiniert $index'; + } + + @override + String get losPointA => 'Punkt A'; + + @override + String get losPointB => 'Punkt B'; + + @override + String losAntennaA(String value, String unit) { + return 'Antenne A: $value $unit'; + } + + @override + String losAntennaB(String value, String unit) { + return 'Antenne B: $value $unit'; + } + + @override + String get losRun => 'Führen Sie LOS aus'; + + @override + String get losNoElevationData => 'Keine Höhendaten'; + + @override + String losProfileClear( + String distance, + String distanceUnit, + String clearance, + String heightUnit, + ) { + return '$distance $distanceUnit, freie Sichtlinie, Mindestabstand $clearance $heightUnit'; + } + + @override + String losProfileBlocked( + String distance, + String distanceUnit, + String obstruction, + String heightUnit, + ) { + return '$distance $distanceUnit, blockiert durch $obstruction $heightUnit'; + } + + @override + String get losStatusChecking => 'LOS: Überprüfen...'; + + @override + String get losStatusNoData => 'LOS: keine Daten'; + + @override + String losStatusSummary(int clear, int total, int blocked, int unknown) { + return 'Sichtlinie: $clear/$total frei, $blocked blockiert, $unknown unbekannt'; + } + + @override + String get losErrorElevationUnavailable => + 'Für eine oder mehrere Proben sind keine Höhendaten verfügbar.'; + + @override + String get losErrorInvalidInput => + 'Ungültige Punkte/Höhendaten für die LOS-Berechnung.'; + + @override + String get losRenameCustomPoint => + 'Benennen Sie den benutzerdefinierten Punkt um'; + + @override + String get losPointName => 'Punktname'; + + @override + String get losShowPanelTooltip => 'LOS-Panel anzeigen'; + + @override + String get losHidePanelTooltip => 'LOS-Panel ausblenden'; + + @override + String get losElevationAttribution => 'Höhendaten: Open-Meteo (CC BY 4.0)'; + @override String get contacts_pathTrace => 'Pfadverfolgung'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index ef7c0c34..7f07e263 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -318,6 +318,10 @@ class AppLocalizationsEn extends AppLocalizations { String get settings_aboutDescription => 'An open-source Flutter client for MeshCore LoRa mesh networking devices.'; + @override + String get settings_aboutOpenMeteoAttribution => + 'LOS elevation data: Open-Meteo (CC BY 4.0)'; + @override String get settings_infoName => 'Name'; @@ -614,6 +618,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get appSettings_offlineMapCache => 'Offline Map Cache'; + @override + String get appSettings_unitsTitle => 'Units'; + + @override + String get appSettings_unitsMetric => 'Metric (m / km)'; + + @override + String get appSettings_unitsImperial => 'Imperial (ft / mi)'; + @override String get appSettings_noAreaSelected => 'No area selected'; @@ -1222,6 +1235,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get map_title => 'Node Map'; + @override + String get map_lineOfSight => 'Line of Sight'; + + @override + String get map_losScreenTitle => 'Line of Sight'; + @override String get map_noNodesWithLocation => 'No nodes with location data'; @@ -2683,6 +2702,115 @@ class AppLocalizationsEn extends AppLocalizations { @override String get pathTrace_clearTooltip => 'Clear path.'; + @override + String get losSelectStartEnd => 'Select start and end nodes for LOS.'; + + @override + String losRunFailed(String error) { + return 'Line-of-sight check failed: $error'; + } + + @override + String get losClearAllPoints => 'Clear all points'; + + @override + String get losRunToViewElevationProfile => + 'Run LOS to view elevation profile'; + + @override + String get losMenuTitle => 'LOS Menu'; + + @override + String get losMenuSubtitle => 'Tap nodes or long-press map for custom points'; + + @override + String get losShowDisplayNodes => 'Show display nodes'; + + @override + String get losCustomPoints => 'Custom points'; + + @override + String losCustomPointLabel(int index) { + return 'Custom $index'; + } + + @override + String get losPointA => 'Point A'; + + @override + String get losPointB => 'Point B'; + + @override + String losAntennaA(String value, String unit) { + return 'Antenna A: $value $unit'; + } + + @override + String losAntennaB(String value, String unit) { + return 'Antenna B: $value $unit'; + } + + @override + String get losRun => 'Run LOS'; + + @override + String get losNoElevationData => 'No elevation data'; + + @override + String losProfileClear( + String distance, + String distanceUnit, + String clearance, + String heightUnit, + ) { + return '$distance $distanceUnit, clear LOS, min clearance $clearance $heightUnit'; + } + + @override + String losProfileBlocked( + String distance, + String distanceUnit, + String obstruction, + String heightUnit, + ) { + return '$distance $distanceUnit, blocked by $obstruction $heightUnit'; + } + + @override + String get losStatusChecking => 'LOS: checking...'; + + @override + String get losStatusNoData => 'LOS: no data'; + + @override + String losStatusSummary(int clear, int total, int blocked, int unknown) { + return 'LOS: $clear/$total clear, $blocked blocked, $unknown unknown'; + } + + @override + String get losErrorElevationUnavailable => + 'Elevation data unavailable for one or more samples.'; + + @override + String get losErrorInvalidInput => + 'Invalid points/elevation data for LOS calculation.'; + + @override + String get losRenameCustomPoint => 'Rename custom point'; + + @override + String get losPointName => 'Point name'; + + @override + String get losShowPanelTooltip => 'Show LOS panel'; + + @override + String get losHidePanelTooltip => 'Hide LOS panel'; + + @override + String get losElevationAttribution => + 'Elevation data: Open-Meteo (CC BY 4.0)'; + @override String get contacts_pathTrace => 'Path Trace'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index f72196db..6409675a 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -323,6 +323,10 @@ class AppLocalizationsEs extends AppLocalizations { String get settings_aboutDescription => 'Un cliente de código abierto de Flutter para dispositivos de red mesh LoRa de MeshCore.'; + @override + String get settings_aboutOpenMeteoAttribution => + 'Datos de elevación LOS: Open-Meteo (CC BY 4.0)'; + @override String get settings_infoName => 'Nombre'; @@ -620,6 +624,15 @@ class AppLocalizationsEs extends AppLocalizations { @override String get appSettings_offlineMapCache => 'Caché de Mapa Offline'; + @override + String get appSettings_unitsTitle => 'Unidades'; + + @override + String get appSettings_unitsMetric => 'Métrico (m/km)'; + + @override + String get appSettings_unitsImperial => 'Imperial (pies/millas)'; + @override String get appSettings_noAreaSelected => 'No se ha seleccionado ningún área'; @@ -1240,6 +1253,12 @@ class AppLocalizationsEs extends AppLocalizations { @override String get map_title => 'Mapa de Nodos'; + @override + String get map_lineOfSight => 'Línea de visión'; + + @override + String get map_losScreenTitle => 'Línea de visión'; + @override String get map_noNodesWithLocation => 'No hay nodos con datos de ubicación'; @@ -2722,6 +2741,118 @@ class AppLocalizationsEs extends AppLocalizations { @override String get pathTrace_clearTooltip => 'Borrar ruta'; + @override + String get losSelectStartEnd => + 'Seleccione los nodos de inicio y fin para LOS.'; + + @override + String losRunFailed(String error) { + return 'Error en la comprobación de la línea de visión: $error'; + } + + @override + String get losClearAllPoints => 'Borrar todos los puntos'; + + @override + String get losRunToViewElevationProfile => + 'Ejecute LOS para ver el perfil de elevación'; + + @override + String get losMenuTitle => 'Menú LOS'; + + @override + String get losMenuSubtitle => + 'Toque nodos o mantenga presionado el mapa para puntos personalizados'; + + @override + String get losShowDisplayNodes => 'Mostrar nodos de visualización'; + + @override + String get losCustomPoints => 'Puntos personalizados'; + + @override + String losCustomPointLabel(int index) { + return 'Personalizado $index'; + } + + @override + String get losPointA => 'Punto A'; + + @override + String get losPointB => 'Punto B'; + + @override + String losAntennaA(String value, String unit) { + return 'Antena A: $value $unit'; + } + + @override + String losAntennaB(String value, String unit) { + return 'Antena B: $value $unit'; + } + + @override + String get losRun => 'Ejecutar LOS'; + + @override + String get losNoElevationData => 'Sin datos de elevación'; + + @override + String losProfileClear( + String distance, + String distanceUnit, + String clearance, + String heightUnit, + ) { + return '$distance $distanceUnit, despejar LOS, autorización mínima $clearance $heightUnit'; + } + + @override + String losProfileBlocked( + String distance, + String distanceUnit, + String obstruction, + String heightUnit, + ) { + return '$distance $distanceUnit, bloqueado por $obstruction $heightUnit'; + } + + @override + String get losStatusChecking => 'LOS: comprobando...'; + + @override + String get losStatusNoData => 'LOS: sin datos'; + + @override + String losStatusSummary(int clear, int total, int blocked, int unknown) { + return 'LOS: $clear/$total claro, $blocked bloqueado, $unknown desconocido'; + } + + @override + String get losErrorElevationUnavailable => + 'Datos de elevación no disponibles para una o más muestras.'; + + @override + String get losErrorInvalidInput => + 'Datos de puntos/elevación no válidos para el cálculo de LOS.'; + + @override + String get losRenameCustomPoint => + 'Cambiar el nombre del punto personalizado'; + + @override + String get losPointName => 'Nombre del punto'; + + @override + String get losShowPanelTooltip => 'Mostrar panel LOS'; + + @override + String get losHidePanelTooltip => 'Ocultar panel LOS'; + + @override + String get losElevationAttribution => + 'Datos de elevación: Open-Meteo (CC BY 4.0)'; + @override String get contacts_pathTrace => 'Rastreo de caminos'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 89785685..3536cf56 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -324,6 +324,10 @@ class AppLocalizationsFr extends AppLocalizations { String get settings_aboutDescription => 'Un client Flutter open source pour les appareils de réseau mesh MeshCore LoRa.'; + @override + String get settings_aboutOpenMeteoAttribution => + 'Données d\'élévation LOS : Open-Meteo (CC BY 4.0)'; + @override String get settings_infoName => 'Nom'; @@ -622,6 +626,15 @@ class AppLocalizationsFr extends AppLocalizations { @override String get appSettings_offlineMapCache => 'Cache de Carte Hors Ligne'; + @override + String get appSettings_unitsTitle => 'Unités'; + + @override + String get appSettings_unitsMetric => 'Métrique (m/km)'; + + @override + String get appSettings_unitsImperial => 'Impérial (ft / mi)'; + @override String get appSettings_noAreaSelected => 'Aucune zone sélectionnée'; @@ -1246,6 +1259,12 @@ class AppLocalizationsFr extends AppLocalizations { @override String get map_title => 'Carte des nœuds'; + @override + String get map_lineOfSight => 'Ligne de vue'; + + @override + String get map_losScreenTitle => 'Ligne de vue'; + @override String get map_noNodesWithLocation => 'Aucun nœud avec des données de localisation'; @@ -2738,6 +2757,117 @@ class AppLocalizationsFr extends AppLocalizations { @override String get pathTrace_clearTooltip => 'Effacer le chemin'; + @override + String get losSelectStartEnd => + 'Sélectionnez les nœuds de début et de fin pour LOS.'; + + @override + String losRunFailed(String error) { + return 'Échec de la vérification de la ligne de vue : $error'; + } + + @override + String get losClearAllPoints => 'Effacer tous les points'; + + @override + String get losRunToViewElevationProfile => + 'Exécutez LOS pour afficher le profil d\'altitude'; + + @override + String get losMenuTitle => 'Menu LOS'; + + @override + String get losMenuSubtitle => + 'Appuyez sur les nœuds ou appuyez longuement sur la carte pour des points personnalisés'; + + @override + String get losShowDisplayNodes => 'Afficher les nœuds d\'affichage'; + + @override + String get losCustomPoints => 'Points personnalisés'; + + @override + String losCustomPointLabel(int index) { + return 'Personnalisé $index'; + } + + @override + String get losPointA => 'Point A'; + + @override + String get losPointB => 'Point B'; + + @override + String losAntennaA(String value, String unit) { + return 'Antenne A : $value $unit'; + } + + @override + String losAntennaB(String value, String unit) { + return 'Antenne B : $value $unit'; + } + + @override + String get losRun => 'Exécuter la LOS'; + + @override + String get losNoElevationData => 'Aucune donnée d\'altitude'; + + @override + String losProfileClear( + String distance, + String distanceUnit, + String clearance, + String heightUnit, + ) { + return '$distance $distanceUnit, LOS clair, clairance minimale $clearance $heightUnit'; + } + + @override + String losProfileBlocked( + String distance, + String distanceUnit, + String obstruction, + String heightUnit, + ) { + return '$distance $distanceUnit, bloqué par $obstruction $heightUnit'; + } + + @override + String get losStatusChecking => 'LOS : vérification...'; + + @override + String get losStatusNoData => 'LOS : aucune donnée'; + + @override + String losStatusSummary(int clear, int total, int blocked, int unknown) { + return 'LOS : $clear/$total clair, $blocked bloqué, $unknown inconnu'; + } + + @override + String get losErrorElevationUnavailable => + 'Données d\'altitude indisponibles pour un ou plusieurs échantillons.'; + + @override + String get losErrorInvalidInput => + 'Données de points/d\'altitude non valides pour le calcul de la LOS.'; + + @override + String get losRenameCustomPoint => 'Renommer le point personnalisé'; + + @override + String get losPointName => 'Nom du point'; + + @override + String get losShowPanelTooltip => 'Afficher le panneau LOS'; + + @override + String get losHidePanelTooltip => 'Masquer le panneau LOS'; + + @override + String get losElevationAttribution => + 'Données d\'altitude : Open-Meteo (CC BY 4.0)'; + @override String get contacts_pathTrace => 'Traçage de chemin'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index a2b790f6..521cfb71 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -322,6 +322,10 @@ class AppLocalizationsIt extends AppLocalizations { String get settings_aboutDescription => 'Un client Flutter open-source per i dispositivi di rete mesh LoRa Core di MeshCore.'; + @override + String get settings_aboutOpenMeteoAttribution => + 'Dati di elevazione LOS: Open-Meteo (CC BY 4.0)'; + @override String get settings_infoName => 'Nome'; @@ -619,6 +623,15 @@ class AppLocalizationsIt extends AppLocalizations { @override String get appSettings_offlineMapCache => 'Cache Mappa Offline'; + @override + String get appSettings_unitsTitle => 'Unità'; + + @override + String get appSettings_unitsMetric => 'Metrico (m/km)'; + + @override + String get appSettings_unitsImperial => 'Imperiale (ft / mi)'; + @override String get appSettings_noAreaSelected => 'Nessun\'area selezionata'; @@ -1239,6 +1252,12 @@ class AppLocalizationsIt extends AppLocalizations { @override String get map_title => 'Mappa Nodi'; + @override + String get map_lineOfSight => 'Linea di vista'; + + @override + String get map_losScreenTitle => 'Linea di vista'; + @override String get map_noNodesWithLocation => 'Nessun nodo con dati di posizione'; @@ -2723,6 +2742,117 @@ class AppLocalizationsIt extends AppLocalizations { @override String get pathTrace_clearTooltip => 'Pulisci percorso'; + @override + String get losSelectStartEnd => + 'Seleziona i nodi iniziali e finali per la LOS.'; + + @override + String losRunFailed(String error) { + return 'Controllo della linea di vista fallito: $error'; + } + + @override + String get losClearAllPoints => 'Cancella tutti i punti'; + + @override + String get losRunToViewElevationProfile => + 'Eseguire LOS per visualizzare il profilo altimetrico'; + + @override + String get losMenuTitle => 'Menù LOS'; + + @override + String get losMenuSubtitle => + 'Tocca i nodi o premi a lungo la mappa per punti personalizzati'; + + @override + String get losShowDisplayNodes => 'Mostra i nodi di visualizzazione'; + + @override + String get losCustomPoints => 'Punti personalizzati'; + + @override + String losCustomPointLabel(int index) { + return 'Personalizzato $index'; + } + + @override + String get losPointA => 'Punto A'; + + @override + String get losPointB => 'Punto B'; + + @override + String losAntennaA(String value, String unit) { + return 'Antenna A: $value $unit'; + } + + @override + String losAntennaB(String value, String unit) { + return 'Antenna B: $value $unit'; + } + + @override + String get losRun => 'Esegui LOS'; + + @override + String get losNoElevationData => 'Nessun dato di elevazione'; + + @override + String losProfileClear( + String distance, + String distanceUnit, + String clearance, + String heightUnit, + ) { + return '$distance $distanceUnit, libera LOS, distanza minima $clearance $heightUnit'; + } + + @override + String losProfileBlocked( + String distance, + String distanceUnit, + String obstruction, + String heightUnit, + ) { + return '$distance $distanceUnit, bloccato da $obstruction $heightUnit'; + } + + @override + String get losStatusChecking => 'LOS: controllo...'; + + @override + String get losStatusNoData => 'LOS: nessun dato'; + + @override + String losStatusSummary(int clear, int total, int blocked, int unknown) { + return 'LOS: $clear/$total libera, $blocked bloccato, $unknown sconosciuto'; + } + + @override + String get losErrorElevationUnavailable => + 'Dati di elevazione non disponibili per uno o più campioni.'; + + @override + String get losErrorInvalidInput => + 'Dati punti/elevazione non validi per il calcolo della LOS.'; + + @override + String get losRenameCustomPoint => 'Rinomina punto personalizzato'; + + @override + String get losPointName => 'Nome del punto'; + + @override + String get losShowPanelTooltip => 'Mostra il pannello LOS'; + + @override + String get losHidePanelTooltip => 'Nascondi il pannello LOS'; + + @override + String get losElevationAttribution => + 'Dati di elevazione: Open-Meteo (CC BY 4.0)'; + @override String get contacts_pathTrace => 'Traccia Percorso'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index a958e795..a7a4c0b8 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -320,6 +320,10 @@ class AppLocalizationsNl extends AppLocalizations { String get settings_aboutDescription => 'Een open-source Flutter client voor MeshCore LoRa mesh netwerkapparaten.'; + @override + String get settings_aboutOpenMeteoAttribution => + 'LOS-hoogtegegevens: Open-Meteo (CC BY 4.0)'; + @override String get settings_infoName => 'Naam'; @@ -617,6 +621,15 @@ class AppLocalizationsNl extends AppLocalizations { @override String get appSettings_offlineMapCache => 'Offline Kaarten Cache'; + @override + String get appSettings_unitsTitle => 'Eenheden'; + + @override + String get appSettings_unitsMetric => 'Metrisch (m / km)'; + + @override + String get appSettings_unitsImperial => 'Imperiaal (ft / mi)'; + @override String get appSettings_noAreaSelected => 'Geen gebied geselecteerd'; @@ -1235,6 +1248,12 @@ class AppLocalizationsNl extends AppLocalizations { @override String get map_title => 'Node Map'; + @override + String get map_lineOfSight => 'Zichtlijn'; + + @override + String get map_losScreenTitle => 'Zichtlijn'; + @override String get map_noNodesWithLocation => 'Geen nodes met locatiegegevens'; @@ -2714,6 +2733,117 @@ class AppLocalizationsNl extends AppLocalizations { @override String get pathTrace_clearTooltip => 'Weg wissen'; + @override + String get losSelectStartEnd => + 'Selecteer begin- en eindknooppunten voor LOS.'; + + @override + String losRunFailed(String error) { + return 'Zichtlijncontrole mislukt: $error'; + } + + @override + String get losClearAllPoints => 'Wis alle punten'; + + @override + String get losRunToViewElevationProfile => + 'Voer LOS uit om het hoogteprofiel te bekijken'; + + @override + String get losMenuTitle => 'LOS-menu'; + + @override + String get losMenuSubtitle => + 'Tik op knooppunten of druk lang op de kaart voor aangepaste punten'; + + @override + String get losShowDisplayNodes => 'Toon weergaveknooppunten'; + + @override + String get losCustomPoints => 'Aangepaste punten'; + + @override + String losCustomPointLabel(int index) { + return 'Aangepast $index'; + } + + @override + String get losPointA => 'Punt A'; + + @override + String get losPointB => 'Punt B'; + + @override + String losAntennaA(String value, String unit) { + return 'Antenne A: $value $unit'; + } + + @override + String losAntennaB(String value, String unit) { + return 'Antenne B: $value $unit'; + } + + @override + String get losRun => 'Voer LOS uit'; + + @override + String get losNoElevationData => 'Geen hoogtegegevens'; + + @override + String losProfileClear( + String distance, + String distanceUnit, + String clearance, + String heightUnit, + ) { + return '$distance $distanceUnit, vrije LOS, min. vrije ruimte $clearance $heightUnit'; + } + + @override + String losProfileBlocked( + String distance, + String distanceUnit, + String obstruction, + String heightUnit, + ) { + return '$distance $distanceUnit, geblokkeerd door $obstruction $heightUnit'; + } + + @override + String get losStatusChecking => 'LOS: controleren...'; + + @override + String get losStatusNoData => 'LOS: geen gegevens'; + + @override + String losStatusSummary(int clear, int total, int blocked, int unknown) { + return 'LOS: $clear/$total gewist, $blocked geblokkeerd, $unknown onbekend'; + } + + @override + String get losErrorElevationUnavailable => + 'Hoogtegegevens niet beschikbaar voor een of meer monsters.'; + + @override + String get losErrorInvalidInput => + 'Ongeldige punten/hoogtegegevens voor LOS-berekening.'; + + @override + String get losRenameCustomPoint => 'Hernoem aangepast punt'; + + @override + String get losPointName => 'Puntnaam'; + + @override + String get losShowPanelTooltip => 'Toon LOS-paneel'; + + @override + String get losHidePanelTooltip => 'LOS-paneel verbergen'; + + @override + String get losElevationAttribution => + 'Hoogtegegevens: Open-Meteo (CC BY 4.0)'; + @override String get contacts_pathTrace => 'Pad Traceren'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 55bc6ec7..88154720 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -323,6 +323,10 @@ class AppLocalizationsPl extends AppLocalizations { String get settings_aboutDescription => 'Otwarty kod źródłowy klient Flutter dla urządzeń do sieci mesh LoRa MeshCore.'; + @override + String get settings_aboutOpenMeteoAttribution => + 'Dane wysokościowe LOS: Open-Meteo (CC BY 4.0)'; + @override String get settings_infoName => 'Imię'; @@ -621,6 +625,15 @@ class AppLocalizationsPl extends AppLocalizations { @override String get appSettings_offlineMapCache => 'Bufor Map Offline'; + @override + String get appSettings_unitsTitle => 'Jednostki'; + + @override + String get appSettings_unitsMetric => 'Metryczne (m / km)'; + + @override + String get appSettings_unitsImperial => 'Imperialne (ft / mi)'; + @override String get appSettings_noAreaSelected => 'Nie zaznaczono żadnej powierzchni.'; @@ -1241,6 +1254,12 @@ class AppLocalizationsPl extends AppLocalizations { @override String get map_title => 'Mapa węzłów'; + @override + String get map_lineOfSight => 'Linia wzroku'; + + @override + String get map_losScreenTitle => 'Linia wzroku'; + @override String get map_noNodesWithLocation => 'Brak węzłów z danymi lokalizacyjnymi'; @@ -2721,6 +2740,116 @@ class AppLocalizationsPl extends AppLocalizations { @override String get pathTrace_clearTooltip => 'Wyczyść ścieżkę'; + @override + String get losSelectStartEnd => 'Wybierz węzły początkowe i końcowe dla LOS.'; + + @override + String losRunFailed(String error) { + return 'Sprawdzenie pola widzenia nie powiodło się: $error'; + } + + @override + String get losClearAllPoints => 'Wyczyść wszystkie punkty'; + + @override + String get losRunToViewElevationProfile => + 'Uruchom LOS, aby wyświetlić profil wysokości'; + + @override + String get losMenuTitle => 'Menu LOS'; + + @override + String get losMenuSubtitle => + 'Stuknij węzły lub naciśnij i przytrzymaj mapę, aby uzyskać niestandardowe punkty'; + + @override + String get losShowDisplayNodes => 'Pokaż węzły wyświetlające'; + + @override + String get losCustomPoints => 'Punkty niestandardowe'; + + @override + String losCustomPointLabel(int index) { + return 'Niestandardowe $index'; + } + + @override + String get losPointA => 'Punkt A'; + + @override + String get losPointB => 'Punkt B'; + + @override + String losAntennaA(String value, String unit) { + return 'Antena A: $value $unit'; + } + + @override + String losAntennaB(String value, String unit) { + return 'Antena B: $value $unit'; + } + + @override + String get losRun => 'Uruchom LOS-a'; + + @override + String get losNoElevationData => 'Brak danych o wysokości'; + + @override + String losProfileClear( + String distance, + String distanceUnit, + String clearance, + String heightUnit, + ) { + return '$distance $distanceUnit, czysty LOS, minimalny prześwit $clearance $heightUnit'; + } + + @override + String losProfileBlocked( + String distance, + String distanceUnit, + String obstruction, + String heightUnit, + ) { + return '$distance $distanceUnit, zablokowane przez $obstruction $heightUnit'; + } + + @override + String get losStatusChecking => 'LOS: sprawdzam...'; + + @override + String get losStatusNoData => 'LOS: brak danych'; + + @override + String losStatusSummary(int clear, int total, int blocked, int unknown) { + return 'LOS: $clear/$total jasne, $blocked zablokowane, $unknown nieznane'; + } + + @override + String get losErrorElevationUnavailable => + 'Dane dotyczące wysokości są niedostępne dla jednej lub większej liczby próbek.'; + + @override + String get losErrorInvalidInput => + 'Nieprawidłowe dane punktów/wysokości do obliczenia LOS.'; + + @override + String get losRenameCustomPoint => 'Zmień nazwę punktu niestandardowego'; + + @override + String get losPointName => 'Nazwa punktu'; + + @override + String get losShowPanelTooltip => 'Pokaż panel LOS'; + + @override + String get losHidePanelTooltip => 'Ukryj panel LOS'; + + @override + String get losElevationAttribution => + 'Dane dotyczące wysokości: Open-Meteo (CC BY 4.0)'; + @override String get contacts_pathTrace => 'Śledzenie Ścieżek'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 596d268b..c7fc7073 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -324,6 +324,10 @@ class AppLocalizationsPt extends AppLocalizations { String get settings_aboutDescription => 'Um cliente Flutter de código aberto para dispositivos de rede mesh LoRa Core da MeshCore.'; + @override + String get settings_aboutOpenMeteoAttribution => + 'Dados de elevação LOS: Open-Meteo (CC BY 4.0)'; + @override String get settings_infoName => 'Nome'; @@ -620,6 +624,15 @@ class AppLocalizationsPt extends AppLocalizations { @override String get appSettings_offlineMapCache => 'Cache de Mapa Offline'; + @override + String get appSettings_unitsTitle => 'Unidades'; + + @override + String get appSettings_unitsMetric => 'Métrico (m/km)'; + + @override + String get appSettings_unitsImperial => 'Imperial (ft/mi)'; + @override String get appSettings_noAreaSelected => 'Nenhuma área selecionada'; @@ -1240,6 +1253,12 @@ class AppLocalizationsPt extends AppLocalizations { @override String get map_title => 'Mapa de Nós'; + @override + String get map_lineOfSight => 'Linha de visão'; + + @override + String get map_losScreenTitle => 'Linha de visão'; + @override String get map_noNodesWithLocation => 'Não existem nós com dados de localização.'; @@ -2723,6 +2742,116 @@ class AppLocalizationsPt extends AppLocalizations { @override String get pathTrace_clearTooltip => 'Limpar caminho'; + @override + String get losSelectStartEnd => 'Selecione nós iniciais e finais para LOS.'; + + @override + String losRunFailed(String error) { + return 'Falha na verificação da linha de visão: $error'; + } + + @override + String get losClearAllPoints => 'Limpe todos os pontos'; + + @override + String get losRunToViewElevationProfile => + 'Execute o LOS para visualizar o perfil de elevação'; + + @override + String get losMenuTitle => 'Menu LOS'; + + @override + String get losMenuSubtitle => + 'Toque nos nós ou mantenha pressionado o mapa para obter pontos personalizados'; + + @override + String get losShowDisplayNodes => 'Mostrar nós de exibição'; + + @override + String get losCustomPoints => 'Pontos personalizados'; + + @override + String losCustomPointLabel(int index) { + return '$index personalizado'; + } + + @override + String get losPointA => 'Ponto A'; + + @override + String get losPointB => 'Ponto B'; + + @override + String losAntennaA(String value, String unit) { + return 'Antena A: $value $unit'; + } + + @override + String losAntennaB(String value, String unit) { + return 'Antena B: $value $unit'; + } + + @override + String get losRun => 'Executar LOS'; + + @override + String get losNoElevationData => 'Sem dados de elevação'; + + @override + String losProfileClear( + String distance, + String distanceUnit, + String clearance, + String heightUnit, + ) { + return '$distance $distanceUnit, limpar LOS, liberação mínima $clearance $heightUnit'; + } + + @override + String losProfileBlocked( + String distance, + String distanceUnit, + String obstruction, + String heightUnit, + ) { + return '$distance $distanceUnit, bloqueado por $obstruction $heightUnit'; + } + + @override + String get losStatusChecking => 'LOS: verificando...'; + + @override + String get losStatusNoData => 'LOS: sem dados'; + + @override + String losStatusSummary(int clear, int total, int blocked, int unknown) { + return 'LOS: $clear/$total limpo, $blocked bloqueado, $unknown desconhecido'; + } + + @override + String get losErrorElevationUnavailable => + 'Dados de elevação indisponíveis para uma ou mais amostras.'; + + @override + String get losErrorInvalidInput => + 'Dados de pontos/elevação inválidos para cálculo de LOS.'; + + @override + String get losRenameCustomPoint => 'Renomear ponto personalizado'; + + @override + String get losPointName => 'Nome do ponto'; + + @override + String get losShowPanelTooltip => 'Mostrar painel LOS'; + + @override + String get losHidePanelTooltip => 'Ocultar painel LOS'; + + @override + String get losElevationAttribution => + 'Dados de elevação: Open-Meteo (CC BY 4.0)'; + @override String get contacts_pathTrace => 'Traçado de Caminho'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 4647746a..2e992bd1 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -321,6 +321,10 @@ class AppLocalizationsRu extends AppLocalizations { String get settings_aboutDescription => 'Открытое клиентское приложение на Flutter для устройств MeshCore с LoRa-сетями.'; + @override + String get settings_aboutOpenMeteoAttribution => + 'Данные о высоте LOS: Open-Meteo (CC BY 4.0)'; + @override String get settings_infoName => 'Имя'; @@ -620,6 +624,15 @@ class AppLocalizationsRu extends AppLocalizations { @override String get appSettings_offlineMapCache => 'Кэш офлайн-карты'; + @override + String get appSettings_unitsTitle => 'Единицы'; + + @override + String get appSettings_unitsMetric => 'Метрическая (м/км)'; + + @override + String get appSettings_unitsImperial => 'Имперская (ft / mi)'; + @override String get appSettings_noAreaSelected => 'Область не выбрана'; @@ -1242,6 +1255,12 @@ class AppLocalizationsRu extends AppLocalizations { @override String get map_title => 'Карта нод'; + @override + String get map_lineOfSight => 'Линия видимости'; + + @override + String get map_losScreenTitle => 'Линия видимости'; + @override String get map_noNodesWithLocation => 'Нет нод с данными о местоположении'; @@ -2726,6 +2745,116 @@ class AppLocalizationsRu extends AppLocalizations { @override String get pathTrace_clearTooltip => 'Очистить путь'; + @override + String get losSelectStartEnd => 'Выберите начальный и конечный узлы для LOS.'; + + @override + String losRunFailed(String error) { + return 'Проверка прямой видимости не удалась: $error'; + } + + @override + String get losClearAllPoints => 'Очистить все точки'; + + @override + String get losRunToViewElevationProfile => + 'Запустите LOS, чтобы просмотреть профиль высот.'; + + @override + String get losMenuTitle => 'ЛОС Меню'; + + @override + String get losMenuSubtitle => + 'Коснитесь узлов или нажмите и удерживайте карту для выбора пользовательских точек.'; + + @override + String get losShowDisplayNodes => 'Показать узлы отображения'; + + @override + String get losCustomPoints => 'Пользовательские точки'; + + @override + String losCustomPointLabel(int index) { + return 'Пользовательский $index'; + } + + @override + String get losPointA => 'Точка А'; + + @override + String get losPointB => 'Точка Б'; + + @override + String losAntennaA(String value, String unit) { + return 'Антенна А: $value $unit'; + } + + @override + String losAntennaB(String value, String unit) { + return 'Антенна Б: $value $unit'; + } + + @override + String get losRun => 'Запустить ЛОС'; + + @override + String get losNoElevationData => 'Нет данных о высоте'; + + @override + String losProfileClear( + String distance, + String distanceUnit, + String clearance, + String heightUnit, + ) { + return '$distance $distanceUnit, свободная зона видимости, минимальный зазор $clearance $heightUnit'; + } + + @override + String losProfileBlocked( + String distance, + String distanceUnit, + String obstruction, + String heightUnit, + ) { + return '$distance $distanceUnit, заблокирован $obstruction $heightUnit'; + } + + @override + String get losStatusChecking => 'ЛОС: проверяю...'; + + @override + String get losStatusNoData => 'ЛОС: нет данных'; + + @override + String losStatusSummary(int clear, int total, int blocked, int unknown) { + return 'LOS: $clear/$total очищено, $blocked заблокировано, $unknown неизвестно.'; + } + + @override + String get losErrorElevationUnavailable => + 'Данные о высоте недоступны для одного или нескольких образцов.'; + + @override + String get losErrorInvalidInput => + 'Неверные данные о точках/высоте для расчета LOS.'; + + @override + String get losRenameCustomPoint => 'Переименовать пользовательскую точку'; + + @override + String get losPointName => 'Имя точки'; + + @override + String get losShowPanelTooltip => 'Показать панель LOS'; + + @override + String get losHidePanelTooltip => 'Скрыть панель LOS'; + + @override + String get losElevationAttribution => + 'Данные о высоте: Open-Meteo (CC BY 4.0)'; + @override String get contacts_pathTrace => 'Трассировка пути'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 8e186631..a51e0591 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -320,6 +320,10 @@ class AppLocalizationsSk extends AppLocalizations { String get settings_aboutDescription => 'Otvorený zdrojový Flutter klient pre MeshCore LoRa sieťové zariadenia.'; + @override + String get settings_aboutOpenMeteoAttribution => + 'Údaje o nadmorskej výške LOS: Open-Meteo (CC BY 4.0)'; + @override String get settings_infoName => 'Meno'; @@ -614,6 +618,15 @@ class AppLocalizationsSk extends AppLocalizations { @override String get appSettings_offlineMapCache => 'Offline Mapa Pamäť'; + @override + String get appSettings_unitsTitle => 'Jednotky'; + + @override + String get appSettings_unitsMetric => 'Metrické (m / km)'; + + @override + String get appSettings_unitsImperial => 'Imperiálne (ft / mi)'; + @override String get appSettings_noAreaSelected => 'Neoznačila sa žiadna oblasť'; @@ -1236,6 +1249,12 @@ class AppLocalizationsSk extends AppLocalizations { @override String get map_title => 'Mapa uzlov'; + @override + String get map_lineOfSight => 'Line of Sight'; + + @override + String get map_losScreenTitle => 'Line of Sight'; + @override String get map_noNodesWithLocation => 'Žiadne uzly s údajmi o polohe'; @@ -2709,6 +2728,116 @@ class AppLocalizationsSk extends AppLocalizations { @override String get pathTrace_clearTooltip => 'Zmazať cestu'; + @override + String get losSelectStartEnd => 'Vyberte počiatočný a koncový uzol pre LOS.'; + + @override + String losRunFailed(String error) { + return 'Kontrola priamej viditeľnosti zlyhala: $error'; + } + + @override + String get losClearAllPoints => 'Vymazať všetky body'; + + @override + String get losRunToViewElevationProfile => + 'Ak chcete zobraziť výškový profil, spustite LOS'; + + @override + String get losMenuTitle => 'Menu LOS'; + + @override + String get losMenuSubtitle => + 'Klepnutím na uzly alebo dlhým stlačením mapy získate vlastné body'; + + @override + String get losShowDisplayNodes => 'Zobraziť uzly zobrazenia'; + + @override + String get losCustomPoints => 'Vlastné body'; + + @override + String losCustomPointLabel(int index) { + return 'Vlastné $index'; + } + + @override + String get losPointA => 'Bod A'; + + @override + String get losPointB => 'Bod B'; + + @override + String losAntennaA(String value, String unit) { + return 'Anténa A: $value $unit'; + } + + @override + String losAntennaB(String value, String unit) { + return 'Anténa B: $value $unit'; + } + + @override + String get losRun => 'Spustite LOS'; + + @override + String get losNoElevationData => 'Žiadne údaje o nadmorskej výške'; + + @override + String losProfileClear( + String distance, + String distanceUnit, + String clearance, + String heightUnit, + ) { + return '$distance $distanceUnit, vymazať LOS, min. vôľa $clearance $heightUnit'; + } + + @override + String losProfileBlocked( + String distance, + String distanceUnit, + String obstruction, + String heightUnit, + ) { + return '$distance $distanceUnit, blokovaný $obstruction $heightUnit'; + } + + @override + String get losStatusChecking => 'LOS: kontrolujem...'; + + @override + String get losStatusNoData => 'LOS: žiadne údaje'; + + @override + String losStatusSummary(int clear, int total, int blocked, int unknown) { + return 'LOS: $clear/$total vymazané, $blocked blokované, $unknown neznáme'; + } + + @override + String get losErrorElevationUnavailable => + 'Údaje o nadmorskej výške nie sú k dispozícii pre jednu alebo viacero vzoriek.'; + + @override + String get losErrorInvalidInput => + 'Neplatné body/údaje o nadmorskej výške pre výpočet LOS.'; + + @override + String get losRenameCustomPoint => 'Premenovať vlastný bod'; + + @override + String get losPointName => 'Názov bodu'; + + @override + String get losShowPanelTooltip => 'Zobraziť panel LOS'; + + @override + String get losHidePanelTooltip => 'Skryť panel LOS'; + + @override + String get losElevationAttribution => + 'Údaje o nadmorskej výške: Open-Meteo (CC BY 4.0)'; + @override String get contacts_pathTrace => 'Sledovanie lúčov'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index b95e7115..5ac7e8b4 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -319,6 +319,10 @@ class AppLocalizationsSl extends AppLocalizations { String get settings_aboutDescription => 'Odprtokodni Flutter klient za naprave za LoRa omrežje MeshCore.'; + @override + String get settings_aboutOpenMeteoAttribution => + 'Podatki o višini LOS: Open-Meteo (CC BY 4.0)'; + @override String get settings_infoName => 'Ime'; @@ -615,6 +619,15 @@ class AppLocalizationsSl extends AppLocalizations { @override String get appSettings_offlineMapCache => 'Shramba zemljevidov brez povezave'; + @override + String get appSettings_unitsTitle => 'Enote'; + + @override + String get appSettings_unitsMetric => 'Metrična (m/km)'; + + @override + String get appSettings_unitsImperial => 'Imperialno (ft / mi)'; + @override String get appSettings_noAreaSelected => 'Območje ni izbrano'; @@ -1231,6 +1244,12 @@ class AppLocalizationsSl extends AppLocalizations { @override String get map_title => 'Mapa omrežja'; + @override + String get map_lineOfSight => 'Linija vida'; + + @override + String get map_losScreenTitle => 'Linija vida'; + @override String get map_noNodesWithLocation => 'Nihče od notranjih elementov nima podatkov o lokaciji.'; @@ -2712,6 +2731,116 @@ class AppLocalizationsSl extends AppLocalizations { @override String get pathTrace_clearTooltip => 'Počisti pot'; + @override + String get losSelectStartEnd => 'Izberite začetno in končno vozlišče za LOS.'; + + @override + String losRunFailed(String error) { + return 'Preverjanje vidnega polja ni uspelo: $error'; + } + + @override + String get losClearAllPoints => 'Počisti vse točke'; + + @override + String get losRunToViewElevationProfile => + 'Zaženite LOS za ogled višinskega profila'; + + @override + String get losMenuTitle => 'LOS meni'; + + @override + String get losMenuSubtitle => + 'Tapnite vozlišča ali dolgo pritisnite na zemljevid za točke po meri'; + + @override + String get losShowDisplayNodes => 'Pokaži prikazna vozlišča'; + + @override + String get losCustomPoints => 'Točke po meri'; + + @override + String losCustomPointLabel(int index) { + return 'Po meri $index'; + } + + @override + String get losPointA => 'Točka A'; + + @override + String get losPointB => 'Točka B'; + + @override + String losAntennaA(String value, String unit) { + return 'Antena A: $value $unit'; + } + + @override + String losAntennaB(String value, String unit) { + return 'Antena B: $value $unit'; + } + + @override + String get losRun => 'Zaženi LOS'; + + @override + String get losNoElevationData => 'Ni podatkov o višini'; + + @override + String losProfileClear( + String distance, + String distanceUnit, + String clearance, + String heightUnit, + ) { + return '$distance $distanceUnit, čisti LOS, najmanjša razdalja $clearance $heightUnit'; + } + + @override + String losProfileBlocked( + String distance, + String distanceUnit, + String obstruction, + String heightUnit, + ) { + return '$distance $distanceUnit, blokiral $obstruction $heightUnit'; + } + + @override + String get losStatusChecking => 'LOS: preverjam ...'; + + @override + String get losStatusNoData => 'LOS: ni podatkov'; + + @override + String losStatusSummary(int clear, int total, int blocked, int unknown) { + return 'LOS: $clear/$total jasno, $blocked blokirano, $unknown neznano'; + } + + @override + String get losErrorElevationUnavailable => + 'Podatki o nadmorski višini niso na voljo za enega ali več vzorcev.'; + + @override + String get losErrorInvalidInput => + 'Neveljavni podatki o točkah/višini za izračun LOS.'; + + @override + String get losRenameCustomPoint => 'Preimenujte točko po meri'; + + @override + String get losPointName => 'Ime točke'; + + @override + String get losShowPanelTooltip => 'Pokaži ploščo LOS'; + + @override + String get losHidePanelTooltip => 'Skrij ploščo LOS'; + + @override + String get losElevationAttribution => + 'Podatki o višini: Open-Meteo (CC BY 4.0)'; + @override String get contacts_pathTrace => 'Sledenje poti'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 10047ca8..6d355d91 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -317,6 +317,10 @@ class AppLocalizationsSv extends AppLocalizations { String get settings_aboutDescription => 'En öppen källkods Flutter-klient för MeshCore LoRa meshnätverksenheter.'; + @override + String get settings_aboutOpenMeteoAttribution => + 'LOS-höjddata: Open-Meteo (CC BY 4.0)'; + @override String get settings_infoName => 'Namn'; @@ -610,6 +614,15 @@ class AppLocalizationsSv extends AppLocalizations { @override String get appSettings_offlineMapCache => 'Offline Kartcache'; + @override + String get appSettings_unitsTitle => 'Enheter'; + + @override + String get appSettings_unitsMetric => 'Metriskt (m/km)'; + + @override + String get appSettings_unitsImperial => 'Imperialt (ft / mi)'; + @override String get appSettings_noAreaSelected => 'Ingen area markerad'; @@ -1228,6 +1241,12 @@ class AppLocalizationsSv extends AppLocalizations { @override String get map_title => 'Nodkarta'; + @override + String get map_lineOfSight => 'Synlinje'; + + @override + String get map_losScreenTitle => 'Synlinje'; + @override String get map_noNodesWithLocation => 'Inga noder med platsinformation'; @@ -2697,6 +2716,114 @@ class AppLocalizationsSv extends AppLocalizations { @override String get pathTrace_clearTooltip => 'Rensa väg'; + @override + String get losSelectStartEnd => 'Välj start- och slutnoder för LOS.'; + + @override + String losRunFailed(String error) { + return 'Synlinjekontroll misslyckades: $error'; + } + + @override + String get losClearAllPoints => 'Rensa alla punkter'; + + @override + String get losRunToViewElevationProfile => 'Kör LOS för att se höjdprofil'; + + @override + String get losMenuTitle => 'LOS-menyn'; + + @override + String get losMenuSubtitle => + 'Tryck på noder eller tryck länge på kartan för anpassade punkter'; + + @override + String get losShowDisplayNodes => 'Visa displaynoder'; + + @override + String get losCustomPoints => 'Anpassade poäng'; + + @override + String losCustomPointLabel(int index) { + return 'Anpassad $index'; + } + + @override + String get losPointA => 'Punkt A'; + + @override + String get losPointB => 'Punkt B'; + + @override + String losAntennaA(String value, String unit) { + return 'Antenn A: $value $unit'; + } + + @override + String losAntennaB(String value, String unit) { + return 'Antenn B: $value $unit'; + } + + @override + String get losRun => 'Kör LOS'; + + @override + String get losNoElevationData => 'Inga höjddata'; + + @override + String losProfileClear( + String distance, + String distanceUnit, + String clearance, + String heightUnit, + ) { + return '$distance $distanceUnit, rensa LOS, min clearance $clearance $heightUnit'; + } + + @override + String losProfileBlocked( + String distance, + String distanceUnit, + String obstruction, + String heightUnit, + ) { + return '$distance $distanceUnit, blockerad av $obstruction $heightUnit'; + } + + @override + String get losStatusChecking => 'LOS: kollar...'; + + @override + String get losStatusNoData => 'LOS: inga data'; + + @override + String losStatusSummary(int clear, int total, int blocked, int unknown) { + return 'LOS: $clear/$total rensa, $blocked blockerad, $unknown okänd'; + } + + @override + String get losErrorElevationUnavailable => + 'Höjddata är inte tillgänglig för ett eller flera prover.'; + + @override + String get losErrorInvalidInput => + 'Ogiltiga poäng/höjddata för LOS-beräkning.'; + + @override + String get losRenameCustomPoint => 'Byt namn på anpassad punkt'; + + @override + String get losPointName => 'Punktnamn'; + + @override + String get losShowPanelTooltip => 'Visa LOS-panelen'; + + @override + String get losHidePanelTooltip => 'Dölj LOS-panelen'; + + @override + String get losElevationAttribution => 'Höjddata: Open-Meteo (CC BY 4.0)'; + @override String get contacts_pathTrace => 'Path Trace'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 9edc64a6..0f3d550c 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -322,6 +322,10 @@ class AppLocalizationsUk extends AppLocalizations { String get settings_aboutDescription => 'Клієнт Flutter з відкритим вихідним кодом для пристроїв мережі MeshCore LoRa.'; + @override + String get settings_aboutOpenMeteoAttribution => + 'Дані про висоту LOS: Open-Meteo (CC BY 4.0)'; + @override String get settings_infoName => 'Ім\'я'; @@ -618,6 +622,15 @@ class AppLocalizationsUk extends AppLocalizations { @override String get appSettings_offlineMapCache => 'Офлайн-кеш карти'; + @override + String get appSettings_unitsTitle => 'одиниці'; + + @override + String get appSettings_unitsMetric => 'Метричний (м / км)'; + + @override + String get appSettings_unitsImperial => 'Імперська (ft / mi)'; + @override String get appSettings_noAreaSelected => 'Область не вибрано'; @@ -1240,6 +1253,12 @@ class AppLocalizationsUk extends AppLocalizations { @override String get map_title => 'Карта вузлів'; + @override + String get map_lineOfSight => 'Пряма видимість'; + + @override + String get map_losScreenTitle => 'Пряма видимість'; + @override String get map_noNodesWithLocation => 'Немає вузлів з даними про розташування'; @@ -2733,6 +2752,117 @@ class AppLocalizationsUk extends AppLocalizations { @override String get pathTrace_clearTooltip => 'Очистити шлях'; + @override + String get losSelectStartEnd => + 'Виберіть початковий і кінцевий вузли для LOS.'; + + @override + String losRunFailed(String error) { + return 'Помилка перевірки прямої видимості: $error'; + } + + @override + String get losClearAllPoints => 'Очистити всі пункти'; + + @override + String get losRunToViewElevationProfile => + 'Запустіть LOS, щоб переглянути профіль висоти'; + + @override + String get losMenuTitle => 'Меню LOS'; + + @override + String get losMenuSubtitle => + 'Торкніться вузлів або утримуйте карту, щоб отримати власні точки'; + + @override + String get losShowDisplayNodes => 'Показати вузли відображення'; + + @override + String get losCustomPoints => 'Користувальницькі точки'; + + @override + String losCustomPointLabel(int index) { + return 'Спеціальний $index'; + } + + @override + String get losPointA => 'Точка А'; + + @override + String get losPointB => 'Точка Б'; + + @override + String losAntennaA(String value, String unit) { + return 'Антена A: $value $unit'; + } + + @override + String losAntennaB(String value, String unit) { + return 'Антена B: $value $unit'; + } + + @override + String get losRun => 'Запустіть LOS'; + + @override + String get losNoElevationData => 'Немає даних про висоту'; + + @override + String losProfileClear( + String distance, + String distanceUnit, + String clearance, + String heightUnit, + ) { + return '$distance $distanceUnit, чистий LOS, мінімальний зазор $clearance $heightUnit'; + } + + @override + String losProfileBlocked( + String distance, + String distanceUnit, + String obstruction, + String heightUnit, + ) { + return '$distance $distanceUnit, заблоковано $obstruction $heightUnit'; + } + + @override + String get losStatusChecking => 'LOS: перевірка...'; + + @override + String get losStatusNoData => 'LOS: немає даних'; + + @override + String losStatusSummary(int clear, int total, int blocked, int unknown) { + return 'LOS: $clear/$total очищено, $blocked заблоковано, $unknown невідомо'; + } + + @override + String get losErrorElevationUnavailable => + 'Дані про висоту недоступні для одного чи кількох зразків.'; + + @override + String get losErrorInvalidInput => + 'Недійсні дані про точки/висоту для розрахунку LOS.'; + + @override + String get losRenameCustomPoint => 'Перейменуйте спеціальну точку'; + + @override + String get losPointName => 'Назва точки'; + + @override + String get losShowPanelTooltip => 'Показати панель LOS'; + + @override + String get losHidePanelTooltip => 'Приховати панель LOS'; + + @override + String get losElevationAttribution => + 'Дані про висоту: Open-Meteo (CC BY 4.0)'; + @override String get contacts_pathTrace => 'Трасування шляхів'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 9753da6e..36a114a5 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -307,6 +307,10 @@ class AppLocalizationsZh extends AppLocalizations { String get settings_aboutDescription => '一个开源的 Flutter 客户端,用于 MeshCore LoRa 无线网络设备。'; + @override + String get settings_aboutOpenMeteoAttribution => + 'LOS 高程数据:Open-Meteo (CC BY 4.0)'; + @override String get settings_infoName => '姓名'; @@ -585,6 +589,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get appSettings_offlineMapCache => '离线地图缓存'; + @override + String get appSettings_unitsTitle => '单位'; + + @override + String get appSettings_unitsMetric => '公制(米/公里)'; + + @override + String get appSettings_unitsImperial => '英制 (ft / mi)'; + @override String get appSettings_noAreaSelected => '未选择任何区域'; @@ -1182,6 +1195,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get map_title => '节点图'; + @override + String get map_lineOfSight => '视线'; + + @override + String get map_losScreenTitle => '视线'; + @override String get map_noNodesWithLocation => '没有包含位置信息的节点'; @@ -2579,6 +2598,111 @@ class AppLocalizationsZh extends AppLocalizations { @override String get pathTrace_clearTooltip => '清除路径'; + @override + String get losSelectStartEnd => '选择 LOS 的起始节点和结束节点。'; + + @override + String losRunFailed(String error) { + return '视线检查失败:$error'; + } + + @override + String get losClearAllPoints => '清除所有点'; + + @override + String get losRunToViewElevationProfile => '运行 LOS 查看高程剖面'; + + @override + String get losMenuTitle => '服务水平菜单'; + + @override + String get losMenuSubtitle => '点击节点或长按地图以获取自定义点'; + + @override + String get losShowDisplayNodes => '显示显示节点'; + + @override + String get losCustomPoints => '自定义积分'; + + @override + String losCustomPointLabel(int index) { + return '自定义 $index'; + } + + @override + String get losPointA => 'A点'; + + @override + String get losPointB => 'B点'; + + @override + String losAntennaA(String value, String unit) { + return '天线 A: $value $unit'; + } + + @override + String losAntennaB(String value, String unit) { + return '天线 B:$value $unit'; + } + + @override + String get losRun => '运行视距'; + + @override + String get losNoElevationData => '无海拔数据'; + + @override + String losProfileClear( + String distance, + String distanceUnit, + String clearance, + String heightUnit, + ) { + return '$distance $distanceUnit,清除 LOS,最小间隙 $clearance $heightUnit'; + } + + @override + String losProfileBlocked( + String distance, + String distanceUnit, + String obstruction, + String heightUnit, + ) { + return '$distance $distanceUnit,被 $obstruction $heightUnit 阻止'; + } + + @override + String get losStatusChecking => '洛斯:正在检查...'; + + @override + String get losStatusNoData => 'LOS:无数据'; + + @override + String losStatusSummary(int clear, int total, int blocked, int unknown) { + return 'LOS:$clear/$total 清除,$blocked 阻塞,$unknown 未知'; + } + + @override + String get losErrorElevationUnavailable => '一个或多个样本的海拔数据不可用。'; + + @override + String get losErrorInvalidInput => '用于 LOS 计算的点/高程数据无效。'; + + @override + String get losRenameCustomPoint => '重命名自定义点'; + + @override + String get losPointName => '点名称'; + + @override + String get losShowPanelTooltip => '显示 LOS 面板'; + + @override + String get losHidePanelTooltip => '隐藏 LOS 面板'; + + @override + String get losElevationAttribution => '高程数据:Open-Meteo (CC BY 4.0)'; + @override String get contacts_pathTrace => '路径追踪'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 859e48d9..733e4dce 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1599,5 +1599,120 @@ "chat_ShowAllPaths": "Toon alle paden", "settings_clientRepeat": "Herhalen: Afgekoppeld", "settings_clientRepeatSubtitle": "Laat dit apparaat de mesh-pakketten opnieuw verzenden voor andere apparaten.", - "settings_clientRepeatFreqWarning": "Om een signaal buiten het netwerk te versturen, zijn frequenties van 433, 869 of 918 MHz vereist." + "settings_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)" } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index d03b9119..35efee18 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1599,5 +1599,120 @@ "chat_ShowAllPaths": "Pokaż wszystkie ścieżki", "settings_clientRepeatSubtitle": "Pozwól temu urządzeniu powtarzać pakiety danych dla innych urządzeń.", "settings_clientRepeat": "Powtórzenie: Niezależne od sieci", - "settings_clientRepeatFreqWarning": "Powtórka poza siecią wymaga częstotliwości 433, 869 lub 918 MHz." + "settings_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)" } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 83a77197..fd742d94 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1599,5 +1599,120 @@ "chat_ShowAllPaths": "Mostrar todos os caminhos", "settings_clientRepeatFreqWarning": "A repetição fora da rede requer frequências de 433, 869 ou 918 MHz.", "settings_clientRepeat": "Repetição sem rede", - "settings_clientRepeatSubtitle": "Permita que este dispositivo repita pacotes de rede para outros dispositivos." + "settings_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)" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 380ba10b..04b2e049 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -839,5 +839,120 @@ "chat_ShowAllPaths": "Показать все пути", "settings_clientRepeatFreqWarning": "Для работы в режиме \"без подключения к сети\" требуется частота 433, 869 или 918 МГц.", "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)" } diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index aca4a29b..6663094a 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -1599,5 +1599,120 @@ "chat_ShowAllPaths": "Zobraziť všetky cesty", "settings_clientRepeat": "Opätovné použitie bez elektrickej siete", "settings_clientRepeatFreqWarning": "Použitie off-grid systému vyžaduje frekvencie 433, 869 alebo 918 MHz.", - "settings_clientRepeatSubtitle": "Umožnite, aby toto zariadenie opakovávalo siete pre ostatných." + "settings_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)" } diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index 59b84342..50a90432 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -1599,5 +1599,120 @@ "chat_ShowAllPaths": "Prikaži vse poti", "settings_clientRepeatFreqWarning": "Za ponovni prenos na brezžični način so potrebne frekvence 433, 869 ali 918 MHz.", "settings_clientRepeatSubtitle": "Omogočite temu naprave, da ponavlja paketne sporočila za druge.", - "settings_clientRepeat": "Neovadno ponavljanje" + "settings_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)" } diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index fa786f70..260a34b8 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -1599,5 +1599,120 @@ "chat_ShowAllPaths": "Visa alla vägar", "settings_clientRepeatSubtitle": "Låt enheten repetera nätpaket för andra användare.", "settings_clientRepeat": "Upprepa utan elnät", - "settings_clientRepeatFreqWarning": "För att kunna kommunicera utanför elnätet krävs frekvenserna 433, 869 eller 918 MHz." + "settings_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)" } diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 3f7b276f..ec414b48 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -1599,5 +1599,120 @@ "chat_ShowAllPaths": "Показати всі шляхи", "settings_clientRepeatFreqWarning": "Повтор без підключення до мережі вимагає частоти 433, 869 або 918 МГц.", "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)" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index bc433927..6b072c9c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1599,5 +1599,120 @@ "chat_ShowAllPaths": "显示所有路径", "settings_clientRepeat": "离网重复", "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)" } diff --git a/lib/main.dart b/lib/main.dart index 8ee0ca47..3650a7e5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter/foundation.dart'; import 'l10n/app_localizations.dart'; import 'package:provider/provider.dart'; @@ -47,6 +48,7 @@ void main() async { final notificationService = NotificationService(); await notificationService.initialize(); await backgroundService.initialize(); + _registerThirdPartyLicenses(); // Wire up connector with services connector.initialize( @@ -80,6 +82,27 @@ void main() async { ); } +void _registerThirdPartyLicenses() { + LicenseRegistry.addLicense(() async* { + yield const LicenseEntryWithLineBreaks( + ['Open-Meteo Elevation API Data'], + ''' +Data used by LOS elevation lookups is provided by Open-Meteo. + +Open-Meteo terms and attribution: +https://open-meteo.com/en/terms + +Elevation API: +https://open-meteo.com/en/docs/elevation-api + +Attribution license reference: +Creative Commons Attribution 4.0 International (CC BY 4.0) +https://creativecommons.org/licenses/by/4.0/ +''', + ); + }); +} + class MeshCoreApp extends StatelessWidget { final MeshCoreConnector connector; final MessageRetryService retryService; diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index 3edb68fa..229a7a64 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -1,3 +1,16 @@ +enum UnitSystem { metric, imperial } + +extension UnitSystemValue on UnitSystem { + String get value { + switch (this) { + case UnitSystem.imperial: + return 'imperial'; + case UnitSystem.metric: + return 'metric'; + } + } +} + class AppSettings { static const Object _unset = Object(); @@ -21,6 +34,7 @@ class AppSettings { final String? languageOverride; // null = system default final bool appDebugLogEnabled; final Map batteryChemistryByDeviceId; + final UnitSystem unitSystem; AppSettings({ this.clearPathOnMaxRetry = false, @@ -43,6 +57,7 @@ class AppSettings { this.languageOverride, this.appDebugLogEnabled = false, Map? batteryChemistryByDeviceId, + this.unitSystem = UnitSystem.metric, }) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {}; Map toJson() { @@ -67,10 +82,18 @@ class AppSettings { 'language_override': languageOverride, 'app_debug_log_enabled': appDebugLogEnabled, 'battery_chemistry_by_device_id': batteryChemistryByDeviceId, + 'unit_system': unitSystem.value, }; } factory AppSettings.fromJson(Map json) { + UnitSystem parseUnitSystem(dynamic value) { + if (value is String && value.toLowerCase() == 'imperial') { + return UnitSystem.imperial; + } + return UnitSystem.metric; + } + return AppSettings( clearPathOnMaxRetry: json['clear_path_on_max_retry'] as bool? ?? false, mapShowRepeaters: json['map_show_repeaters'] as bool? ?? true, @@ -101,6 +124,9 @@ class AppSettings { (key, value) => MapEntry(key.toString(), value.toString()), ) ?? {}, + unitSystem: parseUnitSystem( + json['unit_system'] ?? json['los_unit_system'], + ), ); } @@ -125,6 +151,7 @@ class AppSettings { Object? languageOverride = _unset, bool? appDebugLogEnabled, Map? batteryChemistryByDeviceId, + UnitSystem? unitSystem, }) { return AppSettings( clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry, @@ -154,6 +181,7 @@ class AppSettings { appDebugLogEnabled: appDebugLogEnabled ?? this.appDebugLogEnabled, batteryChemistryByDeviceId: batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId, + unitSystem: unitSystem ?? this.unitSystem, ); } } diff --git a/lib/screens/app_debug_log_screen.dart b/lib/screens/app_debug_log_screen.dart index 5372ea83..48770388 100644 --- a/lib/screens/app_debug_log_screen.dart +++ b/lib/screens/app_debug_log_screen.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../l10n/l10n.dart'; import '../services/app_debug_log_service.dart'; +import '../widgets/adaptive_app_bar_title.dart'; class AppDebugLogScreen extends StatelessWidget { const AppDebugLogScreen({super.key}); @@ -17,7 +18,7 @@ class AppDebugLogScreen extends StatelessWidget { return Scaffold( appBar: AppBar( - title: Text(context.l10n.debugLog_appTitle), + title: AdaptiveAppBarTitle(context.l10n.debugLog_appTitle), centerTitle: true, actions: [ IconButton( diff --git a/lib/screens/app_settings_screen.dart b/lib/screens/app_settings_screen.dart index 4e317335..b309b4db 100644 --- a/lib/screens/app_settings_screen.dart +++ b/lib/screens/app_settings_screen.dart @@ -3,8 +3,10 @@ import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; import '../l10n/l10n.dart'; +import '../models/app_settings.dart'; import '../services/app_settings_service.dart'; import '../services/notification_service.dart'; +import '../widgets/adaptive_app_bar_title.dart'; import 'map_cache_screen.dart'; class AppSettingsScreen extends StatelessWidget { @@ -14,7 +16,7 @@ class AppSettingsScreen extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text(context.l10n.appSettings_title), + title: AdaptiveAppBarTitle(context.l10n.appSettings_title), centerTitle: true, ), body: SafeArea( @@ -360,6 +362,18 @@ class AppSettingsScreen extends StatelessWidget { onTap: () => _showTimeFilterDialog(context, settingsService), ), const Divider(height: 1), + ListTile( + leading: const Icon(Icons.straighten), + title: Text(context.l10n.appSettings_unitsTitle), + subtitle: Text( + settingsService.settings.unitSystem == UnitSystem.imperial + ? context.l10n.appSettings_unitsImperial + : context.l10n.appSettings_unitsMetric, + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showUnitsDialog(context, settingsService), + ), + const Divider(height: 1), ListTile( leading: const Icon(Icons.download_outlined), title: Text(context.l10n.appSettings_offlineMapCache), @@ -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( + groupValue: settingsService.settings.unitSystem, + onChanged: (value) { + if (value != null) { + settingsService.setUnitSystem(value); + Navigator.pop(context); + } + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text(context.l10n.appSettings_unitsMetric), + leading: const Radio(value: UnitSystem.metric), + ), + ListTile( + title: Text(context.l10n.appSettings_unitsImperial), + leading: const Radio(value: UnitSystem.imperial), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(context.l10n.common_close), + ), + ], + ), + ); + } + Widget _buildDebugCard( BuildContext context, AppSettingsService settingsService, diff --git a/lib/screens/ble_debug_log_screen.dart b/lib/screens/ble_debug_log_screen.dart index 7cebb761..88f734bc 100644 --- a/lib/screens/ble_debug_log_screen.dart +++ b/lib/screens/ble_debug_log_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import '../l10n/l10n.dart'; import '../services/ble_debug_log_service.dart'; import '../connector/meshcore_protocol.dart'; +import '../widgets/adaptive_app_bar_title.dart'; enum _BleLogView { frames, rawLogRx } @@ -29,7 +30,7 @@ class _BleDebugLogScreenState extends State { : rawEntries.isNotEmpty; return Scaffold( appBar: AppBar( - title: Text(context.l10n.debugLog_bleTitle), + title: AdaptiveAppBarTitle(context.l10n.debugLog_bleTitle), actions: [ IconButton( tooltip: context.l10n.debugLog_copyLog, diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index e6fcacce..2d1faa3f 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -9,11 +9,14 @@ import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; import '../services/map_tile_cache_service.dart'; +import '../services/app_settings_service.dart'; import '../connector/meshcore_protocol.dart'; import '../l10n/app_localizations.dart'; import '../l10n/l10n.dart'; import '../models/channel_message.dart'; +import '../models/app_settings.dart'; import '../models/contact.dart'; +import '../widgets/adaptive_app_bar_title.dart'; class ChannelMessagePathScreen extends StatelessWidget { final ChannelMessage message; @@ -48,7 +51,7 @@ class ChannelMessagePathScreen extends StatelessWidget { final extraPaths = _otherPaths(primaryPath, message.pathVariants); return Scaffold( appBar: AppBar( - title: Text(l10n.channelPath_title), + title: AdaptiveAppBarTitle(l10n.channelPath_title), actions: [ IconButton( icon: const Icon(Icons.radar_outlined), @@ -297,8 +300,12 @@ class ChannelMessagePathMapScreen extends StatefulWidget { class _ChannelMessagePathMapScreenState extends State { + static const double _labelZoomThreshold = 8.5; + Uint8List? _selectedPath; double _pathDistance = 0.0; + bool _showNodeLabels = true; + bool _didReceivePositionUpdate = false; @override void initState() { @@ -333,6 +340,8 @@ class _ChannelMessagePathMapScreenState Widget build(BuildContext context) { return Consumer( builder: (context, connector, _) { + final settings = context.watch().settings; + final isImperial = settings.unitSystem == UnitSystem.imperial; final tileCache = context.read(); final primaryPath = _selectPrimaryPath( widget.message.pathBytes, @@ -393,6 +402,9 @@ class _ChannelMessagePathMapScreenState ? points.first : const LatLng(0, 0); final initialZoom = points.isNotEmpty ? 13.0 : 2.0; + if (!_didReceivePositionUpdate) { + _showNodeLabels = initialZoom >= _labelZoomThreshold; + } final bounds = points.length > 1 ? LatLngBounds.fromPoints(points) : null; @@ -402,7 +414,9 @@ class _ChannelMessagePathMapScreenState _pathDistance = _getPathDistance(points); return Scaffold( - appBar: AppBar(title: Text(context.l10n.channelPath_mapTitle)), + appBar: AppBar( + title: AdaptiveAppBarTitle(context.l10n.channelPath_mapTitle), + ), body: SafeArea( top: false, child: Stack( @@ -424,6 +438,17 @@ class _ChannelMessagePathMapScreenState interactionOptions: InteractionOptions( flags: ~InteractiveFlag.rotate, ), + onPositionChanged: (camera, hasGesture) { + final shouldShow = camera.zoom >= _labelZoomThreshold; + if (!_didReceivePositionUpdate || + shouldShow != _showNodeLabels) { + if (!mounted) return; + setState(() { + _didReceivePositionUpdate = true; + _showNodeLabels = shouldShow; + }); + } + }, ), children: [ TileLayer( @@ -435,7 +460,12 @@ class _ChannelMessagePathMapScreenState ), if (polylines.isNotEmpty) PolylineLayer(polylines: polylines), - MarkerLayer(markers: _buildHopMarkers(hops)), + MarkerLayer( + markers: _buildHopMarkers( + hops, + showLabels: _showNodeLabels, + ), + ), ], ), if (observedPaths.length > 1) @@ -458,7 +488,7 @@ class _ChannelMessagePathMapScreenState ), ), ), - _buildLegendCard(context, hops), + _buildLegendCard(context, hops, isImperial), ], ), ), @@ -530,45 +560,61 @@ class _ChannelMessagePathMapScreenState ); } - List _buildHopMarkers(List<_PathHop> hops) { - return [ - for (final hop in hops) - if (hop.hasLocation) - Marker( - point: hop.position!, - width: 35, - height: 35, - child: Container( - decoration: BoxDecoration( - color: Colors.green, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - alignment: Alignment.center, - child: Text( - hop.index.toString(), - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 12, + List _buildHopMarkers( + List<_PathHop> hops, { + required bool showLabels, + }) { + final markers = []; + for (final hop in hops) { + if (!hop.hasLocation) continue; + final point = hop.position!; + markers.add( + Marker( + point: point, + width: 35, + height: 35, + child: Container( + decoration: BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + offset: const Offset(0, 2), ), + ], + ), + alignment: Alignment.center, + child: Text( + hop.index.toString(), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 12, ), ), ), - if (context.read().selfLatitude != null && - context.read().selfLongitude != null) - Marker( - point: LatLng( - context.read().selfLatitude!, - context.read().selfLongitude!, + ), + ); + if (showLabels) { + markers.add( + _buildNodeLabelMarker( + point: point, + label: hop.contact?.name ?? _formatPrefix(hop.prefix), ), + ); + } + } + + final selfLat = context.read().selfLatitude; + final selfLon = context.read().selfLongitude; + if (selfLat != null && selfLon != null) { + final selfPoint = LatLng(selfLat, selfLon); + markers.add( + Marker( + point: selfPoint, width: 35, height: 35, child: Container( @@ -595,10 +641,63 @@ 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, -26), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(8), + ), + child: SizedBox( + width: 96, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildLegendCard( + BuildContext context, + List<_PathHop> hops, + bool isImperial, + ) { final l10n = context.l10n; final maxHeight = MediaQuery.of(context).size.height * 0.35; final estimatedHeight = 72.0 + (hops.length * 56.0); @@ -617,7 +716,7 @@ class _ChannelMessagePathMapScreenState Padding( padding: const EdgeInsets.all(12), child: Text( - '${l10n.channelPath_repeaterHops} (${(_pathDistance / 1609.34).toStringAsFixed(2)} Miles / ${(_pathDistance / 1000).toStringAsFixed(2)} Km)', + '${l10n.channelPath_repeaterHops} ${formatDistance(_pathDistance, isImperial: isImperial)}', style: const TextStyle(fontWeight: FontWeight.w600), ), ), diff --git a/lib/screens/community_qr_scanner_screen.dart b/lib/screens/community_qr_scanner_screen.dart index a2914a19..9f8602d3 100644 --- a/lib/screens/community_qr_scanner_screen.dart +++ b/lib/screens/community_qr_scanner_screen.dart @@ -6,6 +6,7 @@ import '../connector/meshcore_connector.dart'; import '../l10n/l10n.dart'; import '../models/community.dart'; import '../storage/community_store.dart'; +import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/qr_scanner_widget.dart'; /// Screen for scanning community QR codes to join communities. @@ -29,7 +30,7 @@ class _CommunityQrScannerScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text(context.l10n.community_scanQr), + title: AdaptiveAppBarTitle(context.l10n.community_scanQr), centerTitle: true, ), body: _isProcessing diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index d4701075..a6828dd1 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -1170,12 +1170,17 @@ class _ContactTile extends StatelessWidget { backgroundColor: _getTypeColor(contact.type), child: _buildContactAvatar(contact), ), - title: Text(contact.name), + title: Text(contact.name, maxLines: 1, overflow: TextOverflow.ellipsis), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(contact.pathLabel), - Text(contact.shortPubKeyHex, style: TextStyle(fontSize: 12)), + Text(contact.pathLabel, maxLines: 1, overflow: TextOverflow.ellipsis), + Text( + contact.shortPubKeyHex, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12), + ), ], ), // Clamp text scaling in trailing section to prevent overflow while @@ -1186,26 +1191,32 @@ class _ContactTile extends StatelessWidget { MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3), ), ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (unreadCount > 0) ...[ - UnreadBadge(count: unreadCount), - const SizedBox(height: 4), - ], - Text( - _formatLastSeen(context, lastSeen), - style: TextStyle(fontSize: 12, color: Colors.grey[600]), - ), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (contact.hasLocation) - Icon(Icons.location_on, size: 14, color: Colors.grey[400]), + child: SizedBox( + width: 120, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (unreadCount > 0) ...[ + UnreadBadge(count: unreadCount), + const SizedBox(height: 4), ], - ), - ], + Text( + _formatLastSeen(context, lastSeen), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (contact.hasLocation) + Icon(Icons.location_on, size: 14, color: Colors.grey[400]), + ], + ), + ], + ), ), ), onTap: onTap, diff --git a/lib/screens/line_of_sight_map_screen.dart b/lib/screens/line_of_sight_map_screen.dart new file mode 100644 index 00000000..a2d4b4ac --- /dev/null +++ b/lib/screens/line_of_sight_map_screen.dart @@ -0,0 +1,1005 @@ +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:provider/provider.dart'; + +import '../l10n/l10n.dart'; +import '../screens/channels_screen.dart'; +import '../screens/contacts_screen.dart'; +import '../models/app_settings.dart'; +import '../services/app_settings_service.dart'; +import '../services/line_of_sight_service.dart'; +import '../services/map_tile_cache_service.dart'; +import '../utils/route_transitions.dart'; +import '../widgets/app_bar.dart'; +import '../widgets/quick_switch_bar.dart'; + +class LineOfSightEndpoint { + final String label; + final LatLng point; + final Color color; + final IconData icon; + final bool isCustom; + + const LineOfSightEndpoint({ + required this.label, + required this.point, + this.color = Colors.green, + this.icon = Icons.location_on, + this.isCustom = false, + }); +} + +class LineOfSightMapScreen extends StatefulWidget { + final String title; + final List candidates; + + const LineOfSightMapScreen({ + super.key, + required this.title, + required this.candidates, + }); + + @override + State createState() => _LineOfSightMapScreenState(); +} + +class _LineOfSightMapScreenState extends State { + static const String _errorSelectStartEnd = 'los_error_select_start_end'; + static const double _metersToFeet = 3.28084; + static const double _kmToMiles = 0.621371; + static const double _maxAntennaFeet = 400.0; + static const double _maxAntennaMeters = _maxAntennaFeet / _metersToFeet; + static const double _labelZoomThreshold = 8.5; + + final LineOfSightService _lineOfSightService = LineOfSightService(); + + bool _loading = false; + String? _error; + LineOfSightPathResult? _result; + LineOfSightEndpoint? _start; + LineOfSightEndpoint? _end; + final List _customEndpoints = []; + double _startAntennaHeight = 5.0; + double _endAntennaHeight = 5.0; + bool _showHud = true; + bool _menuExpanded = true; + bool _showDisplayNodes = true; + bool _showMarkerLabels = true; + bool _didReceivePositionUpdate = false; + int _losRequestNonce = 0; + + @override + void initState() { + super.initState(); + if (widget.candidates.isNotEmpty) { + _start = widget.candidates.first; + if (widget.candidates.length > 1) { + _end = widget.candidates[1]; + } + } + _runLos(); + } + + @override + void dispose() { + _lineOfSightService.dispose(); + super.dispose(); + } + + Future _runLos() async { + final start = _start; + final end = _end; + final startAntenna = _startAntennaHeight; + final endAntenna = _endAntennaHeight; + final requestId = ++_losRequestNonce; + if (start == null || end == null) { + setState(() { + _result = null; + _error = _errorSelectStartEnd; + }); + return; + } + + setState(() { + _loading = true; + _error = null; + }); + + try { + final result = await _lineOfSightService.analyzePath( + [start.point, end.point], + startAntennaHeightMeters: startAntenna, + endAntennaHeightMeters: endAntenna, + ); + if (!mounted) return; + if (!_isRunRequestCurrent( + requestId: requestId, + start: start, + end: end, + startAntenna: startAntenna, + endAntenna: endAntenna, + )) { + return; + } + setState(() { + _result = result; + }); + } catch (e) { + if (!mounted) return; + if (!_isRunRequestCurrent( + requestId: requestId, + start: start, + end: end, + startAntenna: startAntenna, + endAntenna: endAntenna, + )) { + return; + } + setState(() { + _result = null; + _error = context.l10n.losRunFailed(e.toString()); + }); + } finally { + if (mounted && requestId == _losRequestNonce) { + setState(() { + _loading = false; + }); + } + } + } + + bool _isRunRequestCurrent({ + required int requestId, + required LineOfSightEndpoint start, + required LineOfSightEndpoint end, + required double startAntenna, + required double endAntenna, + }) { + return requestId == _losRequestNonce && + identical(_start, start) && + identical(_end, end) && + _startAntennaHeight == startAntenna && + _endAntennaHeight == endAntenna; + } + + void _selectFromMap(LineOfSightEndpoint endpoint) { + setState(() { + _result = null; + _error = null; + if (_start == null || (_start != null && _end != null)) { + _start = endpoint; + if (_end == endpoint) _end = null; + } else { + _end = endpoint; + if (_start == endpoint) _start = null; + } + }); + + if (_start != null && _end != null) { + _runLos(); + } + } + + void _addCustomPoint(LatLng point) { + final endpoint = LineOfSightEndpoint( + label: context.l10n.losCustomPointLabel(_customEndpoints.length + 1), + point: point, + color: Colors.orange, + icon: Icons.push_pin, + isCustom: true, + ); + setState(() { + _customEndpoints.add(endpoint); + }); + _selectFromMap(endpoint); + } + + List _visibleEndpoints() { + return [if (_showDisplayNodes) ...widget.candidates, ..._customEndpoints]; + } + + bool _hasEndpoint( + List endpoints, + LineOfSightEndpoint? e, + ) { + if (e == null) return false; + return endpoints.any((item) => identical(item, e)); + } + + void _sanitizeSelection() { + final visible = _visibleEndpoints(); + if (!_hasEndpoint(visible, _start)) { + _start = null; + } + if (!_hasEndpoint(visible, _end)) { + _end = null; + } + } + + void _clearAllPoints() { + setState(() { + _customEndpoints.clear(); + _start = null; + _end = null; + _result = null; + _error = _errorSelectStartEnd; + }); + } + + void _deleteCustomPoint(LineOfSightEndpoint endpoint) { + setState(() { + _customEndpoints.removeWhere((e) => identical(e, endpoint)); + if (identical(_start, endpoint)) _start = null; + if (identical(_end, endpoint)) _end = null; + _result = null; + }); + } + + Future _renameCustomPoint(LineOfSightEndpoint endpoint) async { + final controller = TextEditingController(text: endpoint.label); + final newLabel = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(context.l10n.losRenameCustomPoint), + content: TextField( + controller: controller, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: context.l10n.losPointName, + ), + autofocus: true, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(context.l10n.common_cancel), + ), + TextButton( + onPressed: () { + final value = controller.text.trim(); + Navigator.pop(dialogContext, value); + }, + child: Text(context.l10n.common_save), + ), + ], + ), + ); + + if (newLabel == null || newLabel.isEmpty) return; + final index = _customEndpoints.indexWhere((e) => identical(e, endpoint)); + if (index < 0) return; + final renamed = LineOfSightEndpoint( + label: newLabel, + point: endpoint.point, + color: endpoint.color, + icon: endpoint.icon, + isCustom: endpoint.isCustom, + ); + setState(() { + _customEndpoints[index] = renamed; + if (identical(_start, endpoint)) _start = renamed; + if (identical(_end, endpoint)) _end = renamed; + }); + } + + @override + Widget build(BuildContext context) { + final settings = context.watch().settings; + final isImperial = settings.unitSystem == UnitSystem.imperial; + final tileCache = context.read(); + final endpoints = _visibleEndpoints(); + final mapPoints = [ + if (_start != null) _start!.point, + if (_end != null) _end!.point, + ]; + final initialCenter = mapPoints.isNotEmpty + ? mapPoints.first + : const LatLng(0, 0); + final bounds = mapPoints.length > 1 + ? LatLngBounds.fromPoints(mapPoints) + : null; + final initialZoom = mapPoints.length > 1 ? 13.0 : 2.0; + if (!_didReceivePositionUpdate) { + _showMarkerLabels = initialZoom >= _labelZoomThreshold; + } + + return Scaffold( + appBar: AppBar( + title: AppBarTitle(widget.title), + centerTitle: true, + actions: [ + IconButton( + icon: _loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.delete_outline), + onPressed: _loading ? null : _clearAllPoints, + tooltip: context.l10n.losClearAllPoints, + ), + ], + ), + body: Stack( + children: [ + FlutterMap( + options: MapOptions( + initialCenter: initialCenter, + initialZoom: initialZoom, + initialCameraFit: bounds == null + ? null + : CameraFit.bounds( + bounds: bounds, + padding: const EdgeInsets.all(64), + maxZoom: 16, + ), + interactionOptions: InteractionOptions( + flags: ~InteractiveFlag.rotate, + ), + onLongPress: (_, point) => _addCustomPoint(point), + onPositionChanged: (camera, hasGesture) { + final shouldShow = camera.zoom >= _labelZoomThreshold; + if (!_didReceivePositionUpdate || + shouldShow != _showMarkerLabels) { + setState(() { + _didReceivePositionUpdate = true; + _showMarkerLabels = shouldShow; + }); + } + }, + ), + children: [ + TileLayer( + urlTemplate: kMapTileUrlTemplate, + tileProvider: tileCache.tileProvider, + userAgentPackageName: MapTileCacheService.userAgentPackageName, + maxZoom: 19, + ), + if (_result != null && _result!.segments.isNotEmpty) + PolylineLayer(polylines: _buildSegmentPolylines(_result!)), + MarkerLayer(markers: _buildMarkers(endpoints)), + ], + ), + if (_showHud) + Positioned( + left: 12, + right: 12, + top: 12, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.52, + ), + child: _buildControlPanel(isImperial), + ), + ), + if (!_showHud && _result != null && _result!.segments.isNotEmpty) + Positioned( + left: 12, + bottom: 12, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: Text( + context.l10n.losElevationAttribution, + style: const TextStyle(fontSize: 10, color: Colors.white), + ), + ), + ), + ), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + setState(() { + _showHud = !_showHud; + }); + }, + tooltip: _showHud + ? context.l10n.losHidePanelTooltip + : context.l10n.losShowPanelTooltip, + child: Icon(_showHud ? Icons.visibility_off : Icons.tune), + ), + bottomNavigationBar: SafeArea( + top: false, + child: QuickSwitchBar( + selectedIndex: 2, + onDestinationSelected: (index) => _handleQuickSwitch(index, context), + ), + ), + ); + } + + Widget _buildControlPanel(bool isImperial) { + _sanitizeSelection(); + final segment = _primarySegmentResult(); + final endpoints = _visibleEndpoints(); + final distanceUnit = isImperial ? 'mi' : 'km'; + final heightUnit = isImperial ? 'ft' : 'm'; + final antennaAMeters = _startAntennaHeight; + final antennaBMeters = _endAntennaHeight; + final antennaADisplay = _toDisplayHeight(antennaAMeters, isImperial); + final antennaBDisplay = _toDisplayHeight(antennaBMeters, isImperial); + final antennaSliderMax = isImperial ? _maxAntennaFeet : _maxAntennaMeters; + final antennaSliderDivisions = isImperial ? 400 : 122; + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (segment != null) + SizedBox( + height: 160, + width: double.infinity, + child: CustomPaint( + painter: _LosProfilePainter( + samples: segment.samples, + distanceUnit: distanceUnit, + heightUnit: heightUnit, + badgeTextStyle: + Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.white70, + fontSize: 10, + fontWeight: FontWeight.w600, + ) ?? + const TextStyle( + color: Colors.white70, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ), + ) + else + SizedBox( + height: 44, + child: Center( + child: Text( + context.l10n.losRunToViewElevationProfile, + style: const TextStyle(fontSize: 11), + ), + ), + ), + const SizedBox(height: 8), + Text( + segment != null + ? _profileStats(segment, isImperial) + : _statusText(), + style: TextStyle( + fontSize: 12, + color: segment != null + ? (segment.isClear ? Colors.green : Colors.red) + : _statusColor(), + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + context.l10n.losElevationAttribution, + style: TextStyle(fontSize: 10, color: Colors.grey[700]), + ), + const SizedBox(height: 6), + ExpansionTile( + initiallyExpanded: _menuExpanded, + onExpansionChanged: (value) { + setState(() { + _menuExpanded = value; + }); + }, + tilePadding: EdgeInsets.zero, + childrenPadding: EdgeInsets.zero, + title: Text( + context.l10n.losMenuTitle, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + subtitle: Text( + context.l10n.losMenuSubtitle, + style: const TextStyle(fontSize: 11), + ), + children: [ + SwitchListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: Text( + context.l10n.losShowDisplayNodes, + style: const TextStyle(fontSize: 12), + ), + value: _showDisplayNodes, + onChanged: (value) { + setState(() { + _showDisplayNodes = value; + _sanitizeSelection(); + _result = null; + }); + }, + ), + if (_customEndpoints.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + context.l10n.losCustomPoints, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + for (final point in _customEndpoints) + ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: Text( + point.label, + style: const TextStyle(fontSize: 12), + ), + subtitle: Text( + '${point.point.latitude.toStringAsFixed(5)}, ${point.point.longitude.toStringAsFixed(5)}', + style: const TextStyle(fontSize: 11), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit, size: 18), + onPressed: () => _renameCustomPoint(point), + tooltip: context.l10n.common_edit, + ), + IconButton( + icon: const Icon(Icons.delete_outline, size: 18), + onPressed: () => _deleteCustomPoint(point), + tooltip: context.l10n.common_delete, + ), + ], + ), + ), + ], + const SizedBox(height: 8), + _buildEndpointRow( + label: context.l10n.losPointA, + value: _start, + candidates: endpoints, + onChanged: (value) { + setState(() { + _start = value; + _result = null; + }); + if (_start != null && _end != null) { + _runLos(); + } + }, + ), + const SizedBox(height: 8), + _buildEndpointRow( + label: context.l10n.losPointB, + value: _end, + candidates: endpoints, + onChanged: (value) { + setState(() { + _end = value; + _result = null; + }); + if (_start != null && _end != null) { + _runLos(); + } + }, + ), + const SizedBox(height: 10), + Text( + context.l10n.losAntennaA( + antennaADisplay.toStringAsFixed(1), + heightUnit, + ), + style: const TextStyle(fontSize: 12), + ), + Slider( + value: antennaADisplay, + min: 0, + max: antennaSliderMax, + divisions: antennaSliderDivisions, + onChanged: (value) { + setState(() { + _startAntennaHeight = _toMetersHeight( + value, + isImperial, + ); + }); + }, + ), + Text( + context.l10n.losAntennaB( + antennaBDisplay.toStringAsFixed(1), + heightUnit, + ), + style: const TextStyle(fontSize: 12), + ), + Slider( + value: antennaBDisplay, + min: 0, + max: antennaSliderMax, + divisions: antennaSliderDivisions, + onChanged: (value) { + setState(() { + _endAntennaHeight = _toMetersHeight(value, isImperial); + }); + }, + ), + Align( + alignment: Alignment.centerRight, + child: ElevatedButton.icon( + onPressed: _loading ? null : _runLos, + icon: const Icon(Icons.visibility), + label: Text(context.l10n.losRun), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildEndpointRow({ + required String label, + required LineOfSightEndpoint? value, + required List candidates, + required ValueChanged onChanged, + }) { + return Row( + children: [ + SizedBox( + width: 54, + child: Text(label, style: const TextStyle(fontSize: 12)), + ), + Expanded( + child: DropdownButton( + value: value, + isExpanded: true, + items: candidates + .map( + (e) => DropdownMenuItem( + value: e, + child: Text(e.label, overflow: TextOverflow.ellipsis), + ), + ) + .toList(), + onChanged: onChanged, + ), + ), + ], + ); + } + + LineOfSightResult? _primarySegmentResult() { + if (_result == null || _result!.segments.isEmpty) return null; + return _result!.segments.first.result; + } + + String _profileStats(LineOfSightResult result, bool isImperial) { + final distance = isImperial + ? (result.totalDistanceMeters / 1000.0) * _kmToMiles + : result.totalDistanceMeters / 1000.0; + final distanceUnit = isImperial ? 'mi' : 'km'; + final heightUnit = isImperial ? 'ft' : 'm'; + final minClearance = result.samples.isEmpty + ? 0.0 + : result.samples.map((s) => s.clearanceMeters).reduce(math.min); + final minClearanceDisplay = isImperial + ? minClearance * _metersToFeet + : minClearance; + final maxObstructionDisplay = isImperial + ? result.maxObstructionMeters * _metersToFeet + : result.maxObstructionMeters; + if (!result.hasData) { + return _localizedLosError(result.errorMessage); + } + if (result.isClear) { + return context.l10n.losProfileClear( + distance.toStringAsFixed(1), + distanceUnit, + minClearanceDisplay.toStringAsFixed(1), + heightUnit, + ); + } + return context.l10n.losProfileBlocked( + distance.toStringAsFixed(1), + distanceUnit, + maxObstructionDisplay.toStringAsFixed(1), + heightUnit, + ); + } + + List _buildSegmentPolylines(LineOfSightPathResult result) { + final polylines = []; + for (final segment in result.segments) { + final color = !segment.result.hasData + ? Colors.grey + : (segment.result.isClear ? Colors.green : Colors.red); + polylines.add( + Polyline( + points: [segment.start, segment.end], + strokeWidth: 4, + color: color, + ), + ); + } + return polylines; + } + + List _buildMarkers(List endpoints) { + return [ + for (final endpoint in endpoints) + Marker( + point: endpoint.point, + width: 36, + height: 36, + child: GestureDetector( + onTap: () => _selectFromMap(endpoint), + child: Container( + decoration: BoxDecoration( + color: endpoint.color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: const [ + BoxShadow(color: Colors.black26, blurRadius: 4), + ], + ), + child: Stack( + children: [ + Center( + child: Icon(endpoint.icon, color: Colors.white, size: 16), + ), + if (endpoint == _start || endpoint == _end) + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 14, + height: 14, + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(7), + border: Border.all(color: Colors.white, width: 1), + ), + alignment: Alignment.center, + child: Text( + endpoint == _start ? 'A' : 'B', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 9, + ), + ), + ), + ), + ], + ), + ), + ), + ), + for (final endpoint in endpoints) + if (_showMarkerLabels) + Marker( + point: endpoint.point, + width: 120, + height: 24, + alignment: Alignment.topCenter, + child: IgnorePointer( + child: Transform.translate( + offset: const Offset(0, -26), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(8), + ), + child: SizedBox( + width: 96, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + endpoint.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 10, + ), + ), + ), + ), + ), + ), + ), + ), + ]; + } + + String _statusText() { + if (_loading) return context.l10n.losStatusChecking; + if (_error == _errorSelectStartEnd) { + return context.l10n.losSelectStartEnd; + } + if (_error != null) return _error!; + if (_result == null) return context.l10n.losStatusNoData; + final total = _result!.segments.length; + return context.l10n.losStatusSummary( + _result!.clearSegments, + total, + _result!.blockedSegments, + _result!.unknownSegments, + ); + } + + Color _statusColor() { + if (_error != null) return Colors.red; + if (_loading) return Colors.orange; + if (_result == null) return Colors.grey; + if (_result!.blockedSegments > 0) return Colors.red; + if (_result!.clearSegments > 0) return Colors.green; + return Colors.grey; + } + + double _toDisplayHeight(double meters, bool isImperial) { + return isImperial ? meters * _metersToFeet : meters; + } + + double _toMetersHeight(double displayHeight, bool isImperial) { + return isImperial ? displayHeight / _metersToFeet : displayHeight; + } + + String _localizedLosError(String? message) { + if (message == LineOfSightService.errorElevationUnavailable) { + return context.l10n.losErrorElevationUnavailable; + } + if (message == LineOfSightService.errorInvalidInput) { + return context.l10n.losErrorInvalidInput; + } + return context.l10n.losNoElevationData; + } + + void _handleQuickSwitch(int index, BuildContext context) { + if (index == 2) { + Navigator.pop(context); + return; + } + switch (index) { + case 0: + Navigator.pushReplacement( + context, + buildQuickSwitchRoute(const ContactsScreen(hideBackButton: true)), + ); + break; + case 1: + Navigator.pushReplacement( + context, + buildQuickSwitchRoute(const ChannelsScreen(hideBackButton: true)), + ); + break; + } + } +} + +class _LosProfilePainter extends CustomPainter { + final List samples; + final String distanceUnit; + final String heightUnit; + final TextStyle badgeTextStyle; + + const _LosProfilePainter({ + required this.samples, + required this.distanceUnit, + required this.heightUnit, + required this.badgeTextStyle, + }); + + @override + void paint(Canvas canvas, Size size) { + final bg = Paint()..color = const Color(0xFF243A63); + canvas.drawRect(Offset.zero & size, bg); + _drawUnitBadge(canvas, size); + + if (samples.length < 2) return; + + final minY = samples + .map((s) => math.min(s.terrainMeters, s.lineHeightMeters)) + .reduce(math.min); + final maxY = samples + .map((s) => math.max(s.terrainMeters, s.lineHeightMeters)) + .reduce(math.max); + final ySpan = math.max(1.0, maxY - minY); + final maxDist = math.max(1.0, samples.last.distanceMeters); + + Offset mapPoint(double x, double y) { + final px = (x / maxDist) * size.width; + final py = size.height - ((y - minY) / ySpan) * size.height; + return Offset(px, py); + } + + final terrainPath = ui.Path(); + terrainPath.moveTo(0, size.height); + for (final s in samples) { + final p = mapPoint(s.distanceMeters, s.terrainMeters); + terrainPath.lineTo(p.dx, p.dy); + } + terrainPath.lineTo(size.width, size.height); + terrainPath.close(); + + canvas.drawPath(terrainPath, Paint()..color = const Color(0xCC7C6F5D)); + + final terrainLine = ui.Path(); + for (int i = 0; i < samples.length; i++) { + final p = mapPoint(samples[i].distanceMeters, samples[i].terrainMeters); + if (i == 0) { + terrainLine.moveTo(p.dx, p.dy); + } else { + terrainLine.lineTo(p.dx, p.dy); + } + } + canvas.drawPath( + terrainLine, + Paint() + ..color = const Color(0xFF9FE870) + ..style = PaintingStyle.stroke + ..strokeWidth = 2, + ); + + final losLine = ui.Path(); + for (int i = 0; i < samples.length; i++) { + final p = mapPoint( + samples[i].distanceMeters, + samples[i].lineHeightMeters, + ); + if (i == 0) { + losLine.moveTo(p.dx, p.dy); + } else { + losLine.lineTo(p.dx, p.dy); + } + } + canvas.drawPath( + losLine, + Paint() + ..color = const Color(0xFFE0E7FF) + ..style = PaintingStyle.stroke + ..strokeWidth = 2, + ); + } + + @override + bool shouldRepaint(covariant _LosProfilePainter oldDelegate) { + return oldDelegate.samples != samples || + oldDelegate.distanceUnit != distanceUnit || + oldDelegate.heightUnit != heightUnit || + oldDelegate.badgeTextStyle != badgeTextStyle; + } + + void _drawUnitBadge(Canvas canvas, Size size) { + final span = TextSpan( + text: '$heightUnit / $distanceUnit', + style: badgeTextStyle, + ); + final painter = TextPainter(text: span, textDirection: TextDirection.ltr) + ..layout(); + painter.paint(canvas, Offset(size.width - painter.width - 8, 8)); + } +} diff --git a/lib/screens/map_cache_screen.dart b/lib/screens/map_cache_screen.dart index 3f611095..1391660e 100644 --- a/lib/screens/map_cache_screen.dart +++ b/lib/screens/map_cache_screen.dart @@ -7,6 +7,7 @@ import '../l10n/app_localizations.dart'; import '../l10n/l10n.dart'; import '../services/app_settings_service.dart'; import '../services/map_tile_cache_service.dart'; +import '../widgets/adaptive_app_bar_title.dart'; class MapCacheScreen extends StatefulWidget { const MapCacheScreen({super.key}); @@ -224,7 +225,10 @@ class _MapCacheScreenState extends State { : (_completedTiles / _estimatedTiles).clamp(0.0, 1.0).toDouble(); return Scaffold( - appBar: AppBar(title: Text(l10n.mapCache_title), centerTitle: true), + appBar: AppBar( + title: AdaptiveAppBarTitle(l10n.mapCache_title), + centerTitle: true, + ), body: Column( children: [ Expanded( diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 1fad04b6..8c13a717 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -11,6 +11,7 @@ import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; import '../l10n/l10n.dart'; import '../connector/meshcore_protocol.dart'; +import '../models/app_settings.dart'; import '../models/channel.dart'; import '../models/contact.dart'; import '../services/app_settings_service.dart'; @@ -26,6 +27,7 @@ import '../widgets/repeater_login_dialog.dart'; import '../widgets/room_login_dialog.dart'; import 'repeater_hub_screen.dart'; import 'settings_screen.dart'; +import 'line_of_sight_map_screen.dart'; class MapScreen extends StatefulWidget { final LatLng? highlightPosition; @@ -46,6 +48,8 @@ class MapScreen extends StatefulWidget { } class _MapScreenState extends State { + static const double _labelZoomThreshold = 8.5; + final MapController _mapController = MapController(); final MapMarkerService _markerService = MapMarkerService(); final Set _hiddenMarkerIds = {}; @@ -58,6 +62,7 @@ class _MapScreenState extends State { final List _points = []; final List _polylines = []; bool _legendExpanded = false; + bool _showNodeLabels = true; @override void initState() { @@ -247,6 +252,7 @@ class _MapScreenState extends State { // Re center map after removed markers have loaded if (!_hasInitializedMap && _removedMarkersLoaded) { _hasInitializedMap = true; + _showNodeLabels = initialZoom >= _labelZoomThreshold; if (hasMapContent) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { @@ -272,6 +278,47 @@ class _MapScreenState extends State { onPressed: () => _startPath(), tooltip: context.l10n.contacts_pathTrace, ), + if (!_isBuildingPathTrace) + IconButton( + icon: const Icon(Icons.visibility), + onPressed: () { + final candidates = []; + if (connector.selfLatitude != null && + connector.selfLongitude != null) { + candidates.add( + LineOfSightEndpoint( + label: context.l10n.pathTrace_you, + point: LatLng( + connector.selfLatitude!, + connector.selfLongitude!, + ), + color: Colors.teal, + icon: Icons.person_pin_circle, + ), + ); + } + for (final c in contactsWithLocation) { + candidates.add( + LineOfSightEndpoint( + label: c.name, + point: LatLng(c.latitude!, c.longitude!), + color: _getNodeColor(c.type), + icon: _getNodeIcon(c.type), + ), + ); + } + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => LineOfSightMapScreen( + title: context.l10n.map_losScreenTitle, + candidates: candidates, + ), + ), + ); + }, + tooltip: context.l10n.map_lineOfSight, + ), PopupMenuButton( itemBuilder: (context) => [ PopupMenuItem( @@ -350,6 +397,14 @@ class _MapScreenState extends State { position: latLng, ); }, + onPositionChanged: (camera, hasGesture) { + final shouldShow = camera.zoom >= _labelZoomThreshold; + if (shouldShow != _showNodeLabels && mounted) { + setState(() { + _showNodeLabels = shouldShow; + }); + } + }, ), children: [ TileLayer( @@ -374,7 +429,11 @@ class _MapScreenState extends State { size: 34, ), ), - ..._buildMarkers(contactsWithLocation, settings), + ..._buildMarkers( + contactsWithLocation, + settings, + showLabels: _showNodeLabels, + ), ...sharedMarkers.map(_buildSharedMarker), if (connector.selfLatitude != null && connector.selfLongitude != null) @@ -413,6 +472,16 @@ class _MapScreenState extends State { ), ), ), + if (_showNodeLabels && + connector.selfLatitude != null && + connector.selfLongitude != null) + _buildNodeLabelMarker( + point: LatLng( + connector.selfLatitude!, + connector.selfLongitude!, + ), + label: connector.deviceDisplayName, + ), ], ), ], @@ -444,7 +513,11 @@ class _MapScreenState extends State { ); } - List _buildMarkers(List contacts, settings) { + List _buildMarkers( + List contacts, + settings, { + required bool showLabels, + }) { final markers = []; for (final contact in contacts) { @@ -499,11 +572,57 @@ class _MapScreenState extends State { ); markers.add(marker); + if (showLabels) { + markers.add( + _buildNodeLabelMarker( + point: LatLng(contact.latitude!, contact.longitude!), + label: contact.name, + ), + ); + } } return markers; } + Marker _buildNodeLabelMarker({required LatLng point, required String label}) { + return Marker( + point: point, + width: 120, + height: 24, + alignment: Alignment.topCenter, + child: IgnorePointer( + child: Transform.translate( + offset: const Offset(0, -26), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(8), + ), + child: SizedBox( + width: 96, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ), + ), + ); + } + Color _getNodeColor(int type) { switch (type) { case advTypeChat: @@ -1519,6 +1638,9 @@ class _MapScreenState extends State { Widget _buildPathTraceOverlay() { final l10n = context.l10n; + final isImperial = + context.read().settings.unitSystem == + UnitSystem.imperial; return Positioned( top: 16, left: 16, @@ -1539,7 +1661,7 @@ class _MapScreenState extends State { const SizedBox(height: 6), if (_pathTrace.isNotEmpty) Text( - "${l10n.path_currentPathLabel} ${formatDistance(getPathDistanceMeters(_points))}", + "${l10n.path_currentPathLabel} ${formatDistance(getPathDistanceMeters(_points), isImperial: isImperial)}", style: TextStyle(fontSize: 12, color: Colors.grey[700]), ), SelectableText( @@ -1549,8 +1671,10 @@ class _MapScreenState extends State { style: TextStyle(fontSize: 18), ), const SizedBox(height: 6), - Row( - mainAxisSize: MainAxisSize.min, + Wrap( + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 8, children: [ if (_pathTrace.isNotEmpty) ElevatedButton( diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index 8e24bee1..c1f7f447 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -8,7 +8,9 @@ import 'package:latlong2/latlong.dart'; import 'package:meshcore_open/connector/meshcore_connector.dart'; import 'package:meshcore_open/connector/meshcore_protocol.dart'; import 'package:meshcore_open/l10n/l10n.dart'; +import 'package:meshcore_open/models/app_settings.dart'; import 'package:meshcore_open/models/contact.dart'; +import 'package:meshcore_open/services/app_settings_service.dart'; import 'package:meshcore_open/services/map_tile_cache_service.dart'; import 'package:meshcore_open/utils/app_logger.dart'; import 'package:meshcore_open/widgets/snr_indicator.dart'; @@ -27,8 +29,11 @@ double getPathDistanceMeters(List points) { return distanceMeters; } -String formatDistance(double distanceMeters) { - return '(${(distanceMeters / 1609.34).toStringAsFixed(2)} Miles / ${(distanceMeters / 1000).toStringAsFixed(2)} Km)'; +String formatDistance(double distanceMeters, {required bool isImperial}) { + if (isImperial) { + return '(${(distanceMeters / 1609.34).toStringAsFixed(2)} mi)'; + } + return '(${(distanceMeters / 1000).toStringAsFixed(2)} km)'; } class PathTraceData { @@ -64,6 +69,8 @@ class PathTraceMapScreen extends StatefulWidget { } class _PathTraceMapScreenState extends State { + static const double _labelZoomThreshold = 8.5; + StreamSubscription? _frameSubscription; Timer? _timeoutTimer; @@ -78,6 +85,7 @@ class _PathTraceMapScreenState extends State { LatLngBounds? _bounds; ValueKey _mapKey = const ValueKey('initial'); double _pathDistanceMeters = 0.0; + bool _showNodeLabels = true; String _formatPathPrefixes(Uint8List pathBytes) { return pathBytes @@ -291,6 +299,8 @@ class _PathTraceMapScreenState extends State { Widget build(BuildContext context) { return Consumer( builder: (context, connector, _) { + final settings = context.watch().settings; + final isImperial = settings.unitSystem == UnitSystem.imperial; final tileCache = context.read(); return Scaffold( @@ -355,7 +365,8 @@ class _PathTraceMapScreenState extends State { ), ), ), - if (_hasData) _buildLegendCard(context, _traceData!), + if (_hasData) + _buildLegendCard(context, _traceData!, isImperial), ], ), ), @@ -364,55 +375,61 @@ class _PathTraceMapScreenState extends State { ); } - List _buildHopMarkers(List pathData) { - return [ - for (final hop in pathData) - if (_traceData!.pathContacts[hop] != null && - _traceData!.pathContacts[hop]!.hasLocation) - Marker( - point: LatLng( - _traceData!.pathContacts[hop]!.latitude!, - _traceData!.pathContacts[hop]!.longitude!, - ), - width: 35, - height: 35, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Colors.green, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - alignment: Alignment.center, - child: Text( - _traceData!.pathContacts[hop]!.publicKey - .sublist(0, 1) - .map( - (b) => b.toRadixString(16).padLeft(2, '0').toUpperCase(), - ) - .join(), - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 12, - ), - ), - ), - ), - if (context.read().selfLatitude != null && - context.read().selfLongitude != null) + List _buildHopMarkers( + List pathData, { + required bool showLabels, + }) { + final markers = []; + for (final hop in pathData) { + final contact = _traceData!.pathContacts[hop]; + if (contact == null || !contact.hasLocation) continue; + final point = LatLng(contact.latitude!, contact.longitude!); + markers.add( Marker( - point: LatLng( - context.read().selfLatitude!, - context.read().selfLongitude!, + point: point, + width: 35, + height: 35, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + alignment: Alignment.center, + child: Text( + contact.publicKey + .sublist(0, 1) + .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) + .join(), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), ), + ), + ); + if (showLabels) { + markers.add(_buildNodeLabelMarker(point: point, label: contact.name)); + } + } + + final selfLat = context.read().selfLatitude; + final selfLon = context.read().selfLongitude; + if (selfLat != null && selfLon != null) { + final selfPoint = LatLng(selfLat, selfLon); + markers.add( + Marker( + point: selfPoint, width: 35, height: 35, child: Container( @@ -440,7 +457,56 @@ class _PathTraceMapScreenState extends State { ), ), ), - ]; + ); + if (showLabels) { + markers.add( + _buildNodeLabelMarker( + point: selfPoint, + label: context.l10n.pathTrace_you, + ), + ); + } + } + + return markers; + } + + Marker _buildNodeLabelMarker({required LatLng point, required String label}) { + return Marker( + point: point, + width: 120, + height: 24, + alignment: Alignment.topCenter, + child: IgnorePointer( + child: Transform.translate( + offset: const Offset(0, -26), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(8), + ), + child: SizedBox( + width: 96, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + 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) { @@ -520,6 +586,14 @@ class _PathTraceMapScreenState extends State { ), minZoom: 2.0, maxZoom: 18.0, + onPositionChanged: (camera, hasGesture) { + final shouldShow = camera.zoom >= _labelZoomThreshold; + if (shouldShow != _showNodeLabels && mounted) { + setState(() { + _showNodeLabels = shouldShow; + }); + } + }, ), children: [ TileLayer( @@ -530,12 +604,21 @@ class _PathTraceMapScreenState extends State { ), if (_polylines.isNotEmpty) PolylineLayer(polylines: _polylines), 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 maxHeight = MediaQuery.of(context).size.height * 0.35; final estimatedHeight = 72.0 + (pathTraceData.pathData.length * 56.0); @@ -554,7 +637,7 @@ class _PathTraceMapScreenState extends State { Padding( padding: const EdgeInsets.all(12), child: Text( - '${l10n.channelPath_repeaterHops} ${formatDistance(_pathDistanceMeters)}', + '${l10n.channelPath_repeaterHops} ${formatDistance(_pathDistanceMeters, isImperial: isImperial)}', style: const TextStyle(fontWeight: FontWeight.w600), ), ), diff --git a/lib/screens/scanner_screen.dart b/lib/screens/scanner_screen.dart index 932e29cf..af9d75ef 100644 --- a/lib/screens/scanner_screen.dart +++ b/lib/screens/scanner_screen.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; import '../l10n/l10n.dart'; +import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/device_tile.dart'; import 'contacts_screen.dart'; @@ -70,7 +71,7 @@ class _ScannerScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text(context.l10n.scanner_title), + title: AdaptiveAppBarTitle(context.l10n.scanner_title), centerTitle: true, automaticallyImplyLeading: false, ), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 94d541b0..a198f991 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -8,6 +8,7 @@ import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; import '../l10n/l10n.dart'; import '../models/radio_settings.dart'; +import '../widgets/adaptive_app_bar_title.dart'; import 'app_settings_screen.dart'; import 'app_debug_log_screen.dart'; import 'ble_debug_log_screen.dart'; @@ -41,7 +42,10 @@ class _SettingsScreenState extends State { Widget build(BuildContext context) { final l10n = context.l10n; return Scaffold( - appBar: AppBar(title: Text(l10n.settings_title), centerTitle: true), + appBar: AppBar( + title: AdaptiveAppBarTitle(l10n.settings_title), + centerTitle: true, + ), body: SafeArea( top: false, child: Consumer( diff --git a/lib/screens/telemetry_screen.dart b/lib/screens/telemetry_screen.dart index 8770938c..88c204d1 100644 --- a/lib/screens/telemetry_screen.dart +++ b/lib/screens/telemetry_screen.dart @@ -5,8 +5,10 @@ import 'package:provider/provider.dart'; import '../l10n/l10n.dart'; import '../models/contact.dart'; import '../models/path_selection.dart'; +import '../models/app_settings.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; +import '../services/app_settings_service.dart'; import '../services/repeater_command_service.dart'; import '../widgets/path_management_dialog.dart'; import '../helpers/cayenne_lpp.dart'; @@ -181,6 +183,8 @@ class _TelemetryScreenState extends State { Widget build(BuildContext context) { final l10n = context.l10n; final connector = context.watch(); + final settings = context.watch().settings; + final isImperialUnits = settings.unitSystem == UnitSystem.imperial; final repeater = _resolveRepeater(connector); final isFloodMode = repeater.pathOverride == -1; @@ -307,6 +311,7 @@ class _TelemetryScreenState extends State { entry['values'], l10n.telemetry_channelTitle(entry['channel']), entry['channel'], + isImperialUnits, ), ], ), @@ -319,6 +324,7 @@ class _TelemetryScreenState extends State { Map channelData, String title, int channel, + bool isImperialUnits, ) { final l10n = context.l10n; return Card( @@ -358,12 +364,12 @@ class _TelemetryScreenState extends State { else if (entry.key == 'temperature' && channel == 1) _buildInfoRow( l10n.telemetry_mcuTemperatureLabel, - _temperatureText(entry.value), + _temperatureText(entry.value, isImperialUnits), ) else if (entry.key == 'temperature') _buildInfoRow( l10n.telemetry_temperatureLabel, - _temperatureText(entry.value), + _temperatureText(entry.value, isImperialUnits), ) else if (entry.key == 'current' && channel == 1) _buildInfoRow( @@ -421,13 +427,13 @@ class _TelemetryScreenState extends State { return (((millivolts - minMv) * 100) / (maxMv - minMv)).round(); } - String _temperatureText(double? tempC) { + String _temperatureText(double? tempC, bool isImperialUnits) { final l10n = context.l10n; if (tempC == null) return l10n.common_notAvailable; final tempF = (tempC * 9 / 5) + 32; - return l10n.telemetry_temperatureValue( - tempC.toStringAsFixed(1), - tempF.toStringAsFixed(1), - ); + if (isImperialUnits) { + return '${tempF.toStringAsFixed(1)}°F'; + } + return '${tempC.toStringAsFixed(1)}°C'; } } diff --git a/lib/services/app_settings_service.dart b/lib/services/app_settings_service.dart index c1e8fc62..a85ab920 100644 --- a/lib/services/app_settings_service.dart +++ b/lib/services/app_settings_service.dart @@ -132,4 +132,14 @@ class AppSettingsService extends ChangeNotifier { _settings.copyWith(batteryChemistryByDeviceId: updated), ); } + + Future setUnitSystem(UnitSystem value) async { + await updateSettings(_settings.copyWith(unitSystem: value)); + } + + Future setLosUnitSystem(String value) async { + await setUnitSystem( + value == 'imperial' ? UnitSystem.imperial : UnitSystem.metric, + ); + } } diff --git a/lib/services/ble_debug_log_service.dart b/lib/services/ble_debug_log_service.dart index 0a9aeaef..bc46b599 100644 --- a/lib/services/ble_debug_log_service.dart +++ b/lib/services/ble_debug_log_service.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; import '../connector/meshcore_protocol.dart'; class BleDebugLogEntry { @@ -44,6 +45,7 @@ class BleDebugLogService extends ChangeNotifier { static const int maxEntries = 500; final List _entries = []; final List _rawLogRxEntries = []; + bool _notifyScheduled = false; List get entries => List.unmodifiable(_entries); List get rawLogRxEntries => @@ -78,13 +80,31 @@ class BleDebugLogService extends ChangeNotifier { } } - notifyListeners(); + _notifyListenersSafely(); } void clear() { _entries.clear(); _rawLogRxEntries.clear(); - notifyListeners(); + _notifyListenersSafely(); + } + + void _notifyListenersSafely() { + final phase = SchedulerBinding.instance.schedulerPhase; + final canNotifyNow = + phase == SchedulerPhase.idle || + phase == SchedulerPhase.postFrameCallbacks; + if (canNotifyNow) { + notifyListeners(); + return; + } + + if (_notifyScheduled) return; + _notifyScheduled = true; + SchedulerBinding.instance.addPostFrameCallback((_) { + _notifyScheduled = false; + notifyListeners(); + }); } String _describeFrame( diff --git a/lib/services/line_of_sight_service.dart b/lib/services/line_of_sight_service.dart new file mode 100644 index 00000000..e9f9f7b6 --- /dev/null +++ b/lib/services/line_of_sight_service.dart @@ -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> Function(List 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 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 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 _elevationCache = {}; + + LineOfSightService({ + http.Client? httpClient, + ElevationDataSource? elevationDataSource, + }) : _httpClient = httpClient ?? http.Client(), + _ownsHttpClient = httpClient == null, + _elevationDataSource = elevationDataSource; + + Future analyzePath( + List points, { + double startAntennaHeightMeters = 1.5, + double endAntennaHeightMeters = 1.5, + double 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 = []; + 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 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(), + startAntennaHeightMeters: startAntennaHeightMeters, + endAntennaHeightMeters: endAntennaHeightMeters, + kFactor: kFactor, + obstructionToleranceMeters: obstructionToleranceMeters, + ); + } + + static LineOfSightResult computeFromElevations({ + required List points, + required List 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 = []; + 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 _buildSamplePoints( + LatLng start, + LatLng end, + double distanceMeters, + ) { + final sampleCount = distanceMeters < 2000 + ? 21 + : distanceMeters < 10000 + ? 41 + : 81; + + final points = []; + for (int i = 0; i < sampleCount; i++) { + final t = i / (sampleCount - 1); + points.add( + LatLng( + start.latitude + (end.latitude - start.latitude) * t, + start.longitude + (end.longitude - start.longitude) * t, + ), + ); + } + return points; + } + + Future> _getElevations(List points) async { + final dataSource = _elevationDataSource; + if (dataSource != null) { + return dataSource(points); + } + + final uncached = {}; + final values = List.filled(points.length, null); + for (int i = 0; i < points.length; i++) { + final key = _cacheKey(points[i]); + final cached = _readCachedValue(key); + if (cached != null) { + values[i] = cached; + } else { + uncached[i] = points[i]; + } + } + + if (uncached.isEmpty) return values; + + final latCsv = uncached.values + .map((p) => p.latitude.toStringAsFixed(6)) + .join(','); + final lonCsv = uncached.values + .map((p) => p.longitude.toStringAsFixed(6)) + .join(','); + + final uri = Uri.parse( + 'https://api.open-meteo.com/v1/elevation?latitude=$latCsv&longitude=$lonCsv', + ); + + final response = await _getWithBackoff(uri); + if (response.statusCode != 200) { + return values; + } + + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + return values; + } + final elevations = decoded['elevation']; + if (elevations is! List) { + return values; + } + + final indices = uncached.keys.toList(); + for (int i = 0; i < min(indices.length, elevations.length); i++) { + final value = elevations[i]; + if (value is! num) continue; + final index = indices[i]; + final elevation = value.toDouble(); + values[index] = elevation; + _elevationCache[_cacheKey(points[index])] = _CachedElevation( + value: elevation, + expiresAt: DateTime.now().add(_cacheTtl), + ); + } + return values; + } + + Future _getWithBackoff(Uri uri) async { + var attempt = 0; + Duration backoff = _initialBackoff; + + while (true) { + attempt++; + try { + final response = await _httpClient.get(uri); + if (!_shouldRetryStatus(response.statusCode) || + attempt >= _maxFetchAttempts) { + return response; + } + } catch (_) { + if (attempt >= _maxFetchAttempts) rethrow; + } + + await Future.delayed(backoff); + backoff *= 2; + } + } + + bool _shouldRetryStatus(int statusCode) { + return statusCode == 429 || statusCode >= 500; + } + + double? _readCachedValue(String key) { + final cached = _elevationCache[key]; + if (cached == null) return null; + if (DateTime.now().isAfter(cached.expiresAt)) { + _elevationCache.remove(key); + return null; + } + return cached.value; + } + + String _cacheKey(LatLng point) { + return '${point.latitude.toStringAsFixed(5)},${point.longitude.toStringAsFixed(5)}'; + } + + void dispose() { + if (_ownsHttpClient) { + _httpClient.close(); + } + } +} + +class _CachedElevation { + final double value; + final DateTime expiresAt; + + const _CachedElevation({required this.value, required this.expiresAt}); +} diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index fc979c6b..0b59bbcc 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -58,11 +58,17 @@ class NotificationService { requestBadgePermission: true, requestSoundPermission: true, ); + const windowsSettings = WindowsInitializationSettings( + appName: 'MeshCore Open', + appUserModelId: 'org.meshcore.open.app', + guid: 'e7ea8f85-72f5-4f36-91f6-038f740ccf86', + ); const initSettings = InitializationSettings( android: androidSettings, iOS: iosSettings, macOS: macSettings, + windows: windowsSettings, ); try { @@ -76,6 +82,13 @@ class NotificationService { } } + Future _ensureInitialized() async { + if (!_isInitialized) { + await initialize(); + } + return _isInitialized; + } + Future requestPermissions() async { if (!_isInitialized) { await initialize(); @@ -114,9 +127,7 @@ class NotificationService { String? contactId, int? badgeCount, }) async { - if (!_isInitialized) { - await initialize(); - } + if (!await _ensureInitialized()) return; final androidDetails = AndroidNotificationDetails( 'messages', @@ -148,13 +159,17 @@ class NotificationService { macOS: macDetails, ); - await _notifications.show( - id: contactId?.hashCode ?? 0, - title: contactName, - body: message, - notificationDetails: notificationDetails, - payload: 'message:$contactId', - ); + try { + await _notifications.show( + id: contactId?.hashCode ?? 0, + title: contactName, + body: message, + notificationDetails: notificationDetails, + payload: 'message:$contactId', + ); + } catch (e) { + debugPrint('Failed to show message notification: $e'); + } } Future _showAdvertNotificationImpl({ @@ -162,9 +177,7 @@ class NotificationService { required String contactType, String? contactId, }) async { - if (!_isInitialized) { - await initialize(); - } + if (!await _ensureInitialized()) return; const androidDetails = AndroidNotificationDetails( 'adverts', @@ -193,13 +206,17 @@ class NotificationService { macOS: macDetails, ); - await _notifications.show( - id: contactId?.hashCode ?? DateTime.now().millisecondsSinceEpoch, - title: _l10n.notification_newTypeDiscovered(contactType), - body: contactName, - notificationDetails: notificationDetails, - payload: 'advert:$contactId', - ); + try { + await _notifications.show( + id: contactId?.hashCode ?? DateTime.now().millisecondsSinceEpoch, + title: _l10n.notification_newTypeDiscovered(contactType), + body: contactName, + notificationDetails: notificationDetails, + payload: 'advert:$contactId', + ); + } catch (e) { + debugPrint('Failed to show advert notification: $e'); + } } Future _showChannelMessageNotificationImpl({ @@ -208,9 +225,7 @@ class NotificationService { int? channelIndex, int? badgeCount, }) async { - if (!_isInitialized) { - await initialize(); - } + if (!await _ensureInitialized()) return; final androidDetails = AndroidNotificationDetails( 'channel_messages', @@ -247,13 +262,17 @@ class NotificationService { ? _l10n.notification_receivedNewMessage : preview; - await _notifications.show( - id: channelIndex?.hashCode ?? DateTime.now().millisecondsSinceEpoch, - title: channelName, - body: body, - notificationDetails: notificationDetails, - payload: 'channel:$channelIndex', - ); + try { + await _notifications.show( + id: channelIndex?.hashCode ?? DateTime.now().millisecondsSinceEpoch, + title: channelName, + body: body, + notificationDetails: notificationDetails, + payload: 'channel:$channelIndex', + ); + } catch (e) { + debugPrint('Failed to show channel notification: $e'); + } } /// Returns a privacy-safe identifier for debug logging. @@ -396,35 +415,39 @@ class NotificationService { Future _showNotificationImmediately( _PendingNotification notification, ) async { - switch (notification.type) { - case _NotificationType.message: - await _showMessageNotificationImpl( - contactName: notification.title, - message: notification.body, - contactId: notification.id, - badgeCount: notification.badgeCount, - ); - break; - case _NotificationType.advert: - await _showAdvertNotificationImpl( - contactName: notification.body, - contactType: notification.title, - contactId: notification.id, - ); - break; - case _NotificationType.channelMessage: - await _showChannelMessageNotificationImpl( - channelName: notification.title, - message: notification.body, - channelIndex: int.tryParse(notification.id ?? ''), - badgeCount: notification.badgeCount, - ); - break; + try { + switch (notification.type) { + case _NotificationType.message: + await _showMessageNotificationImpl( + contactName: notification.title, + message: notification.body, + contactId: notification.id, + badgeCount: notification.badgeCount, + ); + break; + case _NotificationType.advert: + await _showAdvertNotificationImpl( + contactName: notification.body, + contactType: notification.title, + contactId: notification.id, + ); + break; + case _NotificationType.channelMessage: + await _showChannelMessageNotificationImpl( + channelName: notification.title, + message: notification.body, + channelIndex: int.tryParse(notification.id ?? ''), + badgeCount: notification.badgeCount, + ); + break; + } + } catch (e) { + debugPrint('Failed to show immediate notification: $e'); } } Future _showBatchSummary(List<_PendingNotification> batch) async { - if (!_isInitialized) await initialize(); + if (!await _ensureInitialized()) return; // Group by type final messages = batch @@ -468,13 +491,17 @@ class NotificationService { const notificationDetails = NotificationDetails(android: androidDetails); - await _notifications.show( - id: 'batch_summary'.hashCode, - title: _l10n.notification_activityTitle, - body: parts.join(', '), - notificationDetails: notificationDetails, - payload: 'batch', - ); + try { + await _notifications.show( + id: 'batch_summary'.hashCode, + title: _l10n.notification_activityTitle, + body: parts.join(', '), + notificationDetails: notificationDetails, + payload: 'batch', + ); + } catch (e) { + debugPrint('Failed to show batch summary notification: $e'); + } } } diff --git a/lib/widgets/adaptive_app_bar_title.dart b/lib/widgets/adaptive_app_bar_title.dart new file mode 100644 index 00000000..12363dd7 --- /dev/null +++ b/lib/widgets/adaptive_app_bar_title.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +class AdaptiveAppBarTitle extends StatelessWidget { + final String text; + + const AdaptiveAppBarTitle(this.text, {super.key}); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) => SizedBox( + width: constraints.maxWidth, + child: FittedBox(fit: BoxFit.scaleDown, child: Text(text, maxLines: 1)), + ), + ); + } +} diff --git a/test/services/line_of_sight_service_test.dart b/test/services/line_of_sight_service_test.dart new file mode 100644 index 00000000..987ee6c5 --- /dev/null +++ b/test/services/line_of_sight_service_test.dart @@ -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 makePoints(int count) { + return List.generate(count, (i) => LatLng(0, i * 0.00001)); + } + + test('computeFromElevations reports clear LOS on flat terrain', () { + final points = makePoints(21); + final elevations = List.filled(points.length, 100); + + final result = LineOfSightService.computeFromElevations( + points: points, + elevations: elevations, + startAntennaHeightMeters: 2, + endAntennaHeightMeters: 2, + ); + + expect(result.hasData, isTrue); + expect(result.isClear, isTrue); + expect(result.maxObstructionMeters, equals(0)); + expect(result.firstObstructionDistanceMeters, isNull); + }); + + test( + 'computeFromElevations reports blocked LOS with central obstruction', + () { + final points = makePoints(21); + final elevations = List.filled(points.length, 100); + elevations[10] = 300; + + final result = LineOfSightService.computeFromElevations( + points: points, + elevations: elevations, + startAntennaHeightMeters: 1.5, + endAntennaHeightMeters: 1.5, + ); + + expect(result.hasData, isTrue); + expect(result.isClear, isFalse); + expect(result.maxObstructionMeters, greaterThan(0)); + expect(result.firstObstructionDistanceMeters, isNotNull); + }, + ); + + test('analyzePath summarizes clear and blocked segments', () async { + final service = LineOfSightService( + elevationDataSource: (points) async { + final elevations = List.filled(points.length, 100); + if (points.first.longitude > 0.00005) { + elevations[elevations.length ~/ 2] = 300; + } + return elevations; + }, + ); + + final path = [ + const LatLng(0, 0), + const LatLng(0, 0.0001), + const LatLng(0, 0.0002), + ]; + + final result = await service.analyzePath(path); + + expect(result.segments.length, 2); + expect(result.clearSegments, 1); + expect(result.blockedSegments, 1); + expect(result.unknownSegments, 0); + }); +} From 7acfe47fd78e0b49d718887258d7d767ed13a34b Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Fri, 20 Feb 2026 22:09:11 -0800 Subject: [PATCH 04/14] Refactor map legend and filtering logic for contacts with location, to show count of active markers. (#203) --- lib/screens/map_screen.dart | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 8c13a717..ce7a7d1f 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -488,7 +488,8 @@ class _MapScreenState extends State { ), if (!_isBuildingPathTrace) _buildLegend( - contactsWithLocation.length, + contactsWithLocation, + settings, sharedMarkers.length, ), if (_isBuildingPathTrace) _buildPathTraceOverlay(), @@ -524,13 +525,17 @@ class _MapScreenState extends State { if (!contact.hasLocation) continue; // Apply node type filters - if (contact.type == advTypeRepeater && !settings.mapShowRepeaters) { + if (contact.type == advTypeRepeater && + (!settings.mapShowRepeaters && !_isBuildingPathTrace)) { + continue; + } + if (contact.type == advTypeChat && + !(settings.mapShowChatNodes && !_isBuildingPathTrace)) { continue; } - if (contact.type == advTypeChat && !settings.mapShowChatNodes) continue; if (contact.type != advTypeChat && contact.type != advTypeRepeater && - !settings.mapShowOtherNodes) { + (!settings.mapShowOtherNodes && !_isBuildingPathTrace)) { continue; } @@ -653,7 +658,26 @@ class _MapScreenState extends State { } } - Widget _buildLegend(int nodeCount, int markerCount) { + Widget _buildLegend( + List contactsWithLocation, + settings, + int markerCount, + ) { + int nodeCount = 0; + for (final contact in contactsWithLocation) { + // Apply node type filters + if (contact.type == advTypeRepeater && !settings.mapShowRepeaters) { + continue; + } + if (contact.type == advTypeChat && !settings.mapShowChatNodes) continue; + if (contact.type != advTypeChat && + contact.type != advTypeRepeater && + !settings.mapShowOtherNodes) { + continue; + } + nodeCount++; + } + return Positioned( top: 16, right: 16, From 304c3896692f5ad80a4e4a8d904a4fc0f1d7d33c Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Fri, 20 Feb 2026 23:41:20 -0800 Subject: [PATCH 05/14] Refactor label display in Line Of Sight and Map screens for improved alignment and styling (#204) --- lib/screens/line_of_sight_map_screen.dart | 44 +++++++++---------- lib/screens/map_screen.dart | 52 ++++++++++------------- 2 files changed, 44 insertions(+), 52 deletions(-) diff --git a/lib/screens/line_of_sight_map_screen.dart b/lib/screens/line_of_sight_map_screen.dart index a2d4b4ac..b073685a 100644 --- a/lib/screens/line_of_sight_map_screen.dart +++ b/lib/screens/line_of_sight_map_screen.dart @@ -802,29 +802,27 @@ class _LineOfSightMapScreenState extends State { alignment: Alignment.topCenter, child: IgnorePointer( child: Transform.translate( - offset: const Offset(0, -26), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(8), - ), - child: SizedBox( - width: 96, - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerLeft, - child: Text( - endpoint.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontSize: 10, - ), + offset: const Offset(0, -20), + child: FittedBox( + fit: BoxFit.contain, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: Text( + endpoint.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w500, ), ), ), diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index ce7a7d1f..77ec98ce 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -462,13 +462,10 @@ class _MapScreenState extends State { ], ), alignment: Alignment.center, - child: Text( - context.l10n.pathTrace_you, - style: const TextStyle( - color: Colors.black, - fontWeight: FontWeight.bold, - fontSize: 12, - ), + child: const Icon( + Icons.person_pin_circle, + color: Colors.white, + size: 20, ), ), ), @@ -480,7 +477,7 @@ class _MapScreenState extends State { connector.selfLatitude!, connector.selfLongitude!, ), - label: connector.deviceDisplayName, + label: context.l10n.pathTrace_you, ), ], ), @@ -598,27 +595,24 @@ class _MapScreenState extends State { alignment: Alignment.topCenter, child: IgnorePointer( child: Transform.translate( - offset: const Offset(0, -26), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(8), - ), - child: SizedBox( - width: 96, - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerLeft, - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontSize: 11, - fontWeight: FontWeight.w500, - ), + 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, ), ), ), From 061b7156946e51ae3e98acfdb9b85cfdb2ba5bdf Mon Sep 17 00:00:00 2001 From: just_stuff_tm <133525672+just-stuff-tm@users.noreply.github.com> Date: Sat, 21 Feb 2026 17:54:39 -0500 Subject: [PATCH 06/14] Fix repeater battery % inconsistency and add configurable repeater battery chemistry (#199) * fix(repeater): unify battery percentage math and add repeater chemistry setting - Add shared battery percent utility used by connector, repeater status, and telemetry - Add repeater-specific battery chemistry persistence and service accessors - Add repeater chemistry selector in Repeater Hub - Ensure telemetry and status compute percentages consistently from same chemistry - Add focused battery utility tests Refs #116 Refs #174 * fix: Flutter Analyzer Errors fixed Recent Merge Compatible * Unify repeater battery source across status and telemetry --- lib/connector/meshcore_connector.dart | 60 +++++++++++++++--------- lib/models/app_settings.dart | 18 +++++-- lib/screens/repeater_hub_screen.dart | 62 +++++++++++++++++++++++++ lib/screens/repeater_status_screen.dart | 44 ++++++++++++++---- lib/screens/telemetry_screen.dart | 46 ++++++++++++++---- lib/services/app_settings_service.dart | 25 +++++++--- lib/utils/battery_utils.dart | 26 +++++++++++ test/utils/battery_utils_test.dart | 23 +++++++++ 8 files changed, 253 insertions(+), 51 deletions(-) create mode 100644 lib/utils/battery_utils.dart create mode 100644 test/utils/battery_utils_test.dart diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 9b256e29..10ee15bf 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -29,6 +29,7 @@ import '../storage/contact_store.dart'; import '../storage/message_store.dart'; import '../storage/unread_store.dart'; import '../utils/app_logger.dart'; +import '../utils/battery_utils.dart'; import 'meshcore_protocol.dart'; class MeshCoreUuids { @@ -81,6 +82,18 @@ enum MeshCoreConnectionState { disconnecting, } +class RepeaterBatterySnapshot { + final int millivolts; + final DateTime updatedAt; + final String source; + + const RepeaterBatterySnapshot({ + required this.millivolts, + required this.updatedAt, + required this.source, + }); +} + class MeshCoreConnector extends ChangeNotifier { // Message windowing to limit memory usage static const int _messageWindowSize = 200; @@ -187,6 +200,7 @@ class MeshCoreConnector extends ChangeNotifier { final Map _contactSmazEnabled = {}; final Set _knownContactKeys = {}; final Map _contactUnreadCount = {}; + final Map _repeaterBatterySnapshots = {}; bool _unreadStateLoaded = false; final Map _pendingRepeaterAcks = {}; String? _activeContactKey; @@ -254,10 +268,32 @@ class MeshCoreConnector extends ChangeNotifier { : 0; int? get batteryPercent => _batteryMillivolts == null ? null - : _estimateBatteryPercent( + : estimateBatteryPercentFromMillivolts( _batteryMillivolts!, _batteryChemistryForDevice(), ); + RepeaterBatterySnapshot? getRepeaterBatterySnapshot(String contactKeyHex) => + _repeaterBatterySnapshots[contactKeyHex]; + int? getRepeaterBatteryMillivolts(String contactKeyHex) => + _repeaterBatterySnapshots[contactKeyHex]?.millivolts; + + void updateRepeaterBatterySnapshot( + String contactKeyHex, + int millivolts, { + String source = 'unknown', + }) { + if (contactKeyHex.isEmpty || millivolts <= 0) return; + final previous = _repeaterBatterySnapshots[contactKeyHex]; + final snapshot = RepeaterBatterySnapshot( + millivolts: millivolts, + updatedAt: DateTime.now(), + source: source, + ); + _repeaterBatterySnapshots[contactKeyHex] = snapshot; + if (previous?.millivolts != millivolts) { + notifyListeners(); + } + } String _batteryChemistryForDevice() { final deviceId = _device?.remoteId.toString(); @@ -265,27 +301,6 @@ class MeshCoreConnector extends ChangeNotifier { return _appSettingsService!.batteryChemistryForDevice(deviceId); } - int _estimateBatteryPercent(int millivolts, String chemistry) { - final range = _batteryVoltageRange(chemistry); - final minMv = range.$1; - final maxMv = range.$2; - if (millivolts <= minMv) return 0; - if (millivolts >= maxMv) return 100; - return (((millivolts - minMv) * 100) / (maxMv - minMv)).round(); - } - - (int, int) _batteryVoltageRange(String chemistry) { - switch (chemistry) { - case 'lifepo4': - return (2600, 3650); - case 'lipo': - return (3000, 4200); - case 'nmc': - default: - return (3000, 4200); - } - } - List getMessages(Contact contact) { return _conversations[contact.publicKeyHex] ?? []; } @@ -961,6 +976,7 @@ class MeshCoreConnector extends ChangeNotifier { _clientRepeat = null; _firmwareVerCode = null; _batteryMillivolts = null; + _repeaterBatterySnapshots.clear(); _batteryRequested = false; _awaitingSelfInfo = false; _maxContacts = _defaultMaxContacts; diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index 229a7a64..71cafa03 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -34,6 +34,7 @@ class AppSettings { final String? languageOverride; // null = system default final bool appDebugLogEnabled; final Map batteryChemistryByDeviceId; + final Map batteryChemistryByRepeaterId; final UnitSystem unitSystem; AppSettings({ @@ -57,8 +58,10 @@ class AppSettings { this.languageOverride, this.appDebugLogEnabled = false, Map? batteryChemistryByDeviceId, + Map? batteryChemistryByRepeaterId, this.unitSystem = UnitSystem.metric, - }) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {}; + }) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {}, + batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {}; Map toJson() { return { @@ -82,6 +85,7 @@ class AppSettings { 'language_override': languageOverride, 'app_debug_log_enabled': appDebugLogEnabled, 'battery_chemistry_by_device_id': batteryChemistryByDeviceId, + 'battery_chemistry_by_repeater_id': batteryChemistryByRepeaterId, 'unit_system': unitSystem.value, }; } @@ -124,9 +128,12 @@ class AppSettings { (key, value) => MapEntry(key.toString(), value.toString()), ) ?? {}, - unitSystem: parseUnitSystem( - json['unit_system'] ?? json['los_unit_system'], - ), + batteryChemistryByRepeaterId: + (json['battery_chemistry_by_repeater_id'] as Map?)?.map( + (key, value) => MapEntry(key.toString(), value.toString()), + ) ?? + {}, + unitSystem: parseUnitSystem(json['unit_system']), ); } @@ -151,6 +158,7 @@ class AppSettings { Object? languageOverride = _unset, bool? appDebugLogEnabled, Map? batteryChemistryByDeviceId, + Map? batteryChemistryByRepeaterId, UnitSystem? unitSystem, }) { return AppSettings( @@ -181,6 +189,8 @@ class AppSettings { appDebugLogEnabled: appDebugLogEnabled ?? this.appDebugLogEnabled, batteryChemistryByDeviceId: batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId, + batteryChemistryByRepeaterId: + batteryChemistryByRepeaterId ?? this.batteryChemistryByRepeaterId, unitSystem: unitSystem ?? this.unitSystem, ); } diff --git a/lib/screens/repeater_hub_screen.dart b/lib/screens/repeater_hub_screen.dart index a5f503d5..fd2da8e4 100644 --- a/lib/screens/repeater_hub_screen.dart +++ b/lib/screens/repeater_hub_screen.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:meshcore_open/connector/meshcore_protocol.dart'; +import 'package:provider/provider.dart'; import '../l10n/l10n.dart'; import '../models/contact.dart'; +import '../services/app_settings_service.dart'; import 'repeater_status_screen.dart'; import 'repeater_cli_screen.dart'; import 'repeater_settings_screen.dart'; @@ -21,6 +23,10 @@ class RepeaterHubScreen extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = context.l10n; + final settingsService = context.watch(); + final chemistry = settingsService.batteryChemistryForRepeater( + repeater.publicKeyHex, + ); return Scaffold( appBar: AppBar( title: Column( @@ -107,6 +113,62 @@ class RepeaterHubScreen extends StatelessWidget { ), ), const SizedBox(height: 24), + Card( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.battery_full), + const SizedBox(width: 10), + Expanded( + child: Text( + l10n.appSettings_batteryChemistry, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: chemistry, + isExpanded: true, + decoration: const InputDecoration( + border: UnderlineInputBorder(), + isDense: true, + ), + onChanged: (value) { + if (value == null) return; + settingsService.setBatteryChemistryForRepeater( + repeater.publicKeyHex, + value, + ); + }, + items: [ + DropdownMenuItem( + value: 'nmc', + child: Text(l10n.appSettings_batteryNmc), + ), + DropdownMenuItem( + value: 'lifepo4', + child: Text(l10n.appSettings_batteryLifepo4), + ), + DropdownMenuItem( + value: 'lipo', + child: Text(l10n.appSettings_batteryLipo), + ), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 24), Text( l10n.repeater_managementTools, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), diff --git a/lib/screens/repeater_status_screen.dart b/lib/screens/repeater_status_screen.dart index 472b0137..95254f43 100644 --- a/lib/screens/repeater_status_screen.dart +++ b/lib/screens/repeater_status_screen.dart @@ -8,7 +8,9 @@ import '../models/contact.dart'; import '../models/path_selection.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; +import '../services/app_settings_service.dart'; import '../services/repeater_command_service.dart'; +import '../utils/battery_utils.dart'; import '../widgets/path_management_dialog.dart'; class RepeaterStatusScreen extends StatefulWidget { @@ -179,6 +181,12 @@ class _RepeaterStatusScreenState extends State { _dupDirect = directDups; _dupFlood = floodDups; }); + final connector = Provider.of(context, listen: false); + connector.updateRepeaterBatterySnapshot( + widget.repeater.publicKeyHex, + batteryMv, + source: 'status_binary', + ); _recordStatusResult(true); } @@ -201,6 +209,18 @@ class _RepeaterStatusScreenState extends State { _uptimeSecs = _asInt(data['uptime_secs']); _queueLen = _asInt(data['queue_len']); _debugFlags = _asInt(data['errors']); + final batteryMv = _batteryMv; + if (batteryMv != null) { + final connector = Provider.of( + context, + listen: false, + ); + connector.updateRepeaterBatterySnapshot( + widget.repeater.publicKeyHex, + batteryMv, + source: 'status_text', + ); + } } else if (data.containsKey('noise_floor')) { _noiseFloor = _asInt(data['noise_floor']); _lastRssi = _asInt(data['last_rssi']); @@ -590,18 +610,24 @@ class _RepeaterStatusScreenState extends State { } String _batteryText() { - if (_batteryMv == null) return '—'; - final percent = _batteryPercentFromMv(_batteryMv!); - final volts = (_batteryMv! / 1000.0).toStringAsFixed(2); + final connector = context.watch(); + final batteryMv = + connector.getRepeaterBatteryMillivolts(widget.repeater.publicKeyHex) ?? + _batteryMv; + if (batteryMv == null) return '—'; + final percent = estimateBatteryPercentFromMillivolts( + batteryMv, + _batteryChemistry(), + ); + final volts = (batteryMv / 1000.0).toStringAsFixed(2); return '$percent% / ${volts}V'; } - int _batteryPercentFromMv(int millivolts) { - const minMv = 3000; - const maxMv = 4200; - if (millivolts <= minMv) return 0; - if (millivolts >= maxMv) return 100; - return (((millivolts - minMv) * 100) / (maxMv - minMv)).round(); + String _batteryChemistry() { + final settingsService = context.read(); + return settingsService.batteryChemistryForRepeater( + widget.repeater.publicKeyHex, + ); } String _clockText() { diff --git a/lib/screens/telemetry_screen.dart b/lib/screens/telemetry_screen.dart index 88c204d1..3f95ccdb 100644 --- a/lib/screens/telemetry_screen.dart +++ b/lib/screens/telemetry_screen.dart @@ -12,6 +12,7 @@ import '../services/app_settings_service.dart'; import '../services/repeater_command_service.dart'; import '../widgets/path_management_dialog.dart'; import '../helpers/cayenne_lpp.dart'; +import '../utils/battery_utils.dart'; class TelemetryScreen extends StatefulWidget { final Contact repeater; @@ -74,9 +75,19 @@ class _TelemetryScreenState extends State { } void _handleStatusResponse(Uint8List frame) { + final parsedTelemetry = CayenneLpp.parseByChannel(frame); + final batteryMv = _extractTelemetryBatteryMillivolts(parsedTelemetry); + if (batteryMv != null) { + final connector = Provider.of(context, listen: false); + connector.updateRepeaterBatterySnapshot( + widget.repeater.publicKeyHex, + batteryMv, + source: 'telemetry', + ); + } if (!mounted) return; setState(() { - _parsedTelemetry = CayenneLpp.parseByChannel(frame); + _parsedTelemetry = parsedTelemetry; }); ScaffoldMessenger.of(context).showSnackBar( @@ -411,20 +422,35 @@ class _TelemetryScreenState extends State { ); } - String _batteryText(double? batteryMv) { + int? _extractTelemetryBatteryMillivolts(List> entries) { + for (final entry in entries) { + if (entry['channel'] != 1) continue; + final values = entry['values']; + if (values is! Map) continue; + final voltage = values['voltage']; + if (voltage is num) return (voltage.toDouble() * 1000).round(); + } + return null; + } + + String _batteryText(double? telemetryVolts) { final l10n = context.l10n; + final connector = context.watch(); + final batteryMv = + connector.getRepeaterBatteryMillivolts(widget.repeater.publicKeyHex) ?? + (telemetryVolts == null ? null : (telemetryVolts * 1000).round()); if (batteryMv == null) return l10n.common_notAvailable; - final percent = _batteryPercentFromMv(batteryMv); - final volts = batteryMv.toStringAsFixed(2); + final chemistry = _batteryChemistry(); + final percent = estimateBatteryPercentFromMillivolts(batteryMv, chemistry); + final volts = (batteryMv / 1000).toStringAsFixed(2); return l10n.telemetry_batteryValue(percent, volts); } - int _batteryPercentFromMv(double millivolts) { - const minMv = 2.800; - const maxMv = 4.200; - if (millivolts <= minMv) return 0; - if (millivolts >= maxMv) return 100; - return (((millivolts - minMv) * 100) / (maxMv - minMv)).round(); + String _batteryChemistry() { + final settingsService = context.read(); + return settingsService.batteryChemistryForRepeater( + widget.repeater.publicKeyHex, + ); } String _temperatureText(double? tempC, bool isImperialUnits) { diff --git a/lib/services/app_settings_service.dart b/lib/services/app_settings_service.dart index a85ab920..e131eb87 100644 --- a/lib/services/app_settings_service.dart +++ b/lib/services/app_settings_service.dart @@ -17,6 +17,12 @@ class AppSettingsService extends ChangeNotifier { return stored ?? 'nmc'; } + String batteryChemistryForRepeater(String repeaterPubKeyHex) { + final stored = _settings.batteryChemistryByRepeaterId[repeaterPubKeyHex]; + if (stored == 'liion') return 'nmc'; + return stored ?? 'nmc'; + } + Future loadSettings() async { final prefs = PrefsManager.instance; final jsonStr = prefs.getString(_settingsKey); @@ -133,13 +139,20 @@ class AppSettingsService extends ChangeNotifier { ); } + Future setBatteryChemistryForRepeater( + String repeaterPubKeyHex, + String chemistry, + ) async { + final updated = Map.from( + _settings.batteryChemistryByRepeaterId, + ); + updated[repeaterPubKeyHex] = chemistry; + await updateSettings( + _settings.copyWith(batteryChemistryByRepeaterId: updated), + ); + } + Future setUnitSystem(UnitSystem value) async { await updateSettings(_settings.copyWith(unitSystem: value)); } - - Future setLosUnitSystem(String value) async { - await setUnitSystem( - value == 'imperial' ? UnitSystem.imperial : UnitSystem.metric, - ); - } } diff --git a/lib/utils/battery_utils.dart b/lib/utils/battery_utils.dart new file mode 100644 index 00000000..2bf4a5df --- /dev/null +++ b/lib/utils/battery_utils.dart @@ -0,0 +1,26 @@ +typedef BatteryVoltageRange = ({int minMv, int maxMv}); + +BatteryVoltageRange batteryVoltageRange(String chemistry) { + switch (chemistry) { + case 'lifepo4': + return (minMv: 2600, maxMv: 3650); + case 'lipo': + return (minMv: 3000, maxMv: 4200); + case 'nmc': + default: + return (minMv: 3000, maxMv: 4200); + } +} + +int estimateBatteryPercentFromMillivolts(int millivolts, String chemistry) { + final range = batteryVoltageRange(chemistry); + if (millivolts <= range.minMv) return 0; + if (millivolts >= range.maxMv) return 100; + return (((millivolts - range.minMv) * 100) / (range.maxMv - range.minMv)) + .round(); +} + +int estimateBatteryPercentFromVolts(double volts, String chemistry) { + final millivolts = (volts * 1000).round(); + return estimateBatteryPercentFromMillivolts(millivolts, chemistry); +} diff --git a/test/utils/battery_utils_test.dart b/test/utils/battery_utils_test.dart new file mode 100644 index 00000000..65dec1ec --- /dev/null +++ b/test/utils/battery_utils_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/utils/battery_utils.dart'; + +void main() { + group('battery utils', () { + test('nmc range maps 3.0V to 0% and 4.2V to 100%', () { + expect(estimateBatteryPercentFromVolts(3.0, 'nmc'), 0); + expect(estimateBatteryPercentFromVolts(4.2, 'nmc'), 100); + }); + + test('lifepo4 range maps 2.6V to 0% and 3.65V to 100%', () { + expect(estimateBatteryPercentFromVolts(2.6, 'lifepo4'), 0); + expect(estimateBatteryPercentFromVolts(3.65, 'lifepo4'), 100); + }); + + test('unknown chemistry falls back to nmc mapping', () { + expect( + estimateBatteryPercentFromMillivolts(3600, 'unknown'), + estimateBatteryPercentFromMillivolts(3600, 'nmc'), + ); + }); + }); +} From b05b62eeee8431ab4c8bea0582fd9eadebec6426 Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Sat, 21 Feb 2026 14:55:42 -0800 Subject: [PATCH 07/14] Changed all map lables to look the same across all map ui (#206) * Refactor label display in Line Of Sight and Map screens for improved alignment and styling * Refactor label positioning and styling in ChannelMessagePathMap and PathTraceMap screens for improved alignment --- lib/screens/channel_message_path_screen.dart | 39 +++++++++----------- lib/screens/path_trace_map.dart | 39 +++++++++----------- 2 files changed, 36 insertions(+), 42 deletions(-) diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index 2d1faa3f..44dfe797 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -663,27 +663,24 @@ class _ChannelMessagePathMapScreenState alignment: Alignment.topCenter, child: IgnorePointer( child: Transform.translate( - offset: const Offset(0, -26), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(8), - ), - child: SizedBox( - width: 96, - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerLeft, - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontSize: 11, - fontWeight: FontWeight.w500, - ), + 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, ), ), ), diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index c1f7f447..5f86cc16 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -479,27 +479,24 @@ class _PathTraceMapScreenState extends State { alignment: Alignment.topCenter, child: IgnorePointer( child: Transform.translate( - offset: const Offset(0, -26), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(8), - ), - child: SizedBox( - width: 96, - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerLeft, - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontSize: 11, - fontWeight: FontWeight.w500, - ), + 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, ), ), ), From 51d70ce0869e1953eb7d67ed7bf684741fb22d44 Mon Sep 17 00:00:00 2001 From: just_stuff_tm <133525672+just-stuff-tm@users.noreply.github.com> Date: Sat, 21 Feb 2026 18:20:56 -0500 Subject: [PATCH 08/14] fix(appbar): prevent title overflow on narrow widths (#205) Use width-aware layout in AppBarTitle to avoid RenderFlex overflows under tight title constraints and larger text scaling. Hide subtitle and signal indicators progressively when space is limited while preserving normal behavior on wider layouts. --- lib/widgets/app_bar.dart | 69 +++++++++++++++++++++++++--------------- 1 file changed, 44 insertions(+), 25 deletions(-) diff --git a/lib/widgets/app_bar.dart b/lib/widgets/app_bar.dart index c88a5965..e1cda778 100644 --- a/lib/widgets/app_bar.dart +++ b/lib/widgets/app_bar.dart @@ -14,35 +14,54 @@ class AppBarTitle extends StatelessWidget { @override Widget build(BuildContext context) { final connector = context.watch(); + final selfName = connector.selfName; - return Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - leading ?? const SizedBox.shrink(), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + 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: [ - Text(title, overflow: TextOverflow.ellipsis), - if (connector.isConnected && connector.selfName != null) - Text( - '(${connector.selfName})', - style: TextStyle(fontSize: 14, color: Colors.grey[600]), - overflow: TextOverflow.ellipsis, + 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(), ], - ), - const SizedBox(width: 8), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - BatteryIndicator(connector: connector), - SNRIndicator(connector: connector), - ], - ), - trailing ?? const SizedBox.shrink(), - ], + ); + }, ); } } From 2feff809ff76c18ab08913dfe0ce08c862df7f62 Mon Sep 17 00:00:00 2001 From: Aaron Easterling <111671335+Specter242@users.noreply.github.com> Date: Sat, 21 Feb 2026 18:31:51 -0500 Subject: [PATCH 09/14] Mark pending channel messages sent on RESP_CODE_SENT (#186) * Mark pending channel message sent on RESP_CODE_SENT * Disambiguate RESP_CODE_SENT handling for direct vs channel * Handle channel sent feedback when firmware returns RESP_CODE_OK * Correlate channel OK ACKs and queue reaction channel sends --- lib/connector/meshcore_connector.dart | 166 ++++++++++++++++++++++-- lib/screens/contacts_screen.dart | 9 +- lib/services/message_retry_service.dart | 15 ++- 3 files changed, 175 insertions(+), 15 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 10ee15bf..2fbddc7c 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -114,6 +114,10 @@ class MeshCoreConnector extends ChangeNotifier { final List _channels = []; final Map> _conversations = {}; final Map> _channelMessages = {}; + final List _pendingChannelSentQueue = []; + final List<_PendingCommandAck> _pendingGenericAckQueue = []; + static const String _reactionSendQueuePrefix = '__reaction_send__'; + int _reactionSendQueueSequence = 0; final Set _loadedConversationKeys = {}; final Map> _processedChannelReactions = {}; // channelIndex -> Set of "targetHash_emoji" @@ -988,6 +992,9 @@ class MeshCoreConnector extends ChangeNotifier { _isSyncingChannels = false; _channelSyncInFlight = false; _hasLoadedChannels = false; + _pendingChannelSentQueue.clear(); + _pendingGenericAckQueue.clear(); + _reactionSendQueueSequence = 0; _setState(MeshCoreConnectionState.disconnected); if (!manual) { @@ -995,7 +1002,11 @@ class MeshCoreConnector extends ChangeNotifier { } } - Future sendFrame(Uint8List data) async { + Future sendFrame( + Uint8List data, { + String? channelSendQueueId, + bool expectsGenericAck = false, + }) async { if (!isConnected || _rxCharacteristic == null) { throw Exception("Not connected to a MeshCore device"); } @@ -1014,6 +1025,11 @@ class MeshCoreConnector extends ChangeNotifier { data.toList(), withoutResponse: canWriteWithoutResponse, ); + _trackPendingGenericAck( + data, + channelSendQueueId: channelSendQueueId, + expectsGenericAck: expectsGenericAck, + ); } Future requestBatteryStatus({bool force = false}) async { @@ -1369,7 +1385,13 @@ class MeshCoreConnector extends ChangeNotifier { notifyListeners(); // Send the reaction to the device (don't add as a visible message) - await sendFrame(buildSendChannelTextMsgFrame(channel.index, text)); + final reactionQueueId = _nextReactionSendQueueId(); + _pendingChannelSentQueue.add(reactionQueueId); + await sendFrame( + buildSendChannelTextMsgFrame(channel.index, text), + channelSendQueueId: reactionQueueId, + expectsGenericAck: true, + ); return; } @@ -1379,6 +1401,7 @@ class MeshCoreConnector extends ChangeNotifier { channel.index, ); _addChannelMessage(channel.index, message); + _pendingChannelSentQueue.add(message.messageId); notifyListeners(); final trimmed = text.trim(); @@ -1388,7 +1411,11 @@ class MeshCoreConnector extends ChangeNotifier { (isChannelSmazEnabled(channel.index) && !isStructuredPayload) ? Smaz.encodeIfSmaller(text) : text; - await sendFrame(buildSendChannelTextMsgFrame(channel.index, outboundText)); + await sendFrame( + buildSendChannelTextMsgFrame(channel.index, outboundText), + channelSendQueueId: message.messageId, + expectsGenericAck: true, + ); } Future removeContact(Contact contact) async { @@ -1735,6 +1762,9 @@ class MeshCoreConnector extends ChangeNotifier { debugPrint('RX frame: code=$code len=${frame.length}'); switch (code) { + case respCodeOk: + _handleOk(); + break; case respCodeDeviceInfo: _handleDeviceInfo(frame); break; @@ -1829,6 +1859,17 @@ class MeshCoreConnector extends ChangeNotifier { 'Firmware responded with error code: $errCode', tag: 'Protocol', ); + + if (_pendingGenericAckQueue.isEmpty) { + return; + } + + final failedAck = _pendingGenericAckQueue.removeAt(0); + if (failedAck.commandCode != cmdSendChannelTxtMsg || + failedAck.channelSendQueueId == null) { + return; + } + _pendingChannelSentQueue.remove(failedAck.channelSendQueueId); } void _handlePathUpdated(Uint8List frame) { @@ -2611,8 +2652,22 @@ class MeshCoreConnector extends ChangeNotifier { return; } - if (_retryService != null) { - _retryService!.updateMessageFromSent(ackHash, timeoutMs); + final retryService = _retryService; + if (retryService != null && + retryService.updateMessageFromSent( + ackHash, + timeoutMs, + allowQueueFallback: false, + )) { + return; + } + + if (_markNextPendingChannelMessageSent()) { + return; + } + + if (retryService != null) { + retryService.updateMessageFromSent(ackHash, timeoutMs); } } else { // Fallback to old behavior @@ -2629,6 +2684,64 @@ class MeshCoreConnector extends ChangeNotifier { } } + bool _markNextPendingChannelMessageSent() { + while (_pendingChannelSentQueue.isNotEmpty) { + final queuedMessageId = _pendingChannelSentQueue.removeAt(0); + if (_isReactionSendQueueId(queuedMessageId)) { + return true; + } + if (_markPendingChannelMessageSentById(queuedMessageId)) { + return true; + } + } + return false; + } + + bool _markPendingChannelMessageSentById(String messageId) { + for (final entry in _channelMessages.entries) { + final channelMessages = entry.value; + for (int i = channelMessages.length - 1; i >= 0; i--) { + final message = channelMessages[i]; + if (message.messageId != messageId) { + continue; + } + if (!message.isOutgoing || + message.status != ChannelMessageStatus.pending) { + return false; + } + channelMessages[i] = message.copyWith( + status: ChannelMessageStatus.sent, + ); + _pendingChannelSentQueue.remove(messageId); + unawaited( + _channelMessageStore.saveChannelMessages(entry.key, channelMessages), + ); + notifyListeners(); + return true; + } + } + return false; + } + + void _handleOk() { + if (_pendingGenericAckQueue.isEmpty) { + return; + } + + final pendingAck = _pendingGenericAckQueue.removeAt(0); + if (pendingAck.commandCode != cmdSendChannelTxtMsg || + pendingAck.channelSendQueueId == null) { + return; + } + + final queueId = pendingAck.channelSendQueueId!; + _pendingChannelSentQueue.remove(queueId); + if (_isReactionSendQueueId(queueId)) { + return; + } + _markPendingChannelMessageSentById(queueId); + } + void _handleSendConfirmed(Uint8List frame) { // Frame format from C++: // [0] = PUSH_CODE_SEND_CONFIRMED @@ -3207,18 +3320,22 @@ class MeshCoreConnector extends ChangeNotifier { mergedPathBytes.length, ); final newRepeatCount = existing.repeatCount + 1; + final promotedFromPending = + newRepeatCount == 1 && + existing.status == ChannelMessageStatus.pending; messages[existingIndex] = existing.copyWith( repeatCount: newRepeatCount, pathLength: mergedPathLength, pathBytes: mergedPathBytes, pathVariants: mergedPathVariants, // Mark as sent when first repeat is heard - status: - newRepeatCount == 1 && - existing.status == ChannelMessageStatus.pending + status: promotedFromPending ? ChannelMessageStatus.sent : existing.status, ); + if (promotedFromPending) { + _pendingChannelSentQueue.remove(existing.messageId); + } } else { messages.add(processedMessage); } @@ -3391,11 +3508,37 @@ class MeshCoreConnector extends ChangeNotifier { _queuedMessageSyncInFlight = false; _isSyncingChannels = false; _channelSyncInFlight = false; + _pendingChannelSentQueue.clear(); + _pendingGenericAckQueue.clear(); + _reactionSendQueueSequence = 0; _setState(MeshCoreConnectionState.disconnected); _scheduleReconnect(); } + void _trackPendingGenericAck( + Uint8List data, { + String? channelSendQueueId, + required bool expectsGenericAck, + }) { + if (!expectsGenericAck || data.isEmpty) return; + _pendingGenericAckQueue.add( + _PendingCommandAck( + commandCode: data[0], + channelSendQueueId: channelSendQueueId, + ), + ); + } + + String _nextReactionSendQueueId() { + _reactionSendQueueSequence++; + return '$_reactionSendQueuePrefix$_reactionSendQueueSequence'; + } + + bool _isReactionSendQueueId(String queueId) { + return queueId.startsWith(_reactionSendQueuePrefix); + } + Map _parseKeyValueString(String input) { final result = {}; @@ -3691,3 +3834,10 @@ class _RepeaterAckContext { required this.messageBytes, }); } + +class _PendingCommandAck { + final int commandCode; + final String? channelSendQueueId; + + _PendingCommandAck({required this.commandCode, this.channelSendQueueId}); +} diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index a6828dd1..c3f783c6 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -183,14 +183,17 @@ class _ContactsScreenState extends State final connector = Provider.of(context, listen: false); final exportContactFrame = buildExportContactFrame(pubKey); _pendingOperations.add(ContactOperationType.export); - await connector.sendFrame(exportContactFrame); + await connector.sendFrame(exportContactFrame, expectsGenericAck: true); } Future _contactZeroHop(Uint8List pubKey) async { final connector = Provider.of(context, listen: false); final exportContactZeroHopFrame = buildZeroHopContact(pubKey); _pendingOperations.add(ContactOperationType.zeroHopShare); - await connector.sendFrame(exportContactZeroHopFrame); + await connector.sendFrame( + exportContactZeroHopFrame, + expectsGenericAck: true, + ); } Future _contactImport() async { @@ -217,7 +220,7 @@ class _ContactsScreenState extends State try { final importContactFrame = buildImportContactFrame(hexString); _pendingOperations.add(ContactOperationType.import); - await connector.sendFrame(importContactFrame); + await connector.sendFrame(importContactFrame, expectsGenericAck: true); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/services/message_retry_service.dart b/lib/services/message_retry_service.dart index 9cbd68f7..694a6162 100644 --- a/lib/services/message_retry_service.dart +++ b/lib/services/message_retry_service.dart @@ -234,7 +234,11 @@ class MessageRetryService extends ChangeNotifier { } } - void updateMessageFromSent(Uint8List ackHash, int timeoutMs) { + bool updateMessageFromSent( + Uint8List ackHash, + int timeoutMs, { + bool allowQueueFallback = true, + }) { final ackHashHex = ackHash .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(); @@ -277,7 +281,7 @@ class MessageRetryService extends ChangeNotifier { } // FALLBACK: Old queue-based matching (for messages sent before hash computation was added) - if (messageId == null) { + if (messageId == null && allowQueueFallback) { _debugLogService?.warn( 'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue', tag: 'AckHash', @@ -320,7 +324,7 @@ class MessageRetryService extends ChangeNotifier { if (messageId == null || contact == null) { debugPrint('No pending message found for ACK hash: $ackHashHex'); - return; + return false; } // Store the mapping for future lookups (e.g., when ACK arrives) @@ -339,7 +343,7 @@ class MessageRetryService extends ChangeNotifier { 'Message $messageId no longer pending for ACK hash: $ackHashHex', ); _ackHashToMessageId.remove(ackHashHex); - return; + return false; } // Add this ACK hash to the list of expected ACKs for this message (for history) @@ -389,8 +393,11 @@ class MessageRetryService extends ChangeNotifier { _startTimeoutTimer(messageId, actualTimeout); debugPrint('Updated message $messageId with ACK hash: $ackHashHex'); + return true; } + bool get hasPendingMessages => _pendingMessages.isNotEmpty; + void _startTimeoutTimer(String messageId, int timeoutMs) { _timeoutTimers[messageId]?.cancel(); _timeoutTimers[messageId] = Timer(Duration(milliseconds: timeoutMs), () { From 7cb4c5a33445a8fe2759edde44c504561f2325fc Mon Sep 17 00:00:00 2001 From: Leah <45321184+ChaoticLeah@users.noreply.github.com> Date: Sun, 22 Feb 2026 08:44:20 +0100 Subject: [PATCH 10/14] Swipe to reply (#160) * Add swipe to reply * format * Cleaned up code * format * remove my gitignore change - ignore this * fix * Update lib/screens/channel_chat_screen.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update lib/screens/channel_chat_screen.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Refactor onHorizontalDragStart for readability fixed formating. * Fix swipe end handling in channel chat screen * Refactor swipe gesture handling in chat screen * Update lib/screens/channel_chat_screen.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update lib/screens/channel_chat_screen.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Refactor swipe handling for reply functionality * Adjust swipe thresholds and logic in chat screen * Conditionally render reply bubble or padding --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Winston Lowe --- lib/screens/channel_chat_screen.dart | 532 ++++++++++++++++++--------- 1 file changed, 367 insertions(+), 165 deletions(-) diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 021ad7d8..bf05110e 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -271,193 +272,243 @@ class _ChannelChatScreenState extends State { ? message.pathVariants.first : Uint8List(0)); - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), - child: Column( - crossAxisAlignment: isOutgoing - ? CrossAxisAlignment.end - : CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: isOutgoing - ? MainAxisAlignment.end - : MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!isOutgoing) ...[ - _buildAvatar(message.senderName), - const SizedBox(width: 8), - ], - Flexible( - child: GestureDetector( - onTap: () => _showMessagePathInfo(message), - onLongPress: () => _showMessageActions(message), - child: Container( - padding: gifId != null - ? const EdgeInsets.all(4) - : const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.65, - ), - decoration: BoxDecoration( - color: isOutgoing - ? Theme.of(context).colorScheme.primaryContainer - : Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!isOutgoing) ...[ - Padding( - padding: gifId != null - ? const EdgeInsets.only( - left: 8, - top: 4, - bottom: 4, - ) - : EdgeInsets.zero, - child: Text( - message.senderName, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), + const maxSwipeOffset = 64.0; + const replySwipeThreshold = 64.0; + final messageBody = Column( + crossAxisAlignment: isOutgoing + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: isOutgoing + ? MainAxisAlignment.end + : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isOutgoing) ...[ + _buildAvatar(message.senderName), + const SizedBox(width: 8), + ], + Flexible( + child: GestureDetector( + onTap: () => _showMessagePathInfo(message), + onLongPress: () => _showMessageActions(message), + child: Container( + padding: gifId != null + ? const EdgeInsets.all(4) + : const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.65, + ), + decoration: BoxDecoration( + color: isOutgoing + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isOutgoing) ...[ + Padding( + padding: gifId != null + ? const EdgeInsets.only( + left: 8, + top: 4, + bottom: 4, + ) + : EdgeInsets.zero, + child: Text( + message.senderName, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, ), ), - if (gifId == null) const SizedBox(height: 4), - ], - if (message.replyToMessageId != null) ...[ - _buildReplyPreview(message), - const SizedBox(height: 8), - ], - if (poi != null) - _buildPoiMessage(context, poi, isOutgoing) - else if (gifId != null) - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: GifMessage( - url: - 'https://media.giphy.com/media/$gifId/giphy.gif', - backgroundColor: Colors.transparent, - fallbackTextColor: isOutgoing - ? Theme.of(context) - .colorScheme - .onPrimaryContainer - .withValues(alpha: 0.7) - : Theme.of(context).colorScheme.onSurface - .withValues(alpha: 0.6), - ), - ) - 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 (gifId == null) const SizedBox(height: 4), + ], + if (message.replyToMessageId != null) ...[ + _buildReplyPreview(message), + const SizedBox(height: 8), + ], + if (poi != null) + _buildPoiMessage(context, poi, isOutgoing) + else if (gifId != null) + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: GifMessage( + url: + 'https://media.giphy.com/media/$gifId/giphy.gif', + backgroundColor: Colors.transparent, + fallbackTextColor: isOutgoing + ? Theme.of(context) + .colorScheme + .onPrimaryContainer + .withValues(alpha: 0.7) + : Theme.of(context).colorScheme.onSurface + .withValues(alpha: 0.6), ), - if (displayPath.isNotEmpty) ...[ - const SizedBox(height: 4), - Padding( - padding: gifId != null - ? const EdgeInsets.symmetric(horizontal: 8) - : EdgeInsets.zero, - child: Text( - 'via ${_formatPathPrefixes(displayPath)}', + ) + 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), + 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( 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: [ + if (message.repeatCount > 0) ...[ + const SizedBox(width: 6), + Icon( + Icons.repeat, + size: 12, + color: Colors.grey[600], + ), + const SizedBox(width: 2), Text( - _formatTime(message.timestamp), + '${message.repeatCount}', style: TextStyle( fontSize: 11, 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), + ], + ), ); } @@ -1007,6 +1058,157 @@ class _ChannelChatScreenState extends State { } } +class _SwipeReplyBubble extends StatefulWidget { + final double maxSwipeOffset; + final double replySwipeThreshold; + final VoidCallback onReplyTriggered; + final Widget Function({required bool isStart}) hintBuilder; + final Widget child; + + const _SwipeReplyBubble({ + required this.maxSwipeOffset, + required this.replySwipeThreshold, + required this.onReplyTriggered, + required this.hintBuilder, + required this.child, + }); + + @override + State<_SwipeReplyBubble> createState() => _SwipeReplyBubbleState(); +} + +class _SwipeReplyBubbleState extends State<_SwipeReplyBubble> { + Offset? _swipeStartPosition; + double _swipeOffset = 0; + double _maxSwipeDistance = 0; + int? _swipePointerId; + bool _swipeLockedToHorizontal = false; + + void _handleSwipeStart(Offset position) { + _swipeStartPosition = position; + _maxSwipeDistance = 0; + if (_swipeOffset != 0) { + setState(() => _swipeOffset = 0); + } + } + + void _handleSwipePointerDown(PointerDownEvent event) { + _swipePointerId = event.pointer; + _swipeLockedToHorizontal = false; + _handleSwipeStart(event.position); + } + + void _handleSwipePointerMove(PointerMoveEvent event) { + if (_swipePointerId != event.pointer || _swipeStartPosition == null) { + return; + } + + final dx = event.position.dx - _swipeStartPosition!.dx; + + const axisLockThreshold = 12.0; + if (!_swipeLockedToHorizontal) { + if (-dx < axisLockThreshold) { + return; + } + _swipeLockedToHorizontal = true; + } + + _handleSwipeUpdate(event.position); + } + + void _handleSwipeUpdate(Offset position) { + if (_swipeStartPosition == null) return; + + final dx = position.dx - _swipeStartPosition!.dx; + if (dx >= 0) return; + + if (-dx < 6) return; + + if (-dx > _maxSwipeDistance) { + _maxSwipeDistance = -dx; + } + + final double clamped = dx.clamp(-widget.maxSwipeOffset, 0.0).toDouble(); + final adjusted = _applySwipeResistance(clamped, widget.maxSwipeOffset); + if (adjusted != _swipeOffset) { + setState(() => _swipeOffset = adjusted); + } + } + + void _handleSwipePointerUp(Offset position) { + if (_swipeLockedToHorizontal && _swipeStartPosition != null) { + final dx = position.dx - _swipeStartPosition!.dx; + final peak = math.max( + _maxSwipeDistance, + (-dx).clamp(0.0, double.infinity), + ); + if (peak >= widget.replySwipeThreshold) { + widget.onReplyTriggered(); + HapticFeedback.selectionClick(); + } + } + _resetSwipe(); + } + + void _resetSwipe() { + if (_swipeOffset != 0) { + setState(() => _swipeOffset = 0); + } + _swipeStartPosition = null; + _maxSwipeDistance = 0; + _swipePointerId = null; + _swipeLockedToHorizontal = false; + } + + double _applySwipeResistance(double rawOffset, double maxOffset) { + final abs = rawOffset.abs(); + if (abs <= 0) return 0; + final norm = (abs / maxOffset).clamp(0.0, 1.0); + const deadZone = 0.18; + if (norm <= deadZone) { + return rawOffset.sign * maxOffset * (norm * 0.08); + } + final t = ((norm - deadZone) / (1 - deadZone)).clamp(0.0, 1.0); + final curved = t < 0.5 + ? 16 * math.pow(t, 5) + : 1 - math.pow(-2 * t + 2, 5) / 2; + const deadZoneEnd = 0.0144; + return rawOffset.sign * + maxOffset * + (deadZoneEnd + curved * (1 - deadZoneEnd)); + } + + @override + Widget build(BuildContext context) { + return Listener( + onPointerDown: _handleSwipePointerDown, + onPointerMove: _handleSwipePointerMove, + onPointerUp: (event) => _handleSwipePointerUp(event.position), + onPointerCancel: (_) => _resetSwipe(), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + child: Stack( + alignment: Alignment.center, + children: [ + Positioned.fill( + child: Opacity( + opacity: _swipeOffset.abs() / widget.maxSwipeOffset, + child: widget.hintBuilder(isStart: false), + ), + ), + AnimatedContainer( + duration: const Duration(milliseconds: 150), + transform: Matrix4.translationValues(_swipeOffset, 0, 0), + curve: Curves.easeOut, + child: widget.child, + ), + ], + ), + ), + ); + } +} + class _PoiInfo { final double lat; final double lon; From b3ad54f2964c498bd6c6ea942b57804a5f109274 Mon Sep 17 00:00:00 2001 From: Krasimir Kazakov Date: Sun, 22 Feb 2026 09:51:48 +0200 Subject: [PATCH 11/14] Added mute channel functionality (#209) --- lib/connector/meshcore_connector.dart | 2 ++ lib/l10n/app_bg.arb | 2 ++ lib/l10n/app_de.arb | 2 ++ lib/l10n/app_en.arb | 2 ++ lib/l10n/app_es.arb | 2 ++ lib/l10n/app_fr.arb | 2 ++ lib/l10n/app_it.arb | 2 ++ lib/l10n/app_localizations.dart | 12 ++++++++++++ lib/l10n/app_localizations_bg.dart | 6 ++++++ lib/l10n/app_localizations_de.dart | 6 ++++++ lib/l10n/app_localizations_en.dart | 6 ++++++ lib/l10n/app_localizations_es.dart | 6 ++++++ lib/l10n/app_localizations_fr.dart | 6 ++++++ lib/l10n/app_localizations_it.dart | 6 ++++++ lib/l10n/app_localizations_nl.dart | 6 ++++++ lib/l10n/app_localizations_pl.dart | 6 ++++++ lib/l10n/app_localizations_pt.dart | 6 ++++++ lib/l10n/app_localizations_ru.dart | 6 ++++++ lib/l10n/app_localizations_sk.dart | 6 ++++++ lib/l10n/app_localizations_sl.dart | 6 ++++++ lib/l10n/app_localizations_sv.dart | 6 ++++++ lib/l10n/app_localizations_uk.dart | 6 ++++++ lib/l10n/app_localizations_zh.dart | 6 ++++++ lib/l10n/app_nl.arb | 2 ++ lib/l10n/app_pl.arb | 2 ++ lib/l10n/app_pt.arb | 2 ++ lib/l10n/app_ru.arb | 2 ++ lib/l10n/app_sk.arb | 2 ++ lib/l10n/app_sl.arb | 2 ++ lib/l10n/app_sv.arb | 2 ++ lib/l10n/app_uk.arb | 2 ++ lib/l10n/app_zh.arb | 2 ++ lib/models/app_settings.dart | 13 ++++++++++++- lib/screens/channels_screen.dart | 24 ++++++++++++++++++++++++ lib/services/app_settings_service.dart | 15 +++++++++++++++ 35 files changed, 185 insertions(+), 1 deletion(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 2fbddc7c..afd1626d 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -2529,6 +2529,8 @@ class MeshCoreConnector extends ChangeNotifier { } final label = channelName ?? _channelDisplayName(channelIndex); + if (_appSettingsService!.isChannelMuted(label)) return; + _notificationService.showChannelMessageNotification( channelName: label, message: message.text, diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index 5689f951..8609023e 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -334,6 +334,8 @@ "channels_publicChannel": "Публичен канал", "channels_privateChannel": "Частен канал", "channels_editChannel": "Редактирай канал", + "channels_muteChannel": "Заглуши канала", + "channels_unmuteChannel": "Включи известията на канала", "channels_deleteChannel": "Изтрий канала", "channels_deleteChannelConfirm": "Изтрий \"{name}\"? Това не може да бъде отменено.", "@channels_deleteChannelConfirm": { diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 22fdf6bd..e5c82f79 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -334,6 +334,8 @@ "channels_publicChannel": "Öffentlicher Kanal", "channels_privateChannel": "Privater Kanal", "channels_editChannel": "Kanal bearbeiten", + "channels_muteChannel": "Kanal stummschalten", + "channels_unmuteChannel": "Kanal Stummschaltung aufheben", "channels_deleteChannel": "Lösche den Kanal", "channels_deleteChannelConfirm": "Löschen von \"{name}\"? Dies kann nicht rückgängig gemacht werden.", "@channels_deleteChannelConfirm": { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ae245398..67ca72ee 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -352,6 +352,8 @@ "channels_publicChannel": "Public channel", "channels_privateChannel": "Private channel", "channels_editChannel": "Edit channel", + "channels_muteChannel": "Mute channel", + "channels_unmuteChannel": "Unmute channel", "channels_deleteChannel": "Delete channel", "channels_deleteChannelConfirm": "Delete \"{name}\"? This cannot be undone.", "@channels_deleteChannelConfirm": { diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 3a7fe537..483b4d33 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -334,6 +334,8 @@ "channels_publicChannel": "Canal público", "channels_privateChannel": "Canal privado", "channels_editChannel": "Editar canal", + "channels_muteChannel": "Silenciar canal", + "channels_unmuteChannel": "Activar canal", "channels_deleteChannel": "Eliminar canal", "channels_deleteChannelConfirm": "Eliminar \"{name}\"? Esto no se puede deshacer.", "@channels_deleteChannelConfirm": { diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index f962ee5c..e162cdb5 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -334,6 +334,8 @@ "channels_publicChannel": "Canal public", "channels_privateChannel": "Canal privé", "channels_editChannel": "Modifier le canal", + "channels_muteChannel": "Désactiver les notifications du canal", + "channels_unmuteChannel": "Réactiver les notifications du canal", "channels_deleteChannel": "Supprimer le canal", "channels_deleteChannelConfirm": "Supprimer {name}? Cela ne peut pas être annulé.", "@channels_deleteChannelConfirm": { diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 61110043..2f8d186f 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -334,6 +334,8 @@ "channels_publicChannel": "Canale pubblico", "channels_privateChannel": "Canale privato", "channels_editChannel": "Modifica canale", + "channels_muteChannel": "Silenzia canale", + "channels_unmuteChannel": "Attiva notifiche canale", "channels_deleteChannel": "Elimina canale", "channels_deleteChannelConfirm": "Eliminare \"{name}\"? Non può essere annullato.", "@channels_deleteChannelConfirm": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 20d04228..e9686cef 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1546,6 +1546,18 @@ abstract class AppLocalizations { /// **'Edit channel'** String get channels_editChannel; + /// No description provided for @channels_muteChannel. + /// + /// In en, this message translates to: + /// **'Mute channel'** + String get channels_muteChannel; + + /// No description provided for @channels_unmuteChannel. + /// + /// In en, this message translates to: + /// **'Unmute channel'** + String get channels_unmuteChannel; + /// No description provided for @channels_deleteChannel. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 9c66ff22..cf4bf7b5 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -798,6 +798,12 @@ class AppLocalizationsBg extends AppLocalizations { @override String get channels_editChannel => 'Редактирай канал'; + @override + String get channels_muteChannel => 'Заглуши канала'; + + @override + String get channels_unmuteChannel => 'Включи известията на канала'; + @override String get channels_deleteChannel => 'Изтрий канала'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index ef7cd9d2..c6a07a4b 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -795,6 +795,12 @@ class AppLocalizationsDe extends AppLocalizations { @override String get channels_editChannel => 'Kanal bearbeiten'; + @override + String get channels_muteChannel => 'Kanal stummschalten'; + + @override + String get channels_unmuteChannel => 'Kanal Stummschaltung aufheben'; + @override String get channels_deleteChannel => 'Lösche den Kanal'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 7f07e263..254b5f4a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -787,6 +787,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get channels_editChannel => 'Edit channel'; + @override + String get channels_muteChannel => 'Mute channel'; + + @override + String get channels_unmuteChannel => 'Unmute channel'; + @override String get channels_deleteChannel => 'Delete channel'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 6409675a..dcde3653 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -796,6 +796,12 @@ class AppLocalizationsEs extends AppLocalizations { @override String get channels_editChannel => 'Editar canal'; + @override + String get channels_muteChannel => 'Silenciar canal'; + + @override + String get channels_unmuteChannel => 'Activar canal'; + @override String get channels_deleteChannel => 'Eliminar canal'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 3536cf56..d572b8f7 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -798,6 +798,12 @@ class AppLocalizationsFr extends AppLocalizations { @override String get channels_editChannel => 'Modifier le canal'; + @override + String get channels_muteChannel => 'Désactiver les notifications du canal'; + + @override + String get channels_unmuteChannel => 'Réactiver les notifications du canal'; + @override String get channels_deleteChannel => 'Supprimer le canal'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 521cfb71..d8e27f87 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -794,6 +794,12 @@ class AppLocalizationsIt extends AppLocalizations { @override String get channels_editChannel => 'Modifica canale'; + @override + String get channels_muteChannel => 'Silenzia canale'; + + @override + String get channels_unmuteChannel => 'Attiva notifiche canale'; + @override String get channels_deleteChannel => 'Elimina canale'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index a7a4c0b8..0a50e8b0 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -792,6 +792,12 @@ class AppLocalizationsNl extends AppLocalizations { @override String get channels_editChannel => 'Kanaal bewerken'; + @override + String get channels_muteChannel => 'Kanaal dempen'; + + @override + String get channels_unmuteChannel => 'Kanaal dempen opheffen'; + @override String get channels_deleteChannel => 'Kanaal verwijderen'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 88154720..31dd8b56 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -797,6 +797,12 @@ class AppLocalizationsPl extends AppLocalizations { @override String get channels_editChannel => 'Edytuj kanał'; + @override + String get channels_muteChannel => 'Wycisz kanał'; + + @override + String get channels_unmuteChannel => 'Wyłącz wyciszenie kanału'; + @override String get channels_deleteChannel => 'Usuń kanał'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index c7fc7073..5092826f 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -797,6 +797,12 @@ class AppLocalizationsPt extends AppLocalizations { @override String get channels_editChannel => 'Editar canal'; + @override + String get channels_muteChannel => 'Silenciar canal'; + + @override + String get channels_unmuteChannel => 'Ativar canal'; + @override String get channels_deleteChannel => 'Excluir canal'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 2e992bd1..570b7c84 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -795,6 +795,12 @@ class AppLocalizationsRu extends AppLocalizations { @override String get channels_editChannel => 'Изменить канал'; + @override + String get channels_muteChannel => 'Отключить уведомления канала'; + + @override + String get channels_unmuteChannel => 'Включить уведомления канала'; + @override String get channels_deleteChannel => 'Удалить канал'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index a51e0591..8bbb6dea 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -792,6 +792,12 @@ class AppLocalizationsSk extends AppLocalizations { @override String get channels_editChannel => 'Upraviť kanál'; + @override + String get channels_muteChannel => 'Stlmiť kanál'; + + @override + String get channels_unmuteChannel => 'Zrušiť stlmenie kanála'; + @override String get channels_deleteChannel => 'Odstrániť kanál'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 5ac7e8b4..61e30584 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -790,6 +790,12 @@ class AppLocalizationsSl extends AppLocalizations { @override String get channels_editChannel => 'Uredi kanal'; + @override + String get channels_muteChannel => 'Utišaj kanal'; + + @override + String get channels_unmuteChannel => 'Vklopi obvestila kanala'; + @override String get channels_deleteChannel => 'Pošlji kanal'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 6d355d91..79b30b88 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -786,6 +786,12 @@ class AppLocalizationsSv extends AppLocalizations { @override String get channels_editChannel => 'Redigera kanal'; + @override + String get channels_muteChannel => 'Tysta kanal'; + + @override + String get channels_unmuteChannel => 'Slå på ljud för kanal'; + @override String get channels_deleteChannel => 'Ta bort kanal'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 0f3d550c..f3670021 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -793,6 +793,12 @@ class AppLocalizationsUk extends AppLocalizations { @override String get channels_editChannel => 'Редагувати канал'; + @override + String get channels_muteChannel => 'Вимкнути сповіщення каналу'; + + @override + String get channels_unmuteChannel => 'Увімкнути сповіщення каналу'; + @override String get channels_deleteChannel => 'Видалити канал'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 36a114a5..7641800d 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -755,6 +755,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get channels_editChannel => '编辑频道'; + @override + String get channels_muteChannel => '静音频道'; + + @override + String get channels_unmuteChannel => '取消静音频道'; + @override String get channels_deleteChannel => '删除频道'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 733e4dce..57b2fdda 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -334,6 +334,8 @@ "channels_publicChannel": "Open kanaal", "channels_privateChannel": "Private kanaal", "channels_editChannel": "Kanaal bewerken", + "channels_muteChannel": "Kanaal dempen", + "channels_unmuteChannel": "Kanaal dempen opheffen", "channels_deleteChannel": "Kanaal verwijderen", "channels_deleteChannelConfirm": "Verwijderen \"{name}\"? Dit kan niet worden teruggedraaid.", "@channels_deleteChannelConfirm": { diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 35efee18..3787fa71 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -334,6 +334,8 @@ "channels_publicChannel": "Kanał publiczny", "channels_privateChannel": "Prywatny kanał", "channels_editChannel": "Edytuj kanał", + "channels_muteChannel": "Wycisz kanał", + "channels_unmuteChannel": "Wyłącz wyciszenie kanału", "channels_deleteChannel": "Usuń kanał", "channels_deleteChannelConfirm": "Usuń \"{name}\"? Nie można tego cofnąć.", "@channels_deleteChannelConfirm": { diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index fd742d94..7be66945 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -334,6 +334,8 @@ "channels_publicChannel": "Canal público", "channels_privateChannel": "Canal privado", "channels_editChannel": "Editar canal", + "channels_muteChannel": "Silenciar canal", + "channels_unmuteChannel": "Ativar canal", "channels_deleteChannel": "Excluir canal", "channels_deleteChannelConfirm": "Excluir \"{name}\"? Não pode ser desfeito.", "@channels_deleteChannelConfirm": { diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 04b2e049..26cfce3b 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -226,6 +226,8 @@ "channels_publicChannel": "Публичный канал", "channels_privateChannel": "Приватный канал", "channels_editChannel": "Изменить канал", + "channels_muteChannel": "Отключить уведомления канала", + "channels_unmuteChannel": "Включить уведомления канала", "channels_deleteChannel": "Удалить канал", "channels_deleteChannelConfirm": "Удалить \"{name}\"? Это действие нельзя отменить.", "channels_channelDeleted": "Канал \"{name}\" удалён", diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index 6663094a..8b2cb0ae 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -334,6 +334,8 @@ "channels_publicChannel": "Veľké verejne kanály", "channels_privateChannel": "Osobné kanál", "channels_editChannel": "Upraviť kanál", + "channels_muteChannel": "Stlmiť kanál", + "channels_unmuteChannel": "Zrušiť stlmenie kanála", "channels_deleteChannel": "Odstrániť kanál", "channels_deleteChannelConfirm": "Odstrániť \"{name}\"? To sa nedá zrušiť.", "@channels_deleteChannelConfirm": { diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index 50a90432..4d3415d3 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -334,6 +334,8 @@ "channels_publicChannel": "Javni kanal", "channels_privateChannel": "Zasebni kanal", "channels_editChannel": "Uredi kanal", + "channels_muteChannel": "Utišaj kanal", + "channels_unmuteChannel": "Vklopi obvestila kanala", "channels_deleteChannel": "Pošlji kanal", "channels_deleteChannelConfirm": "Izbrišem \"{name}\"? To se ne da povrniti.", "@channels_deleteChannelConfirm": { diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index 260a34b8..8c5e399c 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -334,6 +334,8 @@ "channels_publicChannel": "Allmänt kanal", "channels_privateChannel": "Privat kanal", "channels_editChannel": "Redigera kanal", + "channels_muteChannel": "Tysta kanal", + "channels_unmuteChannel": "Slå på ljud för kanal", "channels_deleteChannel": "Ta bort kanal", "channels_deleteChannelConfirm": "Radera \"{name}\"? Detta kan inte ångras.", "@channels_deleteChannelConfirm": { diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index ec414b48..910f8b0e 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -335,6 +335,8 @@ "channels_publicChannel": "Публічний канал", "channels_privateChannel": "Приватний канал", "channels_editChannel": "Редагувати канал", + "channels_muteChannel": "Вимкнути сповіщення каналу", + "channels_unmuteChannel": "Увімкнути сповіщення каналу", "channels_deleteChannel": "Видалити канал", "channels_deleteChannelConfirm": "Видалити {name}? Це не можна скасувати.", "@channels_deleteChannelConfirm": { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 6b072c9c..d9efce7f 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -342,6 +342,8 @@ "channels_publicChannel": "公共频道", "channels_privateChannel": "私密频道", "channels_editChannel": "编辑频道", + "channels_muteChannel": "静音频道", + "channels_unmuteChannel": "取消静音频道", "channels_deleteChannel": "删除频道", "channels_deleteChannelConfirm": "Delete \"{name}\"? This cannot be undone.", "@channels_deleteChannelConfirm": { diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index 71cafa03..d9504b39 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -36,6 +36,7 @@ class AppSettings { final Map batteryChemistryByDeviceId; final Map batteryChemistryByRepeaterId; final UnitSystem unitSystem; + final Set mutedChannels; AppSettings({ this.clearPathOnMaxRetry = false, @@ -60,8 +61,10 @@ class AppSettings { Map? batteryChemistryByDeviceId, Map? batteryChemistryByRepeaterId, this.unitSystem = UnitSystem.metric, + Set? mutedChannels, }) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {}, - batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {}; + batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {}, + mutedChannels = mutedChannels ?? {}; Map toJson() { return { @@ -87,6 +90,7 @@ class AppSettings { 'battery_chemistry_by_device_id': batteryChemistryByDeviceId, 'battery_chemistry_by_repeater_id': batteryChemistryByRepeaterId, 'unit_system': unitSystem.value, + 'muted_channels': mutedChannels.toList(), }; } @@ -134,6 +138,11 @@ class AppSettings { ) ?? {}, unitSystem: parseUnitSystem(json['unit_system']), + mutedChannels: + ((json['muted_channels'] as List?) + ?.map((e) => e.toString()) + .toSet()) ?? + {}, ); } @@ -160,6 +169,7 @@ class AppSettings { Map? batteryChemistryByDeviceId, Map? batteryChemistryByRepeaterId, UnitSystem? unitSystem, + Set? mutedChannels, }) { return AppSettings( clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry, @@ -192,6 +202,7 @@ class AppSettings { batteryChemistryByRepeaterId: batteryChemistryByRepeaterId ?? this.batteryChemistryByRepeaterId, unitSystem: unitSystem ?? this.unitSystem, + mutedChannels: mutedChannels ?? this.mutedChannels, ); } } diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 26062dea..994d3e7a 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -9,6 +9,7 @@ import 'package:uuid/uuid.dart'; import '../connector/meshcore_connector.dart'; import '../l10n/l10n.dart'; +import '../services/app_settings_service.dart'; import '../models/channel.dart'; import '../models/community.dart'; import '../storage/community_store.dart'; @@ -477,6 +478,9 @@ class _ChannelsScreenState extends State MeshCoreConnector connector, Channel channel, ) { + final settingsService = context.read(); + final isMuted = settingsService.isChannelMuted(channel.name); + showModalBottomSheet( context: context, builder: (context) => SafeArea( @@ -494,6 +498,26 @@ class _ChannelsScreenState extends State } }, ), + 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( leading: const Icon(Icons.delete_outline, color: Colors.red), title: Text( diff --git a/lib/services/app_settings_service.dart b/lib/services/app_settings_service.dart index e131eb87..e80f903d 100644 --- a/lib/services/app_settings_service.dart +++ b/lib/services/app_settings_service.dart @@ -155,4 +155,19 @@ class AppSettingsService extends ChangeNotifier { Future setUnitSystem(UnitSystem value) async { await updateSettings(_settings.copyWith(unitSystem: value)); } + + bool isChannelMuted(String channelName) { + return _settings.mutedChannels.contains(channelName); + } + + Future muteChannel(String channelName) async { + final updated = Set.from(_settings.mutedChannels)..add(channelName); + await updateSettings(_settings.copyWith(mutedChannels: updated)); + } + + Future unmuteChannel(String channelName) async { + final updated = Set.from(_settings.mutedChannels) + ..remove(channelName); + await updateSettings(_settings.copyWith(mutedChannels: updated)); + } } From 230626938448e6e950312161226d10e65fb1131a Mon Sep 17 00:00:00 2001 From: spfmoby <40357319+spfmoby@users.noreply.github.com> Date: Sun, 22 Feb 2026 15:20:55 +0100 Subject: [PATCH 12/14] Better french translations --- lib/l10n/app_fr.arb | 6 +++--- lib/l10n/app_localizations_fr.dart | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index e162cdb5..2d4846c8 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -262,7 +262,7 @@ } }, "contacts_manageRepeater": "Gérer le répéteur", - "contacts_roomLogin": "Connexion Salle", + "contacts_roomLogin": "Connexion Room Server", "contacts_openChat": "Ouverture du Chat", "contacts_editGroup": "Modifier le groupe", "contacts_deleteGroup": "Supprimer le groupe", @@ -798,7 +798,7 @@ "dialog_disconnect": "Déconnecter", "dialog_disconnectConfirm": "Êtes-vous sûr de vouloir vous déconnecter de cet appareil ?", "login_repeaterLogin": "Connexion au répéteur", - "login_roomLogin": "Connexion Salle", + "login_roomLogin": "Connexion Room Server", "login_password": "Mot de passe", "login_enterPassword": "Entrez votre mot de passe", "login_savePassword": "Sauvegarder le mot de passe", @@ -1393,7 +1393,7 @@ "settings_locationIntervalSec": "Intervalle de mise-à-jour du GPS (Secondes)", "settings_locationIntervalInvalid": "L'intervalle doit être compris entre 60 et 86400 secondes.", "contacts_manageRoom": "Gérer le Room Server", - "room_management": "Administración del Servidor de Habitación", + "room_management": "Administrattion Room Server", "@community_joinConfirmation": { "placeholders": { "name": { diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index d572b8f7..c4e1e273 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -696,7 +696,7 @@ class AppLocalizationsFr extends AppLocalizations { String get contacts_manageRoom => 'Gérer le Room Server'; @override - String get contacts_roomLogin => 'Connexion Salle'; + String get contacts_roomLogin => 'Connexion Room Server'; @override String get contacts_openChat => 'Ouverture du Chat'; @@ -1559,7 +1559,7 @@ class AppLocalizationsFr extends AppLocalizations { String get login_repeaterLogin => 'Connexion au répéteur'; @override - String get login_roomLogin => 'Connexion Salle'; + String get login_roomLogin => 'Connexion Room Server'; @override String get login_password => 'Mot de passe'; @@ -1684,7 +1684,7 @@ class AppLocalizationsFr extends AppLocalizations { String get repeater_management => 'Gestion des répéteurs'; @override - String get room_management => 'Administración del Servidor de Habitación'; + String get room_management => 'Administrattion Room Server'; @override String get repeater_managementTools => 'Outils de Gestion'; From 7288f11c88299234c394ca490d6fbc1f40ab96b0 Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Sun, 22 Feb 2026 06:49:14 -0800 Subject: [PATCH 13/14] add chrome in planning --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index bad9b6ca..da92d473 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh - ✅ **Android**: Full support (API 21+) - ✅ **iOS**: Full support (iOS 12+) - 🚧 **Desktop**: Limited support (macOS/Linux/Windows) +- 🚧 **Web**: Limited support (Chrome) ### Dependencies From c7b33f1d1b1db1e1babf13192edc72671ae12b61 Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Sun, 22 Feb 2026 06:51:40 -0800 Subject: [PATCH 14/14] readme update --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index da92d473..10fb0a57 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh - ✅ **Android**: Full support (API 21+) - ✅ **iOS**: Full support (iOS 12+) - 🚧 **Desktop**: Limited support (macOS/Linux/Windows) -- 🚧 **Web**: Limited support (Chrome) +- 🚧 **Web**: Under construction (Chrome) ### Dependencies