Compare commits

...
13 changed files with 571 additions and 251 deletions

No files matched your search

+1
View File
@@ -18,6 +18,7 @@ out/
# Gradle files # Gradle files
.gradle/ .gradle/
build/ build/
.cxx/
# Local configuration file (sdk path, etc) # Local configuration file (sdk path, etc)
local.properties local.properties
+17 -36
View File
@@ -1,45 +1,26 @@
# Look4Sat: Satellite tracker # Look4Sat-BA7OPF
[![Look4Sat CI](https://github.com/rt-bishop/Look4Sat/actions/workflows/release.yml/badge.svg)](https://github.com/rt-bishop/Look4Sat/actions/workflows/release.yml) [![Look4Sat CI](https://github.com/atsunatsu/Look4Sat/actions/workflows/release.yml/badge.svg)](https://github.com/atsunatsu/Look4Sat/actions/workflows/release.yml)
[<img src="https://play.google.com/intl/en_gb/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="80">](https://play.google.com/store/apps/details?id=com.rtbishop.look4sat) **BA7OPF 定制版** — 基于 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的业余无线电卫星追踪器,增加了线性卫星频率计算器等功能。
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80">](https://f-droid.org/packages/com.rtbishop.look4sat/)
### Radio satellite tracker and pass predictor for Android, inspired by Gpredict ## 本仓库特色功能
<p float="left"> - **线性卫星转发器频率计算器** — 在雷达页的 Calculator 标签页中,支持 TX/RX 双向多普勒频率计算,以及下行频率偏移(offset)输入,方便操作带偏移的线性卫星
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="192"/> - **CW 解码器** — 集成 Morse Expert 解码引擎,支持瀑布图、实时解码文本
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="192"/> - **Passband 模式** — 支持通过位置滑块(Passband)自动计算 TX/RX 频率,避免切换时跳变
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="192"/> - **中文界面优化** — 翻译修正、UI 布局调整
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" width="192">
</p>
### Track satellite passes with ease! ## 上游仓库
Thanks to [Celestrak](https://celestrak.com/) and [SatNOGS](https://satnogs.org/) you have access to over 9000 active satellites.\ 本仓库是 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的分支,上游仓库的原始功能包括:
You can search the entire database by NORAD Catalog Number or the satellite's name.
Orbital positions and passes are calculated relative to your location.\ - 基于 Celestrak / SatNOGS 数据的 9000+ 活跃卫星追踪
To get reliable data make sure to set the station position via the app Settings. - SGP4/SDP4 轨道预测,10 天过境预报
- 极坐标雷达图、地面轨迹图
- SSTV 图像解码
- 无广告、无跟踪、完全离线
The application is built using Kotlin, Coroutines, Jetpack Compose and Navigation.\ ## 许可证
It is now and always will be completely ad-free and open-source.
## Main features: GNU General Public License v3.0。详见 [LICENSE](LICENSE)。
* Predicting satellite positions and passes for up to 10 days
* Showing the list of currently active and upcoming satellite passes
* Showing the active pass progress, polar trajectory and transceivers info
* Showing the satellite positional data, footprint and ground track on the map
* Custom TLE satellite data import is available via Three Line Element .txt files
* Offline first: calculations are made offline. Weekly TLE data update is recommended.
## Star History
<a href="https://www.star-history.com/?repos=rt-bishop%2FLook4Sat&type=timeline&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
</picture>
</a>
+15 -3
View File
@@ -3,10 +3,22 @@ plugins {
} }
android { android {
// PR #1 CW decoder uses the Morse Expert native decoder, which ships only armeabi-v7a. namespace = libs.versions.packageName.get()
defaultConfig { defaultConfig {
ndk { applicationId = "cn.ba7opf.look4sat"
abiFilters += listOf("armeabi-v7a") ndk { abiFilters.add("armeabi-v7a") }
}
signingConfigs {
create("release") {
storeFile = file(System.getProperty("user.home") + "/my-release-key.jks")
storePassword = "look4sat123"
keyAlias = "look4sat"
keyPassword = "look4sat123"
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
} }
} }
} }
@@ -16,20 +16,28 @@ import java.util.Locale
/** /**
* Computes Doppler-corrected reciprocal frequencies for linear transponders. * Computes Doppler-corrected reciprocal frequencies for linear transponders.
* *
* For a linear (passband) transponder, uplink and downlink frequencies are * The full physical path:
* related by a fixed passband offset. When the satellite moves, both are
* Doppler-shifted. Given one, we compute the other:
* *
* downlink → uplink: mapDownlinkToUplink (passband) → getUplinkFreq (Doppler) * TX→RX (uplink → downlink):
* uplink → downlink: mapUplinkToDownlink (passband) → getDownlinkFreq (Doppler) * ① 地面发射 f_tx
* ② 卫星收到 f_tx × (c - v) / c (上行多普勒)
* ③ 卫星转发 = passband映射(②) (在卫星上做映射)
* ④ 地面听到 ③ × (c - v) / c (下行多普勒)
*
* RX→TX (downlink → uplink):
* ④ 地面听到 f_rx
* ③ 卫星转发 = f_rx × (c + v) / c (逆下行多普勒)
* ② 卫星收到 = 逆passband映射(③)
* ① 地面应发射 = ② × (c + v) / c (逆上行多普勒)
* *
* Addresses GitHub issue #91 (Custom frequency Doppler correction). * Addresses GitHub issue #91 (Custom frequency Doppler correction).
*/ */
object DopplerFrequencyCalculator { object DopplerFrequencyCalculator {
/** /**
* Given a downlink frequency, compute the Doppler-corrected uplink frequency. * Given a downlink frequency (what the user hears), compute the
* Returns null if the transponder is not a linear passband type. * uplink frequency the user should transmit.
* Full path: ④→③→②→①
*/ */
fun computeUplinkFromDownlink( fun computeUplinkFromDownlink(
downlinkHz: Long, downlinkHz: Long,
@@ -37,17 +45,22 @@ object DopplerFrequencyCalculator {
orbitalPos: OrbitalPos orbitalPos: OrbitalPos
): Long? { ): Long? {
if (!isLinearTransponder(transponder)) return null if (!isLinearTransponder(transponder)) return null
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz, transponder) ?: return null // ④→③ 逆下行多普勒:卫星转发的频率
return orbitalPos.getUplinkFreq(baseUplink) val satTx = orbitalPos.getUplinkFreq(downlinkHz)
// ③→② 逆 passband 映射
val satRx = TransponderMapper.mapDownlinkToUplink(satTx, transponder) ?: return null
// ②→① 逆上行多普勒:地面应发射的频率
return orbitalPos.getUplinkFreq(satRx)
} }
/** /**
* Given a downlink frequency, compute the Doppler-corrected uplink frequency * Given a downlink frequency (what the user hears), compute the
* with an offset applied to the downlink (in Hz). * uplink frequency the user should transmit, with an offset applied
* Returns null if the transponder is not a linear passband type. * to the downlink (in Hz).
* Full path: ④→③→②→①
* *
* The user-entered downlink frequency already includes the offset, so subtract * The user-entered downlink frequency already includes the offset, so subtract
* it before mapping the downlink passband position back to the uplink. * it before the inverse downlink Doppler.
*/ */
fun computeUplinkFromDownlinkWithOffset( fun computeUplinkFromDownlinkWithOffset(
downlinkHz: Long, downlinkHz: Long,
@@ -56,13 +69,20 @@ object DopplerFrequencyCalculator {
offsetHz: Long offsetHz: Long
): Long? { ): Long? {
if (!isLinearTransponder(transponder)) return null if (!isLinearTransponder(transponder)) return null
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz - offsetHz, transponder) ?: return null // ④→③ 逆下行多普勒:卫星转发的频率(含 offset)
return orbitalPos.getUplinkFreq(baseUplink) val satTxWithOffset = orbitalPos.getUplinkFreq(downlinkHz)
// ③ 去掉 offset(offset 在卫星本地频率域)
val satTx = satTxWithOffset - offsetHz
// ③→② 逆 passband 映射
val satRx = TransponderMapper.mapDownlinkToUplink(satTx, transponder) ?: return null
// ②→① 逆上行多普勒:地面应发射的频率
return orbitalPos.getUplinkFreq(satRx)
} }
/** /**
* Given an uplink frequency, compute the Doppler-corrected downlink frequency. * Given an uplink frequency (what the user transmits), compute the
* Returns null if the transponder is not a linear passband type. * downlink frequency the user will hear.
* Full path: ①→②→③→④
*/ */
fun computeDownlinkFromUplink( fun computeDownlinkFromUplink(
uplinkHz: Long, uplinkHz: Long,
@@ -70,14 +90,19 @@ object DopplerFrequencyCalculator {
orbitalPos: OrbitalPos orbitalPos: OrbitalPos
): Long? { ): Long? {
if (!isLinearTransponder(transponder)) return null if (!isLinearTransponder(transponder)) return null
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null // ①→② 上行多普勒:卫星收到的频率
return orbitalPos.getDownlinkFreq(baseDownlink) val satRx = orbitalPos.getDownlinkFreq(uplinkHz)
// ②→③ passband 映射
val satTx = TransponderMapper.mapUplinkToDownlink(satRx, transponder) ?: return null
// ③→④ 下行多普勒:地面听到的
return orbitalPos.getDownlinkFreq(satTx)
} }
/** /**
* Given an uplink frequency, compute the Doppler-corrected downlink frequency * Given an uplink frequency (what the user transmits), compute the
* with an offset applied to the downlink (in Hz). * downlink frequency the user will hear, with an offset applied
* Returns null if the transponder is not a linear passband type. * to the downlink (in Hz).
* Full path: ①→②→③→④
*/ */
fun computeDownlinkFromUplinkWithOffset( fun computeDownlinkFromUplinkWithOffset(
uplinkHz: Long, uplinkHz: Long,
@@ -86,8 +111,13 @@ object DopplerFrequencyCalculator {
offsetHz: Long offsetHz: Long
): Long? { ): Long? {
if (!isLinearTransponder(transponder)) return null if (!isLinearTransponder(transponder)) return null
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null // ①→② 上行多普勒:卫星收到的频率
return orbitalPos.getDownlinkFreq(baseDownlink + offsetHz) val satRx = orbitalPos.getDownlinkFreq(uplinkHz)
// ②→③ passband 映射
val satTx = TransponderMapper.mapUplinkToDownlink(satRx, transponder) ?: return null
// ③ 加上 offset(offset 在卫星本地频率域)
// ③→④ 下行多普勒:地面听到的
return orbitalPos.getDownlinkFreq(satTx + offsetHz)
} }
/** True if this transponder supports linear passband mapping. */ /** True if this transponder supports linear passband mapping. */
@@ -199,7 +199,8 @@
\n• Dave Moten (predict4java) \n• Dave Moten (predict4java)
\n• Alexandru Csete (Gpredict) \n• Alexandru Csete (Gpredict)
\n• Dr T.S. Kelso (Celestrak) \n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS)</string> \n• Libre Space Foundation (SatNOGS)
\n• BG7NTA (CW decoding feature)</string>
<string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string> <string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string>
</resources> </resources>
@@ -175,7 +175,40 @@
\n• Alexandru Csete (Gpredict) \n• Alexandru Csete (Gpredict)
\n• Dr T.S. Kelso (Celestrak) \n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS) \n• Libre Space Foundation (SatNOGS)
\n• xdsopl 和 Robot36 贡献者!</string> \n• xdsopl 和 Robot36 贡献者!
\n• BG7NTA(CW解码功能)</string>
<string name="prefs_outro_license">该应用程序不提供任何保修.</string> <string name="prefs_outro_license">该应用程序不提供任何保修.</string>
<!-- 匹配页 Mutual match page -->
<string name="mutual_title">过境匹配</string>
<string name="mutual_station_you">你的位置</string>
<string name="mutual_station_opposite">友台位置</string>
<string name="mutual_label_you">你</string>
<string name="mutual_label_opposite">友台</string>
<string name="mutual_status_calculating">正在计算双方共同可见窗口…</string>
<string name="mutual_status_error">请修正提示后重新查询</string>
<string name="mutual_status_matches">找到 %d 个匹配 · 点击卡片展开曲线</string>
<string name="mutual_status_none">未找到共同可见窗口,调整时间或仰角后重试</string>
<string name="mutual_status_idle">输入双方位置后点击查询</string>
<string name="mutual_chip_calculating">计算中</string>
<string name="mutual_chip_error">有错误</string>
<string name="mutual_chip_matches">%d 个匹配</string>
<string name="mutual_chip_none">无匹配</string>
<string name="mutual_chip_idle">待查询</string>
<string name="mutual_use_current">填入当前位置</string>
<string name="mutual_lat">纬度</string>
<string name="mutual_lon">经度</string>
<string name="mutual_grid">网格</string>
<string name="mutual_grid_hint">支持 4/6/8 位 Maidenhead 网格;也可直接输入经纬度</string>
<string name="mutual_min_elevation">最小仰角:%d°</string>
<string name="mutual_time_range">时间范围</string>
<string name="mutual_calculating">计算中…</string>
<string name="mutual_query">查询匹配</string>
<string name="mutual_elevation_you">你:%d°</string>
<string name="mutual_elevation_opposite">友台:%d°</string>
<string name="mutual_both_tracks">双方轨迹</string>
<string name="mutual_open_radar">查看雷达</string>
<string name="mutual_station_a">站点A</string>
<string name="mutual_station_b">站点B</string>
</resources> </resources>
@@ -211,7 +211,40 @@
\n* Dr T.S. Kelso (Celestrak) \n* Dr T.S. Kelso (Celestrak)
\n* Libre Space Foundation (SatNOGS) \n* Libre Space Foundation (SatNOGS)
\n* xdsopl and Robot36 contributors! \n* xdsopl and Robot36 contributors!
\n* BG7NTA (CW decoding feature)
</string> </string>
<string name="prefs_outro_license">The app comes with no warranty</string> <string name="prefs_outro_license">The app comes with no warranty</string>
<!-- Mutual match page -->
<string name="mutual_title">Pass Match</string>
<string name="mutual_station_you">Your Station</string>
<string name="mutual_station_opposite">Opposite Station</string>
<string name="mutual_label_you">You</string>
<string name="mutual_label_opposite">Opposite</string>
<string name="mutual_status_calculating">Calculating mutual visibility windows…</string>
<string name="mutual_status_error">Fix the reported issues and retry</string>
<string name="mutual_status_matches">%d matches found · tap a card to expand</string>
<string name="mutual_status_none">No mutual windows found. Adjust time or min elevation and retry</string>
<string name="mutual_status_idle">Enter both stations, then tap Query</string>
<string name="mutual_chip_calculating">Calculating</string>
<string name="mutual_chip_error">Error</string>
<string name="mutual_chip_matches">%d matches</string>
<string name="mutual_chip_none">No matches</string>
<string name="mutual_chip_idle">Idle</string>
<string name="mutual_use_current">Use current</string>
<string name="mutual_lat">Lat</string>
<string name="mutual_lon">Lon</string>
<string name="mutual_grid">Grid</string>
<string name="mutual_grid_hint">Supports 4/6/8-char Maidenhead grid; or enter lat/lon directly</string>
<string name="mutual_min_elevation">Min elevation: %d°</string>
<string name="mutual_time_range">Time range</string>
<string name="mutual_calculating">Calculating…</string>
<string name="mutual_query">Query Match</string>
<string name="mutual_elevation_you">You: %d°</string>
<string name="mutual_elevation_opposite">Opposite: %d°</string>
<string name="mutual_both_tracks">Both tracks</string>
<string name="mutual_open_radar">Open Radar</string>
<string name="mutual_station_a">Station A</string>
<string name="mutual_station_b">Station B</string>
</resources> </resources>
@@ -139,7 +139,7 @@ public class MainActivity {
public void onResume() { public void onResume() {
android.content.SharedPreferences prefs = mActivity.getSharedPreferences( android.content.SharedPreferences prefs = mActivity.getSharedPreferences(
mActivity.getPackageName() + "_preferences", Context.MODE_PRIVATE); mActivity.getPackageName() + "_preferences", Context.MODE_PRIVATE);
String messageType = prefs.getString("message_type", "general_text"); String messageType = prefs.getString("message_type", "ham_radio_qso");
b bVar = this.f11037F; b bVar = this.f11037F;
if (bVar != null) { if (bVar != null) {
bVar.f623s.set(messageType.equals("ham_radio_qso")); bVar.f623s.set(messageType.equals("ham_radio_qso"));
@@ -24,6 +24,7 @@ import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.ui.res.stringResource
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -40,6 +41,7 @@ import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.presentation.R
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@@ -226,8 +228,8 @@ fun ElevationCurveChart(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceEvenly horizontalArrangement = Arrangement.SpaceEvenly
) { ) {
Text("● 站点A", color = colorA, fontSize = 12.sp) Text(stringResource(R.string.mutual_station_a), color = colorA, fontSize = 12.sp)
Text("● 站点B", color = colorB, fontSize = 12.sp) Text(stringResource(R.string.mutual_station_b), color = colorB, fontSize = 12.sp)
} }
} }
} }
@@ -57,8 +57,8 @@ private const val STROKE_WIDTH = 2.5f
fun MutualRadarView( fun MutualRadarView(
trackSamples: List<TrackSample>, trackSamples: List<TrackSample>,
progress: Float = 0.5f, progress: Float = 0.5f,
labelA: String = "站点A", labelA: String = "Station A",
labelB: String = "站点B", labelB: String = "Station B",
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (trackSamples.isEmpty()) return if (trackSamples.isEmpty()) return
@@ -90,7 +90,7 @@ fun MutualScreen(
}, },
topInfo = { topInfo = {
Text( Text(
text = "过境匹配", text = stringResource(R.string.mutual_title),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
@@ -198,7 +198,7 @@ private fun MutualContent(
) { ) {
if (isVertical) { if (isVertical) {
StationInputCard( StationInputCard(
title = "你的位置", title = stringResource(R.string.mutual_station_you),
titleColor = MaterialTheme.colorScheme.primary, titleColor = MaterialTheme.colorScheme.primary,
lat = state.stationALat, lat = state.stationALat,
lon = state.stationALon, lon = state.stationALon,
@@ -214,7 +214,7 @@ private fun MutualContent(
currentPositionAction = onUseCurrentPosition currentPositionAction = onUseCurrentPosition
) )
StationInputCard( StationInputCard(
title = "友台位置", title = stringResource(R.string.mutual_station_opposite),
titleColor = MaterialTheme.colorScheme.tertiary, titleColor = MaterialTheme.colorScheme.tertiary,
lat = state.stationBLat, lat = state.stationBLat,
lon = state.stationBLon, lon = state.stationBLon,
@@ -234,7 +234,7 @@ private fun MutualContent(
horizontalArrangement = Arrangement.spacedBy(6.dp) horizontalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
StationInputCard( StationInputCard(
title = "你的位置", title = stringResource(R.string.mutual_station_you),
titleColor = MaterialTheme.colorScheme.primary, titleColor = MaterialTheme.colorScheme.primary,
lat = state.stationALat, lat = state.stationALat,
lon = state.stationALon, lon = state.stationALon,
@@ -251,7 +251,7 @@ private fun MutualContent(
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
StationInputCard( StationInputCard(
title = "友台位置", title = stringResource(R.string.mutual_station_opposite),
titleColor = MaterialTheme.colorScheme.tertiary, titleColor = MaterialTheme.colorScheme.tertiary,
lat = state.stationBLat, lat = state.stationBLat,
lon = state.stationBLon, lon = state.stationBLon,
@@ -294,8 +294,8 @@ private fun MutualContent(
startTime = pass.startTime, startTime = pass.startTime,
endTime = pass.endTime, endTime = pass.endTime,
maxElev = maxOf(pass.maxElevationA, pass.maxElevationB, 10.0), maxElev = maxOf(pass.maxElevationA, pass.maxElevationB, 10.0),
labelA = "你", labelA = stringResource(R.string.mutual_label_you),
labelB = "友台" labelB = stringResource(R.string.mutual_label_opposite)
) )
MutualPassCard( MutualPassCard(
pass = pass, pass = pass,
@@ -341,20 +341,22 @@ private fun MutualStatusChip(state: MutualUiState) {
} }
} }
@Composable
private fun mutualStatusText(state: MutualUiState): String = when { private fun mutualStatusText(state: MutualUiState): String = when {
state.isCalculating -> "正在计算双方共同可见窗口…" state.isCalculating -> stringResource(R.string.mutual_status_calculating)
state.errorMessage != null -> "请修正提示后重新查询" state.errorMessage != null -> stringResource(R.string.mutual_status_error)
state.mutualPasses.isNotEmpty() -> "找到 ${state.mutualPasses.size} 个匹配 · 点击卡片展开曲线" state.mutualPasses.isNotEmpty() -> stringResource(R.string.mutual_status_matches, state.mutualPasses.size)
state.hasSearched -> "未找到共同可见窗口,调整时间或仰角后重试" state.hasSearched -> stringResource(R.string.mutual_status_none)
else -> "输入双方位置后点击查询" else -> stringResource(R.string.mutual_status_idle)
} }
@Composable
private fun mutualStatusChipText(state: MutualUiState): String = when { private fun mutualStatusChipText(state: MutualUiState): String = when {
state.isCalculating -> "计算中" state.isCalculating -> stringResource(R.string.mutual_chip_calculating)
state.errorMessage != null -> "有错误" state.errorMessage != null -> stringResource(R.string.mutual_chip_error)
state.mutualPasses.isNotEmpty() -> "${state.mutualPasses.size}个匹配" state.mutualPasses.isNotEmpty() -> stringResource(R.string.mutual_chip_matches, state.mutualPasses.size)
state.hasSearched -> "无匹配" state.hasSearched -> stringResource(R.string.mutual_chip_none)
else -> "待查询" else -> stringResource(R.string.mutual_chip_idle)
} }
@Composable @Composable
@@ -393,7 +395,7 @@ private fun StationInputCard(
) )
if (currentPositionAction != null) { if (currentPositionAction != null) {
OutlinedButton(onClick = currentPositionAction) { OutlinedButton(onClick = currentPositionAction) {
Text("填入当前位置", style = MaterialTheme.typography.bodySmall) Text(stringResource(R.string.mutual_use_current), style = MaterialTheme.typography.bodySmall)
} }
} }
} }
@@ -405,7 +407,7 @@ private fun StationInputCard(
OutlinedTextField( OutlinedTextField(
value = lat, value = lat,
onValueChange = onLatChange, onValueChange = onLatChange,
label = { Text("纬度") }, label = { Text(stringResource(R.string.mutual_lat)) },
placeholder = { Text(latPlaceholder) }, placeholder = { Text(latPlaceholder) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true, singleLine = true,
@@ -414,7 +416,7 @@ private fun StationInputCard(
OutlinedTextField( OutlinedTextField(
value = lon, value = lon,
onValueChange = onLonChange, onValueChange = onLonChange,
label = { Text("经度") }, label = { Text(stringResource(R.string.mutual_lon)) },
placeholder = { Text(lonPlaceholder) }, placeholder = { Text(lonPlaceholder) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true, singleLine = true,
@@ -425,15 +427,15 @@ private fun StationInputCard(
OutlinedTextField( OutlinedTextField(
value = grid, value = grid,
onValueChange = onGridChange, onValueChange = onGridChange,
label = { Text("网格") }, label = { Text(stringResource(R.string.mutual_grid)) },
placeholder = { Text(gridPlaceholder) }, placeholder = { Text(gridPlaceholder) },
supportingText = { Text("支持 4/6/8 位 Maidenhead 网格;也可直接输入经纬度") }, supportingText = { Text(stringResource(R.string.mutual_grid_hint)) },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Text( Text(
text = "最小仰角:${minElev.toInt()}°", text = stringResource(R.string.mutual_min_elevation, minElev.toInt()),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -461,7 +463,7 @@ private fun MatchSearchCard(
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
Text( Text(
text = "时间范围", text = stringResource(R.string.mutual_time_range),
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
) )
@@ -492,7 +494,7 @@ private fun MatchSearchCard(
) )
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
} }
Text(if (isCalculating) "计算中..." else "查询匹配") Text(if (isCalculating) stringResource(R.string.mutual_calculating) else stringResource(R.string.mutual_query))
} }
} }
} }
@@ -558,12 +560,12 @@ private fun MutualPassCard(
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Text( Text(
text = "你: ${pass.maxElevationA}°", text = stringResource(R.string.mutual_elevation_you, pass.maxElevationA.toInt()),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
Text( Text(
text = "友台: ${pass.maxElevationB}°", text = stringResource(R.string.mutual_elevation_opposite, pass.maxElevationB.toInt()),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary color = MaterialTheme.colorScheme.tertiary
) )
@@ -579,7 +581,7 @@ private fun MutualPassCard(
// Dual-station radar track (polar plot) // Dual-station radar track (polar plot)
if (visibleTracks.isNotEmpty()) { if (visibleTracks.isNotEmpty()) {
Text( Text(
text = "双方轨迹", text = stringResource(R.string.mutual_both_tracks),
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
@@ -587,8 +589,8 @@ private fun MutualPassCard(
MutualRadarView( MutualRadarView(
trackSamples = visibleTracks, trackSamples = visibleTracks,
progress = dragProgress, progress = dragProgress,
labelA = "你", labelA = stringResource(R.string.mutual_label_you),
labelB = "友台" labelB = stringResource(R.string.mutual_label_opposite)
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
} }
@@ -613,7 +615,7 @@ private fun MutualPassCard(
modifier = Modifier.height(16.dp).width(16.dp) modifier = Modifier.height(16.dp).width(16.dp)
) )
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text("查看雷达") Text(stringResource(R.string.mutual_open_radar))
} }
} }
} }
@@ -158,13 +158,13 @@ class MutualViewModel(
var posB = resolvePosition(state.stationBLat, state.stationBLon, state.stationBGrid) var posB = resolvePosition(state.stationBLat, state.stationBLon, state.stationBGrid)
if (posA == null || posB == null) { if (posA == null || posB == null) {
_uiState.update { it.copy(errorMessage = "请输入有效的位置坐标或网格(4/6/8位)") } _uiState.update { it.copy(errorMessage = "Please enter valid coordinates or a Maidenhead grid (4/6/8 chars)") }
return return
} }
val satellites = satelliteRepo.satellites.value val satellites = satelliteRepo.satellites.value
if (satellites.isEmpty()) { if (satellites.isEmpty()) {
_uiState.update { it.copy(errorMessage = "没有卫星数据,请先在卫星列表中选择卫星") } _uiState.update { it.copy(errorMessage = "No satellite data. Select satellites from the list first.") }
return return
} }
@@ -193,9 +193,9 @@ class MutualViewModel(
val passCount = satelliteRepo.passes.value.size val passCount = satelliteRepo.passes.value.size
val satCount = satellites.size val satCount = satellites.size
when { when {
passCount == 0 -> "过境列表为空,请先在卫星列表中选择卫星" passCount == 0 -> "Pass list is empty. Select satellites from the list first."
satCount == 0 -> "没有已选中的卫星" satCount == 0 -> "No satellites selected."
else -> "未找到共同过境(${passCount}个过境,${satCount}颗卫星)" else -> "No mutual passes found (${passCount} passes, ${satCount} satellites)."
} }
} else null } else null
@@ -23,14 +23,18 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -41,6 +45,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
@@ -49,22 +54,30 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.CardDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -76,6 +89,7 @@ import androidx.constraintlayout.widget.ConstraintLayout
import com.rtbishop.look4sat.core.domain.model.SatRadio import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
import com.rtbishop.look4sat.core.presentation.CardButton import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.formatFrequency import com.rtbishop.look4sat.core.presentation.formatFrequency
@@ -146,9 +160,7 @@ fun CalculatorPage(
} }
LazyColumn( LazyColumn(
modifier = modifier modifier = modifier.fillMaxSize(),
.fillMaxSize()
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
if (calculatorTransceivers.size > 1) { if (calculatorTransceivers.size > 1) {
@@ -532,6 +544,8 @@ private fun ExpandedRadioControl(
} }
} }
private enum class EditedField { TX, PASSBAND, RX }
@Composable @Composable
private fun DopplerFrequencyCalculator( private fun DopplerFrequencyCalculator(
transponder: SatRadio, transponder: SatRadio,
@@ -540,144 +554,351 @@ private fun DopplerFrequencyCalculator(
) { ) {
if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return
var txInputMHz by remember { mutableStateOf("") } var lastEditedField by remember { mutableStateOf(EditedField.TX) }
var rxInputMHz by remember { mutableStateOf("") } var txFrequencyHz by remember { mutableStateOf(0L) }
var rxFrequencyHz by remember { mutableStateOf(0L) }
var passbandPosition by remember { mutableStateOf(0.5f) }
var offsetKHz by remember { mutableStateOf("") } var offsetKHz by remember { mutableStateOf("") }
var lastEditedBy by remember { mutableStateOf(EditedField.TX) } var stepSizeKHz by remember { mutableIntStateOf(1) }
val offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L val offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L
// Real-time refresh: when orbitalPos changes (1Hz), recompute the opposite field val txLow = transponder.uplinkLow ?: return
val txHigh = transponder.uplinkHigh ?: return
val rxLow = transponder.downlinkLow ?: return
val rxHigh = transponder.downlinkHigh ?: return
val txRange = txHigh - txLow
val rxRange = rxHigh - rxLow
if (txRange <= 0L || rxRange <= 0L) return
// 原始转发器值(逆转多普勒),用于 passband 映射计算
val rawTxLow = orbitalPos.getDownlinkFreq(txLow)
val rawTxHigh = orbitalPos.getDownlinkFreq(txHigh)
val rawRxLow = orbitalPos.getUplinkFreq(rxLow)
val rawRxHigh = orbitalPos.getUplinkFreq(rxHigh)
val rawTxRange = rawTxHigh - rawTxLow
val rawRxRange = rawRxHigh - rawRxLow
val rawTransponder = transponder.copy(
uplinkLow = rawTxLow, uplinkHigh = rawTxHigh,
downlinkLow = rawRxLow, downlinkHigh = rawRxHigh
)
// 初始化
LaunchedEffect(Unit) {
if (txFrequencyHz == 0L) {
txFrequencyHz = txLow + txRange / 2
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: (rxLow + rxRange / 2)
}
}
// 实时重算:卫星移动时,锚点侧不变,重算另一侧
LaunchedEffect(orbitalPos) { LaunchedEffect(orbitalPos) {
if (lastEditedBy == EditedField.TX) { if (lastEditedField == EditedField.TX) {
val txMHz = txInputMHz.toDoubleOrNull() // TX 是锚点,不变;重算 RX
if (txMHz != null && txMHz > 0) { rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
val txHz = (txMHz * 1_000_000).toLong() txFrequencyHz, rawTransponder, orbitalPos, offsetHz
val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( ) ?: rxFrequencyHz
txHz, transponder, orbitalPos, offsetHz } else if (lastEditedField == EditedField.RX) {
) // RX 是锚点,不变;重算 TX
if (rxHz != null) { txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxInputMHz = String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0) rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
} ) ?: txFrequencyHz
} else if (lastEditedField == EditedField.PASSBAND) {
// 相对位置不变(passbandPosition),TX 用地面频率,RX 经 Transceiver 完整路径计算
txFrequencyHz = txLow + (passbandPosition * txRange).toLong()
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
}
val txSliderValue = if (lastEditedField == EditedField.PASSBAND) {
passbandPosition
} else {
((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
}
val rxSliderValue = if (lastEditedField == EditedField.PASSBAND) {
((rxFrequencyHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
} else {
((rxFrequencyHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
}
fun updateTx(newTxHz: Long) {
when (lastEditedField) {
EditedField.PASSBAND -> {
// Passband 模式:滑块位置 → passbandPosition → 地面频率,RX 经 Transceiver 计算
val pos = ((newTxHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
passbandPosition = pos
txFrequencyHz = txLow + (pos * txRange).toLong()
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
} }
} else { else -> {
val rxMHz = rxInputMHz.toDoubleOrNull() txFrequencyHz = newTxHz.coerceIn(txLow, txHigh)
if (rxMHz != null && rxMHz > 0) { rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
val rxHz = (rxMHz * 1_000_000).toLong() txFrequencyHz, rawTransponder, orbitalPos, offsetHz
val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset( ) ?: rxFrequencyHz
rxHz, transponder, orbitalPos, offsetHz }
) }
if (txHz != null) { }
txInputMHz = String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0)
} fun updateRx(newRxHz: Long) {
when (lastEditedField) {
EditedField.PASSBAND -> {
// Passband 模式:从 RX 反推 TX 位置,TX 用地面频率
val pos = ((newRxHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
val txFromRx = TransponderMapper.mapDownlinkToUplink(newRxHz, rawTransponder)
passbandPosition = if (txFromRx != null) {
((txFromRx - rawTxLow).toFloat() / rawTxRange).coerceIn(0f, 1f)
} else pos
txFrequencyHz = txLow + (passbandPosition * txRange).toLong()
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
else -> {
rxFrequencyHz = newRxHz.coerceIn(rxLow, rxHigh)
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
} }
} }
} }
Column( Column(
modifier = modifier, modifier = modifier.fillMaxWidth().padding(top = 16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Text( Row(
text = stringResource(R.string.radar_doppler_calc), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
fontSize = 14.sp, verticalAlignment = Alignment.CenterVertically,
fontWeight = FontWeight.Medium, horizontalArrangement = Arrangement.spacedBy(6.dp)
color = MaterialTheme.colorScheme.primary ) {
) // 左半边: TX + RX 按钮
Text( Row(
text = stringResource(R.string.radar_doppler_info), modifier = Modifier.weight(1f),
fontSize = 12.sp, horizontalArrangement = Arrangement.spacedBy(4.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant verticalAlignment = Alignment.CenterVertically
) ) {
FilterChip(
// Offset input selected = lastEditedField == EditedField.TX,
OutlinedTextField( onClick = {
value = offsetKHz, when (lastEditedField) {
onValueChange = { newVal -> EditedField.TX -> {
offsetKHz = newVal passbandPosition = ((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
// Trigger recompute based on last edited field lastEditedField = EditedField.PASSBAND
if (lastEditedBy == EditedField.TX) { rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
val txMHz = txInputMHz.toDoubleOrNull() txFrequencyHz, rawTransponder, orbitalPos, offsetHz
if (txMHz != null && txMHz > 0) { ) ?: rxFrequencyHz
val txHz = (txMHz * 1_000_000).toLong() }
val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( EditedField.PASSBAND -> { lastEditedField = EditedField.TX }
txHz, transponder, orbitalPos, offsetHz else -> { lastEditedField = EditedField.TX }
) }
rxInputMHz = if (rxHz != null) String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0) else "" },
} label = {
} else { Text("TX", fontSize = 13.sp, textAlign = TextAlign.Center,
val rxMHz = rxInputMHz.toDoubleOrNull() modifier = Modifier.fillMaxWidth())
if (rxMHz != null && rxMHz > 0) { },
val rxHz = (rxMHz * 1_000_000).toLong() modifier = Modifier.weight(1f).fillMaxHeight()
val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset( )
rxHz, transponder, orbitalPos, offsetHz FilterChip(
) selected = lastEditedField == EditedField.RX,
txInputMHz = if (txHz != null) String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0) else "" onClick = {
} when (lastEditedField) {
EditedField.RX -> {
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
passbandPosition = ((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
lastEditedField = EditedField.PASSBAND
}
EditedField.PASSBAND -> {
lastEditedField = EditedField.RX
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
}
else -> { lastEditedField = EditedField.RX }
}
},
label = {
Text("RX", fontSize = 13.sp, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth())
},
modifier = Modifier.weight(1f).fillMaxHeight()
)
} }
}, // 右半边: Offset 输入框(带细线边框)
label = { Text(stringResource(R.string.radar_doppler_offset_hint)) }, Box(
singleLine = true, modifier = Modifier
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), .weight(1f)
modifier = Modifier.fillMaxWidth() .height(48.dp)
) .border(1.dp, MaterialTheme.colorScheme.outline, MaterialTheme.shapes.small),
contentAlignment = Alignment.Center
) {
BasicTextField(
value = offsetKHz,
onValueChange = { offsetKHz = it },
singleLine = true,
textStyle = TextStyle(
fontSize = 16.sp,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
),
decorationBox = { innerTextField ->
if (offsetKHz.isEmpty()) {
Text(
text = "Offset (kHz)",
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
innerTextField()
}
)
}
}
// TX and RX inputs on the same row // TX 行
Row( Column(modifier = Modifier.fillMaxWidth()) {
horizontalArrangement = Arrangement.spacedBy(8.dp), Row(verticalAlignment = Alignment.CenterVertically) {
modifier = Modifier.fillMaxWidth() OutlinedButton(
) { onClick = { updateTx(txFrequencyHz - stepSizeKHz * 1000L) },
// TX input → compute RX modifier = Modifier.width(32.dp).height(28.dp),
OutlinedTextField( contentPadding = PaddingValues(0.dp),
value = txInputMHz, shape = MaterialTheme.shapes.extraSmall
onValueChange = { newVal -> ) { Text("−", fontSize = 14.sp) }
txInputMHz = newVal
lastEditedBy = EditedField.TX Slider(
val mhz = newVal.toDoubleOrNull() value = txSliderValue,
if (mhz != null && mhz > 0) { onValueChange = { norm ->
val txHz = (mhz * 1_000_000).toLong() val rawFreq = txLow + (txRange * norm).toLong()
val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( val snapped = ((rawFreq + 500) / 1000L) * 1000L
txHz, transponder, orbitalPos, offsetHz updateTx(snapped.coerceIn(txLow, txHigh))
},
valueRange = 0f..1f,
modifier = Modifier.weight(1f).height(20.dp).padding(horizontal = 4.dp)
)
OutlinedButton(
onClick = { updateTx(txFrequencyHz + stepSizeKHz * 1000L) },
modifier = Modifier.width(32.dp).height(28.dp),
contentPadding = PaddingValues(0.dp),
shape = MaterialTheme.shapes.extraSmall
) { Text("+", fontSize = 14.sp) }
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = String.format(Locale.ENGLISH, "%.4f", txLow / 1_000_000.0),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = String.format(Locale.ENGLISH, "%.6f", txFrequencyHz / 1_000_000.0),
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = " MHz",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
) )
rxInputMHz = if (rxHz != null) String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0) else ""
} else if (newVal.isEmpty()) {
rxInputMHz = ""
} }
}, Text(
label = { Text(stringResource(R.string.radar_doppler_tx_hint)) }, text = String.format(Locale.ENGLISH, "%.4f", txHigh / 1_000_000.0),
singleLine = true, fontSize = 12.sp,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
textAlign = TextAlign.End
)
}
}
HorizontalDivider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
) )
// RX input → compute TX // RX 行
OutlinedTextField( Column(modifier = Modifier.fillMaxWidth()) {
value = rxInputMHz, Row(verticalAlignment = Alignment.CenterVertically) {
onValueChange = { newVal -> OutlinedButton(
rxInputMHz = newVal onClick = { updateRx(rxFrequencyHz - stepSizeKHz * 1000L) },
lastEditedBy = EditedField.RX modifier = Modifier.width(32.dp).height(28.dp),
val mhz = newVal.toDoubleOrNull() contentPadding = PaddingValues(0.dp),
if (mhz != null && mhz > 0) { shape = MaterialTheme.shapes.extraSmall
val rxHz = (mhz * 1_000_000).toLong() ) { Text("−", fontSize = 14.sp) }
val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxHz, transponder, orbitalPos, offsetHz Slider(
value = rxSliderValue,
onValueChange = { norm ->
val rawFreq = rxLow + (rxRange * norm).toLong()
val snapped = ((rawFreq + 500) / 1000L) * 1000L
updateRx(snapped.coerceIn(rxLow, rxHigh))
},
valueRange = 0f..1f,
modifier = Modifier.weight(1f).height(20.dp).padding(horizontal = 4.dp)
)
OutlinedButton(
onClick = { updateRx(rxFrequencyHz + stepSizeKHz * 1000L) },
modifier = Modifier.width(32.dp).height(28.dp),
contentPadding = PaddingValues(0.dp),
shape = MaterialTheme.shapes.extraSmall
) { Text("+", fontSize = 14.sp) }
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = String.format(Locale.ENGLISH, "%.4f", rxLow / 1_000_000.0),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = String.format(Locale.ENGLISH, "%.6f", rxFrequencyHz / 1_000_000.0),
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = " MHz",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
) )
txInputMHz = if (txHz != null) String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0) else ""
} else if (newVal.isEmpty()) {
txInputMHz = ""
} }
}, Text(
label = { Text(stringResource(R.string.radar_doppler_rx_hint)) }, text = String.format(Locale.ENGLISH, "%.4f", rxHigh / 1_000_000.0),
singleLine = true, fontSize = 12.sp,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
) textAlign = TextAlign.End
)
}
}
} }
}
} }
private enum class EditedField { TX, RX }
@Composable @Composable
private fun CwDecoderPanel( private fun CwDecoderPanel(
cw: CwSubState, cw: CwSubState,
@@ -717,30 +938,36 @@ private fun CwDecoderPanel(
// PR #1 Morse Expert engine: mini layout with waterfall + decoded text. // 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. // Keep the old Kotlin decoder state/actions as fallback code, but this panel no longer feeds it.
val context = LocalContext.current val context = LocalContext.current
val activity = remember { context as? Activity } val activity = remember(context) {
context as? Activity ?: error("CwDecoderPanel must be hosted in an Activity")
}
val controller = remember { MainActivity() } val controller = remember { MainActivity() }
val rootView = remember { val rootView = remember(context) {
LayoutInflater.from(context).inflate(CwR.layout.cw_panel_main, null) as ConstraintLayout LayoutInflater.from(context).inflate(CwR.layout.activity_main, null) as ConstraintLayout
} }
var initialized by remember { mutableStateOf(false) } var initialized by remember { mutableStateOf(false) }
var listening by remember { mutableStateOf(false) } var listening by remember { mutableStateOf(false) }
DisposableEffect(Unit) { // 重要: 不在展开时初始化控制器(避免"一展开就崩溃")。
if (activity != null) { // controller.onCreate/onResume/onPermissionGranted 全部推迟到用户点 Start 才执行。
controller.onCreate(activity, rootView, false) fun startDecoding() {
} if (!initialized) {
onDispose { controller.onCreate(activity, rootView)
controller.onPause()
controller.onDestroy()
}
}
LaunchedEffect(cw.hasPermission) {
if (cw.hasPermission && !initialized) {
controller.onPermissionGranted() controller.onPermissionGranted()
controller.onResume() controller.onResume()
initialized = true initialized = true
listening = true } else {
controller.onResume()
}
listening = true
}
DisposableEffect(Unit) {
onDispose {
if (initialized) {
controller.onPause()
controller.onDestroy()
}
} }
} }
@@ -754,14 +981,8 @@ private fun CwDecoderPanel(
onClick = { onClick = {
if (!cw.hasPermission) { if (!cw.hasPermission) {
requestMicPermission() requestMicPermission()
} else if (!initialized) {
controller.onPermissionGranted()
controller.onResume()
initialized = true
listening = true
} else { } else {
controller.onResume() startDecoding()
listening = true
} }
}, },
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
@@ -780,19 +1001,23 @@ private fun CwDecoderPanel(
} }
} }
OutlinedButton( OutlinedButton(
onClick = { controller.clearDecoded() }, onClick = { if (initialized) controller.clearDecoded() },
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { ) {
Text(stringResource(R.string.radar_cw_reset)) Text(stringResource(R.string.radar_cw_reset))
} }
} }
AndroidView( Box(
factory = { rootView },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(150.dp) .height(150.dp)
) ) {
AndroidView(
factory = { rootView },
modifier = Modifier.fillMaxSize()
)
}
} }
} }
} }