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 index f391907d..51c59129 100644 --- 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 @@ -1,4 +1,4 @@ -/* +/* * Look4Sat. Amateur radio satellite tracker and pass predictor. * Copyright (C) 2019-2026 Arty Bishop and contributors. * @@ -11,6 +11,7 @@ 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. @@ -44,6 +45,9 @@ object DopplerFrequencyCalculator { * 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, @@ -52,7 +56,7 @@ object DopplerFrequencyCalculator { offsetHz: Long ): Long? { if (!isLinearTransponder(transponder)) return null - val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz + offsetHz, transponder) ?: return null + val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz - offsetHz, transponder) ?: return null return orbitalPos.getUplinkFreq(baseUplink) } @@ -95,4 +99,26 @@ object DopplerFrequencyCalculator { 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 the named linear + * transponder entry, 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 index 9cbfdedd..709e54d1 100644 --- 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 @@ -13,12 +13,15 @@ class DopplerFrequencyCalculatorTest { upHigh: Long = 145_500_000L, downLow: Long = 435_000_000L, downHigh: Long? = 435_500_000L, - inverted: Boolean = false + inverted: Boolean = false, + info: String = "Linear Transponder", + downlinkMode: String? = "USB", + uplinkMode: String? = "LSB" ) = SatRadio( - uuid = "linear", info = "Linear Transponder", isAlive = true, + uuid = "linear", info = info, isAlive = true, downlinkLow = downLow, downlinkHigh = downHigh, - downlinkMode = "USB", uplinkLow = upLow, uplinkHigh = upHigh, - uplinkMode = "LSB", isInverted = inverted, catnum = 12345 + downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh, + uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345 ) private fun fmTransponder() = SatRadio( @@ -48,6 +51,28 @@ class DopplerFrequencyCalculatorTest { 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() @@ -93,6 +118,53 @@ class DopplerFrequencyCalculatorTest { 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) 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 45565113..e3febb88 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 @@ -63,6 +63,7 @@ import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.MutualPassData import com.rtbishop.look4sat.core.domain.wavelog.UploadOutcome import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue +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 @@ -81,6 +82,7 @@ import kotlin.math.PI private enum class RadarPage(val title: String) { Transceivers("Transceivers"), Log("Log"), + Calculator("Calculator"), Sstv("SSTV") } @@ -234,16 +236,32 @@ private fun PagerCard( showToast: (String) -> 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) + add(RadarPage.Log) + 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) } ) @@ -255,14 +273,19 @@ private fun PagerCard( ) { pageIndex -> when (pages[pageIndex]) { RadarPage.Transceivers -> TransceiversPage( - transceivers = uiState.transceivers.transmitters, - selectedUuid = uiState.transceivers.selectedUuid, - orbitalPos = uiState.orbitalPos, - cw = uiState.cw, - radioControl = uiState.radioControl, - onAction = onAction, - requestMicPermission = requestMicPermission - ) + transceivers = uiState.transceivers.transmitters, + selectedUuid = uiState.transceivers.selectedUuid, + radioControl = uiState.radioControl, + onAction = onAction + ) + RadarPage.Calculator -> CalculatorPage( + transceivers = uiState.transceivers.transmitters, + selectedUuid = uiState.transceivers.selectedUuid, + orbitalPos = uiState.orbitalPos, + cw = uiState.cw, + onAction = onAction, + requestMicPermission = requestMicPermission + ) 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 4dff5258..f4af915b 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 @@ -43,7 +43,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button -import androidx.compose.material3.ElevatedCard import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -90,11 +89,8 @@ import kotlin.time.Duration.Companion.milliseconds fun TransceiversPage( transceivers: List, selectedUuid: String?, - orbitalPos: OrbitalPos?, - cw: CwSubState, radioControl: RadioControlSubState, onAction: (RadarAction) -> Unit, - requestMicPermission: () -> Unit = {}, modifier: Modifier = Modifier ) { if (transceivers.isEmpty()) { @@ -117,11 +113,8 @@ fun TransceiversPage( TransceiverItem( radio = radio, isExpanded = isExpanded, - orbitalPos = orbitalPos, - cw = cw, radioControl = radioControl, onAction = onAction, - requestMicPermission = requestMicPermission, onToggle = { onAction(RadarAction.SelectTransmitter(radio.uuid)) } ) } @@ -129,6 +122,87 @@ fun TransceiversPage( } } +@Composable +fun CalculatorPage( + transceivers: List, + selectedUuid: String?, + orbitalPos: OrbitalPos?, + cw: CwSubState, + onAction: (RadarAction) -> Unit, + requestMicPermission: () -> 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 { + DopplerFrequencyCalculator( + transponder = selectedTransceiver, + orbitalPos = orbitalPos, + modifier = Modifier.fillMaxWidth() + ) + } + + item { + HorizontalDivider( + thickness = 1.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + } + + item { + CwDecoderPanel( + cw = cw, + onAction = onAction, + requestMicPermission = requestMicPermission, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + @Composable private fun EmptyTransceiversContent(modifier: Modifier = Modifier) { Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) { @@ -156,11 +230,8 @@ private fun EmptyTransceiversContent(modifier: Modifier = Modifier) { private fun TransceiverItem( radio: SatRadio, isExpanded: Boolean, - orbitalPos: OrbitalPos?, - cw: CwSubState, radioControl: RadioControlSubState, onAction: (RadarAction) -> Unit, - requestMicPermission: () -> Unit, onToggle: () -> Unit ) { val bgColor = if (isExpanded) MaterialTheme.colorScheme.surfaceContainerHighest @@ -201,11 +272,8 @@ private fun TransceiverItem( ) } // 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, @@ -253,11 +321,8 @@ private fun TransceiverItem( ) { ExpandedRadioControl( radio = radio, - orbitalPos = orbitalPos, - cw = cw, radioControl = radioControl, - onAction = onAction, - requestMicPermission = requestMicPermission + onAction = onAction ) } @@ -326,11 +391,8 @@ private fun UnifiedFrequencyRow( @Composable private fun ExpandedRadioControl( radio: SatRadio, - orbitalPos: OrbitalPos?, - cw: CwSubState, radioControl: RadioControlSubState, onAction: (RadarAction) -> Unit, - requestMicPermission: () -> Unit = {}, ) { Column( modifier = Modifier @@ -439,41 +501,24 @@ private fun ExpandedRadioControl( } } - // Doppler frequency calculator (linear transponders only) - DopplerFrequencyCalculator( - transponder = radio, - orbitalPos = orbitalPos, - modifier = Modifier.fillMaxWidth() - ) - - // CW decoder panel (linear transponders only) - if (DopplerFrequencyCalculator.isLinearTransponder(radio)) { - CwDecoderPanel( - cw = cw, - onAction = onAction, - requestMicPermission = requestMicPermission, - modifier = Modifier.fillMaxWidth() - ) - } - // Control buttons Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) { CardButton( onClick = { onAction(RadarAction.ConnectRadios) }, - text = stringResource(R.string.connect_radio), + text = "Connect", modifier = Modifier.weight(1f) ) } else { CardButton( onClick = { onAction(RadarAction.DisconnectRadios) }, - text = stringResource(R.string.disconnect_radio), + text = "Disconnect", modifier = Modifier.weight(1f) ) } CardButton( onClick = { onAction(RadarAction.ToggleTracking) }, - text = if (radioControl.isTracking) stringResource(R.string.stop_radio) else stringResource(R.string.track_radio), + text = if (radioControl.isTracking) "Stop" else "Track", modifier = Modifier.weight(1f) ) } @@ -573,7 +618,7 @@ private fun DopplerFrequencyCalculator( }, label = { Text(stringResource(R.string.radar_doppler_offset_hint)) }, singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), modifier = Modifier.fillMaxWidth() ) @@ -669,8 +714,8 @@ private fun CwDecoderPanel( } AnimatedVisibility(visible = cw.isExpanded) { - // Morse Expert 引擎(4.5.0 移植): 小布局 cw_panel_main(瀑布图 + 解码文本) - // 自带 Kotlin 贝叶斯引擎(CwDecoder)不再用于此面板(代码保留作回退) + // PR #1 Morse Expert engine: mini layout with waterfall + decoded text. + // Keep the old Kotlin decoder state/actions as fallback code, but this panel no longer feeds it. val context = LocalContext.current val activity = remember { context as? Activity } val controller = remember { MainActivity() } @@ -680,7 +725,6 @@ private fun CwDecoderPanel( var initialized by remember { mutableStateOf(false) } var listening by remember { mutableStateOf(false) } - // 生命周期: 展开即绑定视图(不改宿主窗口); 收起/离开自动释放 DisposableEffect(Unit) { if (activity != null) { controller.onCreate(activity, rootView, false) @@ -690,18 +734,17 @@ private fun CwDecoderPanel( controller.onDestroy() } } - // 权限已授予(首次进入/授权回调统一入口): 初始化引擎 + 启动录音 + LaunchedEffect(cw.hasPermission) { if (cw.hasPermission && !initialized) { - controller.onPermissionGranted() // = v(): 创建音频采集 + 解码核心 - controller.onResume() // 立即启动录音 + controller.onPermissionGranted() + controller.onResume() initialized = true listening = true } } Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - // Control buttons row(映射到控制器) Row( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth() @@ -744,7 +787,6 @@ private fun CwDecoderPanel( } } - // 瀑布图 + 解码文本(原版布局迷你版, 适配容器) AndroidView( factory = { rootView }, modifier = Modifier @@ -771,5 +813,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")