Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8ebfb1ef3 | ||
|
|
12eed1d9a9 | ||
|
|
b3859fbece | ||
|
|
f40a67e1b7 | ||
|
|
16dce5df2a | ||
|
|
80a58bed61 | ||
|
|
a8afc63bba | ||
|
|
dbf9a2a65f | ||
|
|
ddecaa6017 |
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -1,45 +1,26 @@
|
|||||||
# Look4Sat: Satellite tracker
|
# Look4Sat-BA7OPF
|
||||||
|
|
||||||
[](https://github.com/rt-bishop/Look4Sat/actions/workflows/release.yml)
|
[](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
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+54
-24
@@ -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"));
|
||||||
|
|||||||
+4
-2
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -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
|
||||||
|
|
||||||
|
|||||||
+370
-145
@@ -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()
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user