diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DopplerFrequencyCalculator.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DopplerFrequencyCalculator.kt
new file mode 100644
index 00000000..6b5e9575
--- /dev/null
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DopplerFrequencyCalculator.kt
@@ -0,0 +1,122 @@
+/*
+ * Look4Sat. Amateur radio satellite tracker and pass predictor.
+ * Copyright (C) 2019-2026 Arty Bishop and contributors.
+ *
+ * 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.
+ */
+package com.rtbishop.look4sat.core.domain.utility
+
+import com.rtbishop.look4sat.core.domain.model.SatRadio
+import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
+import java.util.Locale
+
+/**
+ * Computes Doppler-corrected reciprocal frequencies for linear transponders.
+ *
+ * For a linear (passband) transponder, uplink and downlink frequencies are
+ * related by a fixed passband offset. When the satellite moves, both are
+ * Doppler-shifted. Given one, this computes the other:
+ *
+ * downlink → uplink: mapDownlinkToUplink (passband) → getUplinkFreq (Doppler)
+ * uplink → downlink: mapUplinkToDownlink (passband) → getDownlinkFreq (Doppler)
+ */
+object DopplerFrequencyCalculator {
+
+ /**
+ * Given a downlink frequency, compute the Doppler-corrected uplink frequency.
+ * Returns null if the transponder is not a linear passband type.
+ */
+ fun computeUplinkFromDownlink(
+ downlinkHz: Long,
+ transponder: SatRadio,
+ orbitalPos: OrbitalPos
+ ): Long? {
+ if (!isLinearTransponder(transponder)) return null
+ val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz, transponder) ?: return null
+ return orbitalPos.getUplinkFreq(baseUplink)
+ }
+
+ /**
+ * Given a downlink frequency, compute the Doppler-corrected uplink frequency
+ * with an offset applied to the downlink (in Hz).
+ * Returns null if the transponder is not a linear passband type.
+ *
+ * The user-entered downlink frequency already includes the offset, so subtract
+ * it before mapping the downlink passband position back to the uplink.
+ */
+ fun computeUplinkFromDownlinkWithOffset(
+ downlinkHz: Long,
+ transponder: SatRadio,
+ orbitalPos: OrbitalPos,
+ offsetHz: Long
+ ): Long? {
+ if (!isLinearTransponder(transponder)) return null
+ val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz - offsetHz, transponder) ?: return null
+ return orbitalPos.getUplinkFreq(baseUplink)
+ }
+
+ /**
+ * Given an uplink frequency, compute the Doppler-corrected downlink frequency.
+ * Returns null if the transponder is not a linear passband type.
+ */
+ fun computeDownlinkFromUplink(
+ uplinkHz: Long,
+ transponder: SatRadio,
+ orbitalPos: OrbitalPos
+ ): Long? {
+ if (!isLinearTransponder(transponder)) return null
+ val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null
+ return orbitalPos.getDownlinkFreq(baseDownlink)
+ }
+
+ /**
+ * Given an uplink frequency, compute the Doppler-corrected downlink frequency
+ * with an offset applied to the downlink (in Hz).
+ * Returns null if the transponder is not a linear passband type.
+ */
+ fun computeDownlinkFromUplinkWithOffset(
+ uplinkHz: Long,
+ transponder: SatRadio,
+ orbitalPos: OrbitalPos,
+ offsetHz: Long
+ ): Long? {
+ if (!isLinearTransponder(transponder)) return null
+ val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null
+ return orbitalPos.getDownlinkFreq(baseDownlink + offsetHz)
+ }
+
+ /** True if this transponder supports linear passband mapping. */
+ fun isLinearTransponder(transponder: SatRadio): Boolean {
+ val upLow = transponder.uplinkLow
+ val upHigh = transponder.uplinkHigh
+ val downLow = transponder.downlinkLow
+ val downHigh = transponder.downlinkHigh
+ return upLow != null && upHigh != null && downLow != null && downHigh != null
+ && upLow != upHigh && downLow != downHigh
+ }
+
+ /**
+ * True for the radio entry that should drive the standalone Calculator page.
+ *
+ * A frequency range alone is not enough: some non-user-facing or drifting data entries
+ * can also have low/high frequencies. The calculator is meant for named linear
+ * transponders, e.g. "Linear Transponder", "Linear Transp.", "SSB Transponder".
+ */
+ fun isNamedLinearTransponder(transponder: SatRadio): Boolean {
+ if (!isLinearTransponder(transponder)) return false
+
+ val info = transponder.info.lowercase(Locale.ENGLISH)
+ val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
+ .joinToString(separator = " ")
+ .lowercase(Locale.ENGLISH)
+ val hasLinearName = info.contains("linear")
+ val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
+ info.contains("xponder") || info.contains("xpdr")
+ val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) }
+
+ return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode)
+ }
+}
diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DopplerFrequencyCalculatorTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DopplerFrequencyCalculatorTest.kt
new file mode 100644
index 00000000..49e8ebb6
--- /dev/null
+++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DopplerFrequencyCalculatorTest.kt
@@ -0,0 +1,190 @@
+package com.rtbishop.look4sat.core.domain
+
+import com.rtbishop.look4sat.core.domain.model.SatRadio
+import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
+import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class DopplerFrequencyCalculatorTest {
+
+ private fun linearTransponder(
+ upLow: Long = 145_000_000L,
+ upHigh: Long = 145_500_000L,
+ downLow: Long = 435_000_000L,
+ downHigh: Long? = 435_500_000L,
+ inverted: Boolean = false,
+ info: String = "Linear Transponder",
+ downlinkMode: String? = "USB",
+ uplinkMode: String? = "LSB"
+ ) = SatRadio(
+ uuid = "linear", info = info, isAlive = true,
+ downlinkLow = downLow, downlinkHigh = downHigh,
+ downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
+ uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
+ )
+
+ private fun fmTransponder() = SatRadio(
+ uuid = "fm", info = "FM Repeater", isAlive = true,
+ downlinkLow = 435_600_000L, downlinkHigh = null,
+ downlinkMode = "FM", uplinkLow = 145_900_000L, uplinkHigh = null,
+ uplinkMode = "FM", isInverted = false, catnum = 99999
+ )
+
+ private fun pos(distanceRateKmS: Double = 0.0) = OrbitalPos().apply {
+ this.distanceRate = distanceRateKmS
+ }
+
+ @Test
+ fun isLinearTransponder_returnsTrueForLinear() {
+ assertTrue(DopplerFrequencyCalculator.isLinearTransponder(linearTransponder()))
+ }
+
+ @Test
+ fun isLinearTransponder_returnsFalseForFM() {
+ assertFalse(DopplerFrequencyCalculator.isLinearTransponder(fmTransponder()))
+ }
+
+ @Test
+ fun isLinearTransponder_returnsFalseForNullDownlinkHigh() {
+ val xpdr = linearTransponder(downHigh = null)
+ assertFalse(DopplerFrequencyCalculator.isLinearTransponder(xpdr))
+ }
+
+ @Test
+ fun isNamedLinearTransponder_returnsTrueForLinearTransponderName() {
+ assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(linearTransponder()))
+ }
+
+ @Test
+ fun isNamedLinearTransponder_returnsTrueForSsbTransponderName() {
+ val xpdr = linearTransponder(info = "Mode V/U SSB Transponder", downlinkMode = "USB", uplinkMode = "LSB")
+ assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(xpdr))
+ }
+
+ @Test
+ fun isNamedLinearTransponder_returnsFalseForRangeEntryWithoutTransponderName() {
+ val driftingRangeEntry = linearTransponder(info = "Upper side band (drifting)")
+ assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry))
+ }
+
+ @Test
+ fun isNamedLinearTransponder_returnsFalseForFmRepeater() {
+ assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder()))
+ }
+
+ @Test
+ fun computeUplinkFromDownlink_linear_noDoppler() {
+ val xpdr = linearTransponder()
+ val orbitalPos = pos(0.0)
+ val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
+ assertNotNull(uplink)
+ assertEquals(145_200_000L, uplink)
+ }
+
+ @Test
+ fun computeDownlinkFromUplink_linear_noDoppler() {
+ val xpdr = linearTransponder()
+ val orbitalPos = pos(0.0)
+ val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_200_000L, xpdr, orbitalPos)
+ assertNotNull(downlink)
+ assertEquals(435_200_000L, downlink)
+ }
+
+ @Test
+ fun computeUplinkFromDownlink_withDoppler_positiveRangeRate() {
+ // Satellite receding (positive range rate) → ground must transmit higher freq to compensate.
+ val xpdr = linearTransponder()
+ val orbitalPos = pos(7.0)
+ val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
+ assertNotNull(uplink)
+ assertTrue(uplink!! > 145_200_000L)
+ }
+
+ @Test
+ fun computeUplinkFromDownlink_fmTransponder_returnsNull() {
+ val orbitalPos = pos()
+ val result = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_600_000L, fmTransponder(), orbitalPos)
+ assertNull(result)
+ }
+
+ @Test
+ fun computeDownlinkFromUplink_fmTransponder_returnsNull() {
+ val orbitalPos = pos()
+ val result = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_900_000L, fmTransponder(), orbitalPos)
+ assertNull(result)
+ }
+
+ @Test
+ fun computeDownlinkFromUplink_withPositiveOffset_addsOffsetToDownlink() {
+ val xpdr = linearTransponder()
+ val orbitalPos = pos(0.0)
+ val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
+ uplinkHz = 145_200_000L,
+ transponder = xpdr,
+ orbitalPos = orbitalPos,
+ offsetHz = 2_500L
+ )
+ assertEquals(435_202_500L, downlink)
+ }
+
+ @Test
+ fun computeUplinkFromDownlink_withPositiveOffset_subtractsOffsetBeforeMapping() {
+ val xpdr = linearTransponder()
+ val orbitalPos = pos(0.0)
+ val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
+ downlinkHz = 435_202_500L,
+ transponder = xpdr,
+ orbitalPos = orbitalPos,
+ offsetHz = 2_500L
+ )
+ assertEquals(145_200_000L, uplink)
+ }
+
+ @Test
+ fun computeOffsetRoundTrip_handlesNegativeOffset() {
+ val xpdr = linearTransponder()
+ val orbitalPos = pos(0.0)
+ val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
+ uplinkHz = 145_200_000L,
+ transponder = xpdr,
+ orbitalPos = orbitalPos,
+ offsetHz = -2_500L
+ )
+ assertEquals(435_197_500L, downlink)
+
+ val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
+ downlinkHz = downlink!!,
+ transponder = xpdr,
+ orbitalPos = orbitalPos,
+ offsetHz = -2_500L
+ )
+ assertEquals(145_200_000L, uplink)
+ }
+
+ @Test
+ fun computeUplinkFromDownlink_invertedTransponder() {
+ val xpdr = linearTransponder(inverted = true, downHigh = 435_500_000L)
+ val orbitalPos = pos(0.0)
+ val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
+ assertNotNull(uplink)
+ assertEquals(145_300_000L, uplink)
+ }
+
+ @Test
+ fun computeUplinkFromDownlink_roundTrip() {
+ val xpdr = linearTransponder()
+ val orbitalPos = pos(3.5)
+ val originalDownlink = 435_250_000L
+ val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(originalDownlink, xpdr, orbitalPos)
+ assertNotNull(uplink)
+ val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos)
+ assertNotNull(roundTripDownlink)
+ val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink)
+ assertTrue("Round-trip error too large: $error", error < 10000)
+ }
+}
diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml
index d5169543..42507759 100644
--- a/core/presentation/src/main/res/values-zh/strings.xml
+++ b/core/presentation/src/main/res/values-zh/strings.xml
@@ -60,6 +60,9 @@
否
是
日照中
+ 输入上行频率 (MHz)
+ 输入下行频率 (MHz)
+ 偏移 (kHz)
上一个
diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml
index d571a14b..f88540cb 100644
--- a/core/presentation/src/main/res/values/strings.xml
+++ b/core/presentation/src/main/res/values/strings.xml
@@ -84,6 +84,9 @@
No
Yes
Visible
+ Enter TX freq (MHz)
+ Enter RX freq (MHz)
+ Offset (kHz)
Prev
diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt
index aa164b32..b94227ca 100644
--- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt
+++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt
@@ -45,6 +45,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -59,6 +60,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
+import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import com.rtbishop.look4sat.core.domain.utility.toDegrees
import com.rtbishop.look4sat.core.presentation.EmptyListCard
import com.rtbishop.look4sat.core.presentation.IconCard
@@ -74,6 +76,7 @@ import kotlinx.coroutines.launch
private enum class RadarPage(val title: String) {
Transceivers("Transceivers"),
+ Calculator("Calculator"),
Sstv("SSTV")
}
@@ -150,16 +153,31 @@ private fun PagerCard(
requestMicPermission: () -> Unit,
modifier: Modifier = Modifier
) {
- val pages = RadarPage.entries
+ val hasCalculatorPage = remember(uiState.transceivers.transmitters) {
+ uiState.transceivers.transmitters.any(DopplerFrequencyCalculator::isNamedLinearTransponder)
+ }
+ val pages = remember(hasCalculatorPage) {
+ buildList {
+ add(RadarPage.Transceivers)
+ if (hasCalculatorPage) add(RadarPage.Calculator)
+ add(RadarPage.Sstv)
+ }
+ }
val pagerState = rememberPagerState(pageCount = { pages.size })
val coroutineScope = rememberCoroutineScope()
+ LaunchedEffect(pages.size) {
+ val lastPage = pages.lastIndex
+ if (pagerState.currentPage > lastPage) pagerState.scrollToPage(lastPage)
+ }
+
ElevatedCard(modifier = modifier) {
Column(modifier = Modifier.fillMaxSize()) {
- PrimaryTabRow(selectedTabIndex = pagerState.currentPage) {
+ val selectedTabIndex = pagerState.currentPage.coerceIn(0, pages.lastIndex)
+ PrimaryTabRow(selectedTabIndex = selectedTabIndex) {
pages.forEachIndexed { index, page ->
Tab(
- selected = pagerState.currentPage == index,
+ selected = selectedTabIndex == index,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
)
@@ -176,6 +194,12 @@ private fun PagerCard(
radioControl = uiState.radioControl,
onAction = onAction
)
+ RadarPage.Calculator -> CalculatorPage(
+ transceivers = uiState.transceivers.transmitters,
+ selectedUuid = uiState.transceivers.selectedUuid,
+ orbitalPos = uiState.orbitalPos,
+ onAction = onAction
+ )
RadarPage.Sstv -> SstvPage(
sstv = uiState.sstv,
dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) },
diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt
index c47f20e1..1a4d2e10 100644
--- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt
+++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt
@@ -39,14 +39,19 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+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
@@ -55,11 +60,14 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.model.SatRadio
+import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
+import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.formatFrequency
@@ -104,6 +112,69 @@ fun TransceiversPage(
}
}
+@Composable
+fun CalculatorPage(
+ transceivers: List,
+ selectedUuid: String?,
+ orbitalPos: OrbitalPos?,
+ onAction: (RadarAction) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val calculatorTransceivers = remember(transceivers) {
+ transceivers.filter(DopplerFrequencyCalculator::isNamedLinearTransponder)
+ }
+ val selectedTransceiver = remember(calculatorTransceivers, selectedUuid) {
+ calculatorTransceivers.firstOrNull { it.uuid == selectedUuid }
+ ?: calculatorTransceivers.firstOrNull()
+ }
+
+ if (selectedTransceiver == null) {
+ EmptyTransceiversContent(modifier)
+ return
+ }
+
+ LazyColumn(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(8.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ if (calculatorTransceivers.size > 1) {
+ item {
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ calculatorTransceivers.forEach { radio ->
+ FilterChip(
+ selected = radio.uuid == selectedTransceiver.uuid,
+ onClick = {
+ if (radio.uuid != selectedUuid) onAction(RadarAction.SelectTransmitter(radio.uuid))
+ },
+ label = {
+ Text(
+ text = transceiverTitle(radio),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ )
+ }
+ }
+ }
+ }
+
+ item {
+ DopplerCalculatorPanel(
+ transponder = selectedTransceiver,
+ orbitalPos = orbitalPos,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ }
+}
+
@Composable
private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) {
@@ -172,12 +243,8 @@ private fun TransceiverItem(
.rotate(if (isExpanded) 270f else 90f)
)
}
- // Title with mode
- val title = if (radio.isInverted) "INV: ${radio.info}" else radio.info
- val mode = "${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"}"
- val fullTitle = "$title ($mode)"
Text(
- text = fullTitle,
+ text = transceiverTitle(radio),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
@@ -437,6 +504,108 @@ private fun ExpandedRadioControl(
}
}
+@Composable
+private fun DopplerCalculatorPanel(
+ transponder: SatRadio,
+ orbitalPos: OrbitalPos?,
+ modifier: Modifier = Modifier
+) {
+ if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return
+
+ var txInputMHz by remember(transponder.uuid) { mutableStateOf("") }
+ var rxInputMHz by remember(transponder.uuid) { mutableStateOf("") }
+ var offsetKHz by remember(transponder.uuid) { mutableStateOf("") }
+ var lastEditedBy by remember(transponder.uuid) { mutableStateOf(EditedField.TX) }
+
+ fun offsetHz(text: String): Long = text.toDoubleOrNull()?.let { (it * 1000).toLong() } ?: 0L
+ fun formatHz(hz: Long): String = String.format(Locale.ENGLISH, "%.6f", hz / 1_000_000.0)
+ fun mhzToHz(text: String): Long? = text.toDoubleOrNull()
+ ?.takeIf { it > 0.0 }
+ ?.let { (it * 1_000_000).toLong() }
+
+ fun calculateDownlink(txText: String, offsetText: String): String? {
+ val txHz = mhzToHz(txText) ?: return null
+ return DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
+ uplinkHz = txHz,
+ transponder = transponder,
+ orbitalPos = orbitalPos,
+ offsetHz = offsetHz(offsetText)
+ )?.let(::formatHz)
+ }
+
+ fun calculateUplink(rxText: String, offsetText: String): String? {
+ val rxHz = mhzToHz(rxText) ?: return null
+ return DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
+ downlinkHz = rxHz,
+ transponder = transponder,
+ orbitalPos = orbitalPos,
+ offsetHz = offsetHz(offsetText)
+ )?.let(::formatHz)
+ }
+
+ LaunchedEffect(orbitalPos, transponder.uuid) {
+ if (lastEditedBy == EditedField.TX) {
+ calculateDownlink(txInputMHz, offsetKHz)?.let { rxInputMHz = it }
+ } else {
+ calculateUplink(rxInputMHz, offsetKHz)?.let { txInputMHz = it }
+ }
+ }
+
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ OutlinedTextField(
+ value = offsetKHz,
+ onValueChange = { newValue ->
+ offsetKHz = newValue
+ if (lastEditedBy == EditedField.TX) {
+ calculateDownlink(txInputMHz, newValue)?.let { rxInputMHz = it }
+ } else {
+ calculateUplink(rxInputMHz, newValue)?.let { txInputMHz = it }
+ }
+ },
+ label = { Text(stringResource(R.string.radar_doppler_offset_hint)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ OutlinedTextField(
+ value = txInputMHz,
+ onValueChange = { newValue ->
+ txInputMHz = newValue
+ lastEditedBy = EditedField.TX
+ rxInputMHz = calculateDownlink(newValue, offsetKHz) ?: if (newValue.isEmpty()) "" else rxInputMHz
+ },
+ label = { Text(stringResource(R.string.radar_doppler_tx_hint)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
+ modifier = Modifier.weight(1f)
+ )
+
+ OutlinedTextField(
+ value = rxInputMHz,
+ onValueChange = { newValue ->
+ rxInputMHz = newValue
+ lastEditedBy = EditedField.RX
+ txInputMHz = calculateUplink(newValue, offsetKHz) ?: if (newValue.isEmpty()) "" else txInputMHz
+ },
+ label = { Text(stringResource(R.string.radar_doppler_rx_hint)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
+ modifier = Modifier.weight(1f)
+ )
+ }
+ }
+}
+
+private enum class EditedField { TX, RX }
+
@Composable
private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
val text = frequency?.let {
@@ -452,5 +621,11 @@ private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
)
}
+private fun transceiverTitle(radio: SatRadio): String {
+ val title = if (radio.isInverted) "INV: ${radio.info}" else radio.info
+ val mode = "${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"}"
+ return "$title ($mode)"
+}
+
private val FREQ_ADJUSTMENTS =
listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")