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] 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 b6f4301..5689f95 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 077c398..22fdf6b 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 bf49d7e..ae24539 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 1896b4f..3a7fe53 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 d1befce..f962ee5 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 22371ba..6111004 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 2bcda78..20d0422 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 137d48a..9c66ff2 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 927ac48..ef7cd9d 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 ef7c0c3..7f07e26 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 f72196d..6409675 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 8978568..3536cf5 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 a2b790f..521cfb7 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 a958e79..a7a4c0b 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 55bc6ec..8815472 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 596d268..c7fc707 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 4647746..2e992bd 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 8e18663..a51e059 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 b95e711..5ac7e8b 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 10047ca..6d355d9 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 9edc64a..0f3d550 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 9753da6..36a114a 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 859e48d..733e4dc 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 d03b911..35efee1 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 83a7719..fd742d9 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 380ba10..04b2e04 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 aca4a29..6663094 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 59b8434..50a9043 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 fa786f7..260a34b 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 3f7b276..ec414b4 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 bc43392..6b072c9 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 8ee0ca4..3650a7e 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 3edb68f..229a7a6 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 5372ea8..4877038 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 4e31733..b309b4d 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 7cebb76..88f734b 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 e6fcacc..2d1faa3 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 a2914a1..9f8602d 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 d470107..a6828dd 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 0000000..a2d4b4a --- /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 3f61109..1391660 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 1fad04b..8c13a71 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 8e24bee..c1f7f44 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 932e29c..af9d75e 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 94d541b..a198f99 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 8770938..88c204d 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 c1e8fc6..a85ab92 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 0a9aeae..bc46b59 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 0000000..e9f9f7b --- /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 fc979c6..0b59bbc 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 0000000..12363dd --- /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 0000000..987ee6c --- /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); + }); +}