diff --git a/lib/icons/los_icon.dart b/lib/icons/los_icon.dart new file mode 100644 index 0000000..58d75b0 --- /dev/null +++ b/lib/icons/los_icon.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; + +class LosIcon extends StatelessWidget { + final double size; + final Color? color; + + const LosIcon({super.key, this.size = 24, this.color}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final iconTheme = IconTheme.of(context); + final iconColor = + color ?? + iconTheme.color ?? + theme.iconTheme.color ?? + theme.colorScheme.onSurface; + + return Icon(Symbols.elevation, size: size, color: iconColor); + } +} diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index e9f46c6..b88e3ba 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -1,4 +1,6 @@ { + "channels_channelDeleteFailed": "Неуспешно изтриване на канала \"{name}\"", + "@channels_channelDeleteFailed": { "placeholders": { "name": { "type": "String" } } }, "@@locale": "bg", "appTitle": "MeshCore Open", "nav_contacts": "Контакти", @@ -1718,5 +1720,29 @@ "losPointName": "Име на точката", "losShowPanelTooltip": "Показване на LOS панел", "losHidePanelTooltip": "Скриване на LOS панела", - "losElevationAttribution": "Данни за надморска височина: Open-Meteo (CC BY 4.0)" -} \ No newline at end of file + "losElevationAttribution": "Данни за надморска височина: Open-Meteo (CC BY 4.0)", + "losLegendRadioHorizon": "Радиохоризонт", + "losLegendLosBeam": "Линия на видимост", + "losLegendTerrain": "Терен", + "losFrequencyLabel": "Честота", + "losFrequencyInfoTooltip": "Преглед на детайли за изчислението", + "losFrequencyDialogTitle": "Изчисляване на радиохоризонта", + "losFrequencyDialogDescription": "Започвайки от k={baselineK} при {baselineFreq} MHz, изчислението коригира k-фактора за текущата {frequencyMHz} MHz лента, която определя границата на извития радиохоризонт.", + "@losFrequencyDialogDescription": { + "description": "Explain how the calculation uses the baseline frequency and derived k-factor.", + "placeholders": { + "baselineK": { + "type": "double" + }, + "baselineFreq": { + "type": "double" + }, + "frequencyMHz": { + "type": "double" + }, + "kFactor": { + "type": "double" + } + } + } +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index bdea574..a1647fa 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1,4 +1,6 @@ { + "channels_channelDeleteFailed": "Kanal {name} konnte nicht gelöscht werden", + "@channels_channelDeleteFailed": { "placeholders": { "name": { "type": "String" } } }, "@@locale": "de", "appTitle": "MeshCore Open", "nav_contacts": "Kontakte", @@ -1746,5 +1748,29 @@ "losPointName": "Punktname", "losShowPanelTooltip": "LOS-Panel anzeigen", "losHidePanelTooltip": "LOS-Panel ausblenden", - "losElevationAttribution": "Höhendaten: Open-Meteo (CC BY 4.0)" -} \ No newline at end of file + "losElevationAttribution": "Höhendaten: Open-Meteo (CC BY 4.0)", + "losLegendRadioHorizon": "Funkhorizont", + "losLegendLosBeam": "Sichtlinie", + "losLegendTerrain": "Gelände", + "losFrequencyLabel": "Frequenz", + "losFrequencyInfoTooltip": "Details zur Berechnung anzeigen", + "losFrequencyDialogTitle": "Berechnung des Funkhorizonts", + "losFrequencyDialogDescription": "Ausgehend von k={baselineK} bei {baselineFreq} MHz passt die Berechnung den k-Faktor für das aktuelle {frequencyMHz} MHz-Band an, das die gekrümmte Funkhorizontobergrenze definiert.", + "@losFrequencyDialogDescription": { + "description": "Explain how the calculation uses the baseline frequency and derived k-factor.", + "placeholders": { + "baselineK": { + "type": "double" + }, + "baselineFreq": { + "type": "double" + }, + "frequencyMHz": { + "type": "double" + }, + "kFactor": { + "type": "double" + } + } + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0e96e46..eec03bc 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -365,6 +365,14 @@ } } }, + "channels_channelDeleteFailed": "Failed to delete channel \"{name}\"", + "@channels_channelDeleteFailed": { + "placeholders": { + "name": { + "type": "String" + } + } + }, "channels_channelDeleted": "Channel \"{name}\" deleted", "@channels_channelDeleted": { "placeholders": { @@ -1667,6 +1675,30 @@ "losShowPanelTooltip": "Show LOS panel", "losHidePanelTooltip": "Hide LOS panel", "losElevationAttribution": "Elevation data: Open-Meteo (CC BY 4.0)", + "losLegendRadioHorizon": "Radio horizon", + "losLegendLosBeam": "LOS beam", + "losLegendTerrain": "Terrain", + "losFrequencyLabel": "Frequency", + "losFrequencyInfoTooltip": "View calculation details", + "losFrequencyDialogTitle": "Radio horizon calculation", + "losFrequencyDialogDescription": "Starting from k={baselineK} at {baselineFreq} MHz, the calculation adjusts the k-factor for the current {frequencyMHz} MHz band, which defines the curved radio horizon cap.", + "@losFrequencyDialogDescription": { + "description": "Explain how the calculation uses the baseline frequency and derived k-factor.", + "placeholders": { + "baselineK": { + "type": "double" + }, + "baselineFreq": { + "type": "double" + }, + "frequencyMHz": { + "type": "double" + }, + "kFactor": { + "type": "double" + } + } + }, "contacts_pathTrace": "Path Trace", "contacts_ping": "Ping", "contacts_repeaterPathTrace": "Path trace to repeater", @@ -1747,4 +1779,4 @@ "settings_gpxExportShareSubject": "meshcore-open GPX map data export", "snrIndicator_nearByRepeaters": "Nearby Repeaters", "snrIndicator_lastSeen": "Last seen" -} +} \ No newline at end of file diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 99db15d..2f62b54 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1,4 +1,6 @@ { + "channels_channelDeleteFailed": "No se pudo eliminar el canal \"{name}\"", + "@channels_channelDeleteFailed": { "placeholders": { "name": { "type": "String" } } }, "@@locale": "es", "appTitle": "MeshCore Open", "nav_contacts": "Contactos", @@ -1746,5 +1748,29 @@ "losPointName": "Nombre del punto", "losShowPanelTooltip": "Mostrar panel LOS", "losHidePanelTooltip": "Ocultar panel LOS", - "losElevationAttribution": "Datos de elevación: Open-Meteo (CC BY 4.0)" -} \ No newline at end of file + "losElevationAttribution": "Datos de elevación: Open-Meteo (CC BY 4.0)", + "losLegendRadioHorizon": "Horizonte radioeléctrico", + "losLegendLosBeam": "Línea de visión", + "losLegendTerrain": "Terreno", + "losFrequencyLabel": "Frecuencia", + "losFrequencyInfoTooltip": "Ver detalles del cálculo", + "losFrequencyDialogTitle": "Cálculo del horizonte radioeléctrico", + "losFrequencyDialogDescription": "A partir de k={baselineK} en {baselineFreq} MHz, el cálculo ajusta el factor k para la banda actual de {frequencyMHz} MHz, que define el límite curvo del horizonte de radio.", + "@losFrequencyDialogDescription": { + "description": "Explain how the calculation uses the baseline frequency and derived k-factor.", + "placeholders": { + "baselineK": { + "type": "double" + }, + "baselineFreq": { + "type": "double" + }, + "frequencyMHz": { + "type": "double" + }, + "kFactor": { + "type": "double" + } + } + } +} diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index bc82195..9eb66e7 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1,4 +1,6 @@ { + "channels_channelDeleteFailed": "Échec de la suppression de la chaîne \"{name}\"", + "@channels_channelDeleteFailed": { "placeholders": { "name": { "type": "String" } } }, "@@locale": "fr", "appTitle": "MeshCore Open", "nav_contacts": "Contacts", @@ -1718,5 +1720,29 @@ "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)" -} \ No newline at end of file + "losElevationAttribution": "Données d’altitude : Open-Meteo (CC BY 4.0)", + "losLegendRadioHorizon": "Horizon radio", + "losLegendLosBeam": "Ligne de visée", + "losLegendTerrain": "Terrain", + "losFrequencyLabel": "Fréquence", + "losFrequencyInfoTooltip": "Voir les détails du calcul", + "losFrequencyDialogTitle": "Calcul de l’horizon radio", + "losFrequencyDialogDescription": "À partir de k={baselineK} à {baselineFreq} MHz, le calcul ajuste le facteur k pour la bande actuelle de {frequencyMHz} MHz, ce qui définit la limite incurvée de l'horizon radio.", + "@losFrequencyDialogDescription": { + "description": "Explain how the calculation uses the baseline frequency and derived k-factor.", + "placeholders": { + "baselineK": { + "type": "double" + }, + "baselineFreq": { + "type": "double" + }, + "frequencyMHz": { + "type": "double" + }, + "kFactor": { + "type": "double" + } + } + } +} diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index fe4bffc..e08dd67 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1,4 +1,6 @@ { + "channels_channelDeleteFailed": "Impossibile eliminare il canale \"{name}\"", + "@channels_channelDeleteFailed": { "placeholders": { "name": { "type": "String" } } }, "@@locale": "it", "appTitle": "MeshCore Open", "nav_contacts": "Contatti", @@ -1718,5 +1720,29 @@ "losPointName": "Nome del punto", "losShowPanelTooltip": "Mostra il pannello LOS", "losHidePanelTooltip": "Nascondi il pannello LOS", - "losElevationAttribution": "Dati di elevazione: Open-Meteo (CC BY 4.0)" -} \ No newline at end of file + "losElevationAttribution": "Dati di elevazione: Open-Meteo (CC BY 4.0)", + "losLegendRadioHorizon": "Orizzonte radio", + "losLegendLosBeam": "Linea di vista", + "losLegendTerrain": "Terreno", + "losFrequencyLabel": "Frequenza", + "losFrequencyInfoTooltip": "Visualizza i dettagli del calcolo", + "losFrequencyDialogTitle": "Calcolo dell’orizzonte radio", + "losFrequencyDialogDescription": "Partendo da k={baselineK} a {baselineFreq} MHz, il calcolo regola il fattore k per l'attuale banda {frequencyMHz} MHz, che definisce il limite curvo dell'orizzonte radio.", + "@losFrequencyDialogDescription": { + "description": "Explain how the calculation uses the baseline frequency and derived k-factor.", + "placeholders": { + "baselineK": { + "type": "double" + }, + "baselineFreq": { + "type": "double" + }, + "frequencyMHz": { + "type": "double" + }, + "kFactor": { + "type": "double" + } + } + } +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 6097f86..54e9cdc 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1582,6 +1582,12 @@ abstract class AppLocalizations { /// **'Delete \"{name}\"? This cannot be undone.'** String channels_deleteChannelConfirm(String name); + /// No description provided for @channels_channelDeleteFailed. + /// + /// In en, this message translates to: + /// **'Failed to delete channel \"{name}\"'** + String channels_channelDeleteFailed(String name); + /// No description provided for @channels_channelDeleted. /// /// In en, this message translates to: @@ -5004,6 +5010,53 @@ abstract class AppLocalizations { /// **'Elevation data: Open-Meteo (CC BY 4.0)'** String get losElevationAttribution; + /// No description provided for @losLegendRadioHorizon. + /// + /// In en, this message translates to: + /// **'Radio horizon'** + String get losLegendRadioHorizon; + + /// No description provided for @losLegendLosBeam. + /// + /// In en, this message translates to: + /// **'LOS beam'** + String get losLegendLosBeam; + + /// No description provided for @losLegendTerrain. + /// + /// In en, this message translates to: + /// **'Terrain'** + String get losLegendTerrain; + + /// No description provided for @losFrequencyLabel. + /// + /// In en, this message translates to: + /// **'Frequency'** + String get losFrequencyLabel; + + /// No description provided for @losFrequencyInfoTooltip. + /// + /// In en, this message translates to: + /// **'View calculation details'** + String get losFrequencyInfoTooltip; + + /// No description provided for @losFrequencyDialogTitle. + /// + /// In en, this message translates to: + /// **'Radio horizon calculation'** + String get losFrequencyDialogTitle; + + /// Explain how the calculation uses the baseline frequency and derived k-factor. + /// + /// In en, this message translates to: + /// **'Starting from k={baselineK} at {baselineFreq} MHz, the calculation adjusts the k-factor for the current {frequencyMHz} MHz band, which defines the curved radio horizon cap.'** + String losFrequencyDialogDescription( + double baselineK, + double baselineFreq, + double frequencyMHz, + double kFactor, + ); + /// 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 94f9f7a..21a6e79 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -820,6 +820,11 @@ class AppLocalizationsBg extends AppLocalizations { return 'Изтрий \"$name\"? Това не може да бъде отменено.'; } + @override + String channels_channelDeleteFailed(String name) { + return 'Неуспешно изтриване на канала \"$name\"'; + } + @override String channels_channelDeleted(String name) { return 'Каналът \"$name\" е изтрит'; @@ -2867,6 +2872,34 @@ class AppLocalizationsBg extends AppLocalizations { String get losElevationAttribution => 'Данни за надморска височина: Open-Meteo (CC BY 4.0)'; + @override + String get losLegendRadioHorizon => 'Радиохоризонт'; + + @override + String get losLegendLosBeam => 'Линия на видимост'; + + @override + String get losLegendTerrain => 'Терен'; + + @override + String get losFrequencyLabel => 'Честота'; + + @override + String get losFrequencyInfoTooltip => 'Преглед на детайли за изчислението'; + + @override + String get losFrequencyDialogTitle => 'Изчисляване на радиохоризонта'; + + @override + String losFrequencyDialogDescription( + double baselineK, + double baselineFreq, + double frequencyMHz, + double kFactor, + ) { + return 'Започвайки от k=$baselineK при $baselineFreq MHz, изчислението коригира k-фактора за текущата $frequencyMHz MHz лента, която определя границата на извития радиохоризонт.'; + } + @override String get contacts_pathTrace => 'Пътен проследяване'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index ba0f5da..e5a3a49 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -817,6 +817,11 @@ class AppLocalizationsDe extends AppLocalizations { return 'Löschen von \"$name\"? Dies kann nicht rückgängig gemacht werden.'; } + @override + String channels_channelDeleteFailed(String name) { + return 'Kanal $name konnte nicht gelöscht werden'; + } + @override String channels_channelDeleted(String name) { return 'Kanal \"$name\" gelöscht'; @@ -2873,6 +2878,34 @@ class AppLocalizationsDe extends AppLocalizations { @override String get losElevationAttribution => 'Höhendaten: Open-Meteo (CC BY 4.0)'; + @override + String get losLegendRadioHorizon => 'Funkhorizont'; + + @override + String get losLegendLosBeam => 'Sichtlinie'; + + @override + String get losLegendTerrain => 'Gelände'; + + @override + String get losFrequencyLabel => 'Frequenz'; + + @override + String get losFrequencyInfoTooltip => 'Details zur Berechnung anzeigen'; + + @override + String get losFrequencyDialogTitle => 'Berechnung des Funkhorizonts'; + + @override + String losFrequencyDialogDescription( + double baselineK, + double baselineFreq, + double frequencyMHz, + double kFactor, + ) { + return 'Ausgehend von k=$baselineK bei $baselineFreq MHz passt die Berechnung den k-Faktor für das aktuelle $frequencyMHz MHz-Band an, das die gekrümmte Funkhorizontobergrenze definiert.'; + } + @override String get contacts_pathTrace => 'Pfadverfolgung'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index ce5b2f0..a56e217 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -808,6 +808,11 @@ class AppLocalizationsEn extends AppLocalizations { return 'Delete \"$name\"? This cannot be undone.'; } + @override + String channels_channelDeleteFailed(String name) { + return 'Failed to delete channel \"$name\"'; + } + @override String channels_channelDeleted(String name) { return 'Channel \"$name\" deleted'; @@ -2824,6 +2829,34 @@ class AppLocalizationsEn extends AppLocalizations { String get losElevationAttribution => 'Elevation data: Open-Meteo (CC BY 4.0)'; + @override + String get losLegendRadioHorizon => 'Radio horizon'; + + @override + String get losLegendLosBeam => 'LOS beam'; + + @override + String get losLegendTerrain => 'Terrain'; + + @override + String get losFrequencyLabel => 'Frequency'; + + @override + String get losFrequencyInfoTooltip => 'View calculation details'; + + @override + String get losFrequencyDialogTitle => 'Radio horizon calculation'; + + @override + String losFrequencyDialogDescription( + double baselineK, + double baselineFreq, + double frequencyMHz, + double kFactor, + ) { + return 'Starting from k=$baselineK at $baselineFreq MHz, the calculation adjusts the k-factor for the current $frequencyMHz MHz band, which defines the curved radio horizon cap.'; + } + @override String get contacts_pathTrace => 'Path Trace'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 3c7838d..98cd658 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -818,6 +818,11 @@ class AppLocalizationsEs extends AppLocalizations { return 'Eliminar \"$name\"? Esto no se puede deshacer.'; } + @override + String channels_channelDeleteFailed(String name) { + return 'No se pudo eliminar el canal \"$name\"'; + } + @override String channels_channelDeleted(String name) { return 'Canal \"$name\" eliminado'; @@ -2867,6 +2872,34 @@ class AppLocalizationsEs extends AppLocalizations { String get losElevationAttribution => 'Datos de elevación: Open-Meteo (CC BY 4.0)'; + @override + String get losLegendRadioHorizon => 'Horizonte radioeléctrico'; + + @override + String get losLegendLosBeam => 'Línea de visión'; + + @override + String get losLegendTerrain => 'Terreno'; + + @override + String get losFrequencyLabel => 'Frecuencia'; + + @override + String get losFrequencyInfoTooltip => 'Ver detalles del cálculo'; + + @override + String get losFrequencyDialogTitle => 'Cálculo del horizonte radioeléctrico'; + + @override + String losFrequencyDialogDescription( + double baselineK, + double baselineFreq, + double frequencyMHz, + double kFactor, + ) { + return 'A partir de k=$baselineK en $baselineFreq MHz, el cálculo ajusta el factor k para la banda actual de $frequencyMHz MHz, que define el límite curvo del horizonte de radio.'; + } + @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 ee58fb4..a52ff00 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -820,6 +820,11 @@ class AppLocalizationsFr extends AppLocalizations { return 'Supprimer $name? Cela ne peut pas être annulé.'; } + @override + String channels_channelDeleteFailed(String name) { + return 'Échec de la suppression de la chaîne \"$name\"'; + } + @override String channels_channelDeleted(String name) { return 'Le canal \"$name\" a été supprimé'; @@ -2880,7 +2885,35 @@ class AppLocalizationsFr extends AppLocalizations { @override String get losElevationAttribution => - 'Données d\'altitude : Open-Meteo (CC BY 4.0)'; + 'Données d’altitude : Open-Meteo (CC BY 4.0)'; + + @override + String get losLegendRadioHorizon => 'Horizon radio'; + + @override + String get losLegendLosBeam => 'Ligne de visée'; + + @override + String get losLegendTerrain => 'Terrain'; + + @override + String get losFrequencyLabel => 'Fréquence'; + + @override + String get losFrequencyInfoTooltip => 'Voir les détails du calcul'; + + @override + String get losFrequencyDialogTitle => 'Calcul de l’horizon radio'; + + @override + String losFrequencyDialogDescription( + double baselineK, + double baselineFreq, + double frequencyMHz, + double kFactor, + ) { + return 'À partir de k=$baselineK à $baselineFreq MHz, le calcul ajuste le facteur k pour la bande actuelle de $frequencyMHz MHz, ce qui définit la limite incurvée de l\'horizon radio.'; + } @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 b3c41e7..aebea2f 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -816,6 +816,11 @@ class AppLocalizationsIt extends AppLocalizations { return 'Eliminare \"$name\"? Non può essere annullato.'; } + @override + String channels_channelDeleteFailed(String name) { + return 'Impossibile eliminare il canale \"$name\"'; + } + @override String channels_channelDeleted(String name) { return 'Canale \"$name\" eliminato'; @@ -2867,6 +2872,34 @@ class AppLocalizationsIt extends AppLocalizations { String get losElevationAttribution => 'Dati di elevazione: Open-Meteo (CC BY 4.0)'; + @override + String get losLegendRadioHorizon => 'Orizzonte radio'; + + @override + String get losLegendLosBeam => 'Linea di vista'; + + @override + String get losLegendTerrain => 'Terreno'; + + @override + String get losFrequencyLabel => 'Frequenza'; + + @override + String get losFrequencyInfoTooltip => 'Visualizza i dettagli del calcolo'; + + @override + String get losFrequencyDialogTitle => 'Calcolo dell’orizzonte radio'; + + @override + String losFrequencyDialogDescription( + double baselineK, + double baselineFreq, + double frequencyMHz, + double kFactor, + ) { + return 'Partendo da k=$baselineK a $baselineFreq MHz, il calcolo regola il fattore k per l\'attuale banda $frequencyMHz MHz, che definisce il limite curvo dell\'orizzonte radio.'; + } + @override String get contacts_pathTrace => 'Traccia Percorso'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 0ff00ad..5460d29 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -813,6 +813,11 @@ class AppLocalizationsNl extends AppLocalizations { return 'Verwijderen \"$name\"? Dit kan niet worden teruggedraaid.'; } + @override + String channels_channelDeleteFailed(String name) { + return 'Kan kanaal $name niet verwijderen'; + } + @override String channels_channelDeleted(String name) { return 'Kanaal \"$name\" verwijderd'; @@ -2857,6 +2862,34 @@ class AppLocalizationsNl extends AppLocalizations { String get losElevationAttribution => 'Hoogtegegevens: Open-Meteo (CC BY 4.0)'; + @override + String get losLegendRadioHorizon => 'Radiohorizon'; + + @override + String get losLegendLosBeam => 'Zichtlijn'; + + @override + String get losLegendTerrain => 'Terrein'; + + @override + String get losFrequencyLabel => 'Frequentie'; + + @override + String get losFrequencyInfoTooltip => 'Bekijk details van de berekening'; + + @override + String get losFrequencyDialogTitle => 'Berekening van de radiohorizon'; + + @override + String losFrequencyDialogDescription( + double baselineK, + double baselineFreq, + double frequencyMHz, + double kFactor, + ) { + return 'Beginnend met k=$baselineK bij $baselineFreq MHz, wordt bij de berekening de k-factor aangepast voor de huidige $frequencyMHz MHz-band, die de gebogen radiohorizonkap definieert.'; + } + @override String get contacts_pathTrace => 'Pad Traceren'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index d6c1e15..7286033 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -818,6 +818,11 @@ class AppLocalizationsPl extends AppLocalizations { return 'Usuń \"$name\"? Nie można tego cofnąć.'; } + @override + String channels_channelDeleteFailed(String name) { + return 'Nie udało się usunąć kanału \"$name\"'; + } + @override String channels_channelDeleted(String name) { return 'Kanał \"$name\" usunięto'; @@ -2863,6 +2868,34 @@ class AppLocalizationsPl extends AppLocalizations { String get losElevationAttribution => 'Dane dotyczące wysokości: Open-Meteo (CC BY 4.0)'; + @override + String get losLegendRadioHorizon => 'Horyzont radiowy'; + + @override + String get losLegendLosBeam => 'Linia widoczności'; + + @override + String get losLegendTerrain => 'Teren'; + + @override + String get losFrequencyLabel => 'Częstotliwość'; + + @override + String get losFrequencyInfoTooltip => 'Zobacz szczegóły obliczenia'; + + @override + String get losFrequencyDialogTitle => 'Obliczanie horyzontu radiowego'; + + @override + String losFrequencyDialogDescription( + double baselineK, + double baselineFreq, + double frequencyMHz, + double kFactor, + ) { + return 'Zaczynając od k=$baselineK przy $baselineFreq MHz, obliczenia korygują współczynnik k dla bieżącego pasma $frequencyMHz MHz, które definiuje zakrzywiony limit horyzontu radiowego.'; + } + @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 a300ba9..025c81c 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -819,6 +819,11 @@ class AppLocalizationsPt extends AppLocalizations { return 'Excluir \"$name\"? Não pode ser desfeito.'; } + @override + String channels_channelDeleteFailed(String name) { + return 'Falha ao excluir o canal \"$name\"'; + } + @override String channels_channelDeleted(String name) { return 'Canal \"$name\" excluído'; @@ -2866,6 +2871,34 @@ class AppLocalizationsPt extends AppLocalizations { String get losElevationAttribution => 'Dados de elevação: Open-Meteo (CC BY 4.0)'; + @override + String get losLegendRadioHorizon => 'Horizonte de rádio'; + + @override + String get losLegendLosBeam => 'Linha de visada'; + + @override + String get losLegendTerrain => 'Terreno'; + + @override + String get losFrequencyLabel => 'Frequência'; + + @override + String get losFrequencyInfoTooltip => 'Ver detalhes do cálculo'; + + @override + String get losFrequencyDialogTitle => 'Cálculo do horizonte de rádio'; + + @override + String losFrequencyDialogDescription( + double baselineK, + double baselineFreq, + double frequencyMHz, + double kFactor, + ) { + return 'Começando em k=$baselineK em $baselineFreq MHz, o cálculo ajusta o fator k para a banda atual de $frequencyMHz MHz, que define o limite do horizonte de rádio curvo.'; + } + @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 c4c1633..399b158 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -817,6 +817,11 @@ class AppLocalizationsRu extends AppLocalizations { return 'Удалить \"$name\"? Это действие нельзя отменить.'; } + @override + String channels_channelDeleteFailed(String name) { + return 'Не удалось удалить канал $name.'; + } + @override String channels_channelDeleted(String name) { return 'Канал \"$name\" удалён'; @@ -2869,6 +2874,34 @@ class AppLocalizationsRu extends AppLocalizations { String get losElevationAttribution => 'Данные о высоте: Open-Meteo (CC BY 4.0)'; + @override + String get losLegendRadioHorizon => 'Радиогоризонт'; + + @override + String get losLegendLosBeam => 'Линия прямой видимости'; + + @override + String get losLegendTerrain => 'Рельеф'; + + @override + String get losFrequencyLabel => 'Частота'; + + @override + String get losFrequencyInfoTooltip => 'Просмотреть детали расчёта'; + + @override + String get losFrequencyDialogTitle => 'Расчёт радиогоризонта'; + + @override + String losFrequencyDialogDescription( + double baselineK, + double baselineFreq, + double frequencyMHz, + double kFactor, + ) { + return 'Начиная с k=$baselineK на частоте $baselineFreq МГц, расчет корректирует коэффициент k для текущего диапазона $frequencyMHz МГц, который определяет изогнутую границу радиогоризонта.'; + } + @override String get contacts_pathTrace => 'Трассировка пути'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 0df70a6..5138311 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -813,6 +813,11 @@ class AppLocalizationsSk extends AppLocalizations { return 'Odstrániť \"$name\"? To sa nedá zrušiť.'; } + @override + String channels_channelDeleteFailed(String name) { + return 'Kanál \"$name\" sa nepodarilo odstrániť'; + } + @override String channels_channelDeleted(String name) { return 'Kanál \"$name\" bol odstránený'; @@ -2851,6 +2856,34 @@ class AppLocalizationsSk extends AppLocalizations { String get losElevationAttribution => 'Údaje o nadmorskej výške: Open-Meteo (CC BY 4.0)'; + @override + String get losLegendRadioHorizon => 'Rádiový horizont'; + + @override + String get losLegendLosBeam => 'Priama viditeľnosť'; + + @override + String get losLegendTerrain => 'Terén'; + + @override + String get losFrequencyLabel => 'Frekvencia'; + + @override + String get losFrequencyInfoTooltip => 'Zobraziť podrobnosti výpočtu'; + + @override + String get losFrequencyDialogTitle => 'Výpočet rádiového horizontu'; + + @override + String losFrequencyDialogDescription( + double baselineK, + double baselineFreq, + double frequencyMHz, + double kFactor, + ) { + return 'Počnúc od k=$baselineK pri $baselineFreq MHz výpočet upraví k-faktor pre aktuálne pásmo $frequencyMHz MHz, ktorý definuje zakrivený strop rádiového horizontu.'; + } + @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 4be105e..f42e8e0 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -811,6 +811,11 @@ class AppLocalizationsSl extends AppLocalizations { return 'Izbrišem \"$name\"? To se ne da povrniti.'; } + @override + String channels_channelDeleteFailed(String name) { + return 'Kanala $name ni bilo mogoče izbrisati'; + } + @override String channels_channelDeleted(String name) { return 'Kanal \"$name\" izbrisan.'; @@ -2854,6 +2859,34 @@ class AppLocalizationsSl extends AppLocalizations { String get losElevationAttribution => 'Podatki o višini: Open-Meteo (CC BY 4.0)'; + @override + String get losLegendRadioHorizon => 'Radijski horizont'; + + @override + String get losLegendLosBeam => 'Linija vidnosti'; + + @override + String get losLegendTerrain => 'Teren'; + + @override + String get losFrequencyLabel => 'Frekvenca'; + + @override + String get losFrequencyInfoTooltip => 'Prikaži podrobnosti izračuna'; + + @override + String get losFrequencyDialogTitle => 'Izračun radijskega horizonta'; + + @override + String losFrequencyDialogDescription( + double baselineK, + double baselineFreq, + double frequencyMHz, + double kFactor, + ) { + return 'Začenši od k=$baselineK pri $baselineFreq MHz, izračun prilagodi k-faktor za trenutni pas $frequencyMHz MHz, ki določa ukrivljeno zgornjo mejo radijskega horizonta.'; + } + @override String get contacts_pathTrace => 'Sledenje poti'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 52fa531..ba99455 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -807,6 +807,11 @@ class AppLocalizationsSv extends AppLocalizations { return 'Radera \"$name\"? Detta kan inte ångras.'; } + @override + String channels_channelDeleteFailed(String name) { + return 'Det gick inte att ta bort kanalen \"$name\"'; + } + @override String channels_channelDeleted(String name) { return 'Kanalen \"$name\" raderad'; @@ -2837,6 +2842,34 @@ class AppLocalizationsSv extends AppLocalizations { @override String get losElevationAttribution => 'Höjddata: Open-Meteo (CC BY 4.0)'; + @override + String get losLegendRadioHorizon => 'Radiohorisont'; + + @override + String get losLegendLosBeam => 'Siktlinje'; + + @override + String get losLegendTerrain => 'Terräng'; + + @override + String get losFrequencyLabel => 'Frekvens'; + + @override + String get losFrequencyInfoTooltip => 'Visa detaljer om beräkningen'; + + @override + String get losFrequencyDialogTitle => 'Beräkning av radiohorisonten'; + + @override + String losFrequencyDialogDescription( + double baselineK, + double baselineFreq, + double frequencyMHz, + double kFactor, + ) { + return 'Med start från k=$baselineK vid $baselineFreq MHz, justerar beräkningen k-faktorn för det aktuella $frequencyMHz MHz-bandet, som definierar den böjda radiohorisonten.'; + } + @override String get contacts_pathTrace => 'Path Trace'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 4847009..e2bbbe8 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -815,6 +815,11 @@ class AppLocalizationsUk extends AppLocalizations { return 'Видалити $name? Це не можна скасувати.'; } + @override + String channels_channelDeleteFailed(String name) { + return 'Не вдалося видалити канал \"$name\"'; + } + @override String channels_channelDeleted(String name) { return 'Канал «$name» видалено'; @@ -2877,6 +2882,34 @@ class AppLocalizationsUk extends AppLocalizations { String get losElevationAttribution => 'Дані про висоту: Open-Meteo (CC BY 4.0)'; + @override + String get losLegendRadioHorizon => 'Радіогоризонт'; + + @override + String get losLegendLosBeam => 'Лінія прямої видимості'; + + @override + String get losLegendTerrain => 'Рельєф'; + + @override + String get losFrequencyLabel => 'Частота'; + + @override + String get losFrequencyInfoTooltip => 'Переглянути деталі розрахунку'; + + @override + String get losFrequencyDialogTitle => 'Розрахунок радіогоризонту'; + + @override + String losFrequencyDialogDescription( + double baselineK, + double baselineFreq, + double frequencyMHz, + double kFactor, + ) { + return 'Починаючи з k=$baselineK на $baselineFreq МГц, обчислення коригує k-фактор для поточного діапазону $frequencyMHz МГц, який визначає викривлену межу радіогоризонту.'; + } + @override String get contacts_pathTrace => 'Трасування шляхів'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 454f127..4da17ea 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -775,6 +775,11 @@ class AppLocalizationsZh extends AppLocalizations { return 'Delete \"$name\"? This cannot be undone.'; } + @override + String channels_channelDeleteFailed(String name) { + return '无法删除频道 \"$name\"'; + } + @override String channels_channelDeleted(String name) { return '删除频道 \"$name\"'; @@ -2715,6 +2720,34 @@ class AppLocalizationsZh extends AppLocalizations { @override String get losElevationAttribution => '高程数据:Open-Meteo (CC BY 4.0)'; + @override + String get losLegendRadioHorizon => '无线电地平线'; + + @override + String get losLegendLosBeam => '视距波束'; + + @override + String get losLegendTerrain => '地形'; + + @override + String get losFrequencyLabel => '频率'; + + @override + String get losFrequencyInfoTooltip => '查看计算详情'; + + @override + String get losFrequencyDialogTitle => '无线电地平线计算'; + + @override + String losFrequencyDialogDescription( + double baselineK, + double baselineFreq, + double frequencyMHz, + double kFactor, + ) { + return '从 $baselineFreq MHz 处的 k=$baselineK 开始,计算调整当前 $frequencyMHz MHz 频段的 k 因子,该因子定义了弯曲的无线电范围上限。'; + } + @override String get contacts_pathTrace => '路径追踪'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 2f39fdf..cb690fb 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1,4 +1,6 @@ { + "channels_channelDeleteFailed": "Kan kanaal {name} niet verwijderen", + "@channels_channelDeleteFailed": { "placeholders": { "name": { "type": "String" } } }, "@@locale": "nl", "appTitle": "MeshCore Open", "nav_contacts": "Contacten", @@ -1718,5 +1720,29 @@ "losPointName": "Puntnaam", "losShowPanelTooltip": "Toon LOS-paneel", "losHidePanelTooltip": "LOS-paneel verbergen", - "losElevationAttribution": "Hoogtegegevens: Open-Meteo (CC BY 4.0)" -} \ No newline at end of file + "losElevationAttribution": "Hoogtegegevens: Open-Meteo (CC BY 4.0)", + "losLegendRadioHorizon": "Radiohorizon", + "losLegendLosBeam": "Zichtlijn", + "losLegendTerrain": "Terrein", + "losFrequencyLabel": "Frequentie", + "losFrequencyInfoTooltip": "Bekijk details van de berekening", + "losFrequencyDialogTitle": "Berekening van de radiohorizon", + "losFrequencyDialogDescription": "Beginnend met k={baselineK} bij {baselineFreq} MHz, wordt bij de berekening de k-factor aangepast voor de huidige {frequencyMHz} MHz-band, die de gebogen radiohorizonkap definieert.", + "@losFrequencyDialogDescription": { + "description": "Explain how the calculation uses the baseline frequency and derived k-factor.", + "placeholders": { + "baselineK": { + "type": "double" + }, + "baselineFreq": { + "type": "double" + }, + "frequencyMHz": { + "type": "double" + }, + "kFactor": { + "type": "double" + } + } + } +} diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 0432f8f..2161983 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1,4 +1,6 @@ { + "channels_channelDeleteFailed": "Nie udało się usunąć kanału \"{name}\"", + "@channels_channelDeleteFailed": { "placeholders": { "name": { "type": "String" } } }, "@@locale": "pl", "appTitle": "MeshCore Open", "nav_contacts": "Kontakty", @@ -1718,5 +1720,29 @@ "losPointName": "Nazwa punktu", "losShowPanelTooltip": "Pokaż panel LOS", "losHidePanelTooltip": "Ukryj panel LOS", - "losElevationAttribution": "Dane dotyczące wysokości: Open-Meteo (CC BY 4.0)" -} \ No newline at end of file + "losElevationAttribution": "Dane dotyczące wysokości: Open-Meteo (CC BY 4.0)", + "losLegendRadioHorizon": "Horyzont radiowy", + "losLegendLosBeam": "Linia widoczności", + "losLegendTerrain": "Teren", + "losFrequencyLabel": "Częstotliwość", + "losFrequencyInfoTooltip": "Zobacz szczegóły obliczenia", + "losFrequencyDialogTitle": "Obliczanie horyzontu radiowego", + "losFrequencyDialogDescription": "Zaczynając od k={baselineK} przy {baselineFreq} MHz, obliczenia korygują współczynnik k dla bieżącego pasma {frequencyMHz} MHz, które definiuje zakrzywiony limit horyzontu radiowego.", + "@losFrequencyDialogDescription": { + "description": "Explain how the calculation uses the baseline frequency and derived k-factor.", + "placeholders": { + "baselineK": { + "type": "double" + }, + "baselineFreq": { + "type": "double" + }, + "frequencyMHz": { + "type": "double" + }, + "kFactor": { + "type": "double" + } + } + } +} diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 01c5a83..8a02a4d 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1,4 +1,6 @@ { + "channels_channelDeleteFailed": "Falha ao excluir o canal \"{name}\"", + "@channels_channelDeleteFailed": { "placeholders": { "name": { "type": "String" } } }, "@@locale": "pt", "appTitle": "MeshCore Open", "nav_contacts": "Contactos", @@ -1718,5 +1720,29 @@ "losPointName": "Nome do ponto", "losShowPanelTooltip": "Mostrar painel LOS", "losHidePanelTooltip": "Ocultar painel LOS", - "losElevationAttribution": "Dados de elevação: Open-Meteo (CC BY 4.0)" -} \ No newline at end of file + "losElevationAttribution": "Dados de elevação: Open-Meteo (CC BY 4.0)", + "losLegendRadioHorizon": "Horizonte de rádio", + "losLegendLosBeam": "Linha de visada", + "losLegendTerrain": "Terreno", + "losFrequencyLabel": "Frequência", + "losFrequencyInfoTooltip": "Ver detalhes do cálculo", + "losFrequencyDialogTitle": "Cálculo do horizonte de rádio", + "losFrequencyDialogDescription": "Começando em k={baselineK} em {baselineFreq} MHz, o cálculo ajusta o fator k para a banda atual de {frequencyMHz} MHz, que define o limite do horizonte de rádio curvo.", + "@losFrequencyDialogDescription": { + "description": "Explain how the calculation uses the baseline frequency and derived k-factor.", + "placeholders": { + "baselineK": { + "type": "double" + }, + "baselineFreq": { + "type": "double" + }, + "frequencyMHz": { + "type": "double" + }, + "kFactor": { + "type": "double" + } + } + } +} diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index b8a20d9..ab2ed36 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1,4 +1,6 @@ { + "channels_channelDeleteFailed": "Не удалось удалить канал {name}.", + "@channels_channelDeleteFailed": { "placeholders": { "name": { "type": "String" } } }, "@@locale": "ru", "appTitle": "MeshCore Open", "nav_contacts": "Контакты", @@ -958,5 +960,29 @@ "losPointName": "Имя точки", "losShowPanelTooltip": "Показать панель LOS", "losHidePanelTooltip": "Скрыть панель LOS", - "losElevationAttribution": "Данные о высоте: Open-Meteo (CC BY 4.0)" -} \ No newline at end of file + "losElevationAttribution": "Данные о высоте: Open-Meteo (CC BY 4.0)", + "losLegendRadioHorizon": "Радиогоризонт", + "losLegendLosBeam": "Линия прямой видимости", + "losLegendTerrain": "Рельеф", + "losFrequencyLabel": "Частота", + "losFrequencyInfoTooltip": "Просмотреть детали расчёта", + "losFrequencyDialogTitle": "Расчёт радиогоризонта", + "losFrequencyDialogDescription": "Начиная с k={baselineK} на частоте {baselineFreq} МГц, расчет корректирует коэффициент k для текущего диапазона {frequencyMHz} МГц, который определяет изогнутую границу радиогоризонта.", + "@losFrequencyDialogDescription": { + "description": "Explain how the calculation uses the baseline frequency and derived k-factor.", + "placeholders": { + "baselineK": { + "type": "double" + }, + "baselineFreq": { + "type": "double" + }, + "frequencyMHz": { + "type": "double" + }, + "kFactor": { + "type": "double" + } + } + } +} diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index 3245282..ff95897 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -1,4 +1,6 @@ { + "channels_channelDeleteFailed": "Kanál \"{name}\" sa nepodarilo odstrániť", + "@channels_channelDeleteFailed": { "placeholders": { "name": { "type": "String" } } }, "@@locale": "sk", "appTitle": "MeshCore Open", "nav_contacts": "Kontakty", @@ -1718,5 +1720,29 @@ "losPointName": "Názov bodu", "losShowPanelTooltip": "Zobraziť panel LOS", "losHidePanelTooltip": "Skryť panel LOS", - "losElevationAttribution": "Údaje o nadmorskej výške: Open-Meteo (CC BY 4.0)" -} \ No newline at end of file + "losElevationAttribution": "Údaje o nadmorskej výške: Open-Meteo (CC BY 4.0)", + "losLegendRadioHorizon": "Rádiový horizont", + "losLegendLosBeam": "Priama viditeľnosť", + "losLegendTerrain": "Terén", + "losFrequencyLabel": "Frekvencia", + "losFrequencyInfoTooltip": "Zobraziť podrobnosti výpočtu", + "losFrequencyDialogTitle": "Výpočet rádiového horizontu", + "losFrequencyDialogDescription": "Počnúc od k={baselineK} pri {baselineFreq} MHz výpočet upraví k-faktor pre aktuálne pásmo {frequencyMHz} MHz, ktorý definuje zakrivený strop rádiového horizontu.", + "@losFrequencyDialogDescription": { + "description": "Explain how the calculation uses the baseline frequency and derived k-factor.", + "placeholders": { + "baselineK": { + "type": "double" + }, + "baselineFreq": { + "type": "double" + }, + "frequencyMHz": { + "type": "double" + }, + "kFactor": { + "type": "double" + } + } + } +} diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index c560c31..bd431d4 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -1,4 +1,6 @@ { + "channels_channelDeleteFailed": "Kanala {name} ni bilo mogoče izbrisati", + "@channels_channelDeleteFailed": { "placeholders": { "name": { "type": "String" } } }, "@@locale": "sl", "appTitle": "MeshCore Open", "nav_contacts": "Stiki", @@ -1718,5 +1720,29 @@ "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)" -} \ No newline at end of file + "losElevationAttribution": "Podatki o višini: Open-Meteo (CC BY 4.0)", + "losLegendRadioHorizon": "Radijski horizont", + "losLegendLosBeam": "Linija vidnosti", + "losLegendTerrain": "Teren", + "losFrequencyLabel": "Frekvenca", + "losFrequencyInfoTooltip": "Prikaži podrobnosti izračuna", + "losFrequencyDialogTitle": "Izračun radijskega horizonta", + "losFrequencyDialogDescription": "Začenši od k={baselineK} pri {baselineFreq} MHz, izračun prilagodi k-faktor za trenutni pas {frequencyMHz} MHz, ki določa ukrivljeno zgornjo mejo radijskega horizonta.", + "@losFrequencyDialogDescription": { + "description": "Explain how the calculation uses the baseline frequency and derived k-factor.", + "placeholders": { + "baselineK": { + "type": "double" + }, + "baselineFreq": { + "type": "double" + }, + "frequencyMHz": { + "type": "double" + }, + "kFactor": { + "type": "double" + } + } + } +} diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index b93c5ca..86da858 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -1,4 +1,6 @@ { + "channels_channelDeleteFailed": "Det gick inte att ta bort kanalen \"{name}\"", + "@channels_channelDeleteFailed": { "placeholders": { "name": { "type": "String" } } }, "@@locale": "sv", "appTitle": "MeshCore Open", "nav_contacts": "Kontakter", @@ -1718,5 +1720,29 @@ "losPointName": "Punktnamn", "losShowPanelTooltip": "Visa LOS-panelen", "losHidePanelTooltip": "Dölj LOS-panelen", - "losElevationAttribution": "Höjddata: Open-Meteo (CC BY 4.0)" -} \ No newline at end of file + "losElevationAttribution": "Höjddata: Open-Meteo (CC BY 4.0)", + "losLegendRadioHorizon": "Radiohorisont", + "losLegendLosBeam": "Siktlinje", + "losLegendTerrain": "Terräng", + "losFrequencyLabel": "Frekvens", + "losFrequencyInfoTooltip": "Visa detaljer om beräkningen", + "losFrequencyDialogTitle": "Beräkning av radiohorisonten", + "losFrequencyDialogDescription": "Med start från k={baselineK} vid {baselineFreq} MHz, justerar beräkningen k-faktorn för det aktuella {frequencyMHz} MHz-bandet, som definierar den böjda radiohorisonten.", + "@losFrequencyDialogDescription": { + "description": "Explain how the calculation uses the baseline frequency and derived k-factor.", + "placeholders": { + "baselineK": { + "type": "double" + }, + "baselineFreq": { + "type": "double" + }, + "frequencyMHz": { + "type": "double" + }, + "kFactor": { + "type": "double" + } + } + } +} diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 235e4ed..691aa8d 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -1,4 +1,6 @@ { + "channels_channelDeleteFailed": "Не вдалося видалити канал \"{name}\"", + "@channels_channelDeleteFailed": { "placeholders": { "name": { "type": "String" } } }, "@@locale": "uk", "appTitle": "MeshCore Open", "nav_contacts": "Контакти", @@ -1718,5 +1720,29 @@ "losPointName": "Назва точки", "losShowPanelTooltip": "Показати панель LOS", "losHidePanelTooltip": "Приховати панель LOS", - "losElevationAttribution": "Дані про висоту: Open-Meteo (CC BY 4.0)" -} \ No newline at end of file + "losElevationAttribution": "Дані про висоту: Open-Meteo (CC BY 4.0)", + "losLegendRadioHorizon": "Радіогоризонт", + "losLegendLosBeam": "Лінія прямої видимості", + "losLegendTerrain": "Рельєф", + "losFrequencyLabel": "Частота", + "losFrequencyInfoTooltip": "Переглянути деталі розрахунку", + "losFrequencyDialogTitle": "Розрахунок радіогоризонту", + "losFrequencyDialogDescription": "Починаючи з k={baselineK} на {baselineFreq} МГц, обчислення коригує k-фактор для поточного діапазону {frequencyMHz} МГц, який визначає викривлену межу радіогоризонту.", + "@losFrequencyDialogDescription": { + "description": "Explain how the calculation uses the baseline frequency and derived k-factor.", + "placeholders": { + "baselineK": { + "type": "double" + }, + "baselineFreq": { + "type": "double" + }, + "frequencyMHz": { + "type": "double" + }, + "kFactor": { + "type": "double" + } + } + } +} diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 72f48ad..f6bb526 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1,4 +1,6 @@ { + "channels_channelDeleteFailed": "无法删除频道 \"{name}\"", + "@channels_channelDeleteFailed": { "placeholders": { "name": { "type": "String" } } }, "@@locale": "zh", "appTitle": "MeshCore Open", "nav_contacts": "联系方式", @@ -1718,5 +1720,29 @@ "losPointName": "点名称", "losShowPanelTooltip": "显示 LOS 面板", "losHidePanelTooltip": "隐藏 LOS 面板", - "losElevationAttribution": "高程数据:Open-Meteo (CC BY 4.0)" -} \ No newline at end of file + "losElevationAttribution": "高程数据:Open-Meteo (CC BY 4.0)", + "losLegendRadioHorizon": "无线电地平线", + "losLegendLosBeam": "视距波束", + "losLegendTerrain": "地形", + "losFrequencyLabel": "频率", + "losFrequencyInfoTooltip": "查看计算详情", + "losFrequencyDialogTitle": "无线电地平线计算", + "losFrequencyDialogDescription": "从 {baselineFreq} MHz 处的 k={baselineK} 开始,计算调整当前 {frequencyMHz} MHz 频段的 k 因子,该因子定义了弯曲的无线电范围上限。", + "@losFrequencyDialogDescription": { + "description": "Explain how the calculation uses the baseline frequency and derived k-factor.", + "placeholders": { + "baselineK": { + "type": "double" + }, + "baselineFreq": { + "type": "double" + }, + "frequencyMHz": { + "type": "double" + }, + "kFactor": { + "type": "double" + } + } + } +} diff --git a/lib/main.dart b/lib/main.dart index 3650a7e..5a11188 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,6 +15,7 @@ import 'services/ble_debug_log_service.dart'; import 'services/app_debug_log_service.dart'; import 'services/background_service.dart'; import 'services/map_tile_cache_service.dart'; +import 'services/chat_text_scale_service.dart'; import 'storage/prefs_manager.dart'; import 'utils/app_logger.dart'; @@ -34,6 +35,7 @@ void main() async { final appDebugLogService = AppDebugLogService(); final backgroundService = BackgroundService(); final mapTileCacheService = MapTileCacheService(); + final chatTextScaleService = ChatTextScaleService(); // Load settings await appSettingsService.loadSettings(); @@ -50,6 +52,8 @@ void main() async { await backgroundService.initialize(); _registerThirdPartyLicenses(); + await chatTextScaleService.initialize(); + // Wire up connector with services connector.initialize( retryService: retryService, @@ -78,6 +82,7 @@ void main() async { bleDebugLogService: bleDebugLogService, appDebugLogService: appDebugLogService, mapTileCacheService: mapTileCacheService, + chatTextScaleService: chatTextScaleService, ), ); } @@ -112,6 +117,7 @@ class MeshCoreApp extends StatelessWidget { final BleDebugLogService bleDebugLogService; final AppDebugLogService appDebugLogService; final MapTileCacheService mapTileCacheService; + final ChatTextScaleService chatTextScaleService; const MeshCoreApp({ super.key, @@ -123,6 +129,7 @@ class MeshCoreApp extends StatelessWidget { required this.bleDebugLogService, required this.appDebugLogService, required this.mapTileCacheService, + required this.chatTextScaleService, }); @override @@ -135,6 +142,7 @@ class MeshCoreApp extends StatelessWidget { ChangeNotifierProvider.value(value: appSettingsService), ChangeNotifierProvider.value(value: bleDebugLogService), ChangeNotifierProvider.value(value: appDebugLogService), + ChangeNotifierProvider.value(value: chatTextScaleService), Provider.value(value: storage), Provider.value(value: mapTileCacheService), ], diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 9df91c3..79d30e5 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -18,7 +18,9 @@ import '../l10n/l10n.dart'; import '../models/channel.dart'; import '../models/channel_message.dart'; import '../services/app_settings_service.dart'; +import '../services/chat_text_scale_service.dart'; import '../utils/emoji_utils.dart'; +import '../widgets/chat_zoom_wrapper.dart'; import '../widgets/emoji_picker.dart'; import '../widgets/gif_message.dart'; import '../widgets/jump_to_bottom_button.dart'; @@ -219,37 +221,50 @@ class _ChannelChatScreenState extends State { return Stack( children: [ - ListView.builder( - reverse: true, // List grows from bottom up - controller: _scrollController, - padding: const EdgeInsets.all(8), - itemCount: itemCount, - itemBuilder: (context, index) { - // Loading indicator now appears at end (bottom) of reversed list - if (_isLoadingOlder && index == itemCount - 1) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: Center( - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, + ChatZoomWrapper( + child: ListView.builder( + reverse: true, // List grows from bottom up + controller: _scrollController, + padding: const EdgeInsets.all(8), + itemCount: itemCount, + itemBuilder: (context, index) { + // Loading indicator now appears at end (bottom) of reversed list + if (_isLoadingOlder && index == itemCount - 1) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), ), ), + ); + } + final messageIndex = index; + final message = reversedMessages[messageIndex]; + if (!_messageKeys.containsKey(message.messageId)) { + _messageKeys[message.messageId] = GlobalKey(); + } + return Container( + key: _messageKeys[message.messageId]!, + child: Builder( + builder: (context) { + final textScale = context + .select( + (service) => service.scale, + ); + return _buildMessageBubble( + message, + textScale, + ); + }, ), ); - } - final messageIndex = index; - final message = reversedMessages[messageIndex]; - if (!_messageKeys.containsKey(message.messageId)) { - _messageKeys[message.messageId] = GlobalKey(); - } - return Container( - key: _messageKeys[message.messageId]!, - child: _buildMessageBubble(message), - ); - }, + }, + ), ), JumpToBottomButton(scrollController: _scrollController), ], @@ -264,7 +279,7 @@ class _ChannelChatScreenState extends State { ); } - Widget _buildMessageBubble(ChannelMessage message) { + Widget _buildMessageBubble(ChannelMessage message, double textScale) { final settingsService = context.watch(); final enableTracing = settingsService.settings.enableMessageTracing; final isOutgoing = message.isOutgoing; @@ -278,6 +293,7 @@ class _ChannelChatScreenState extends State { const maxSwipeOffset = 64.0; const replySwipeThreshold = 64.0; + const bodyFontSize = 14.0; final messageBody = Column( crossAxisAlignment: isOutgoing ? CrossAxisAlignment.end @@ -334,7 +350,7 @@ class _ChannelChatScreenState extends State { if (gifId == null) const SizedBox(height: 4), ], if (message.replyToMessageId != null) ...[ - _buildReplyPreview(message), + _buildReplyPreview(message, textScale), const SizedBox(height: 8), ], if (poi != null) @@ -342,6 +358,7 @@ class _ChannelChatScreenState extends State { context, poi, isOutgoing, + textScale, trailing: (!enableTracing && isOutgoing) ? Padding( padding: const EdgeInsets.only(bottom: 2), @@ -415,9 +432,11 @@ class _ChannelChatScreenState extends State { Flexible( child: Linkify( text: message.text, - style: const TextStyle(fontSize: 14), - linkStyle: const TextStyle( - fontSize: 14, + style: TextStyle( + fontSize: bodyFontSize * textScale, + ), + linkStyle: TextStyle( + fontSize: bodyFontSize * textScale, color: Colors.green, decoration: TextDecoration.underline, ), @@ -595,7 +614,7 @@ class _ChannelChatScreenState extends State { ); } - Widget _buildReplyPreview(ChannelMessage message) { + Widget _buildReplyPreview(ChannelMessage message, double textScale) { final connector = context.read(); final isOwnNode = message.replyToSenderName == connector.selfName; final replyText = message.replyToText ?? ''; @@ -623,7 +642,7 @@ class _ChannelChatScreenState extends State { const SizedBox(width: 4), Text( context.l10n.chat_location, - style: TextStyle(fontSize: 12, color: previewTextColor), + style: TextStyle(fontSize: 12 * textScale, color: previewTextColor), ), ], ); @@ -633,7 +652,7 @@ class _ChannelChatScreenState extends State { maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( - fontSize: 12, + fontSize: 12 * textScale, color: previewTextColor, fontStyle: FontStyle.italic, ), @@ -657,7 +676,7 @@ class _ChannelChatScreenState extends State { Text( context.l10n.chat_replyTo(message.replyToSenderName ?? ''), style: TextStyle( - fontSize: 11, + fontSize: 11 * textScale, fontWeight: FontWeight.bold, color: isOwnNode ? Theme.of(context).colorScheme.primary @@ -736,7 +755,8 @@ class _ChannelChatScreenState extends State { Widget _buildPoiMessage( BuildContext context, _PoiInfo poi, - bool isOutgoing, { + bool isOutgoing, + double textScale, { Widget? trailing, }) { final colorScheme = Theme.of(context).colorScheme; @@ -774,12 +794,16 @@ class _ChannelChatScreenState extends State { children: [ Text( context.l10n.chat_poiShared, - style: TextStyle(color: textColor, fontWeight: FontWeight.w600), + style: TextStyle( + color: textColor, + fontWeight: FontWeight.w600, + fontSize: 14 * textScale, + ), ), if (poi.label.isNotEmpty) Text( poi.label, - style: TextStyle(color: metaColor, fontSize: 12), + style: TextStyle(color: metaColor, fontSize: 12 * textScale), ), ], ), @@ -849,7 +873,7 @@ class _ChannelChatScreenState extends State { return colors[hash.abs() % colors.length]; } - Widget _buildReplyBanner() { + Widget _buildReplyBanner(double textScale) { final message = _replyingToMessage!; return Container( width: double.infinity, @@ -875,7 +899,7 @@ class _ChannelChatScreenState extends State { Text( context.l10n.chat_replyingTo(message.senderName), style: TextStyle( - fontSize: 12, + fontSize: 12 * textScale, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSecondaryContainer, ), @@ -885,7 +909,7 @@ class _ChannelChatScreenState extends State { maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( - fontSize: 11, + fontSize: 11 * textScale, color: Theme.of( context, ).colorScheme.onSecondaryContainer.withValues(alpha: 0.7), @@ -912,7 +936,15 @@ class _ChannelChatScreenState extends State { return Column( mainAxisSize: MainAxisSize.min, children: [ - if (_replyingToMessage != null) _buildReplyBanner(), + if (_replyingToMessage != null) + Builder( + builder: (context) { + final textScale = context.select( + (service) => service.scale, + ); + return _buildReplyBanner(textScale); + }, + ), Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 12e2a31..582fee7 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -489,12 +489,13 @@ class _ChannelsScreenState extends State ChannelMessageStore channelMessageStore, Channel channel, ) { + final parentContext = context; final settingsService = context.read(); final isMuted = settingsService.isChannelMuted(channel.name); showModalBottomSheet( - context: context, - builder: (context) => SafeArea( + context: parentContext, + builder: (sheetContext) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -502,10 +503,10 @@ class _ChannelsScreenState extends State leading: const Icon(Icons.edit_outlined), title: Text(context.l10n.channels_editChannel), onTap: () async { - Navigator.pop(context); + Navigator.pop(sheetContext); await Future.delayed(const Duration(milliseconds: 100)); - if (context.mounted) { - _showEditChannelDialog(context, connector, channel); + if (parentContext.mounted) { + _showEditChannelDialog(parentContext, connector, channel); } }, ), @@ -521,7 +522,7 @@ class _ChannelsScreenState extends State : context.l10n.channels_muteChannel, ), onTap: () async { - Navigator.pop(context); + Navigator.pop(sheetContext); if (isMuted) { await settingsService.unmuteChannel(channel.name); } else { @@ -536,9 +537,9 @@ class _ChannelsScreenState extends State style: const TextStyle(color: Colors.red), ), onTap: () async { - Navigator.pop(context); + Navigator.pop(sheetContext); await Future.delayed(const Duration(milliseconds: 100)); - if (context.mounted) { + if (parentContext.mounted) { _confirmDeleteChannel( context, connector, @@ -1454,7 +1455,7 @@ class _ChannelsScreenState extends State child: Text(dialogContext.l10n.common_cancel), ), FilledButton( - onPressed: () { + onPressed: () async { final name = nameController.text.trim(); final pskHex = pskController.text.trim(); @@ -1471,13 +1472,25 @@ class _ChannelsScreenState extends State } Navigator.pop(dialogContext); - connector.setChannel(channel.index, name, psk); - connector.setChannelSmazEnabled(channel.index, smazEnabled); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.channels_channelUpdated(name)), - ), - ); + try { + await connector.setChannel(channel.index, name, psk); + await connector.setChannelSmazEnabled( + channel.index, + smazEnabled, + ); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.channels_channelUpdated(name)), + ), + ); + } catch (e, st) { + debugPrint(st.toString()); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to update channel: $e')), + ); + } }, child: Text(dialogContext.l10n.common_save), ), @@ -1506,17 +1519,36 @@ class _ChannelsScreenState extends State child: Text(dialogContext.l10n.common_cancel), ), TextButton( - onPressed: () { + onPressed: () async { Navigator.pop(dialogContext); - connector.deleteChannel(channel.index); - channelMessageStore.clearChannelMessages(channel.index); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.channels_channelDeleted(channel.name), + try { + await connector.deleteChannel(channel.index); + + channelMessageStore.clearChannelMessages(channel.index); + + if (!context.mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.channels_channelDeleted(channel.name), + ), ), - ), - ); + ); + } catch (e, st) { + if (!context.mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.channels_channelDeleteFailed(channel.name), + ), + ), + ); + + // Preserve existing logging (if it was there) + debugPrint('Failed to delete channel: $e\n$st'); + } }, child: Text( dialogContext.l10n.common_delete, diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 3556d6d..d623f33 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -22,7 +22,9 @@ import '../models/contact.dart'; import '../models/message.dart'; import '../models/path_history.dart'; import '../services/app_settings_service.dart'; +import '../services/chat_text_scale_service.dart'; import '../services/path_history_service.dart'; +import '../widgets/chat_zoom_wrapper.dart'; import '../widgets/elements_ui.dart'; import 'channel_message_path_screen.dart'; import 'map_screen.dart'; @@ -270,52 +272,62 @@ class _ChatScreenState extends State { _scrollController.scrollToBottomIfAtBottom(); }); - return ListView.builder( - reverse: true, // List grows from bottom up - controller: _scrollController, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16), - itemCount: itemCount, - itemBuilder: (context, index) { - // Loading indicator now appears at end (bottom) of reversed list - if (_isLoadingOlder && index == itemCount - 1) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: Center( - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), + return ChatZoomWrapper( + child: ListView.builder( + reverse: true, // List grows from bottom up + controller: _scrollController, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16), + itemCount: itemCount, + itemBuilder: (context, index) { + // Loading indicator now appears at end (bottom) of reversed list + if (_isLoadingOlder && index == itemCount - 1) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), ), - ), - ); - } - final messageIndex = index; - Contact contact = widget.contact; - final message = reversedMessages[messageIndex]; - String fourByteHex = ''; - if (widget.contact.type == advTypeRoom) { - contact = _resolveContactFrom4Bytes( - connector, - message.fourByteRoomContactKey.isEmpty - ? Uint8List.fromList([0, 0, 0, 0]) - : message.fourByteRoomContactKey, - ); - fourByteHex = message.fourByteRoomContactKey - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join() - .toUpperCase(); - } + ); + } + final messageIndex = index; + Contact contact = widget.contact; + final message = reversedMessages[messageIndex]; + String fourByteHex = ''; + if (widget.contact.type == advTypeRoom) { + contact = _resolveContactFrom4Bytes( + connector, + message.fourByteRoomContactKey.isEmpty + ? Uint8List.fromList([0, 0, 0, 0]) + : message.fourByteRoomContactKey, + ); + fourByteHex = message.fourByteRoomContactKey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toUpperCase(); + } - return _MessageBubble( - message: message, - senderName: widget.contact.type == advTypeRoom - ? "${contact.name} [$fourByteHex]" - : contact.name, - isRoomServer: widget.contact.type == advTypeRoom, - onTap: () => _openMessagePath(message, contact), - onLongPress: () => _showMessageActions(message, contact), - ); - }, + return Builder( + builder: (context) { + final textScale = context.select( + (service) => service.scale, + ); + return _MessageBubble( + message: message, + senderName: widget.contact.type == advTypeRoom + ? "${contact.name} [$fourByteHex]" + : contact.name, + isRoomServer: widget.contact.type == advTypeRoom, + textScale: textScale, + onTap: () => _openMessagePath(message, contact), + onLongPress: () => _showMessageActions(message, contact), + ); + }, + ); + }, + ), ); } @@ -1163,11 +1175,13 @@ class _MessageBubble extends StatelessWidget { final bool isRoomServer; final VoidCallback? onTap; final VoidCallback? onLongPress; + final double textScale; const _MessageBubble({ required this.message, required this.senderName, required this.isRoomServer, + required this.textScale, this.onTap, this.onLongPress, }); @@ -1190,6 +1204,7 @@ class _MessageBubble extends StatelessWidget { ? colorScheme.onErrorContainer : (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface); final metaColor = textColor.withValues(alpha: 0.7); + const bodyFontSize = 14.0; String messageText = message.text; if (isRoomServer && !isOutgoing) { messageText = message.text.substring(4.clamp(0, message.text.length)); @@ -1258,6 +1273,7 @@ class _MessageBubble extends StatelessWidget { poi, textColor, metaColor, + textScale, trailing: (!enableTracing && isOutgoing) ? Padding( padding: const EdgeInsets.only(bottom: 2), @@ -1321,10 +1337,14 @@ class _MessageBubble extends StatelessWidget { Flexible( child: Linkify( text: messageText, - style: TextStyle(color: textColor), - linkStyle: const TextStyle( + style: TextStyle( + color: textColor, + fontSize: bodyFontSize * textScale, + ), + linkStyle: TextStyle( color: Colors.green, decoration: TextDecoration.underline, + fontSize: bodyFontSize * textScale, ), options: const LinkifyOptions( humanize: false, @@ -1464,7 +1484,8 @@ class _MessageBubble extends StatelessWidget { BuildContext context, _PoiInfo poi, Color textColor, - Color metaColor, { + Color metaColor, + double textScale, { Widget? trailing, }) { return Row( @@ -1493,12 +1514,16 @@ class _MessageBubble extends StatelessWidget { children: [ Text( context.l10n.chat_poiShared, - style: TextStyle(color: textColor, fontWeight: FontWeight.w600), + style: TextStyle( + color: textColor, + fontWeight: FontWeight.w600, + fontSize: 14 * textScale, + ), ), if (poi.label.isNotEmpty) Text( poi.label, - style: TextStyle(color: metaColor, fontSize: 12), + style: TextStyle(color: metaColor, fontSize: 12 * textScale), ), ], ), diff --git a/lib/screens/line_of_sight_map_screen.dart b/lib/screens/line_of_sight_map_screen.dart index b073685..ec8a391 100644 --- a/lib/screens/line_of_sight_map_screen.dart +++ b/lib/screens/line_of_sight_map_screen.dart @@ -14,8 +14,10 @@ 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 '../connector/meshcore_connector.dart'; import '../widgets/app_bar.dart'; import '../widgets/quick_switch_bar.dart'; +import '../icons/los_icon.dart'; class LineOfSightEndpoint { final String label; @@ -71,6 +73,7 @@ class _LineOfSightMapScreenState extends State { bool _showMarkerLabels = true; bool _didReceivePositionUpdate = false; int _losRequestNonce = 0; + bool _initialLosScheduled = false; @override void initState() { @@ -81,7 +84,16 @@ class _LineOfSightMapScreenState extends State { _end = widget.candidates[1]; } } - _runLos(); + _scheduleInitialRun(); + } + + void _scheduleInitialRun() { + if (_initialLosScheduled) return; + _initialLosScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _runLos(); + }); } @override @@ -110,10 +122,13 @@ class _LineOfSightMapScreenState extends State { }); try { + final connector = context.read(); + final frequencyMHz = _normalizeFrequencyMHz(connector.currentFreqHz); final result = await _lineOfSightService.analyzePath( [start.point, end.point], startAntennaHeightMeters: startAntenna, endAntennaHeightMeters: endAntenna, + frequencyMHz: frequencyMHz, ); if (!mounted) return; if (!_isRunRequestCurrent( @@ -424,6 +439,12 @@ class _LineOfSightMapScreenState extends State { Widget _buildControlPanel(bool isImperial) { _sanitizeSelection(); final segment = _primarySegmentResult(); + final connector = context.read(); + final reportedFrequencyMHz = _normalizeFrequencyMHz( + connector.currentFreqHz, + ); + final displayFrequencyMHz = segment?.frequencyMHz ?? reportedFrequencyMHz; + final kFactorUsed = segment?.usedKFactor; final endpoints = _visibleEndpoints(); final distanceUnit = isImperial ? 'mi' : 'km'; final heightUnit = isImperial ? 'ft' : 'm'; @@ -461,6 +482,9 @@ class _LineOfSightMapScreenState extends State { fontSize: 10, fontWeight: FontWeight.w600, ), + terrainLabel: context.l10n.losLegendTerrain, + losBeamLabel: context.l10n.losLegendLosBeam, + radioHorizonLabel: context.l10n.losLegendRadioHorizon, ), ), ) @@ -474,6 +498,14 @@ class _LineOfSightMapScreenState extends State { ), ), ), + if (segment != null) ...[ + const SizedBox(height: 8), + _LosLegend( + terrainLabel: context.l10n.losLegendTerrain, + losBeamLabel: context.l10n.losLegendLosBeam, + radioHorizonLabel: context.l10n.losLegendRadioHorizon, + ), + ], const SizedBox(height: 8), Text( segment != null @@ -488,6 +520,52 @@ class _LineOfSightMapScreenState extends State { ), ), const SizedBox(height: 4), + if (displayFrequencyMHz != null) + Padding( + padding: const EdgeInsets.only(top: 2, bottom: 4), + child: Row( + children: [ + Text( + context.l10n.losFrequencyLabel, + style: TextStyle( + fontSize: 11, + color: Colors.grey[700], + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 8), + Text( + '${displayFrequencyMHz.toStringAsFixed(3)} MHz', + style: TextStyle(fontSize: 11, color: Colors.grey[700]), + ), + if (kFactorUsed != null) ...[ + const SizedBox(width: 8), + Text( + 'k=${kFactorUsed.toStringAsFixed(3)}', + style: TextStyle( + fontSize: 11, + color: Colors.grey[700], + ), + ), + const SizedBox(width: 4), + IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + icon: const Icon(Icons.info_outline, size: 16), + color: Colors.grey[600], + tooltip: context.l10n.losFrequencyInfoTooltip, + onPressed: () { + _showFrequencyInfoDialog( + context, + displayFrequencyMHz, + kFactorUsed, + ); + }, + ), + ], + ], + ), + ), Text( context.l10n.losElevationAttribution, style: TextStyle(fontSize: 10, color: Colors.grey[700]), @@ -642,7 +720,7 @@ class _LineOfSightMapScreenState extends State { alignment: Alignment.centerRight, child: ElevatedButton.icon( onPressed: _loading ? null : _runLos, - icon: const Icon(Icons.visibility), + icon: const LosIcon(), label: Text(context.l10n.losRun), ), ), @@ -896,6 +974,40 @@ class _LineOfSightMapScreenState extends State { break; } } + + void _showFrequencyInfoDialog( + BuildContext context, + double frequencyMHz, + double kFactor, + ) { + final baselineFreq = LineOfSightService.baselineFrequencyMHz; + final baselineK = LineOfSightService.baselineKFactor; + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(context.l10n.losFrequencyDialogTitle), + content: Text( + context.l10n.losFrequencyDialogDescription( + baselineK, + baselineFreq, + frequencyMHz, + kFactor, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(context.l10n.common_ok), + ), + ], + ), + ); + } + + double? _normalizeFrequencyMHz(int? frequencyKHz) { + if (frequencyKHz == null || frequencyKHz <= 0) return null; + return frequencyKHz / 1000.0; + } } class _LosProfilePainter extends CustomPainter { @@ -903,12 +1015,18 @@ class _LosProfilePainter extends CustomPainter { final String distanceUnit; final String heightUnit; final TextStyle badgeTextStyle; + final String terrainLabel; + final String losBeamLabel; + final String radioHorizonLabel; const _LosProfilePainter({ required this.samples, required this.distanceUnit, required this.heightUnit, required this.badgeTextStyle, + required this.terrainLabel, + required this.losBeamLabel, + required this.radioHorizonLabel, }); @override @@ -920,44 +1038,108 @@ class _LosProfilePainter extends CustomPainter { if (samples.length < 2) return; final minY = samples - .map((s) => math.min(s.terrainMeters, s.lineHeightMeters)) + .map( + (s) => math.min( + math.min(s.terrainMeters, s.lineHeightMeters), + s.refractedHeightMeters, + ), + ) .reduce(math.min); final maxY = samples - .map((s) => math.max(s.terrainMeters, s.lineHeightMeters)) + .map( + (s) => math.max( + math.max(s.terrainMeters, s.lineHeightMeters), + s.refractedHeightMeters, + ), + ) .reduce(math.max); final ySpan = math.max(1.0, maxY - minY); final maxDist = math.max(1.0, samples.last.distanceMeters); + const horizontalPadding = 12.0; + const verticalPadding = 12.0; + final chartWidth = math.max(1.0, size.width - horizontalPadding * 2); + final chartHeight = math.max(1.0, size.height - verticalPadding * 2); Offset mapPoint(double x, double y) { - final px = (x / maxDist) * size.width; - final py = size.height - ((y - minY) / ySpan) * size.height; + final px = horizontalPadding + (x / maxDist) * chartWidth; + final py = + size.height - verticalPadding - ((y - minY) / ySpan) * chartHeight; 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); + final firstTerrainPoint = mapPoint( + samples.first.distanceMeters, + samples.first.terrainMeters, + ); + final lastTerrainPoint = mapPoint( + samples.last.distanceMeters, + samples.last.terrainMeters, + ); + + double distanceForCanvasX(double x) { + final normalized = ((x - horizontalPadding) / chartWidth).clamp(0.0, 1.0); + return normalized * maxDist; + } + + double elevationToPixel(double elevation) { + final normalized = ((elevation - minY) / ySpan).clamp(0.0, 1.0); + return size.height - verticalPadding - normalized * chartHeight; + } + + double extrapolateTerrain(double distance, bool isLeft) { + final samplesForSlope = isLeft + ? samples.sublist(0, math.min(2, samples.length)) + : samples.sublist(samples.length - math.min(2, samples.length)); + if (samplesForSlope.length < 2) { + return samplesForSlope.first.terrainMeters; + } + final a = samplesForSlope.first; + final b = samplesForSlope.last; + final dx = b.distanceMeters - a.distanceMeters; + if (dx.abs() < 1e-6) return a.terrainMeters; + final slope = (b.terrainMeters - a.terrainMeters) / dx; + return a.terrainMeters + slope * (distance - a.distanceMeters); + } + + final leftDistance = distanceForCanvasX(0.0); + final rightDistance = distanceForCanvasX(size.width); + final leftEdgeTerrain = extrapolateTerrain(leftDistance, true); + final rightEdgeTerrain = extrapolateTerrain(rightDistance, false); + final leftEdgePoint = Offset(0.0, elevationToPixel(leftEdgeTerrain)); + final rightEdgePoint = Offset( + size.width, + elevationToPixel(rightEdgeTerrain), + ); + + final terrainPath = ui.Path() + ..moveTo(0, size.height) + ..lineTo(leftEdgePoint.dx, leftEdgePoint.dy) + ..lineTo(firstTerrainPoint.dx, firstTerrainPoint.dy); + for (final sample in samples) { + final p = mapPoint(sample.distanceMeters, sample.terrainMeters); terrainPath.lineTo(p.dx, p.dy); } - terrainPath.lineTo(size.width, size.height); - terrainPath.close(); + terrainPath + ..lineTo(lastTerrainPoint.dx, lastTerrainPoint.dy) + ..lineTo(rightEdgePoint.dx, rightEdgePoint.dy) + ..lineTo(size.width, size.height) + ..close(); - canvas.drawPath(terrainPath, Paint()..color = const Color(0xCC7C6F5D)); + const terrainFillColor = Color(0xCC7C6F5D); + const terrainLineColor = Color(0xFF9FE870); + const losLineColor = Color(0xFFE0E7FF); + canvas.drawPath(terrainPath, Paint()..color = terrainFillColor); - 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); - } + final terrainLine = ui.Path()..moveTo(leftEdgePoint.dx, leftEdgePoint.dy); + for (final sample in samples) { + final p = mapPoint(sample.distanceMeters, sample.terrainMeters); + terrainLine.lineTo(p.dx, p.dy); } + terrainLine.lineTo(rightEdgePoint.dx, rightEdgePoint.dy); canvas.drawPath( terrainLine, Paint() - ..color = const Color(0xFF9FE870) + ..color = terrainLineColor ..style = PaintingStyle.stroke ..strokeWidth = 2, ); @@ -977,10 +1159,59 @@ class _LosProfilePainter extends CustomPainter { canvas.drawPath( losLine, Paint() - ..color = const Color(0xFFE0E7FF) + ..color = losLineColor ..style = PaintingStyle.stroke ..strokeWidth = 2, ); + + const refractedLineColor = Color(0xFFFFD57F); + final refractedLine = ui.Path(); + for (int i = 0; i < samples.length; i++) { + final p = mapPoint( + samples[i].distanceMeters, + samples[i].refractedHeightMeters, + ); + if (i == 0) { + refractedLine.moveTo(p.dx, p.dy); + } else { + refractedLine.lineTo(p.dx, p.dy); + } + } + canvas.drawPath( + refractedLine, + Paint() + ..color = refractedLineColor + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5, + ); + + final capPath = ui.Path(); + for (int i = 0; i < samples.length; i++) { + final p = mapPoint( + samples[i].distanceMeters, + samples[i].refractedHeightMeters, + ); + if (i == 0) { + capPath.moveTo(p.dx, p.dy); + } else { + capPath.lineTo(p.dx, p.dy); + } + } + for (int i = samples.length - 1; i >= 0; i--) { + final p = mapPoint( + samples[i].distanceMeters, + samples[i].lineHeightMeters, + ); + capPath.lineTo(p.dx, p.dy); + } + capPath.close(); + const horizonFillColor = Color(0x40FFD57F); + canvas.drawPath( + capPath, + Paint() + ..color = horizonFillColor + ..style = PaintingStyle.fill, + ); } @override @@ -988,7 +1219,10 @@ class _LosProfilePainter extends CustomPainter { return oldDelegate.samples != samples || oldDelegate.distanceUnit != distanceUnit || oldDelegate.heightUnit != heightUnit || - oldDelegate.badgeTextStyle != badgeTextStyle; + oldDelegate.badgeTextStyle != badgeTextStyle || + oldDelegate.terrainLabel != terrainLabel || + oldDelegate.losBeamLabel != losBeamLabel || + oldDelegate.radioHorizonLabel != radioHorizonLabel; } void _drawUnitBadge(Canvas canvas, Size size) { @@ -1001,3 +1235,73 @@ class _LosProfilePainter extends CustomPainter { painter.paint(canvas, Offset(size.width - painter.width - 8, 8)); } } + +class _LosLegend extends StatelessWidget { + static const _terrainColor = Color(0xFF9FE870); + static const _losColor = Color(0xFFE0E7FF); + static const _radioColor = Color(0xFFFFD57F); + + final String terrainLabel; + final String losBeamLabel; + final String radioHorizonLabel; + + const _LosLegend({ + required this.terrainLabel, + required this.losBeamLabel, + required this.radioHorizonLabel, + }); + + @override + Widget build(BuildContext context) { + final textStyle = + Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.white70, + fontSize: 11, + fontWeight: FontWeight.w500, + ) ?? + const TextStyle( + color: Colors.white70, + fontSize: 11, + fontWeight: FontWeight.w500, + ); + + final entries = [ + _LegendEntry(terrainLabel, _terrainColor), + _LegendEntry(losBeamLabel, _losColor), + _LegendEntry(radioHorizonLabel, _radioColor), + ]; + + const swatchSize = 10.0; + + return Wrap( + spacing: 16, + runSpacing: 6, + children: entries + .map( + (entry) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: swatchSize, + height: swatchSize, + decoration: BoxDecoration( + color: entry.color, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 6), + Text(entry.label, style: textStyle), + ], + ), + ) + .toList(), + ); + } +} + +class _LegendEntry { + final String label; + final Color color; + + const _LegendEntry(this.label, this.color); +} diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 77ec98c..b688a30 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -20,6 +20,7 @@ import '../services/map_tile_cache_service.dart'; import '../utils/contact_search.dart'; import '../utils/route_transitions.dart'; import '../widgets/quick_switch_bar.dart'; +import '../icons/los_icon.dart'; import 'channels_screen.dart'; import 'chat_screen.dart'; import 'contacts_screen.dart'; @@ -280,7 +281,7 @@ class _MapScreenState extends State { ), if (!_isBuildingPathTrace) IconButton( - icon: const Icon(Icons.visibility), + icon: const LosIcon(), onPressed: () { final candidates = []; if (connector.selfLatitude != null && diff --git a/lib/services/chat_text_scale_service.dart b/lib/services/chat_text_scale_service.dart new file mode 100644 index 0000000..21d6a5f --- /dev/null +++ b/lib/services/chat_text_scale_service.dart @@ -0,0 +1,72 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../storage/prefs_manager.dart'; + +/// Client-side accessibility/UI service that exposes a persistent shared text scale +/// factor. No MeshCoreConnector/RoomServer or protocol interaction occurs, and the +/// value is saved locally via SharedPreferences so it can be reused in Markdown +/// viewers, log panels, or other text-heavy widgets without redundant network +/// dependencies. +/// +/// Widgets should scope rebuilds using the snippet below so only the scaled text +/// is rebuilt instead of the entire chat list: +/// ```dart +/// context.select( +/// (service) => service.scale, +/// ) +/// ``` +class ChatTextScaleService extends ChangeNotifier { + static const _prefKey = 'chat_text_scale'; + static const double _minScale = 0.8; + static const double _maxScale = 1.8; + + double _scale = 1.0; + Timer? _saveTimer; + + double get scale => _scale; + + Future initialize() async { + final stored = PrefsManager.instance.getDouble(_prefKey); + if (stored != null) { + _scale = _clamp(stored); + } + } + + void setScale(double value, {bool persistImmediately = false}) { + final next = _clamp(value); + if (next == _scale) return; + _scale = next; + notifyListeners(); + if (persistImmediately) { + _commitScale(); + } else { + _scheduleSave(); + } + } + + void reset() { + setScale(1.0, persistImmediately: true); + } + + void persist() => _commitScale(); + + @override + void dispose() { + _saveTimer?.cancel(); + super.dispose(); + } + + void _scheduleSave() { + _saveTimer?.cancel(); + _saveTimer = Timer(const Duration(milliseconds: 250), _commitScale); + } + + void _commitScale() { + _saveTimer?.cancel(); + PrefsManager.instance.setDouble(_prefKey, _scale); + } + + double _clamp(double value) => value.clamp(_minScale, _maxScale).toDouble(); +} diff --git a/lib/services/line_of_sight_service.dart b/lib/services/line_of_sight_service.dart index e9f9f7b..7f056c8 100644 --- a/lib/services/line_of_sight_service.dart +++ b/lib/services/line_of_sight_service.dart @@ -12,12 +12,14 @@ class LineOfSightSample { final double distanceMeters; final double terrainMeters; final double lineHeightMeters; + final double refractedHeightMeters; final double clearanceMeters; const LineOfSightSample({ required this.distanceMeters, required this.terrainMeters, required this.lineHeightMeters, + required this.refractedHeightMeters, required this.clearanceMeters, }); } @@ -30,6 +32,8 @@ class LineOfSightResult { final double? firstObstructionDistanceMeters; final List samples; final String? errorMessage; + final double usedKFactor; + final double? frequencyMHz; const LineOfSightResult({ required this.hasData, @@ -38,12 +42,16 @@ class LineOfSightResult { required this.maxObstructionMeters, required this.firstObstructionDistanceMeters, required this.samples, + required this.usedKFactor, + this.frequencyMHz, this.errorMessage, }); const LineOfSightResult.error({ required this.totalDistanceMeters, required this.errorMessage, + this.usedKFactor = 4.0 / 3.0, + this.frequencyMHz, }) : hasData = false, isClear = false, maxObstructionMeters = 0, @@ -89,6 +97,11 @@ class LineOfSightService { static const Duration _cacheTtl = Duration(hours: 24); static const int _maxFetchAttempts = 4; // initial try + 3 retries static const Duration _initialBackoff = Duration(milliseconds: 300); + static const double _baselineFrequencyMHz = 915.0; + static const double _baselineKFactor = 4.0 / 3.0; + + static double get baselineFrequencyMHz => _baselineFrequencyMHz; + static double get baselineKFactor => _baselineKFactor; final http.Client _httpClient; final bool _ownsHttpClient; @@ -106,7 +119,7 @@ class LineOfSightService { List points, { double startAntennaHeightMeters = 1.5, double endAntennaHeightMeters = 1.5, - double kFactor = 4.0 / 3.0, + double? frequencyMHz, double obstructionToleranceMeters = 0.0, }) async { if (points.length < 2) { @@ -123,6 +136,7 @@ class LineOfSightService { var blockedSegments = 0; var unknownSegments = 0; + final kFactor = _kFactorForFrequency(frequencyMHz); for (int i = 0; i < points.length - 1; i++) { final result = await analyzeLink( points[i], @@ -130,6 +144,7 @@ class LineOfSightService { startAntennaHeightMeters: startAntennaHeightMeters, endAntennaHeightMeters: endAntennaHeightMeters, kFactor: kFactor, + frequencyMHz: frequencyMHz, obstructionToleranceMeters: obstructionToleranceMeters, ); segments.add( @@ -163,7 +178,8 @@ class LineOfSightService { LatLng end, { double startAntennaHeightMeters = 1.5, double endAntennaHeightMeters = 1.5, - double kFactor = 4.0 / 3.0, + required double kFactor, + double? frequencyMHz, double obstructionToleranceMeters = 0.0, }) async { final totalDistanceMeters = _distance.as(LengthUnit.Meter, start, end); @@ -175,6 +191,8 @@ class LineOfSightService { maxObstructionMeters: 0, firstObstructionDistanceMeters: null, samples: const [], + usedKFactor: kFactor, + frequencyMHz: frequencyMHz, ); } @@ -185,6 +203,8 @@ class LineOfSightService { return LineOfSightResult.error( totalDistanceMeters: totalDistanceMeters, errorMessage: errorElevationUnavailable, + usedKFactor: kFactor, + frequencyMHz: frequencyMHz, ); } @@ -194,6 +214,7 @@ class LineOfSightService { startAntennaHeightMeters: startAntennaHeightMeters, endAntennaHeightMeters: endAntennaHeightMeters, kFactor: kFactor, + frequencyMHz: frequencyMHz, obstructionToleranceMeters: obstructionToleranceMeters, ); } @@ -203,13 +224,16 @@ class LineOfSightService { required List elevations, double startAntennaHeightMeters = 1.5, double endAntennaHeightMeters = 1.5, - double kFactor = 4.0 / 3.0, + required double kFactor, + double? frequencyMHz, double obstructionToleranceMeters = 0.0, }) { if (points.length < 2 || elevations.length != points.length) { - return const LineOfSightResult.error( + return LineOfSightResult.error( totalDistanceMeters: 0, errorMessage: errorInvalidInput, + usedKFactor: kFactor, + frequencyMHz: frequencyMHz, ); } @@ -238,6 +262,10 @@ class LineOfSightService { (2 * effectiveEarthRadius); final terrainHeight = elevations[i] + earthBulge; final clearance = lineHeight - terrainHeight; + final unrefBulge = + (distanceFromStart * (totalDistanceMeters - distanceFromStart)) / + (2 * _earthRadiusMeters); + final refractedHeight = lineHeight + (unrefBulge - earthBulge); if (clearance < -obstructionToleranceMeters) { isClear = false; @@ -253,6 +281,7 @@ class LineOfSightService { distanceMeters: distanceFromStart, terrainMeters: terrainHeight, lineHeightMeters: lineHeight, + refractedHeightMeters: refractedHeight, clearanceMeters: clearance, ), ); @@ -265,9 +294,20 @@ class LineOfSightService { maxObstructionMeters: maxObstructionMeters, firstObstructionDistanceMeters: firstObstructionDistanceMeters, samples: samples, + usedKFactor: kFactor, + frequencyMHz: frequencyMHz, ); } + static double _kFactorForFrequency(double? frequencyMHz) { + if (frequencyMHz == null) return _baselineKFactor; + final delta = + (frequencyMHz - _baselineFrequencyMHz) / _baselineFrequencyMHz; + final adjustment = delta * 0.15; + final scaled = _baselineKFactor * (1 + adjustment); + return scaled.clamp(1.1, 1.6).toDouble(); + } + List _buildSamplePoints( LatLng start, LatLng end, diff --git a/lib/widgets/chat_zoom_wrapper.dart b/lib/widgets/chat_zoom_wrapper.dart new file mode 100644 index 0000000..f0c6815 --- /dev/null +++ b/lib/widgets/chat_zoom_wrapper.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../services/chat_text_scale_service.dart'; + +/// Gesture wrapper that exposes two-finger pinch-to-zoom for chat scrollables. +/// Double-tap resets the scale. Only the wrapper itself listens to gestures; +/// child scrollables keep their normal touch handling. +class ChatZoomWrapper extends StatefulWidget { + const ChatZoomWrapper({super.key, required this.child, this.onDoubleTap}); + + final Widget child; + final VoidCallback? onDoubleTap; + + @override + State createState() => _ChatZoomWrapperState(); +} + +class _ChatZoomWrapperState extends State { + double? _startScale; + + @override + Widget build(BuildContext context) { + final service = context.read(); + + return GestureDetector( + behavior: HitTestBehavior.translucent, + onDoubleTap: () { + service.reset(); + service.persist(); + widget.onDoubleTap?.call(); + }, + onScaleStart: (details) { + if (details.pointerCount != 2) return; + _startScale = service.scale; + }, + onScaleUpdate: (details) { + if (details.pointerCount != 2) return; + final baseScale = _startScale ?? service.scale; + service.setScale(baseScale * details.scale); + }, + onScaleEnd: (_) { + _startScale = null; + service.persist(); + }, + child: widget.child, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index ed84c40..fa23b27 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: archive - sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff url: "https://pub.dev" source: hosted - version: "4.0.7" + version: "4.0.9" args: dependency: transitive description: @@ -347,6 +347,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.2.2" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" + url: "https://pub.dev" + source: hosted + version: "2.2.3" flutter_test: dependency: "direct dev" description: flutter @@ -401,10 +409,10 @@ packages: dependency: transitive description: name: image - sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c" + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce url: "https://pub.dev" source: hosted - version: "4.7.2" + version: "4.8.0" intl: dependency: "direct main" description: @@ -417,10 +425,10 @@ packages: dependency: transitive description: name: json_annotation - sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df" + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 url: "https://pub.dev" source: hosted - version: "4.10.0" + version: "4.11.0" latlong2: dependency: "direct main" description: @@ -509,6 +517,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.0" + material_symbols_icons: + dependency: "direct main" + description: + name: material_symbols_icons + sha256: c62b15f2b3de98d72cbff0148812f5ef5159f05e61fc9f9a089ec2bb234df082 + url: "https://pub.dev" + source: hosted + version: "4.2906.0" meta: dependency: transitive description: @@ -537,10 +553,10 @@ packages: dependency: "direct main" description: name: mobile_scanner - sha256: c6184bf2913dd66be244108c9c27ca04b01caf726321c44b0e7a7a1e32d41044 + sha256: c92c26bf2231695b6d3477c8dcf435f51e28f87b1745966b1fe4c47a286171ce url: "https://pub.dev" source: hosted - version: "7.1.4" + version: "7.2.0" native_toolchain_c: dependency: transitive description: @@ -597,6 +613,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: "direct main" description: @@ -649,10 +673,10 @@ packages: dependency: transitive description: name: petitparser - sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "7.0.2" platform: dependency: transitive description: @@ -681,10 +705,10 @@ packages: dependency: transitive description: name: posix - sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" url: "https://pub.dev" source: hosted - version: "6.0.3" + version: "6.5.0" proj4dart: dependency: transitive description: @@ -1006,10 +1030,34 @@ packages: dependency: "direct main" description: name: uuid - sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" url: "https://pub.dev" source: hosted - version: "4.5.2" + version: "4.5.3" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + url: "https://pub.dev" + source: hosted + version: "1.1.19" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74" + url: "https://pub.dev" + source: hosted + version: "1.2.0" vector_math: dependency: transitive description: @@ -1043,7 +1091,7 @@ packages: source: hosted version: "1.3.0" web: - dependency: transitive + dependency: "direct main" description: name: web sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" diff --git a/pubspec.yaml b/pubspec.yaml index 54aab31..fb68b00 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -60,6 +60,7 @@ dependencies: gpx: ^2.3.0 path_provider: ^2.1.5 share_plus: ^12.0.1 + material_symbols_icons: ^4.2906.0 web: ^1.1.1 flutter_svg: ^2.0.10+1 diff --git a/test/services/line_of_sight_service_test.dart b/test/services/line_of_sight_service_test.dart index 987ee6c..267a70b 100644 --- a/test/services/line_of_sight_service_test.dart +++ b/test/services/line_of_sight_service_test.dart @@ -16,6 +16,7 @@ void main() { elevations: elevations, startAntennaHeightMeters: 2, endAntennaHeightMeters: 2, + kFactor: 4.0 / 3.0, ); expect(result.hasData, isTrue); @@ -36,6 +37,7 @@ void main() { elevations: elevations, startAntennaHeightMeters: 1.5, endAntennaHeightMeters: 1.5, + kFactor: 4.0 / 3.0, ); expect(result.hasData, isTrue);