diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index b494598ca..9c0f3577c 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -1450,6 +1450,32 @@ shutdown_on_power_loss shutdown_warning signal signal_quality +### SITE ### +site_planner +site_planner_antenna_gain_dbi +site_planner_antenna_height_meters +site_planner_color_scale +site_planner_estimate +site_planner_estimating +site_planner_failed +site_planner_frequency_mhz +site_planner_high_resolution +site_planner_invalid_latitude +site_planner_invalid_longitude +site_planner_invalid_positive +site_planner_invalid_rx_sensitivity +site_planner_max_range_km +site_planner_rx_height_meters +site_planner_rx_sensitivity_dbm +site_planner_section_display +site_planner_section_receiver +site_planner_section_simulation +site_planner_section_transmitter +site_planner_subtitle +site_planner_tx_power_watts +site_planner_use_current_location +site_planner_use_map_center +site_planner_use_node_location skip slot smart_position diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt index 48b1aa7fc..146164e84 100644 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt +++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt @@ -19,3 +19,6 @@ package org.meshtastic.app.map import org.meshtastic.core.ui.util.MapViewProvider fun getMapViewProvider(): MapViewProvider = FdroidMapViewProvider() + +/** Site Planner (coverage-estimate) is Google-flavor only; the F-Droid map has no overlay-layer support. */ +fun sitePlannerAvailable(): Boolean = false diff --git a/androidApp/src/google/AndroidManifest.xml b/androidApp/src/google/AndroidManifest.xml index bab70fc2d..835daf559 100644 --- a/androidApp/src/google/AndroidManifest.xml +++ b/androidApp/src/google/AndroidManifest.xml @@ -29,6 +29,35 @@ android:name="android.app.appfunctions.app_metadata" android:resource="@xml/app_metadata" /> + + + + + + + + + + + + + + + + + + + + + + + Site Planner + Antenna gain (dBi) + Antenna height (m) + Color palette + Estimate coverage + Estimating coverage… + Coverage estimate failed + Frequency (MHz) + High-resolution terrain + Enter −90 to 90 + Enter −180 to 180 + Must be greater than 0 + Enter −150 to −30 dBm + Max range (km) + Receiver height (m) + Receiver sensitivity (dBm) + Display + Receiver + Simulation Options + Site / Transmitter + Estimate RF coverage for a transmitter + TX power (W) + Use current location + Use map center + Use current node location Skip Slot Smart Position diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalSitePlannerAvailable.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalSitePlannerAvailable.kt new file mode 100644 index 000000000..10293e343 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalSitePlannerAvailable.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ui.util + +import androidx.compose.runtime.compositionLocalOf + +/** + * Whether the Site Planner coverage-estimate flow is available (Google flavor only, backed by the official hosted + * planner). Gates the "Estimate coverage" action on the node detail screen; the map's own control is gated equivalently + * in the Google MapView. Defaults to false so F-Droid/release never surface a dead action. + * + * Flavor-injected feature availability, mirroring the other Local*Provider seams in this package (all baselined for + * this rule); a plain boolean local is the lightest way to gate a commonMain UI action on a flavor capability. + */ +@Suppress("CompositionLocalAllowlist") +val LocalSitePlannerAvailable = compositionLocalOf { false } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt index f84647a77..aabba7f83 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt @@ -48,6 +48,7 @@ import org.meshtastic.core.resources.one_hour import org.meshtastic.core.resources.two_days import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed +import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits import org.meshtastic.proto.Position import org.meshtastic.proto.Waypoint @@ -79,6 +80,12 @@ open class BaseMapViewModel( val ourNodeInfo = nodeRepository.ourNodeInfo + /** + * Connected radio's channel set (primary-channel frequency + LoRa config); used to prefill a Site Planner estimate. + */ + val channelSet: StateFlow = + radioConfigRepository.channelSetFlow.stateInWhileSubscribed(initialValue = null) + val myNodeNum get() = myNodeInfo.value?.myNodeNum diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt index 454bc72b2..b71585034 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt @@ -35,7 +35,9 @@ import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.map_filter import org.meshtastic.core.resources.orient_north import org.meshtastic.core.resources.refresh +import org.meshtastic.core.resources.site_planner import org.meshtastic.core.resources.toggle_my_position +import org.meshtastic.core.ui.icon.CellTower import org.meshtastic.core.ui.icon.LocationDisabled import org.meshtastic.core.ui.icon.MapCompass import org.meshtastic.core.ui.icon.MeshtasticIcons @@ -72,6 +74,7 @@ fun MapControlsOverlay( filterDropdownContent: @Composable () -> Unit = {}, mapTypeContent: @Composable () -> Unit = {}, layersContent: @Composable () -> Unit = {}, + onSitePlannerClick: (() -> Unit)? = null, isLocationTrackingEnabled: Boolean = false, onToggleLocationTracking: () -> Unit = {}, showRefresh: Boolean = false, @@ -102,6 +105,15 @@ fun MapControlsOverlay( // Layers button (flavor-specific) layersContent() + // Site Planner coverage estimate (optional; provided by the Google flavor) + onSitePlannerClick?.let { onClick -> + MapButton( + icon = MeshtasticIcons.CellTower, + contentDescription = stringResource(Res.string.site_planner), + onClick = onClick, + ) + } + // Refresh button (optional) if (showRefresh) { if (isRefreshing) { diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerParams.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerParams.kt new file mode 100644 index 000000000..b3e4c6069 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerParams.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.component + +/** + * Transmitter parameters for a Meshtastic Site Planner coverage estimate. Maps onto the planner's flat query contract + * (`?lat=&lon=&name=&tx_power=&tx_freq=&tx_height=&tx_gain=`), so [toQueryUrl] can hand a configured, auto-running view + * to the planner without knowing its internal parameter schema. + */ +data class SitePlannerParams( + val name: String, + val latitude: Double, + val longitude: Double, + val txPowerWatts: Double = DEFAULT_TX_POWER_WATTS, + val txFreqMhz: Double = DEFAULT_TX_FREQ_MHZ, + val txHeightMeters: Double = DEFAULT_TX_HEIGHT_METERS, + val txGainDbi: Double = DEFAULT_TX_GAIN_DBI, + val colorScale: String = DEFAULT_COLOR_SCALE, + // Advanced fields — default to the planner's own values, so sending them is behaviorally identical to omitting + // them (they just make the hand-off explicit). Surfaced behind an "Advanced" section in the form. + val rxSensitivityDbm: Double = DEFAULT_RX_SENSITIVITY_DBM, + val rxHeightMeters: Double = DEFAULT_RX_HEIGHT_METERS, + val maxRangeKm: Double = DEFAULT_MAX_RANGE_KM, + val highResolution: Boolean = false, + val minDbm: Double = DEFAULT_MIN_DBM, + val maxDbm: Double = DEFAULT_MAX_DBM, + val overlayTransparency: Int = DEFAULT_OVERLAY_TRANSPARENCY, +) { + /** + * Build the planner URL that prefills these params and auto-runs the simulation (`run=1`), asking the planner to + * hand the result to a native host bridge (`bridge=1`) rather than the share sheet. Advanced params map onto the + * planner's flat query contract (receiver / simulation / display sections). + */ + fun toQueryUrl(baseUrl: String): String { + val query = buildString { + append("lat=").append(latitude) + append("&lon=").append(longitude) + append("&name=").append(encodeQueryComponent(name)) + append("&tx_power=").append(txPowerWatts) + append("&tx_freq=").append(txFreqMhz) + append("&tx_height=").append(txHeightMeters) + append("&tx_gain=").append(txGainDbi) + append("&color_scale=").append(encodeQueryComponent(colorScale)) + append("&rx_sensitivity=").append(rxSensitivityDbm) + append("&rx_height=").append(rxHeightMeters) + append("&max_range=").append(maxRangeKm) + if (highResolution) append("&high_res=1") + append("&min_dbm=").append(minDbm) + append("&max_dbm=").append(maxDbm) + append("&overlay_transparency=").append(overlayTransparency) + append("&run=1&bridge=1") + } + val separator = if (baseUrl.endsWith("/")) "" else "/" + return "$baseUrl$separator?$query" + } + + companion object { + // Meshtastic-typical stock defaults; all editable in the form before submission. Values mirror the planner's + // own factory defaults (src/store.ts defaultParams()) so an untouched form matches a fresh planner session. + const val DEFAULT_TX_POWER_WATTS: Double = 0.1 + const val DEFAULT_TX_FREQ_MHZ: Double = 907.0 + const val DEFAULT_TX_HEIGHT_METERS: Double = 2.0 + const val DEFAULT_TX_GAIN_DBI: Double = 2.0 + const val DEFAULT_COLOR_SCALE: String = "plasma" + const val DEFAULT_RX_SENSITIVITY_DBM: Double = -130.0 + const val DEFAULT_RX_HEIGHT_METERS: Double = 1.0 + const val DEFAULT_MAX_RANGE_KM: Double = 30.0 + const val DEFAULT_MIN_DBM: Double = -130.0 + const val DEFAULT_MAX_DBM: Double = -80.0 + const val DEFAULT_OVERLAY_TRANSPARENCY: Int = 50 + + // Validation ranges mirroring the planner's input constraints (src/components/*.vue). + const val MIN_FREQ_MHZ: Double = 20.0 + const val MAX_FREQ_MHZ: Double = 20_000.0 + const val MIN_RX_SENSITIVITY_DBM: Double = -150.0 + const val MAX_RX_SENSITIVITY_DBM: Double = -30.0 + const val MAX_RANGE_STANDARD_KM: Double = 150.0 + const val MAX_RANGE_HIGH_RES_KM: Double = 70.0 + const val MIN_TRANSPARENCY: Int = 0 + const val MAX_TRANSPARENCY: Int = 100 + + /** Coverage palettes the planner ships (value → label), mirroring its Display.vue picker. */ + val COLOR_SCALES: List> = + listOf( + "plasma" to "Plasma", + "viridis" to "Viridis (colorblind-safe)", + "CMRmap" to "CMR map", + "cool" to "Cool", + "turbo" to "Turbo", + "jet" to "Jet", + ) + } +} + +private const val UNRESERVED = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~" +private val HEX = "0123456789ABCDEF".toCharArray() +private const val BYTE_MASK = 0xFF +private const val ASCII_LIMIT = 0x80 +private const val NIBBLE_BITS = 4 +private const val NIBBLE_MASK = 0xF + +/** Percent-encode a query-component value (UTF-8), so names with spaces/non-ASCII survive the round trip. */ +internal fun encodeQueryComponent(value: String): String = buildString { + for (byte in value.encodeToByteArray()) { + val code = byte.toInt() and BYTE_MASK + if (code < ASCII_LIMIT && code.toChar() in UNRESERVED) { + append(code.toChar()) + } else { + append('%').append(HEX[code shr NIBBLE_BITS]).append(HEX[code and NIBBLE_MASK]) + } + } +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerSheet.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerSheet.kt new file mode 100644 index 000000000..aba76a149 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerSheet.kt @@ -0,0 +1,438 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@file:Suppress("TooManyFunctions") // small, single-purpose section composables mirroring the planner's panels + +package org.meshtastic.feature.map.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.latitude +import org.meshtastic.core.resources.longitude +import org.meshtastic.core.resources.name +import org.meshtastic.core.resources.site_planner +import org.meshtastic.core.resources.site_planner_antenna_gain_dbi +import org.meshtastic.core.resources.site_planner_antenna_height_meters +import org.meshtastic.core.resources.site_planner_color_scale +import org.meshtastic.core.resources.site_planner_estimate +import org.meshtastic.core.resources.site_planner_frequency_mhz +import org.meshtastic.core.resources.site_planner_high_resolution +import org.meshtastic.core.resources.site_planner_invalid_latitude +import org.meshtastic.core.resources.site_planner_invalid_longitude +import org.meshtastic.core.resources.site_planner_invalid_positive +import org.meshtastic.core.resources.site_planner_invalid_rx_sensitivity +import org.meshtastic.core.resources.site_planner_max_range_km +import org.meshtastic.core.resources.site_planner_rx_height_meters +import org.meshtastic.core.resources.site_planner_rx_sensitivity_dbm +import org.meshtastic.core.resources.site_planner_section_display +import org.meshtastic.core.resources.site_planner_section_receiver +import org.meshtastic.core.resources.site_planner_section_simulation +import org.meshtastic.core.resources.site_planner_section_transmitter +import org.meshtastic.core.resources.site_planner_subtitle +import org.meshtastic.core.resources.site_planner_tx_power_watts +import org.meshtastic.core.resources.site_planner_use_current_location +import org.meshtastic.core.resources.site_planner_use_map_center +import org.meshtastic.core.resources.site_planner_use_node_location +import org.meshtastic.core.ui.icon.ExpandLess +import org.meshtastic.core.ui.icon.ExpandMore +import org.meshtastic.core.ui.icon.Map +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.MyLocation +import org.meshtastic.core.ui.icon.PinDrop + +private const val LAT_LIMIT = 90.0 +private const val LON_LIMIT = 180.0 +private val SWATCH_WIDTH = 40.dp +private val SWATCH_HEIGHT = 16.dp + +/** Editable form state; string-backed so partial/invalid input is preserved while typing. */ +@Stable +private class SiteFormState(initial: SitePlannerParams) { + var name by mutableStateOf(initial.name) + var lat by mutableStateOf(initial.latitude.toString()) + var lon by mutableStateOf(initial.longitude.toString()) + var power by mutableStateOf(initial.txPowerWatts.toString()) + var freq by mutableStateOf(initial.txFreqMhz.toString()) + var height by mutableStateOf(initial.txHeightMeters.toString()) + var gain by mutableStateOf(initial.txGainDbi.toString()) + var colorScale by mutableStateOf(initial.colorScale) + + // Advanced (coverage-affecting) fields. + var rxSensitivity by mutableStateOf(initial.rxSensitivityDbm.toString()) + var rxHeight by mutableStateOf(initial.rxHeightMeters.toString()) + var maxRange by mutableStateOf(initial.maxRangeKm.toString()) + var highResolution by mutableStateOf(initial.highResolution) + + // Validation — computed from the (observable) string fields, so callers just read the booleans. + private val latValue + get() = lat.toDoubleOrNull() + + private val lonValue + get() = lon.toDoubleOrNull() + + val latBad + get() = latValue.let { it == null || it !in -LAT_LIMIT..LAT_LIMIT } + + val lonBad + get() = lonValue.let { it == null || it !in -LON_LIMIT..LON_LIMIT } + + val powerBad + get() = (power.toDoubleOrNull() ?: 0.0) <= 0.0 + + val freqBad + get() = (freq.toDoubleOrNull() ?: 0.0) <= 0.0 + + val rxSensBad + get() = + rxSensitivity.toDoubleOrNull()?.let { + it !in SitePlannerParams.MIN_RX_SENSITIVITY_DBM..SitePlannerParams.MAX_RX_SENSITIVITY_DBM + } ?: false + + // Guard the null-island (0,0) case so an empty ocean run can't be submitted. + val canSubmit + get() = !latBad && !lonBad && !powerBad && !freqBad && !rxSensBad && (latValue != 0.0 || lonValue != 0.0) +} + +/** + * Bottom-sheet form for a Site Planner coverage estimate, pre-filled with [initial]. [onSubmit] receives the edited + * params once they validate. Location shortcut chips ([onUseCurrentLocation]/[onUseNodeLocation]/[onUseMapCenter]) + * re-seed the coordinate fields when provided, preserving edits to the other fields. + */ +@Composable +fun SitePlannerSheet( + initial: SitePlannerParams, + onSubmit: (SitePlannerParams) -> Unit, + onDismiss: () -> Unit, + onUseCurrentLocation: (() -> Unit)? = null, + onUseNodeLocation: (() -> Unit)? = null, + onUseMapCenter: (() -> Unit)? = null, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val state = remember { SiteFormState(initial) } + // Re-seed only the coordinates when a shortcut changes them; other fields keep the user's edits. + LaunchedEffect(initial.latitude, initial.longitude) { + state.lat = initial.latitude.toString() + state.lon = initial.longitude.toString() + } + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + SiteFormContent(state, initial, onSubmit, onUseCurrentLocation, onUseNodeLocation, onUseMapCenter) + } +} + +@Composable +private fun SiteFormContent( + state: SiteFormState, + initial: SitePlannerParams, + onSubmit: (SitePlannerParams) -> Unit, + onUseCurrentLocation: (() -> Unit)?, + onUseNodeLocation: (() -> Unit)?, + onUseMapCenter: (() -> Unit)?, +) { + Column( + modifier = + Modifier.fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp) + .padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(Res.string.site_planner), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.semantics { heading() }, + ) + Text( + text = stringResource(Res.string.site_planner_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + // Sections mirror the planner's own panel order + default-open state (Site Parameters drawer): + // Site / Transmitter (open) → Receiver → Simulation Options → Display (open). + TransmitterSection(state, onUseCurrentLocation, onUseNodeLocation, onUseMapCenter) + ReceiverSection(state) + SimulationSection(state) + DisplaySection(state) + Button( + onClick = { onSubmit(buildSubmitParams(state, initial)) }, + enabled = state.canSubmit, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(Res.string.site_planner_estimate)) + } + } +} + +/** A collapsible titled section, matching the planner's `Section` panels. */ +@Composable +private fun FormSection(title: String, defaultExpanded: Boolean, content: @Composable ColumnScope.() -> Unit) { + var expanded by remember { mutableStateOf(defaultExpanded) } + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth().clickable { expanded = !expanded }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(title, style = MaterialTheme.typography.titleSmall, modifier = Modifier.semantics { heading() }) + Icon(if (expanded) MeshtasticIcons.ExpandLess else MeshtasticIcons.ExpandMore, contentDescription = null) + } + if (expanded) content() + } +} + +@Composable +private fun TransmitterSection( + state: SiteFormState, + onUseCurrentLocation: (() -> Unit)?, + onUseNodeLocation: (() -> Unit)?, + onUseMapCenter: (() -> Unit)?, +) { + val posMsg = stringResource(Res.string.site_planner_invalid_positive) + FormSection(stringResource(Res.string.site_planner_section_transmitter), defaultExpanded = true) { + SiteField(state.name, { state.name = it }, Res.string.name, keyboardType = KeyboardType.Text) + LocationChips(onUseCurrentLocation, onUseNodeLocation, onUseMapCenter) + TransmitterFields( + state = state, + latError = if (state.latBad) stringResource(Res.string.site_planner_invalid_latitude) else null, + lonError = if (state.lonBad) stringResource(Res.string.site_planner_invalid_longitude) else null, + powerError = if (state.powerBad) posMsg else null, + freqError = if (state.freqBad) posMsg else null, + ) + } +} + +@Composable +private fun ReceiverSection(state: SiteFormState) { + FormSection(stringResource(Res.string.site_planner_section_receiver), defaultExpanded = false) { + SiteField( + state.rxSensitivity, + { state.rxSensitivity = it }, + Res.string.site_planner_rx_sensitivity_dbm, + error = if (state.rxSensBad) stringResource(Res.string.site_planner_invalid_rx_sensitivity) else null, + ) + SiteField(state.rxHeight, { state.rxHeight = it }, Res.string.site_planner_rx_height_meters) + } +} + +@Composable +private fun SimulationSection(state: SiteFormState) { + FormSection(stringResource(Res.string.site_planner_section_simulation), defaultExpanded = false) { + SiteField(state.maxRange, { state.maxRange = it }, Res.string.site_planner_max_range_km) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(stringResource(Res.string.site_planner_high_resolution)) + Switch(checked = state.highResolution, onCheckedChange = { state.highResolution = it }) + } + } +} + +@Composable +private fun DisplaySection(state: SiteFormState) { + FormSection(stringResource(Res.string.site_planner_section_display), defaultExpanded = true) { + PalettePicker(state.colorScale) { state.colorScale = it } + } +} + +/** Quick shortcuts (chips) that re-seed the coordinates from the device, this node, or the map center. */ +@Composable +private fun LocationChips( + onUseCurrentLocation: (() -> Unit)?, + onUseNodeLocation: (() -> Unit)?, + onUseMapCenter: (() -> Unit)?, +) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + onUseCurrentLocation?.let { + LocationChip(it, MeshtasticIcons.MyLocation, Res.string.site_planner_use_current_location) + } + onUseNodeLocation?.let { LocationChip(it, MeshtasticIcons.PinDrop, Res.string.site_planner_use_node_location) } + onUseMapCenter?.let { LocationChip(it, MeshtasticIcons.Map, Res.string.site_planner_use_map_center) } + } +} + +@Composable +private fun LocationChip(onClick: () -> Unit, icon: ImageVector, label: StringResource) { + AssistChip( + onClick = onClick, + leadingIcon = { Icon(icon, null, Modifier.size(AssistChipDefaults.IconSize)) }, + label = { Text(stringResource(label)) }, + ) +} + +@Composable +private fun TransmitterFields( + state: SiteFormState, + latError: String?, + lonError: String?, + powerError: String?, + freqError: String?, +) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + SiteField(state.lat, { state.lat = it }, Res.string.latitude, error = latError) + SiteField(state.lon, { state.lon = it }, Res.string.longitude, error = lonError) + SiteField(state.power, { state.power = it }, Res.string.site_planner_tx_power_watts, error = powerError) + SiteField(state.freq, { state.freq = it }, Res.string.site_planner_frequency_mhz, error = freqError) + SiteField(state.height, { state.height = it }, Res.string.site_planner_antenna_height_meters) + SiteField(state.gain, { state.gain = it }, Res.string.site_planner_antenna_gain_dbi) + } +} + +@Composable +private fun SiteField( + value: String, + onValueChange: (String) -> Unit, + label: StringResource, + modifier: Modifier = Modifier, + keyboardType: KeyboardType = KeyboardType.Number, + error: String? = null, +) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(stringResource(label)) }, + singleLine = true, + isError = error != null, + supportingText = error?.let { { Text(it) } }, + keyboardOptions = KeyboardOptions(keyboardType = keyboardType), + modifier = modifier.fillMaxWidth(), + ) +} + +/** Color-scale picker with a gradient swatch for each palette (recognition over recall). */ +@Composable +private fun PalettePicker(value: String, onChange: (String) -> Unit) { + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + OutlinedTextField( + value = SitePlannerParams.COLOR_SCALES.firstOrNull { it.first == value }?.second ?: value, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(Res.string.site_planner_color_scale)) }, + leadingIcon = { PaletteSwatch(value) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable), + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + SitePlannerParams.COLOR_SCALES.forEach { (key, label) -> + DropdownMenuItem( + text = { Text(label) }, + leadingIcon = { PaletteSwatch(key) }, + onClick = { + onChange(key) + expanded = false + }, + ) + } + } + } +} + +@Composable +private fun PaletteSwatch(name: String) { + Box( + Modifier.width(SWATCH_WIDTH) + .height(SWATCH_HEIGHT) + .clip(RoundedCornerShape(4.dp)) + .background(Brush.horizontalGradient(paletteStops(name))), + ) +} + +/** Representative gradient stops per planner palette (approximations of the matplotlib LUTs for a preview swatch). */ +@Suppress("MagicNumber") +private fun paletteStops(name: String): List { + val argb = + when (name) { + "viridis" -> listOf(0xFF440154, 0xFF31688E, 0xFF35B779, 0xFFFDE725) + "CMRmap" -> listOf(0xFF000000, 0xFF3B0F70, 0xFF8C2981, 0xFFDE4968, 0xFFFE9F6D, 0xFFFFFFFF) + "cool" -> listOf(0xFF00FFFF, 0xFFFF00FF) + "turbo" -> listOf(0xFF30123B, 0xFF4585F9, 0xFF1BD0D5, 0xFFA4FC3B, 0xFFFABA39, 0xFFE5460A, 0xFF7A0403) + "jet" -> listOf(0xFF000080, 0xFF0000FF, 0xFF00FFFF, 0xFFFFFF00, 0xFFFF0000, 0xFF800000) + else -> listOf(0xFF0D0887, 0xFF7E03A8, 0xFFCC4778, 0xFFF89540, 0xFFF0F921) + } + return argb.map { Color(it) } +} + +/** Build params from the (string) form fields, falling back to the matching [initial] value when a field is invalid. */ +private fun buildSubmitParams(state: SiteFormState, initial: SitePlannerParams): SitePlannerParams = SitePlannerParams( + name = state.name.ifBlank { initial.name }, + latitude = state.lat.toDoubleOrNull() ?: initial.latitude, + longitude = state.lon.toDoubleOrNull() ?: initial.longitude, + txPowerWatts = state.power.toDoubleOrNull() ?: initial.txPowerWatts, + txFreqMhz = state.freq.toDoubleOrNull() ?: initial.txFreqMhz, + txHeightMeters = state.height.toDoubleOrNull() ?: initial.txHeightMeters, + txGainDbi = state.gain.toDoubleOrNull() ?: initial.txGainDbi, + colorScale = state.colorScale, + rxSensitivityDbm = state.rxSensitivity.toDoubleOrNull() ?: initial.rxSensitivityDbm, + rxHeightMeters = state.rxHeight.toDoubleOrNull() ?: initial.rxHeightMeters, + maxRangeKm = state.maxRange.toDoubleOrNull() ?: initial.maxRangeKm, + highResolution = state.highResolution, + minDbm = initial.minDbm, + maxDbm = initial.maxDbm, + overlayTransparency = initial.overlayTransparency, +) diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/component/SitePlannerParamsTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/component/SitePlannerParamsTest.kt new file mode 100644 index 000000000..1dc447c11 --- /dev/null +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/component/SitePlannerParamsTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.component + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SitePlannerParamsTest { + + @Test + fun `toQueryUrl emits the planner's flat contract with advanced defaults and run and bridge flags`() { + val url = + SitePlannerParams( + name = "Tower A", + latitude = 51.05, + longitude = -114.07, + txPowerWatts = 0.5, + txFreqMhz = 915.0, + txHeightMeters = 12.0, + txGainDbi = 5.5, + colorScale = "turbo", + ) + .toQueryUrl("http://localhost:5173") + + // Advanced params carry their (planner-matching) defaults; high_res is omitted while false. + assertEquals( + "http://localhost:5173/?lat=51.05&lon=-114.07&name=Tower%20A" + + "&tx_power=0.5&tx_freq=915.0&tx_height=12.0&tx_gain=5.5&color_scale=turbo" + + "&rx_sensitivity=-130.0&rx_height=1.0&max_range=30.0" + + "&min_dbm=-130.0&max_dbm=-80.0&overlay_transparency=50&run=1&bridge=1", + url, + ) + } + + @Test + fun `toQueryUrl emits advanced overrides and high_res only when enabled`() { + val url = + SitePlannerParams( + name = "N", + latitude = 1.0, + longitude = 2.0, + rxSensitivityDbm = -139.0, + maxRangeKm = 60.0, + highResolution = true, + ) + .toQueryUrl("http://localhost:5173") + + assertTrue(url.contains("&rx_sensitivity=-139.0"), url) + assertTrue(url.contains("&max_range=60.0"), url) + assertTrue(url.contains("&high_res=1"), url) + } + + @Test + fun `toQueryUrl does not double the slash when the base already ends in one`() { + val url = SitePlannerParams("N", 1.0, 2.0).toQueryUrl("https://planner.example/") + assertTrue(url.startsWith("https://planner.example/?lat=1.0&lon=2.0&name=N")) + } + + @Test + fun `encodeQueryComponent percent-encodes spaces and non-ASCII`() { + assertEquals("Tower%20%C3%91%C3%B6r%C3%B0", encodeQueryComponent("Tower Ñörð")) + assertEquals("plain-name_1.0~", encodeQueryComponent("plain-name_1.0~")) + } +} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt index 13db96d5a..a21e81204 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt @@ -38,12 +38,16 @@ import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.toDistanceString +import org.meshtastic.core.navigation.MapRoute import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.open_compass +import org.meshtastic.core.resources.site_planner_estimate +import org.meshtastic.core.ui.icon.CellTower import org.meshtastic.core.ui.icon.Compass import org.meshtastic.core.ui.icon.Distance import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.util.LocalInlineMapProvider +import org.meshtastic.core.ui.util.LocalSitePlannerAvailable import org.meshtastic.feature.node.model.NodeDetailAction import org.meshtastic.proto.Config @@ -79,6 +83,24 @@ internal fun PositionInlineContent( overflow = TextOverflow.Ellipsis, ) } + // Estimate RF coverage for this node in the Site Planner (Google flavor, position-gated). + if (LocalSitePlannerAvailable.current && node.validPosition != null) { + Spacer(Modifier.height(8.dp)) + FilledTonalButton( + onClick = { onAction(NodeDetailAction.Navigate(MapRoute.Map(sitePlannerNodeNum = node.num))) }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + ) { + Icon(MeshtasticIcons.CellTower, null, Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text( + text = stringResource(Res.string.site_planner_estimate), + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } } @Composable