From 425229fce8b15dc8809a86b74e1cf5fdd1d0459d Mon Sep 17 00:00:00 2001 From: HDDen <62592944+HDDen@users.noreply.github.com> Date: Tue, 19 May 2026 20:37:34 +0300 Subject: [PATCH] Telemetry request: map and autorefresh --- lib/l10n/app_bg.arb | 4 + lib/l10n/app_de.arb | 4 + lib/l10n/app_en.arb | 4 + lib/l10n/app_es.arb | 4 + lib/l10n/app_fr.arb | 4 + lib/l10n/app_hu.arb | 4 + lib/l10n/app_it.arb | 4 + lib/l10n/app_ja.arb | 4 + lib/l10n/app_ko.arb | 4 + lib/l10n/app_localizations.dart | 24 ++ lib/l10n/app_localizations_bg.dart | 12 + lib/l10n/app_localizations_de.dart | 12 + lib/l10n/app_localizations_en.dart | 12 + lib/l10n/app_localizations_es.dart | 12 + lib/l10n/app_localizations_fr.dart | 12 + lib/l10n/app_localizations_hu.dart | 12 + lib/l10n/app_localizations_it.dart | 12 + lib/l10n/app_localizations_ja.dart | 12 + lib/l10n/app_localizations_ko.dart | 12 + lib/l10n/app_localizations_nl.dart | 12 + lib/l10n/app_localizations_pl.dart | 12 + lib/l10n/app_localizations_pt.dart | 12 + lib/l10n/app_localizations_ru.dart | 12 + lib/l10n/app_localizations_sk.dart | 12 + lib/l10n/app_localizations_sl.dart | 12 + lib/l10n/app_localizations_sv.dart | 12 + lib/l10n/app_localizations_uk.dart | 12 + lib/l10n/app_localizations_zh.dart | 12 + lib/l10n/app_nl.arb | 4 + lib/l10n/app_pl.arb | 4 + lib/l10n/app_pt.arb | 4 + lib/l10n/app_ru.arb | 4 + lib/l10n/app_sk.arb | 4 + lib/l10n/app_sl.arb | 4 + lib/l10n/app_sv.arb | 4 + lib/l10n/app_uk.arb | 4 + lib/l10n/app_zh.arb | 4 + lib/screens/telemetry_screen.dart | 415 ++++++++++++++++++++++- lib/widgets/telemetry_location_map.dart | 422 ++++++++++++++++++++++++ 39 files changed, 1131 insertions(+), 18 deletions(-) create mode 100644 lib/widgets/telemetry_location_map.dart diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index 45536cf6..183c07a9 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -33,6 +33,8 @@ "common_remove": "Изтрий", "common_enable": "Активирай", "common_disable": "Деактивирай", + "common_autoRefresh": "Автоматично обновяване", + "common_interval": "Интервал", "common_reboot": "Рестартирай", "common_loading": "Зареждане...", "common_notAvailable": "—", @@ -1280,6 +1282,8 @@ } } }, + "telemetry_autoFetchQuantity": "Брой заявки", + "telemetry_error": "Неуспешно получаване на данни", "telemetry_noData": "Няма налични данни за телеметрията.", "telemetry_channelTitle": "Канал {channel}", "@telemetry_channelTitle": { diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 7f491d4f..80a030af 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -33,6 +33,8 @@ "common_remove": "Löschen", "common_enable": "Aktivieren", "common_disable": "Deaktivieren", + "common_autoRefresh": "Automatische Aktualisierung", + "common_interval": "Intervall", "common_reboot": "Neustart", "common_loading": "Laden...", "common_notAvailable": "—", @@ -1280,6 +1282,8 @@ } } }, + "telemetry_autoFetchQuantity": "Anzahl der Anfragen", + "telemetry_error": "Daten konnten nicht abgerufen werden", "telemetry_noData": "Keine Telemetriedaten verfügbar.", "telemetry_channelTitle": "Kanal {channel}", "@telemetry_channelTitle": { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 456fab56..bc7bec99 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -47,6 +47,8 @@ } } }, + "common_autoRefresh": "Autorefresh", + "common_interval": "Interval", "scanner_title": "MeshCore Open", "connectionChoiceUsbLabel": "USB", "connectionChoiceBluetoothLabel": "Bluetooth", @@ -1661,6 +1663,8 @@ } } }, + "telemetry_autoFetchQuantity": "Requests quantity", + "telemetry_error": "Unable to retrieve data", "neighbors_receivedData": "Received Neighbors Data", "neighbors_requestTimedOut": "Neighbors request timed out.", "neighbors_errorLoading": "Error loading neighbors: {error}", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index d4cf27da..89574799 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -33,6 +33,8 @@ "common_remove": "Eliminar", "common_enable": "Activar", "common_disable": "Desactivar", + "common_autoRefresh": "Actualización automática", + "common_interval": "Intervalo", "common_reboot": "Reiniciar", "common_loading": "Cargando...", "common_notAvailable": "—", @@ -1280,6 +1282,8 @@ } } }, + "telemetry_autoFetchQuantity": "Número de solicitudes", + "telemetry_error": "No se pudieron obtener los datos", "telemetry_noData": "No hay datos de telemetría disponibles.", "telemetry_channelTitle": "Canal {channel}", "@telemetry_channelTitle": { diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 7817c20d..e5c5091a 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -33,6 +33,8 @@ "common_remove": "Supprimer", "common_enable": "Activer", "common_disable": "Désactiver", + "common_autoRefresh": "Actualisation automatique", + "common_interval": "Intervalle", "common_reboot": "Redémarrer", "common_loading": "Chargement...", "common_notAvailable": "—", @@ -1280,6 +1282,8 @@ } } }, + "telemetry_autoFetchQuantity": "Nombre de requêtes", + "telemetry_error": "Impossible de récupérer les données", "telemetry_noData": "Aucune donnée de télémétrie disponible.", "telemetry_channelTitle": "Canal {channel}", "@telemetry_channelTitle": { diff --git a/lib/l10n/app_hu.arb b/lib/l10n/app_hu.arb index 97426183..38618779 100644 --- a/lib/l10n/app_hu.arb +++ b/lib/l10n/app_hu.arb @@ -27,6 +27,8 @@ "common_remove": "Eltávolít", "common_enable": "Engedélyezés", "common_disable": "Leteteszt", + "common_autoRefresh": "Automatikus frissítés", + "common_interval": "Intervallum", "common_reboot": "Újraindítás", "common_loading": "Betöltés...", "common_notAvailable": "—", @@ -1466,6 +1468,8 @@ } } }, + "telemetry_autoFetchQuantity": "Kérések száma", + "telemetry_error": "Nem sikerült lekérni az adatokat", "telemetry_noData": "Nincsenek elérhető telemetriadatok.", "telemetry_channelTitle": "{channel} csatorna", "@telemetry_channelTitle": { diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 664bceef..2ee686ec 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -33,6 +33,8 @@ "common_remove": "Elimina", "common_enable": "Abilita", "common_disable": "Disattivare", + "common_autoRefresh": "Aggiornamento automatico", + "common_interval": "Intervallo", "common_reboot": "Riavvia", "common_loading": "Caricamento...", "common_notAvailable": "—", @@ -1280,6 +1282,8 @@ } } }, + "telemetry_autoFetchQuantity": "Numero di richieste", + "telemetry_error": "Impossibile recuperare i dati", "telemetry_noData": "Nessun dato di telemetria disponibile.", "telemetry_channelTitle": "Canale {channel}", "@telemetry_channelTitle": { diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 9d2cef21..92f2ed65 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -27,6 +27,8 @@ "common_remove": "削除", "common_enable": "有効化する", "common_disable": "無効化する", + "common_autoRefresh": "自動更新", + "common_interval": "間隔", "common_reboot": "再起動", "common_loading": "読み込み中...", "common_notAvailable": "—", @@ -1466,6 +1468,8 @@ } } }, + "telemetry_autoFetchQuantity": "リクエスト数", + "telemetry_error": "データを取得できません", "telemetry_noData": "テレメトリデータは利用できません。", "telemetry_channelTitle": "チャンネル {channel}", "@telemetry_channelTitle": { diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index d778429b..ff27b6ad 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -27,6 +27,8 @@ "common_remove": "제거", "common_enable": "활성화", "common_disable": "비활성화", + "common_autoRefresh": "자동 새로고침", + "common_interval": "간격", "common_reboot": "재부팅", "common_loading": "로딩 중...", "common_notAvailable": "—", @@ -1466,6 +1468,8 @@ } } }, + "telemetry_autoFetchQuantity": "요청 수", + "telemetry_error": "데이터를 가져올 수 없습니다", "telemetry_noData": "텔레메트리 데이터는 제공되지 않습니다.", "telemetry_channelTitle": "채널 {channel}", "@telemetry_channelTitle": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 159d755c..949dd518 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -328,6 +328,18 @@ abstract class AppLocalizations { /// **'{percent}%'** String common_percentValue(int percent); + /// No description provided for @common_autoRefresh. + /// + /// In en, this message translates to: + /// **'Autorefresh'** + String get common_autoRefresh; + + /// No description provided for @common_interval. + /// + /// In en, this message translates to: + /// **'Interval'** + String get common_interval; + /// No description provided for @scanner_title. /// /// In en, this message translates to: @@ -5690,6 +5702,18 @@ abstract class AppLocalizations { /// **'{celsius}°C / {fahrenheit}°F'** String telemetry_temperatureValue(String celsius, String fahrenheit); + /// No description provided for @telemetry_autoFetchQuantity. + /// + /// In en, this message translates to: + /// **'Requests quantity'** + String get telemetry_autoFetchQuantity; + + /// No description provided for @telemetry_error. + /// + /// In en, this message translates to: + /// **'Unable to retrieve data'** + String get telemetry_error; + /// No description provided for @neighbors_receivedData. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 409803b2..89c75f4d 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -111,6 +111,12 @@ class AppLocalizationsBg extends AppLocalizations { return '$percent%'; } + @override + String get common_autoRefresh => 'Автоматично обновяване'; + + @override + String get common_interval => 'Интервал'; + @override String get scanner_title => 'MeshCore – Отворена версия'; @@ -3276,6 +3282,12 @@ class AppLocalizationsBg extends AppLocalizations { return '$celsius°C / $fahrenheit°F'; } + @override + String get telemetry_autoFetchQuantity => 'Брой заявки'; + + @override + String get telemetry_error => 'Неуспешно получаване на данни'; + @override String get neighbors_receivedData => 'Получени данни за съседи'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 9f06906d..844b6066 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -111,6 +111,12 @@ class AppLocalizationsDe extends AppLocalizations { return '$percent%'; } + @override + String get common_autoRefresh => 'Automatische Aktualisierung'; + + @override + String get common_interval => 'Intervall'; + @override String get scanner_title => 'MeshCore – Open-Version'; @@ -3282,6 +3288,12 @@ class AppLocalizationsDe extends AppLocalizations { return '$celsius°C / $fahrenheit°F'; } + @override + String get telemetry_autoFetchQuantity => 'Anzahl der Anfragen'; + + @override + String get telemetry_error => 'Daten konnten nicht abgerufen werden'; + @override String get neighbors_receivedData => 'Empfangene Nachbarsdaten'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6def06b7..6f9e03c6 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -111,6 +111,12 @@ class AppLocalizationsEn extends AppLocalizations { return '$percent%'; } + @override + String get common_autoRefresh => 'Autorefresh'; + + @override + String get common_interval => 'Interval'; + @override String get scanner_title => 'MeshCore Open'; @@ -3213,6 +3219,12 @@ class AppLocalizationsEn extends AppLocalizations { return '$celsius°C / $fahrenheit°F'; } + @override + String get telemetry_autoFetchQuantity => 'Requests quantity'; + + @override + String get telemetry_error => 'Unable to retrieve data'; + @override String get neighbors_receivedData => 'Received Neighbors Data'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index bb4f40e8..4c127a23 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -111,6 +111,12 @@ class AppLocalizationsEs extends AppLocalizations { return '$percent%'; } + @override + String get common_autoRefresh => 'Actualización automática'; + + @override + String get common_interval => 'Intervalo'; + @override String get scanner_title => 'MeshCore: Versión abierta'; @@ -3270,6 +3276,12 @@ class AppLocalizationsEs extends AppLocalizations { return '$celsius°C / $fahrenheit°F'; } + @override + String get telemetry_autoFetchQuantity => 'Número de solicitudes'; + + @override + String get telemetry_error => 'No se pudieron obtener los datos'; + @override String get neighbors_receivedData => 'Recibidas Datos de Vecinos'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 44e40657..b5f1c840 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -111,6 +111,12 @@ class AppLocalizationsFr extends AppLocalizations { return '$percent%'; } + @override + String get common_autoRefresh => 'Actualisation automatique'; + + @override + String get common_interval => 'Intervalle'; + @override String get scanner_title => 'MeshCore Open'; @@ -3293,6 +3299,12 @@ class AppLocalizationsFr extends AppLocalizations { return '$celsius°C / $fahrenheit°F'; } + @override + String get telemetry_autoFetchQuantity => 'Nombre de requêtes'; + + @override + String get telemetry_error => 'Impossible de récupérer les données'; + @override String get neighbors_receivedData => 'Données des voisins reçues'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index 298a6fd6..d4f1b2b7 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -111,6 +111,12 @@ class AppLocalizationsHu extends AppLocalizations { return '$percent%'; } + @override + String get common_autoRefresh => 'Automatikus frissítés'; + + @override + String get common_interval => 'Intervallum'; + @override String get scanner_title => 'MeshCore nyitott'; @@ -3286,6 +3292,12 @@ class AppLocalizationsHu extends AppLocalizations { return '$celsius °C / $fahrenheit °F'; } + @override + String get telemetry_autoFetchQuantity => 'Kérések száma'; + + @override + String get telemetry_error => 'Nem sikerült lekérni az adatokat'; + @override String get neighbors_receivedData => 'Kapott szomszédok adatait'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index e3f7ee58..666d3137 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -111,6 +111,12 @@ class AppLocalizationsIt extends AppLocalizations { return '$percent%'; } + @override + String get common_autoRefresh => 'Aggiornamento automatico'; + + @override + String get common_interval => 'Intervallo'; + @override String get scanner_title => 'MeshCore Open'; @@ -3276,6 +3282,12 @@ class AppLocalizationsIt extends AppLocalizations { return '$celsius°C / $fahrenheit°F'; } + @override + String get telemetry_autoFetchQuantity => 'Numero di richieste'; + + @override + String get telemetry_error => 'Impossibile recuperare i dati'; + @override String get neighbors_receivedData => 'Ricevute dati vicini'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 263a906e..f2fb24b3 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -111,6 +111,12 @@ class AppLocalizationsJa extends AppLocalizations { return '$percent%'; } + @override + String get common_autoRefresh => '自動更新'; + + @override + String get common_interval => '間隔'; + @override String get scanner_title => 'MeshCore オープン'; @@ -3094,6 +3100,12 @@ class AppLocalizationsJa extends AppLocalizations { return '$celsius℃ / $fahrenheit°F'; } + @override + String get telemetry_autoFetchQuantity => 'リクエスト数'; + + @override + String get telemetry_error => 'データを取得できません'; + @override String get neighbors_receivedData => '近隣住民のデータを受信'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index f9c0d760..f35ca675 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -111,6 +111,12 @@ class AppLocalizationsKo extends AppLocalizations { return '$percent%'; } + @override + String get common_autoRefresh => '자동 새로고침'; + + @override + String get common_interval => '간격'; + @override String get scanner_title => 'MeshCore 공개'; @@ -3096,6 +3102,12 @@ class AppLocalizationsKo extends AppLocalizations { return '$celsius°C / $fahrenheit°F'; } + @override + String get telemetry_autoFetchQuantity => '요청 수'; + + @override + String get telemetry_error => '데이터를 가져올 수 없습니다'; + @override String get neighbors_receivedData => '이웃 정보 수집'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index ad140bf3..6a68bd16 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -111,6 +111,12 @@ class AppLocalizationsNl extends AppLocalizations { return '$percent%'; } + @override + String get common_autoRefresh => 'Automatisch vernieuwen'; + + @override + String get common_interval => 'Tijdsinterval'; + @override String get scanner_title => 'MeshCore Open'; @@ -3256,6 +3262,12 @@ class AppLocalizationsNl extends AppLocalizations { return '$celsius°C / $fahrenheit°F'; } + @override + String get telemetry_autoFetchQuantity => 'Aantal aanvragen'; + + @override + String get telemetry_error => 'Kan gegevens niet ophalen'; + @override String get neighbors_receivedData => 'Ontvangen Buurdata'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 6c7c3696..aa2c091d 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -111,6 +111,12 @@ class AppLocalizationsPl extends AppLocalizations { return '$percent%'; } + @override + String get common_autoRefresh => 'Automatyczne odświeżanie'; + + @override + String get common_interval => 'Interwał'; + @override String get scanner_title => 'MeshCore – wersja open source'; @@ -3288,6 +3294,12 @@ class AppLocalizationsPl extends AppLocalizations { return '$celsius°C / $fahrenheit°F'; } + @override + String get telemetry_autoFetchQuantity => 'Liczba żądań'; + + @override + String get telemetry_error => 'Nie udało się pobrać danych'; + @override String get neighbors_receivedData => 'Otrzymano dane sąsiedztwa'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index e6fd49e3..7104c570 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -111,6 +111,12 @@ class AppLocalizationsPt extends AppLocalizations { return '$percent%'; } + @override + String get common_autoRefresh => 'Atualização automática'; + + @override + String get common_interval => 'Intervalo'; + @override String get scanner_title => 'MeshCore: Versão aberta'; @@ -3269,6 +3275,12 @@ class AppLocalizationsPt extends AppLocalizations { return '$celsius°C / $fahrenheit°F'; } + @override + String get telemetry_autoFetchQuantity => 'Número de solicitações'; + + @override + String get telemetry_error => 'Não foi possível obter os dados'; + @override String get neighbors_receivedData => 'Dados dos Vizinhos Recebidos'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 25452003..adc7f492 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -111,6 +111,12 @@ class AppLocalizationsRu extends AppLocalizations { return '$percent%'; } + @override + String get common_autoRefresh => 'Автообновление'; + + @override + String get common_interval => 'Интервал'; + @override String get scanner_title => 'MeshCore Open'; @@ -3277,6 +3283,12 @@ class AppLocalizationsRu extends AppLocalizations { return '$celsius°C / $fahrenheit°F'; } + @override + String get telemetry_autoFetchQuantity => 'Количество запросов'; + + @override + String get telemetry_error => 'Не удалось получить данные'; + @override String get neighbors_receivedData => 'Полученные данные о соседях'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 91461385..2ba7d16c 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -111,6 +111,12 @@ class AppLocalizationsSk extends AppLocalizations { return '$percent%'; } + @override + String get common_autoRefresh => 'Automatické obnovenie'; + + @override + String get common_interval => 'Časový interval'; + @override String get scanner_title => 'MeshCore – Verzia pre verejnosť'; @@ -3255,6 +3261,12 @@ class AppLocalizationsSk extends AppLocalizations { return '$celsius°C / $fahrenheit°F'; } + @override + String get telemetry_autoFetchQuantity => 'Počet požiadaviek'; + + @override + String get telemetry_error => 'Nepodarilo sa získať údaje'; + @override String get neighbors_receivedData => 'Obdielo dáta suseda'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 48103647..d7a36d54 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -111,6 +111,12 @@ class AppLocalizationsSl extends AppLocalizations { return '$percent %'; } + @override + String get common_autoRefresh => 'Samodejno osveževanje'; + + @override + String get common_interval => 'Časovni interval'; + @override String get scanner_title => 'MeshCore – Odprto'; @@ -3250,6 +3256,12 @@ class AppLocalizationsSl extends AppLocalizations { return '$celsius°C / $fahrenheit°F'; } + @override + String get telemetry_autoFetchQuantity => 'Število zahtev'; + + @override + String get telemetry_error => 'Podatkov ni bilo mogoče pridobiti'; + @override String get neighbors_receivedData => 'Prejeto podatke o sosedih'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 7453c18f..2dbacd72 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -111,6 +111,12 @@ class AppLocalizationsSv extends AppLocalizations { return '$percent%'; } + @override + String get common_autoRefresh => 'Automatisk uppdatering'; + + @override + String get common_interval => 'Intervall'; + @override String get scanner_title => 'MeshCore – Öppen version'; @@ -3231,6 +3237,12 @@ class AppLocalizationsSv extends AppLocalizations { return '$celsius°C / $fahrenheit°F'; } + @override + String get telemetry_autoFetchQuantity => 'Antal förfrågningar'; + + @override + String get telemetry_error => 'Det gick inte att hämta data'; + @override String get neighbors_receivedData => 'Mottagna grannars data'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 9c30fd8e..6067b573 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -111,6 +111,12 @@ class AppLocalizationsUk extends AppLocalizations { return '$percent%'; } + @override + String get common_autoRefresh => 'Автооновлення'; + + @override + String get common_interval => 'Інтервал'; + @override String get scanner_title => 'MeshCore: Відкритий доступ'; @@ -3274,6 +3280,12 @@ class AppLocalizationsUk extends AppLocalizations { return '$celsius°C / $fahrenheit°F'; } + @override + String get telemetry_autoFetchQuantity => 'Кількість запитів'; + + @override + String get telemetry_error => 'Не вдалося отримати дані'; + @override String get neighbors_receivedData => 'Дані сусідів отримано'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index d2304a66..a57f45a7 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -111,6 +111,12 @@ class AppLocalizationsZh extends AppLocalizations { return '$percent%'; } + @override + String get common_autoRefresh => '自动刷新'; + + @override + String get common_interval => '间隔'; + @override String get scanner_title => '连接设备'; @@ -2999,6 +3005,12 @@ class AppLocalizationsZh extends AppLocalizations { return '$celsius°C / $fahrenheit°F'; } + @override + String get telemetry_autoFetchQuantity => '请求次数'; + + @override + String get telemetry_error => '无法获取数据'; + @override String get neighbors_receivedData => '已接收邻居信息'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 7e83215b..151369fe 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -33,6 +33,8 @@ "common_remove": "Verwijderen", "common_enable": "Activeren", "common_disable": "Uitschakelen", + "common_autoRefresh": "Automatisch vernieuwen", + "common_interval": "Tijdsinterval", "common_reboot": "Herstarten", "common_loading": "Laden...", "common_notAvailable": "—", @@ -1280,6 +1282,8 @@ } } }, + "telemetry_autoFetchQuantity": "Aantal aanvragen", + "telemetry_error": "Kan gegevens niet ophalen", "telemetry_noData": "Geen telemetriedata beschikbaar.", "telemetry_channelTitle": "Kanaal {channel}", "@telemetry_channelTitle": { diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 4366e273..524838d2 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -33,6 +33,8 @@ "common_remove": "Usuń", "common_enable": "Włącz", "common_disable": "Wyłącz", + "common_autoRefresh": "Automatyczne odświeżanie", + "common_interval": "Interwał", "common_reboot": "Uruchom ponownie", "common_loading": "Ładowanie...", "common_notAvailable": "—", @@ -1290,6 +1292,8 @@ } } }, + "telemetry_autoFetchQuantity": "Liczba żądań", + "telemetry_error": "Nie udało się pobrać danych", "telemetry_noData": "Brak dostępnych danych telemetrycznych.", "telemetry_channelTitle": "Kanał {channel}", "@telemetry_channelTitle": { diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 6b8abbbc..e0f6a0d1 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -33,6 +33,8 @@ "common_remove": "Remover", "common_enable": "Ativar", "common_disable": "Desativar", + "common_autoRefresh": "Atualização automática", + "common_interval": "Intervalo", "common_reboot": "Reiniciar", "common_loading": "Carregando...", "common_notAvailable": "—", @@ -1280,6 +1282,8 @@ } } }, + "telemetry_autoFetchQuantity": "Número de solicitações", + "telemetry_error": "Não foi possível obter os dados", "telemetry_noData": "Não estão disponíveis dados de telemetria.", "telemetry_channelTitle": "Canal {channel}", "@telemetry_channelTitle": { diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 5d5bcfc3..ef5f1c63 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -39,6 +39,8 @@ "common_notAvailable": "—", "common_voltageValue": "{volts} В", "common_percentValue": "{percent}%", + "common_autoRefresh": "Автообновление", + "common_interval": "Интервал", "scanner_title": "MeshCore Open", "scanner_scanning": "Поиск устройств...", "scanner_connecting": "Подключение...", @@ -686,6 +688,8 @@ "telemetry_voltageValue": "{volts}В", "telemetry_currentValue": "{amps}А", "telemetry_temperatureValue": "{celsius}°C / {fahrenheit}°F", + "telemetry_autoFetchQuantity": "Количество запросов", + "telemetry_error": "Не удалось получить данные", "neighbors_receivedData": "Полученные данные о соседях", "neighbors_requestTimedOut": "Время ожидания данных о соседях истекло.", "neighbors_errorLoading": "Ошибка загрузки соседей: {error}", diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index 713c3976..a2b6c2e5 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -33,6 +33,8 @@ "common_remove": "Odstrániť", "common_enable": "Povolit", "common_disable": "Zakázať", + "common_autoRefresh": "Automatické obnovenie", + "common_interval": "Časový interval", "common_reboot": "Restartovať", "common_loading": "Načítavanie...", "common_notAvailable": "—", @@ -1280,6 +1282,8 @@ } } }, + "telemetry_autoFetchQuantity": "Počet požiadaviek", + "telemetry_error": "Nepodarilo sa získať údaje", "telemetry_noData": "Nejsú dostupné žiadne údaje z telemetrie.", "telemetry_channelTitle": "Kanál {channel}", "@telemetry_channelTitle": { diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index 00930c85..dd61e335 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -33,6 +33,8 @@ "common_remove": "Izbrisati", "common_enable": "Omogoči", "common_disable": "Izklopiti", + "common_autoRefresh": "Samodejno osveževanje", + "common_interval": "Časovni interval", "common_reboot": "Ponoviti", "common_loading": "Naložanje...", "common_notAvailable": "—", @@ -1280,6 +1282,8 @@ } } }, + "telemetry_autoFetchQuantity": "Število zahtev", + "telemetry_error": "Podatkov ni bilo mogoče pridobiti", "telemetry_noData": "Niso na voljo podatki o telemetriji.", "telemetry_channelTitle": "Kanal {channel}", "@telemetry_channelTitle": { diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index 00fd3b08..04bdd9cb 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -33,6 +33,8 @@ "common_remove": "Ta bort", "common_enable": "Aktivera", "common_disable": "Inaktivera", + "common_autoRefresh": "Automatisk uppdatering", + "common_interval": "Intervall", "common_reboot": "Start om", "common_loading": "Laddar...", "common_notAvailable": "—", @@ -1280,6 +1282,8 @@ } } }, + "telemetry_autoFetchQuantity": "Antal förfrågningar", + "telemetry_error": "Det gick inte att hämta data", "telemetry_noData": "Inga telemetridata tillgängliga.", "telemetry_channelTitle": "Kanal {channel}", "@telemetry_channelTitle": { diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 8330d364..487d4675 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -34,6 +34,8 @@ "common_remove": "Прибрати", "common_enable": "Увімкнути", "common_disable": "Вимкнути", + "common_autoRefresh": "Автооновлення", + "common_interval": "Інтервал", "common_reboot": "Перезавантажити", "common_loading": "Завантаження...", "common_notAvailable": "—", @@ -1291,6 +1293,8 @@ } } }, + "telemetry_autoFetchQuantity": "Кількість запитів", + "telemetry_error": "Не вдалося отримати дані", "telemetry_noData": "Дані телеметрії недоступні.", "telemetry_channelTitle": "Канал {channel}", "@telemetry_channelTitle": { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 1e289315..8a40bfc0 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -34,6 +34,8 @@ "common_remove": "移除", "common_enable": "启用", "common_disable": "禁用", + "common_autoRefresh": "自动刷新", + "common_interval": "间隔", "common_reboot": "重启", "common_loading": "正在加载...", "common_notAvailable": "—", @@ -1310,6 +1312,8 @@ } } }, + "telemetry_autoFetchQuantity": "请求次数", + "telemetry_error": "无法获取数据", "telemetry_noData": "暂无遥测数据", "telemetry_channelTitle": "频道 {channel}", "@telemetry_channelTitle": { diff --git a/lib/screens/telemetry_screen.dart b/lib/screens/telemetry_screen.dart index 47593a3f..d28cbcef 100644 --- a/lib/screens/telemetry_screen.dart +++ b/lib/screens/telemetry_screen.dart @@ -1,11 +1,13 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../l10n/l10n.dart'; import '../models/contact.dart'; import '../models/path_selection.dart'; import '../models/app_settings.dart'; +import '../storage/prefs_manager.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; import '../services/app_settings_service.dart'; @@ -15,6 +17,7 @@ import '../widgets/path_management_dialog.dart'; import '../helpers/cayenne_lpp.dart'; import '../utils/battery_utils.dart'; import '../helpers/snack_bar_builder.dart'; +import '../widgets/telemetry_location_map.dart'; class TelemetryScreen extends StatefulWidget { final Contact contact; @@ -26,6 +29,13 @@ class TelemetryScreen extends StatefulWidget { } class _TelemetryScreenState extends State { + static const int _autoRefreshDefaultIntervalSeconds = 20; + static const int _autoRefreshDefaultQuantity = 10; + static const int _autoRefreshMinIntervalSeconds = 10; + static const int _autoRefreshMaxIntervalSeconds = 300; + static const int _autoRefreshMinQuantity = 1; + static const int _autoRefreshMaxQuantity = 10; + int _tagData = 0; bool _isLoading = false; @@ -36,6 +46,17 @@ class _TelemetryScreenState extends State { RepeaterCommandService? _commandService; PathSelection? _pendingStatusSelection; List>? _parsedTelemetry; + final TextEditingController _autoRefreshIntervalController = + TextEditingController(text: '$_autoRefreshDefaultIntervalSeconds'); + final TextEditingController _autoRefreshQuantityController = + TextEditingController(text: '$_autoRefreshDefaultQuantity'); + Timer? _autoRefreshTimer; + bool _isAutoRefreshEnabled = false; + bool _activeTelemetryRequestIsAutoRefresh = false; + bool _autoRefreshLastAttemptFailed = false; + int _autoRefreshCurrentAttempt = 0; + int _autoRefreshTotalAttempts = 0; + int _autoRefreshIntervalSeconds = _autoRefreshDefaultIntervalSeconds; int _tripTime = 0; @@ -62,6 +83,7 @@ class _TelemetryScreenState extends State { super.initState(); final connector = Provider.of(context, listen: false); _commandService = RepeaterCommandService(connector); + _loadAutoRefreshSettings(); _setupMessageListener(); _loadTelemetry(); _hasData = false; @@ -81,17 +103,26 @@ class _TelemetryScreenState extends State { _tagData = reader.readUInt32LE(); _tripTime = reader.readUInt32LE(); _statusTimeout?.cancel(); + final isAutoRefreshRequest = _activeTelemetryRequestIsAutoRefresh; _statusTimeout = Timer(Duration(milliseconds: _tripTime), () { if (!mounted) return; setState(() { _isLoading = false; _isLoaded = false; + if (isAutoRefreshRequest && _isAutoRefreshEnabled) { + _autoRefreshLastAttemptFailed = true; + } }); - showDismissibleSnackBar( - context, - content: Text(context.l10n.telemetry_requestTimeout), - backgroundColor: Colors.red, - ); + if (!isAutoRefreshRequest) { + showDismissibleSnackBar( + context, + content: Text(context.l10n.telemetry_requestTimeout), + backgroundColor: Colors.red, + ); + } + if (isAutoRefreshRequest && _isAutoRefreshEnabled) { + _scheduleNextAutoRefreshAttempt(); + } _recordTelemetryResult(false); }); } @@ -133,15 +164,22 @@ class _TelemetryScreenState extends State { ); } if (!mounted) return; + final isAutoRefreshRequest = _activeTelemetryRequestIsAutoRefresh; setState(() { _parsedTelemetry = parsedTelemetry; + if (isAutoRefreshRequest) { + _autoRefreshLastAttemptFailed = false; + } + _activeTelemetryRequestIsAutoRefresh = false; }); - showDismissibleSnackBar( - context, - content: Text(context.l10n.telemetry_receivedData), - backgroundColor: Colors.green, - ); + if (!isAutoRefreshRequest) { + showDismissibleSnackBar( + context, + content: Text(context.l10n.telemetry_receivedData), + backgroundColor: Colors.green, + ); + } _statusTimeout?.cancel(); if (!mounted) return; setState(() { @@ -149,14 +187,18 @@ class _TelemetryScreenState extends State { _isLoaded = true; _hasData = true; }); + if (isAutoRefreshRequest) { + _scheduleNextAutoRefreshAttempt(); + } } - Future _loadTelemetry() async { + Future _loadTelemetry({bool isAutoRefresh = false}) async { if (_commandService == null) return; setState(() { _isLoading = true; _isLoaded = false; + _activeTelemetryRequestIsAutoRefresh = isAutoRefresh; }); try { final connector = Provider.of(context, listen: false); @@ -179,17 +221,76 @@ class _TelemetryScreenState extends State { setState(() { _isLoading = false; _isLoaded = false; + if (isAutoRefresh) { + _autoRefreshLastAttemptFailed = true; + } + _activeTelemetryRequestIsAutoRefresh = false; }); + if (isAutoRefresh) { + _scheduleNextAutoRefreshAttempt(); + } - showDismissibleSnackBar( - context, - content: Text(context.l10n.telemetry_errorLoading(e.toString())), - backgroundColor: Colors.red, - ); + if (!isAutoRefresh) { + showDismissibleSnackBar( + context, + content: Text(context.l10n.telemetry_errorLoading(e.toString())), + backgroundColor: Colors.red, + ); + } } } } + void _loadAutoRefreshSettings() { + final prefs = PrefsManager.instance; + final contactKey = widget.contact.publicKeyHex; + final interval = + (prefs.getInt(_autoRefreshIntervalKey(contactKey)) ?? + _autoRefreshDefaultIntervalSeconds) + .clamp( + _autoRefreshMinIntervalSeconds, + _autoRefreshMaxIntervalSeconds, + ) + .toInt(); + final quantity = + (prefs.getInt(_autoRefreshQuantityKey(contactKey)) ?? + _autoRefreshDefaultQuantity) + .clamp(_autoRefreshMinQuantity, _autoRefreshMaxQuantity) + .toInt(); + + _autoRefreshIntervalSeconds = interval; + _autoRefreshIntervalController.text = interval.toString(); + _autoRefreshQuantityController.text = quantity.toString(); + } + + Future _saveAutoRefreshSettings() async { + final contactKey = widget.contact.publicKeyHex; + final interval = _clampControllerValue( + controller: _autoRefreshIntervalController, + min: _autoRefreshMinIntervalSeconds, + max: _autoRefreshMaxIntervalSeconds, + fallback: _autoRefreshIntervalSeconds, + ); + final quantity = _clampControllerValue( + controller: _autoRefreshQuantityController, + min: _autoRefreshMinQuantity, + max: _autoRefreshMaxQuantity, + fallback: _autoRefreshDefaultQuantity, + ); + + final prefs = PrefsManager.instance; + await prefs.setInt(_autoRefreshIntervalKey(contactKey), interval); + await prefs.setInt(_autoRefreshQuantityKey(contactKey), quantity); + } + + String _autoRefreshIntervalKey(String contactKey) { + return 'telemetry_auto_refresh_interval_$contactKey'; + } + + String _autoRefreshQuantityKey(String contactKey) { + return 'telemetry_auto_refresh_quantity_$contactKey'; + } + void _recordTelemetryResult(bool success) { final selection = _pendingStatusSelection; if (selection == null) return; @@ -205,9 +306,13 @@ class _TelemetryScreenState extends State { @override void dispose() { + unawaited(_saveAutoRefreshSettings()); _frameSubscription?.cancel(); _commandService?.dispose(); _statusTimeout?.cancel(); + _autoRefreshTimer?.cancel(); + _autoRefreshIntervalController.dispose(); + _autoRefreshQuantityController.dispose(); super.dispose(); } @@ -313,7 +418,9 @@ class _TelemetryScreenState extends State { child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.refresh), - onPressed: _isLoading ? null : _loadTelemetry, + onPressed: (_isLoading || _isAutoRefreshEnabled) + ? null + : () => _loadTelemetry(), tooltip: l10n.repeater_refresh, ), ], @@ -321,7 +428,8 @@ class _TelemetryScreenState extends State { body: SafeArea( top: false, child: RefreshIndicator( - onRefresh: _loadTelemetry, + onRefresh: () => + _isAutoRefreshEnabled ? Future.value() : _loadTelemetry(), child: ListView( padding: const EdgeInsets.all(16), children: [ @@ -344,6 +452,7 @@ class _TelemetryScreenState extends State { entry['channel'], isImperialUnits, ), + _buildAutoRefreshCard(), ], ), ), @@ -407,6 +516,8 @@ class _TelemetryScreenState extends State { l10n.telemetry_currentLabel, l10n.telemetry_currentValue(entry.value.toString()), ) + else if (entry.key == 'gps') + _buildGpsInfo(entry.value) else _buildInfoRow(entry.key, entry.value.toString()), ], @@ -415,6 +526,274 @@ class _TelemetryScreenState extends State { ); } + Widget _buildAutoRefreshCard() { + final l10n = context.l10n; + final counterText = _autoRefreshCounterText(); + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon( + Icons.autorenew, + color: Theme.of(context).textTheme.headlineSmall?.color, + ), + const SizedBox(width: 8), + Text( + l10n.common_autoRefresh, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const Divider(), + _buildAutoRefreshNumberField( + controller: _autoRefreshIntervalController, + label: l10n.common_interval, + min: _autoRefreshMinIntervalSeconds, + max: _autoRefreshMaxIntervalSeconds, + fallback: _autoRefreshIntervalSeconds, + ), + const SizedBox(height: 12), + _buildAutoRefreshNumberField( + controller: _autoRefreshQuantityController, + label: l10n.telemetry_autoFetchQuantity, + min: _autoRefreshMinQuantity, + max: _autoRefreshMaxQuantity, + fallback: _autoRefreshDefaultQuantity, + ), + if (counterText != null) ...[ + const SizedBox(height: 12), + Text( + counterText, + textAlign: TextAlign.center, + style: TextStyle( + color: _autoRefreshLastAttemptFailed + ? Theme.of(context).colorScheme.error + : null, + fontWeight: FontWeight.w600, + ), + ), + ], + const SizedBox(height: 12), + FilledButton( + onPressed: _isLoading && !_isAutoRefreshEnabled + ? null + : _toggleAutoRefresh, + child: _isAutoRefreshEnabled + ? SizedBox( + width: double.infinity, + height: 20, + child: Stack( + alignment: Alignment.center, + children: [ + Center(child: Text(l10n.common_disable)), + const Positioned( + right: 0, + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ), + ), + ], + ), + ) + : Text(l10n.common_enable), + ), + ], + ), + ), + ); + } + + Widget _buildAutoRefreshNumberField({ + required TextEditingController controller, + required String label, + required int min, + required int max, + required int fallback, + }) { + return TextField( + controller: controller, + enabled: !_isAutoRefreshEnabled, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + isDense: true, + ), + onEditingComplete: () { + _clampControllerValue( + controller: controller, + min: min, + max: max, + fallback: fallback, + ); + unawaited(_saveAutoRefreshSettings()); + FocusScope.of(context).unfocus(); + }, + onSubmitted: (_) => unawaited(_saveAutoRefreshSettings()), + onTapOutside: (_) { + unawaited(_saveAutoRefreshSettings()); + FocusScope.of(context).unfocus(); + }, + ); + } + + String? _autoRefreshCounterText() { + if (!_isAutoRefreshEnabled && _autoRefreshCurrentAttempt == 0) return null; + final counter = '$_autoRefreshCurrentAttempt/$_autoRefreshTotalAttempts'; + if (_autoRefreshLastAttemptFailed) { + return '${context.l10n.telemetry_error}: $counter'; + } + return counter; + } + + void _toggleAutoRefresh() { + if (_isAutoRefreshEnabled) { + _stopAutoRefresh(); + return; + } + _startAutoRefresh(); + } + + void _startAutoRefresh() { + final interval = _clampControllerValue( + controller: _autoRefreshIntervalController, + min: _autoRefreshMinIntervalSeconds, + max: _autoRefreshMaxIntervalSeconds, + fallback: _autoRefreshIntervalSeconds, + ); + final quantity = _clampControllerValue( + controller: _autoRefreshQuantityController, + min: _autoRefreshMinQuantity, + max: _autoRefreshMaxQuantity, + fallback: _autoRefreshDefaultQuantity, + ); + unawaited(_saveAutoRefreshSettings()); + + setState(() { + _isAutoRefreshEnabled = true; + _autoRefreshIntervalSeconds = interval; + _autoRefreshTotalAttempts = quantity; + _autoRefreshCurrentAttempt = 0; + _autoRefreshLastAttemptFailed = false; + }); + _runAutoRefreshAttempt(); + } + + void _stopAutoRefresh() { + _autoRefreshTimer?.cancel(); + _autoRefreshTimer = null; + if (!mounted) return; + setState(() { + _isAutoRefreshEnabled = false; + }); + } + + Future _runAutoRefreshAttempt() async { + if (!_isAutoRefreshEnabled || !mounted) return; + if (_autoRefreshCurrentAttempt >= _autoRefreshTotalAttempts) { + _stopAutoRefresh(); + return; + } + + setState(() { + _autoRefreshCurrentAttempt += 1; + }); + await _loadTelemetry(isAutoRefresh: true); + } + + void _scheduleNextAutoRefreshAttempt() { + if (!_isAutoRefreshEnabled || !mounted) return; + _autoRefreshTimer?.cancel(); + if (_autoRefreshCurrentAttempt >= _autoRefreshTotalAttempts) { + _stopAutoRefresh(); + return; + } + // Start the interval only after the current request has finished: after a + // telemetry response, timeout, or send error. This keeps slow replies from + // shortening the intended pause between requests. + _autoRefreshTimer = Timer( + Duration(seconds: _autoRefreshIntervalSeconds), + _runAutoRefreshAttempt, + ); + } + + int _clampControllerValue({ + required TextEditingController controller, + required int min, + required int max, + required int fallback, + }) { + final parsed = int.tryParse(controller.text); + final value = (parsed ?? fallback).clamp(min, max).toInt(); + controller.text = value.toString(); + controller.selection = TextSelection.collapsed( + offset: controller.text.length, + ); + return value; + } + + Widget _buildGpsInfo(dynamic value) { + final latitude = _readGpsValue(value, 'latitude'); + final longitude = _readGpsValue(value, 'longitude'); + final altitude = _readGpsValue(value, 'altitude'); + final isValidPosition = _isValidGpsPosition(latitude, longitude); + final gpsText = isValidPosition + ? [ + latitude!.toStringAsFixed(5), + longitude!.toStringAsFixed(5), + if (altitude != null) '${altitude.toStringAsFixed(1)} m', + ].join(', ') + : value.toString(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoRow('gps', gpsText), + if (isValidPosition) + TelemetryLocationMap( + // The map renders only after bounds validation, keeping malformed + // Cayenne payloads from creating an invalid FlutterMap center. + latitude: latitude!, + longitude: longitude!, + label: widget.contact.name, + contactType: widget.contact.type, + contactPublicKeyHex: widget.contact.publicKeyHex, + ), + ], + ); + } + + double? _readGpsValue(dynamic value, String key) { + if (value is! Map) return null; + final rawValue = value[key]; + if (rawValue is num) return rawValue.toDouble(); + return null; + } + + bool _isValidGpsPosition(double? latitude, double? longitude) { + if (latitude == null || longitude == null) return false; + const double epsilon = 1e-6; + return (latitude.abs() > epsilon || longitude.abs() > epsilon) && + latitude >= -90.0 && + latitude <= 90.0 && + longitude >= -180.0 && + longitude <= 180.0; + } + Widget _buildInfoRow(String label, String value) { return Padding( padding: const EdgeInsets.symmetric(vertical: 6), diff --git a/lib/widgets/telemetry_location_map.dart b/lib/widgets/telemetry_location_map.dart new file mode 100644 index 00000000..f6baff71 --- /dev/null +++ b/lib/widgets/telemetry_location_map.dart @@ -0,0 +1,422 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:provider/provider.dart'; + +import '../connector/meshcore_connector.dart'; +import '../connector/meshcore_protocol.dart'; +import '../l10n/l10n.dart'; +import '../models/app_settings.dart'; +import '../models/contact.dart'; +import '../services/app_settings_service.dart'; +import '../services/map_tile_cache_service.dart'; + +class TelemetryLocationMap extends StatefulWidget { + final double latitude; + final double longitude; + final String label; + final int contactType; + final String contactPublicKeyHex; + + const TelemetryLocationMap({ + super.key, + required this.latitude, + required this.longitude, + required this.label, + required this.contactType, + required this.contactPublicKeyHex, + }); + + @override + State createState() => _TelemetryLocationMapState(); +} + +class _TelemetryLocationMapState extends State { + static const double _initialZoom = 14.0; + static const double _minZoom = 2.0; + static const double _maxZoom = 18.0; + + final MapController _mapController = MapController(); + + LatLng get _position => LatLng(widget.latitude, widget.longitude); + + @override + void didUpdateWidget(covariant TelemetryLocationMap oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.latitude == widget.latitude && + oldWidget.longitude == widget.longitude) { + return; + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _mapController.move(_position, _initialZoom); + } + }); + } + + @override + Widget build(BuildContext context) { + final connector = context.watch(); + final settingsService = context.watch(); + final settings = settingsService.settings; + final tileCache = context.read(); + final contacts = _filteredContacts(connector, settings); + final isDesktop = _isDesktopPlatform(defaultTargetPlatform); + + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 4), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: LayoutBuilder( + builder: (context, constraints) { + final maxHeight = MediaQuery.sizeOf(context).height * 0.75; + final squareHeight = constraints.maxWidth.isFinite + ? constraints.maxWidth + : maxHeight; + // Prefer square sizing by width, but cap only the height so the map + // remains usable on wide screens without growing past the viewport. + final mapHeight = squareHeight > maxHeight + ? maxHeight + : squareHeight; + + return SizedBox( + width: double.infinity, + height: mapHeight, + child: Stack( + children: [ + FlutterMap( + mapController: _mapController, + options: MapOptions( + initialCenter: _position, + initialZoom: _initialZoom, + minZoom: _minZoom, + maxZoom: _maxZoom, + interactionOptions: InteractionOptions( + flags: ~InteractiveFlag.rotate, + scrollWheelVelocity: isDesktop ? 0.012 : 0.005, + cursorKeyboardRotationOptions: + CursorKeyboardRotationOptions.disabled(), + keyboardOptions: isDesktop + ? const KeyboardOptions( + enableArrowKeysPanning: true, + enableWASDPanning: true, + enableRFZooming: true, + ) + : const KeyboardOptions.disabled(), + ), + ), + children: [ + TileLayer( + urlTemplate: kMapTileUrlTemplate, + tileProvider: tileCache.tileProvider, + userAgentPackageName: + MapTileCacheService.userAgentPackageName, + maxZoom: 19, + ), + MarkerLayer( + markers: [ + ...contacts.map(_buildContactMarker), + _buildTelemetryMarker(), + _buildLabelMarker(_position, widget.label), + ], + ), + ], + ), + Positioned( + top: 8, + right: 8, + child: _MapButton( + icon: Icons.filter_list, + tooltip: context.l10n.map_filterNodes, + onPressed: () => + _showFilterDialog(context, settingsService), + ), + ), + Positioned( + left: 8, + top: 8, + child: Column( + children: [ + _MapButton( + icon: Icons.add, + tooltip: 'Zoom in', + onPressed: () => _zoomBy(1), + ), + const SizedBox(height: 6), + _MapButton( + icon: Icons.remove, + tooltip: 'Zoom out', + onPressed: () => _zoomBy(-1), + ), + const SizedBox(height: 6), + _MapButton( + icon: Icons.my_location, + tooltip: 'Center map', + onPressed: () => + _mapController.move(_position, _initialZoom), + ), + ], + ), + ), + ], + ), + ); + }, + ), + ), + ); + } + + List _filteredContacts( + MeshCoreConnector connector, + AppSettings settings, + ) { + final contacts = settings.mapShowDiscoveryContacts + ? connector.allContacts + : connector.allContacts.where((contact) => contact.isActive).toList(); + + return contacts.where((contact) { + if (!contact.hasLocation) return false; + if (contact.publicKeyHex == widget.contactPublicKeyHex) return false; + if (contact.type == advTypeChat) return settings.mapShowChatNodes; + if (contact.type == advTypeRepeater) return settings.mapShowRepeaters; + return settings.mapShowOtherNodes; + }).toList(); + } + + Marker _buildTelemetryMarker() { + return Marker( + point: _position, + width: 44, + height: 44, + child: IgnorePointer( + child: _MarkerBubble( + color: Colors.red, + icon: _getNodeIcon(widget.contactType), + size: 24, + ), + ), + ); + } + + Marker _buildContactMarker(Contact contact) { + return Marker( + point: LatLng(contact.latitude!, contact.longitude!), + width: 34, + height: 34, + child: IgnorePointer( + child: _MarkerBubble( + color: _getNodeColor(contact.type), + icon: _getNodeIcon(contact.type), + size: 18, + ), + ), + ); + } + + Marker _buildLabelMarker(LatLng point, String label) { + return Marker( + point: point, + width: 140, + height: 24, + alignment: Alignment.topCenter, + child: IgnorePointer( + child: Transform.translate( + offset: const Offset(0, -24), + child: FittedBox( + fit: BoxFit.contain, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ), + ); + } + + void _zoomBy(double delta) { + final camera = _mapController.camera; + final nextZoom = (camera.zoom + delta).clamp(_minZoom, _maxZoom).toDouble(); + _mapController.move(camera.center, nextZoom); + } + + bool _isDesktopPlatform(TargetPlatform platform) { + return platform == TargetPlatform.linux || + platform == TargetPlatform.windows || + platform == TargetPlatform.macOS; + } + + Color _getNodeColor(int type) { + switch (type) { + case advTypeChat: + return Colors.blue; + case advTypeRepeater: + return Colors.green; + case advTypeRoom: + return Colors.purple; + case advTypeSensor: + return Colors.orange; + default: + return Colors.grey; + } + } + + IconData _getNodeIcon(int type) { + switch (type) { + case advTypeChat: + return Icons.person; + case advTypeRepeater: + return Icons.router; + case advTypeRoom: + return Icons.meeting_room; + case advTypeSensor: + return Icons.sensors; + default: + return Icons.device_unknown; + } + } + + void _showFilterDialog( + BuildContext context, + AppSettingsService settingsService, + ) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(context.l10n.map_filterNodes), + content: SingleChildScrollView( + child: Consumer( + builder: (consumerContext, service, child) { + final settings = service.settings; + // Reuse the global map filters so the telemetry preview and the + // main map stay consistent without another settings model. + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + CheckboxListTile( + title: Text(context.l10n.map_chatNodes), + value: settings.mapShowChatNodes, + onChanged: (value) { + service.setMapShowChatNodes(value ?? true); + }, + contentPadding: EdgeInsets.zero, + ), + CheckboxListTile( + title: Text(context.l10n.map_repeaters), + value: settings.mapShowRepeaters, + onChanged: (value) { + service.setMapShowRepeaters(value ?? true); + }, + contentPadding: EdgeInsets.zero, + ), + CheckboxListTile( + title: Text(context.l10n.map_otherNodes), + value: settings.mapShowOtherNodes, + onChanged: (value) { + service.setMapShowOtherNodes(value ?? true); + }, + contentPadding: EdgeInsets.zero, + ), + CheckboxListTile( + title: Text(context.l10n.map_showDiscoveryContacts), + value: settings.mapShowDiscoveryContacts, + onChanged: (value) { + service.setMapShowDiscoveryContacts(value ?? true); + }, + contentPadding: EdgeInsets.zero, + ), + ], + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(context.l10n.common_close), + ), + ], + ), + ); + } +} + +class _MarkerBubble extends StatelessWidget { + final Color color; + final IconData icon; + final double size; + + const _MarkerBubble({ + required this.color, + required this.icon, + required this.size, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: color, + 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: Icon(icon, color: Colors.white, size: size), + ); + } +} + +class _MapButton extends StatelessWidget { + final IconData icon; + final String tooltip; + final VoidCallback onPressed; + + const _MapButton({ + required this.icon, + required this.tooltip, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: Theme.of(context).colorScheme.surface, + elevation: 3, + borderRadius: BorderRadius.circular(8), + clipBehavior: Clip.antiAlias, + child: IconButton( + icon: Icon(icon), + tooltip: tooltip, + onPressed: onPressed, + constraints: const BoxConstraints.tightFor(width: 40, height: 40), + padding: EdgeInsets.zero, + iconSize: 20, + ), + ); + } +}