Compare commits

..
Author SHA1 Message Date
mckero e8ebfb1ef3 feat(credits): add BG7NTA to the thanks list
Add BG7NTA (CW decoding feature) to the in-app credits/thanks section
across all three locale files (en, zh, tr).
2026-08-08 03:52:24 +00:00
atsunatsu 12eed1d9a9 i18n: use string resources for mutual match page (zh/en) 2026-08-07 21:27:01 +08:00
atsunatsu b3859fbece i18n: translate '友台' to 'opposite' for clarity 2026-08-07 20:06:43 +08:00
atsunatsu f40a67e1b7 revert: remove fldigi CW decoder migration, keep Morse Expert 2026-08-07 20:01:53 +08:00
atsunatsu 16dce5df2a chore: remove .cxx build artifacts from git tracking 2026-08-07 19:59:03 +08:00
atsunatsu 80a58bed61 i18n: localize mutual match page to English 2026-08-07 19:53:36 +08:00
atsunatsu a8afc63bba fix: default CW decoder to ham radio QSO mode 2026-08-07 18:43:01 +08:00
atsunatsu dbf9a2a65f fix: native decoder crash on 64-bit devices; restore calculator layout 2026-08-07 11:25:42 +08:00
atsunatsu ddecaa6017 docs: update README for BA7OPF fork features 2026-08-06 13:19:32 +08:00
atsunatsu 418a05e343 fix(i18n): update Chinese station position wording 2026-08-04 21:03:35 +08:00
atsunatsu c80e351212 fix: correct linear calculator offset mapping 2026-08-04 18:41:53 +08:00
atsunatsu a333192df5 feat: add CW decoder and linear calculator page 2026-08-04 17:49:28 +08:00
atsunatsu a7b41c95c7 fix: improve match header and daily sun times
Match page UI:
- Always show a meaningful status line in the top bar instead of leaving the second row blank on first entry.
- Add a compact status chip for waiting, calculating, result, no-match, and error states.
- Remove the duplicate intro card so the first screen starts directly with station inputs.

Pass list sun times:
- Compute sunrise/sunset from each visible date group's 00:00 in the selected timezone.
- Avoid using an arbitrary pass AOS as the rise/set search start, which could jump later-day headers to the following day's events.
2026-08-03 21:38:57 +08:00
50 changed files with 1033 additions and 1939 deletions

No files matched your search

+40 -19
View File
@@ -4,15 +4,10 @@ on:
push:
tags:
- v**
workflow_dispatch:
inputs:
tag_name:
description: 'Release tag name (e.g. v4.4.3)'
required: false
default: ''
env:
TAG_NAME: ${{ github.event.inputs.tag_name || github.ref_name }}
TAG_NAME: ${{ github.ref_name }}
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
jobs:
release:
@@ -21,40 +16,66 @@ jobs:
contents: write
steps:
- name: Checkout Repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Setup Java
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '21'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
uses: gradle/actions/setup-gradle@v6
- name: Assemble APK
run: ./gradlew assembleRelease
- name: Assemble Artifacts
run: ./gradlew assembleRelease bundleRelease
- name: Sign APK
run: |
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
APK=$(find app/build/outputs/apk/release -name "*.apk" | head -1)
VERSION=${TAG_NAME#v}
SIGNED_APK="app/build/outputs/apk/release/Look4Sat-Pro-${VERSION}.apk"
BUILD_TOOLS=$(ls -d ${ANDROID_HOME}/build-tools/*/ | sort -V | tail -1)
${BUILD_TOOLS}apksigner sign \
--ks keystore.jks \
--ks-pass pass:${{ secrets.KEY_STORE_PASSWORD }} \
--ks-key-alias ${{ secrets.KEY_ALIAS }} \
--key-pass pass:${{ secrets.KEY_PASSWORD }} \
--out "$SIGNED_APK" \
--out app/build/outputs/apk/release/look4sat.apk \
"$APK"
rm keystore.jks
echo "SIGNED_APK=$SIGNED_APK" >> "$GITHUB_ENV"
- name: Sign Bundle
run: |
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \
-keystore keystore.jks \
-storepass ${{ secrets.KEY_STORE_PASSWORD }} \
-keypass ${{ secrets.KEY_PASSWORD }} \
"$AAB" ${{ secrets.KEY_ALIAS }}
rm keystore.jks
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4'
- name: Deploy Bundle to Google Play
run: |
gem install multi_json
gem install fastlane --no-document
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json
fastlane supply \
--aab "$AAB" \
--json_key service_account.json \
--package_name com.rtbishop.look4sat \
--track production \
--skip_upload_images true \
--skip_upload_screenshots true \
rm service_account.json
- name: Create Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create $TAG_NAME --title=$TAG_NAME --generate-notes
gh release upload $TAG_NAME "$SIGNED_APK"
gh release upload $TAG_NAME app/build/outputs/apk/release/look4sat.apk
+3 -1
View File
@@ -18,6 +18,7 @@ out/
# Gradle files
.gradle/
build/
.cxx/
# Local configuration file (sdk path, etc)
local.properties
@@ -39,7 +40,8 @@ captures/
.idea/
# Keystore files
*.jks
# Uncomment the following line if you do not want to check your keystore files in.
#*.jks
/*.properties
/keystore.properties
/app/keystore.jks
+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)
[<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/)
**BA7OPF 定制版** — 基于 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的业余无线电卫星追踪器,增加了线性卫星频率计算器等功能。
### Radio satellite tracker and pass predictor for Android, inspired by Gpredict
## 本仓库特色功能
<p float="left">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="192"/>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="192"/>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="192"/>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" width="192">
</p>
- **线性卫星转发器频率计算器** — 在雷达页的 Calculator 标签页中,支持 TX/RX 双向多普勒频率计算,以及下行频率偏移(offset)输入,方便操作带偏移的线性卫星
- **CW 解码器** — 集成 Morse Expert 解码引擎,支持瀑布图、实时解码文本
- **Passband 模式** — 支持通过位置滑块(Passband)自动计算 TX/RX 频率,避免切换时跳变
- **中文界面优化** — 翻译修正、UI 布局调整
### Track satellite passes with ease!
## 上游仓库
Thanks to [Celestrak](https://celestrak.com/) and [SatNOGS](https://satnogs.org/) you have access to over 9000 active satellites.\
You can search the entire database by NORAD Catalog Number or the satellite's name.
本仓库是 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的分支,上游仓库的原始功能包括:
Orbital positions and passes are calculated relative to your location.\
To get reliable data make sure to set the station position via the app Settings.
- 基于 Celestrak / SatNOGS 数据的 9000+ 活跃卫星追踪
- 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:
* 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>
GNU General Public License v3.0。详见 [LICENSE](LICENSE)。
+10 -22
View File
@@ -1,36 +1,24 @@
import java.util.Properties
plugins {
alias(libs.plugins.convention.applicationPlugin)
}
// Load signing config from keystore.properties (gitignored, never commit credentials)
val keystoreProperties = Properties().apply {
val propsFile = rootProject.file("keystore.properties")
if (propsFile.exists()) propsFile.inputStream().use { load(it) }
}
android {
// CW 解码 native 库仅 armeabi-v7a(照搬 Morse Expert 1.15): 全 ABI 打包会在
// arm64 设备 loadLibrary 失败, 强制 32 位兼容(用户设备为 32 位软件)
namespace = libs.versions.packageName.get()
defaultConfig {
ndk {
abiFilters += listOf("armeabi-v7a")
}
applicationId = "cn.ba7opf.look4sat"
ndk { abiFilters.add("armeabi-v7a") }
}
signingConfigs {
if (keystoreProperties["storeFile"] != null) {
create("release") {
storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
}
create("release") {
storeFile = file(System.getProperty("user.home") + "/my-release-key.jks")
storePassword = "look4sat123"
keyAlias = "look4sat"
keyPassword = "look4sat123"
}
}
buildTypes {
release {
signingConfig = signingConfigs.findByName("release")
signingConfig = signingConfigs.getByName("release")
}
}
}
}
+1 -1
View File
@@ -43,7 +43,7 @@
<meta-data
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
android:value="com.rtbishop.look4sat.bg7nta" />
android:value="com.rtbishop.look4sat" />
</application>
</manifest>
@@ -80,13 +80,11 @@ import com.rtbishop.look4sat.core.presentation.RadarDestination
import com.rtbishop.look4sat.core.presentation.Screen
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
import com.rtbishop.look4sat.feature.cw.CwDecodeScreen
import com.rtbishop.look4sat.feature.map.MapDestination
import com.rtbishop.look4sat.feature.mutual.MutualScreen
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
import com.rtbishop.look4sat.feature.passes.PassesDestination
import com.rtbishop.look4sat.feature.radar.RadarDestination
import com.rtbishop.look4sat.feature.roaming.RoamingScreen
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
import com.rtbishop.look4sat.feature.settings.SettingsDestination
@@ -132,21 +130,12 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
val currentKey = backStack.lastOrNull()
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Map, Screen.Settings)
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
// UI 设置: 按 screenOrder 排序(空 = 默认顺序), 再按 hiddenScreens 过滤(设置页固定保留)
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Roaming, Screen.CwDecode, Screen.Map, Screen.Settings)
.sortedBy { screen ->
// 未知(新页面如 CwDecode 不在旧持久化顺序里): 用默认顺序位置(漫游↔地图), 再兜底最后
val idx = otherSettings.screenOrder.indexOf(screen.screenId)
if (idx != -1) idx
else com.rtbishop.look4sat.core.presentation.defaultScreenOrder.indexOf(screen.screenId).let {
if (it != -1) it else Int.MAX_VALUE
}
}
.filter { it.screenId !in otherSettings.hiddenScreens || it is Screen.Settings }
// Activity-scoped so the mutual query results survive navigation to Radar and back
val mutualViewModel: MutualViewModel = viewModel(
viewModelStoreOwner = context as ViewModelStoreOwner,
@@ -167,7 +156,6 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
is Screen.Passes -> screen is Screen.Passes
is Screen.Radar -> screen is Screen.Radar
is Screen.Mutual -> screen is Screen.Mutual
is Screen.CwDecode -> screen is Screen.CwDecode
is Screen.Map -> screen is Screen.Map
is Screen.Settings -> screen is Screen.Settings
else -> false
@@ -236,12 +224,6 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
}
)
}
entry<Screen.Roaming> {
RoamingScreen()
}
entry<Screen.CwDecode> {
CwDecodeScreen()
}
entry<Screen.Settings> {
SettingsDestination()
}
@@ -298,4 +280,4 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
}
}
}
}
}
@@ -31,12 +31,10 @@ internal class ApplicationPlugin : Plugin<Project> {
implementation(project(":core:data"))
implementation(project(":core:domain"))
implementation(project(":core:presentation"))
implementation(project(":feature:cw"))
implementation(project(":feature:map"))
implementation(project(":feature:mutual"))
implementation(project(":feature:passes"))
implementation(project(":feature:radar"))
implementation(project(":feature:roaming"))
implementation(project(":feature:satellites"))
implementation(project(":feature:settings"))
implementation(libs.androidx.core.splashscreen)
@@ -57,7 +57,7 @@ internal fun Project.setupAndroidApp() {
namespace = libs.versions.packageName.get()
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
applicationId = libs.versions.applicationId.get()
applicationId = libs.versions.packageName.get()
minSdk = libs.versions.minSdk.get().toInt()
versionCode = libs.versions.appVersionCode.get().toInt()
versionName = libs.versions.appVersionName.get()
@@ -69,14 +69,11 @@ class DatabaseRepo(
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
val tleUrls = buildMap {
putAll(Sources.satelliteDataUrls)
// 开关开且 URL 非空 -> All 用自定义 URL;否则用默认 URL(在线更新默认源)
put("All", if (dataSourcesSettings.useCustomTLE && dataSourcesSettings.tleUrl.isNotBlank())
dataSourcesSettings.tleUrl else Sources.defaultTleUrl)
if (dataSourcesSettings.useCustomTLE) put(customSourceType, dataSourcesSettings.tleUrl)
}.filterValues { it.isNotBlank() }
val radioUrls = buildMap {
putAll(Sources.transceiversDataUrls)
put("SatNOGS", if (dataSourcesSettings.useCustomTransceivers && dataSourcesSettings.transceiversUrl.isNotBlank())
dataSourcesSettings.transceiversUrl else Sources.defaultTransceiversUrl)
if (dataSourcesSettings.useCustomTransceivers) put(customSourceType, dataSourcesSettings.transceiversUrl)
}.filterValues { it.isNotBlank() }
// launch all network requests concurrently
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
@@ -95,8 +95,14 @@ class SatelliteRepo(
}
}
override suspend fun getRadios(satPos: OrbitalPos, radios: List<SatRadio>): List<SatRadio> {
override suspend fun getRadios(
sat: OrbitalObject,
pos: GeoPos,
radios: List<SatRadio>,
time: Long
): List<SatRadio> {
return withContext(dispatcher) {
val satPos = sat.getPosition(pos, time)
radios.map { transmitter ->
transmitter.copy(
downlinkLow = transmitter.downlinkLow?.let { satPos.getDownlinkFreq(it) },
@@ -236,16 +242,13 @@ class SatelliteRepo(
if (elevation > maxElevation) maxElevation = elevation
} while (elevation < 0.0)
// refine AOS to ~500ms precision via binary search.
// Elevation is monotonic across the horizon crossing, so binary search
// finds the crossing in ~8 SGP4 calls instead of up to 120 linear steps.
var aosLo = calendarTimeMillis - 60L * 1000L // elevation < 0 (below horizon)
var aosHi = calendarTimeMillis // elevation >= 0 (above horizon)
while (aosHi - aosLo > 500L) {
val mid = (aosLo + aosHi) / 2
if (sat.getElevation(pos, mid) < 0.0) aosLo = mid else aosHi = mid
}
calendarTimeMillis = aosHi
// refine AOS to ~500ms precision
calendarTimeMillis -= 60L * 1000L
do {
calendarTimeMillis += 500L
elevation = sat.getElevation(pos, calendarTimeMillis)
if (elevation > maxElevation) maxElevation = elevation
} while (elevation < 0.0)
// Get full position for AOS data (azimuth, altitude)
val aosPos = sat.getFullPosition(pos, calendarTimeMillis)
@@ -259,14 +262,13 @@ class SatelliteRepo(
if (elevation > maxElevation) maxElevation = elevation
} while (elevation > 0.0)
// refine LOS to ~500ms precision via binary search (same monotonic argument)
var losLo = calendarTimeMillis - 30L * 1000L // elevation > 0 (above horizon)
var losHi = calendarTimeMillis // elevation <= 0 (below horizon)
while (losHi - losLo > 500L) {
val mid = (losLo + losHi) / 2
if (sat.getElevation(pos, mid) > 0.0) losLo = mid else losHi = mid
}
calendarTimeMillis = losHi
// refine LOS to ~500ms precision
calendarTimeMillis -= 30L * 1000L
do {
calendarTimeMillis += 500L
elevation = sat.getElevation(pos, calendarTimeMillis)
if (elevation > maxElevation) maxElevation = elevation
} while (elevation > 0.0)
// Get full position for LOS data (azimuth, altitude)
val losPos = sat.getFullPosition(pos, calendarTimeMillis)
@@ -29,7 +29,6 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.utility.positionToQth
@@ -77,8 +76,6 @@ class SettingsRepo(
private val keyStateOfUtc = "stateOfUtc"
private val keyStateOfLightTheme = "stateOfLightTheme"
private val keyStateOfNightMode = "stateOfNightMode"
private val keyHiddenScreens = "hiddenScreens"
private val keyScreenOrder = "screenOrder"
private val keyStationAltitude = "stationAltitude"
private val keyStationLatitude = "stationLatitude"
private val keyStationLongitude = "stationLongitude"
@@ -216,8 +213,8 @@ class SettingsRepo(
}
private fun setStationPosition(latitude: Double, longitude: Double, altitude: Double, locator: String) {
val newLat = latitude.round(5)
val newLon = longitude.round(5)
val newLat = latitude.round(4)
val newLon = longitude.round(4)
val newAlt = altitude.round(1)
val timestamp = System.currentTimeMillis()
println("Received new Position($newLat, $newLon, $newAlt) & Locator $locator")
@@ -359,8 +356,6 @@ class SettingsRepo(
putString(keySstvMode, new.sstvMode)
putLong(keyLowElevation, new.lowElevation.toRawBits())
putLong(keyHighElevation, new.highElevation.toRawBits())
putStringSet(keyHiddenScreens, new.hiddenScreens.toSet())
putString(keyScreenOrder, new.screenOrder.joinToString(","))
}
new
}
@@ -376,8 +371,6 @@ class SettingsRepo(
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
hiddenScreens = preferences.getStringSet(keyHiddenScreens, emptySet())?.toList() ?: emptyList(),
screenOrder = preferences.getString(keyScreenOrder, null)?.split(",")?.filter { it.isNotBlank() } ?: emptyList(),
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits()))
)
@@ -397,20 +390,12 @@ class SettingsRepo(
_dataSourcesSettings.value = settings
}
private fun getDataSourcesSettings(): DataSourcesSettings {
// 4.4.8 修复: 旧版 example.com 占位 URL 视为未配置 -> 替换为真实默认 URL 且开关强制关闭,
// 否则在线更新的 All/SatNOGS 源会指向错误地址导致更新失败
val storedTleUrl = preferences.getString(keyTleUrl, Sources.defaultTleUrl) ?: Sources.defaultTleUrl
val storedTxUrl = preferences.getString(keyTransceiversUrl, Sources.defaultTransceiversUrl) ?: Sources.defaultTransceiversUrl
val tleUrl = if (storedTleUrl == "https://example.com/tle.txt") Sources.defaultTleUrl else storedTleUrl
val txUrl = if (storedTxUrl == "https://example.com/radio.json") Sources.defaultTransceiversUrl else storedTxUrl
return DataSourcesSettings(
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false) && tleUrl != Sources.defaultTleUrl,
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false) && txUrl != Sources.defaultTransceiversUrl,
tleUrl = tleUrl,
transceiversUrl = txUrl
)
}
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false),
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false),
tleUrl = preferences.getString(keyTleUrl, "https://example.com/tle.txt") ?: "",
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: ""
)
//endregion
//region # Radio control settings
@@ -102,8 +102,7 @@ class DatabaseRepoTest {
repository.updateFromRemote()
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
// 新语义: 开关开 + URL 非空 -> All 源用自定义 URL, 数据归入 All 类型
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"])
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
}
private fun validCsvStream(): InputStream = """
@@ -62,11 +62,7 @@ data class OtherSettings(
val shouldSeeWhatsNew: Boolean,
val sstvMode: String = "Auto",
val lowElevation: Double = 15.0,
val highElevation: Double = 45.0,
// UI 设置: 底部导航栏隐藏的页面(Screen simpleName 列表, 默认空 = 全部显示)
val hiddenScreens: List<String> = emptyList(),
// UI 设置: 页面顺序(空 = 默认顺序: 卫星/过境/雷达/匹配/漫游/地图/设置)
val screenOrder: List<String> = emptyList()
val highElevation: Double = 45.0
)
data class DataSourcesSettings(
@@ -61,7 +61,7 @@ interface ISatelliteRepo {
suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos>
/** Get Doppler-shifted radio frequencies for a satellite at the given time. */
suspend fun getRadios(satPos: OrbitalPos, radios: List<SatRadio>): List<SatRadio>
suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List<SatRadio>, time: Long): List<SatRadio>
/** Fetch radio transceivers for a satellite by its catalog number. */
suspend fun getRadiosWithId(id: Int): List<SatRadio>
@@ -18,10 +18,6 @@
package com.rtbishop.look4sat.core.domain.source
object Sources {
// 在线更新的默认 URL(用户可通过"自定义URL"对话框修改/重置)
val defaultTleUrl = "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv"
val defaultTransceiversUrl = "https://db.satnogs.org/api/transmitters/?format=json&status=active"
val satelliteDataUrls = mapOf(
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
@@ -1,4 +1,4 @@
/*
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
@@ -11,24 +11,33 @@ package com.rtbishop.look4sat.core.domain.utility
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import java.util.Locale
/**
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
*
* For a linear (passband) transponder, uplink and downlink frequencies are
* related by a fixed passband offset. When the satellite moves, both are
* Doppler-shifted. Given one, we compute the other:
* The full physical path:
*
* downlink → uplink: mapDownlinkToUplink (passband) → getUplinkFreq (Doppler)
* uplink → downlink: mapUplinkToDownlink (passband) → getDownlinkFreq (Doppler)
* TX→RX (uplink → downlink):
* ① 地面发射 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).
*/
object DopplerFrequencyCalculator {
/**
* Given a downlink frequency, compute the Doppler-corrected uplink frequency.
* Returns null if the transponder is not a linear passband type.
* Given a downlink frequency (what the user hears), compute the
* uplink frequency the user should transmit.
* Full path: ④→③→②→①
*/
fun computeUplinkFromDownlink(
downlinkHz: Long,
@@ -36,14 +45,22 @@ object DopplerFrequencyCalculator {
orbitalPos: OrbitalPos
): Long? {
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
* with an offset applied to the downlink (in Hz).
* Returns null if the transponder is not a linear passband type.
* Given a downlink frequency (what the user hears), compute the
* uplink frequency the user should transmit, with an offset applied
* to the downlink (in Hz).
* Full path: ④→③→②→①
*
* The user-entered downlink frequency already includes the offset, so subtract
* it before the inverse downlink Doppler.
*/
fun computeUplinkFromDownlinkWithOffset(
downlinkHz: Long,
@@ -52,13 +69,20 @@ object DopplerFrequencyCalculator {
offsetHz: Long
): Long? {
if (!isLinearTransponder(transponder)) return null
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz + offsetHz, transponder) ?: return null
return orbitalPos.getUplinkFreq(baseUplink)
// ④→③ 逆下行多普勒:卫星转发的频率(含 offset)
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.
* Returns null if the transponder is not a linear passband type.
* Given an uplink frequency (what the user transmits), compute the
* downlink frequency the user will hear.
* Full path: ①→②→③→④
*/
fun computeDownlinkFromUplink(
uplinkHz: Long,
@@ -66,14 +90,19 @@ object DopplerFrequencyCalculator {
orbitalPos: OrbitalPos
): Long? {
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
* with an offset applied to the downlink (in Hz).
* Returns null if the transponder is not a linear passband type.
* Given an uplink frequency (what the user transmits), compute the
* downlink frequency the user will hear, with an offset applied
* to the downlink (in Hz).
* Full path: ①→②→③→④
*/
fun computeDownlinkFromUplinkWithOffset(
uplinkHz: Long,
@@ -82,8 +111,13 @@ object DopplerFrequencyCalculator {
offsetHz: Long
): Long? {
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. */
@@ -95,4 +129,26 @@ object DopplerFrequencyCalculator {
return upLow != null && upHigh != null && downLow != null && downHigh != null
&& upLow != upHigh && downLow != downHigh
}
/**
* True for the radio entry that should drive the standalone Calculator page.
*
* A frequency range alone is not enough: some non-user-facing or drifting data entries
* can also have low/high frequencies. The calculator is meant for the named linear
* transponder entry, e.g. "Linear Transponder", "Linear Transp.", "SSB Transponder".
*/
fun isNamedLinearTransponder(transponder: SatRadio): Boolean {
if (!isLinearTransponder(transponder)) return false
val info = transponder.info.lowercase(Locale.ENGLISH)
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
.joinToString(separator = " ")
.lowercase(Locale.ENGLISH)
val hasLinearName = info.contains("linear")
val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
info.contains("xponder") || info.contains("xpdr")
val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) }
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode)
}
}
@@ -19,78 +19,31 @@ package com.rtbishop.look4sat.core.domain.utility
import com.rtbishop.look4sat.core.domain.predict.GeoPos
/**
* Converts a Maidenhead locator (QTH grid square) to a GeoPos.
* Supports 6-char (3 pair), 8-char (4 pair) and 10-char (5 pair) locators.
* The returned position is the center of the finest cell encoded by the locator:
* - 6 char: 5' lon x 2.5' lat cell center
* - 8 char: 30" lon x 15" lat cell center
* - 10 char: 1.25" lon x 0.625" lat cell center
*/
fun qthToPosition(locator: String): GeoPos? {
val trimmedQth = locator.trim().uppercase()
val trimmedQth = locator.take(6)
if (!isValidLocator(trimmedQth)) return null
val lonFirst = (trimmedQth[0].code - 65) * 20
val latFirst = (trimmedQth[1].code - 65) * 10
val lonFirst = (trimmedQth[0].uppercaseChar().code - 65) * 20
val latFirst = (trimmedQth[1].uppercaseChar().code - 65) * 10
val lonSecond = trimmedQth[2].toString().toInt() * 2
val latSecond = trimmedQth[3].toString().toInt()
val lonThird = (trimmedQth[4].lowercaseChar().code - 97) / 12.0
val latThird = (trimmedQth[5].lowercaseChar().code - 97) / 24.0
var longitude = lonFirst + lonSecond + lonThird - 180
var latitude = latFirst + latSecond + latThird - 90
// 8-char extension: 4th pair, digits, 30" lon x 15" lat cells
if (trimmedQth.length >= 8) {
longitude += trimmedQth[6].toString().toInt() / 120.0
latitude += trimmedQth[7].toString().toInt() / 240.0
}
// 10-char extension: 5th pair, letters, 1.25" lon x 0.625" lat cells
if (trimmedQth.length >= 10) {
longitude += (trimmedQth[8].lowercaseChar().code - 97) / 2880.0
latitude += (trimmedQth[9].lowercaseChar().code - 97) / 5760.0
}
// Offset to the center of the finest encoded cell
when (trimmedQth.length) {
8 -> {
longitude += 1.0 / 240.0
latitude += 1.0 / 480.0
}
10 -> {
longitude += 1.0 / 5760.0
latitude += 1.0 / 11520.0
}
else -> {
longitude += 1.0 / 24.0
latitude += 1.0 / 48.0
}
}
return GeoPos(latitude.round(6), longitude.round(6))
val lonThird = (((trimmedQth[4].lowercaseChar().code - 97) / 12.0) + (1.0 / 24.0)) - 180
val latThird = (((trimmedQth[5].lowercaseChar().code - 97) / 24.0) + (1.0 / 48.0)) - 90
val longitude = (lonFirst + lonSecond + lonThird).round(4)
val latitude = (latFirst + latSecond + latThird).round(4)
return GeoPos(latitude, longitude)
}
/**
* Converts a GeoPos to a Maidenhead locator (QTH grid square).
* Default precision is 8 characters (4 pairs) giving 30" lon x 15" lat resolution,
* matching common 8-char grid square tools. Pass precision = 6 for the classic
* 5' x 2.5' resolution, or precision = 10 for the finest 1.25" x 0.625" resolution.
*/
fun positionToQth(latitude: Double, longitude: Double, precision: Int = 8): String? {
fun positionToQth(latitude: Double, longitude: Double): String? {
if (!isValidPosition(latitude, longitude)) return null
val newLongitude = longitude + 180
val newLongitude = if (longitude > 180.0) longitude else longitude + 180
val newLatitude = latitude + 90
val lonFirst = (65 + (newLongitude / 20).toInt().coerceIn(0, 17)).toChar()
val latFirst = (65 + (newLatitude / 10).toInt().coerceIn(0, 17)).toChar()
val lonSecond = ((newLongitude % 20) / 2).toInt()
val lonFirst = (65 + (newLongitude / 20)).toInt().toChar()
val latFirst = (65 + (newLatitude / 10)).toInt().toChar()
val lonSecond = ((newLongitude / 2) % 10).toInt()
val latSecond = (newLatitude % 10).toInt()
val lonThird = (65 + (newLongitude % 2) * 12).toInt().toChar().lowercaseChar()
val latThird = (65 + (newLatitude % 1) * 24).toInt().toChar().lowercaseChar()
val qth = "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
if (precision < 8) return qth
val lonFourth = ((newLongitude % (1.0 / 12.0)) * 120).toInt()
val latFourth = ((newLatitude % (1.0 / 24.0)) * 240).toInt()
val qth8 = "$qth$lonFourth$latFourth"
if (precision < 10) return qth8
val lonFifth = (65 + (newLongitude % (1.0 / 120.0)) * 2880).toInt().toChar().lowercaseChar()
val latFifth = (65 + (newLatitude % (1.0 / 240.0)) * 5760).toInt().toChar().lowercaseChar()
return "$qth8$lonFifth$latFifth"
return "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
}
private fun isValidPosition(lat: Double, lon: Double): Boolean {
@@ -98,49 +51,5 @@ private fun isValidPosition(lat: Double, lon: Double): Boolean {
}
private fun isValidLocator(locator: String): Boolean {
return locator.matches("[a-xA-X]{2}\\d{2}[a-xA-X]{2}(?:\\d{2}(?:[a-xA-X]{2})?)?".toRegex())
return locator.matches("[a-xA-X][a-xA-X]\\d\\d[a-xA-X][a-xA-X]".toRegex())
}
/**
* Returns the 4-char field+square part of a locator, e.g. "OL42ih45" -> "OL42".
* A 4-char input is returned as-is when valid.
*/
fun qthToSquare(locator: String): String {
val upper = locator.trim().uppercase()
return when {
upper.length >= 4 && isValidLocator(upper) -> upper.take(4)
upper.length == 4 && upper.matches("[a-xA-X]{2}\\d{2}".toRegex()) -> upper
else -> "----"
}
}
/**
* Builds the 3x3 grid of 4-char squares surrounding [square] (e.g. "OL42").
* Row 0 = north (lat +1), col 0 = west (lon -1). Handles field/square carry
* at boundaries (e.g. "AA00" wraps to "RR99" at the south-west corner).
* Mirrors the neighbor logic decompiled from the QTH定位器 app.
*/
fun qthNeighbors(square: String): List<String> {
if (square.length != 4) return emptyList()
val lonField = (square[0].uppercaseChar().code - 65).coerceIn(0, 17)
val latField = (square[1].uppercaseChar().code - 65).coerceIn(0, 17)
val lonSquare = square[2].digitToCharOrNull() ?: return emptyList()
val latSquare = square[3].digitToCharOrNull() ?: return emptyList()
val result = mutableListOf<String>()
for (dLat in 1 downTo -1) { // north -> south
for (dLon in -1..1) { // west -> east
var lf = lonField
var tf = latField
var ls = lonSquare + dLon
var ts = latSquare + dLat
if (ls < 0) { lf -= 1; ls = 9 } else if (ls > 9) { lf += 1; ls = 0 }
if (ts < 0) { tf -= 1; ts = 9 } else if (ts > 9) { tf += 1; ts = 0 }
lf = (lf + 18) % 18
tf = (tf + 18) % 18
result += "${('A' + lf).toChar()}${('A' + tf).toChar()}$ls$ts"
}
}
return result
}
private fun Char.digitToCharOrNull(): Int? = digitToIntOrNull()
@@ -13,12 +13,15 @@ class DopplerFrequencyCalculatorTest {
upHigh: Long = 145_500_000L,
downLow: Long = 435_000_000L,
downHigh: Long? = 435_500_000L,
inverted: Boolean = false
inverted: Boolean = false,
info: String = "Linear Transponder",
downlinkMode: String? = "USB",
uplinkMode: String? = "LSB"
) = SatRadio(
uuid = "linear", info = "Linear Transponder", isAlive = true,
uuid = "linear", info = info, isAlive = true,
downlinkLow = downLow, downlinkHigh = downHigh,
downlinkMode = "USB", uplinkLow = upLow, uplinkHigh = upHigh,
uplinkMode = "LSB", isInverted = inverted, catnum = 12345
downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
)
private fun fmTransponder() = SatRadio(
@@ -48,6 +51,28 @@ class DopplerFrequencyCalculatorTest {
assertFalse(DopplerFrequencyCalculator.isLinearTransponder(xpdr))
}
@Test
fun isNamedLinearTransponder_returnsTrueForLinearTransponderName() {
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(linearTransponder()))
}
@Test
fun isNamedLinearTransponder_returnsTrueForSsbTransponderName() {
val xpdr = linearTransponder(info = "Mode V/U SSB Transponder", downlinkMode = "USB", uplinkMode = "LSB")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(xpdr))
}
@Test
fun isNamedLinearTransponder_returnsFalseForRangeEntryWithoutTransponderName() {
val driftingRangeEntry = linearTransponder(info = "Upper side band (drifting)")
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry))
}
@Test
fun isNamedLinearTransponder_returnsFalseForFmRepeater() {
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder()))
}
@Test
fun computeUplinkFromDownlink_linear_noDoppler() {
val xpdr = linearTransponder()
@@ -93,6 +118,53 @@ class DopplerFrequencyCalculatorTest {
assertNull(result)
}
@Test
fun computeDownlinkFromUplink_withPositiveOffset_addsOffsetToDownlink() {
val xpdr = linearTransponder()
val orbitalPos = pos(0.0)
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
uplinkHz = 145_200_000L,
transponder = xpdr,
orbitalPos = orbitalPos,
offsetHz = 2_500L
)
assertEquals(435_202_500L, downlink)
}
@Test
fun computeUplinkFromDownlink_withPositiveOffset_subtractsOffsetBeforeMapping() {
val xpdr = linearTransponder()
val orbitalPos = pos(0.0)
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
downlinkHz = 435_202_500L,
transponder = xpdr,
orbitalPos = orbitalPos,
offsetHz = 2_500L
)
assertEquals(145_200_000L, uplink)
}
@Test
fun computeOffsetRoundTrip_handlesNegativeOffset() {
val xpdr = linearTransponder()
val orbitalPos = pos(0.0)
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
uplinkHz = 145_200_000L,
transponder = xpdr,
orbitalPos = orbitalPos,
offsetHz = -2_500L
)
assertEquals(435_197_500L, downlink)
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
downlinkHz = downlink!!,
transponder = xpdr,
orbitalPos = orbitalPos,
offsetHz = -2_500L
)
assertEquals(145_200_000L, uplink)
}
@Test
fun computeUplinkFromDownlink_invertedTransponder() {
val xpdr = linearTransponder(inverted = true, downHigh = 435_500_000L)
@@ -18,9 +18,7 @@
package com.rtbishop.look4sat.core.domain
import com.rtbishop.look4sat.core.domain.utility.positionToQth
import com.rtbishop.look4sat.core.domain.utility.qthNeighbors
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
import com.rtbishop.look4sat.core.domain.utility.qthToSquare
import org.junit.Test
class QthConverterTest {
@@ -28,36 +26,21 @@ class QthConverterTest {
@Test
fun `Given valid QTH returns correct POS`() {
var result = qthToPosition("io91VL39FX")
assert(result?.latitude == 51.499913 && result.longitude == -0.22309)
assert(result?.latitude == 51.4792 && result.longitude == -0.2083)
result = qthToPosition("gf15vc")
assert(result?.latitude == -34.895833 && result.longitude == -56.208333)
// 8-char locators: finer 30" x 15" cell center
result = qthToPosition("io91vl47")
assert(result?.latitude == 51.489583 && result.longitude == -0.2125)
result = qthToPosition("jn58td25")
assert(result?.latitude == 48.147917 && result.longitude == 11.604167)
assert(result?.latitude == -34.8958 && result.longitude == -56.2083)
}
@Test
fun `Given invalid QTH returns null`() {
assert(qthToPosition("ZZ00zz") == null)
assert(qthToPosition("JN58") == null)
assert(qthToPosition("io9") == null)
assert(qthToPosition("IO91VL7") == null)
assert(qthToPosition("IO91VL4X") == null)
}
@Test
fun `Given valid POS returns correct QTH`() {
// default precision is 8 chars
assert(positionToQth(51.4878, -0.2146) == "IO91vl47")
assert(positionToQth(48.1466, 11.6083) == "JN58td25")
// 6-char precision still available for backwards compatibility
assert(positionToQth(51.4878, -0.2146, 6) == "IO91vl")
assert(positionToQth(48.1466, 11.6083, 6) == "JN58td")
// 10-char precision
assert(positionToQth(51.4878, -0.2146, 10) == "IO91vl47fb")
assert(positionToQth(48.1466, 11.6083, 10) == "JN58td25xe")
assert(positionToQth(51.4878, -0.2146) == "IO91vl")
assert(positionToQth(48.1466, 11.6083) == "JN58td")
}
@Test
@@ -65,61 +48,4 @@ class QthConverterTest {
assert(positionToQth(91.0542, -170.1142) == null)
assert(positionToQth(89.0542, -240.1142) == null)
}
@Test
fun `Given boundary POS stays in valid grid`() {
// antipodal / edge cases must not overflow the A-R / 0-9 / a-x alphabet
assert(positionToQth(-90.0, -180.0, 8) == "AA00aa00")
assert(positionToQth(90.0, 180.0, 8) == "RR00aa00")
assert(positionToQth(0.0, 0.0, 8) == "JJ00aa00")
// roundtrip stability: 8-char roundtrip is stable across a sample of positions
val positions = listOf(
Pair(51.4878, -0.2146), Pair(48.1466, 11.6083), Pair(-33.8688, 151.2093),
Pair(39.9042, 116.4074), Pair(35.6895, 139.6917), Pair(41.714, -72.727)
)
positions.forEach { (lat, lon) ->
val qth = positionToQth(lat, lon, 8)
val pos = qthToPosition(qth!!)
val qth2 = positionToQth(pos!!.latitude, pos.longitude, 8)
assert(qth == qth2) { "Roundtrip failed for ($lat, $lon): $qth -> $qth2" }
}
}
@Test
fun `Given square returns correct 3x3 neighbors`() {
// Reference grid from the QTH定位器 screenshot: OL42
val neighbors = qthNeighbors("OL42")
assert(neighbors == listOf(
"OL33", "OL43", "OL53",
"OL32", "OL42", "OL52",
"OL31", "OL41", "OL51"
)) { "OL42 grid mismatch: $neighbors" }
// Center cell must be the input itself
assert(neighbors[4] == "OL42")
// 9 cells, all distinct
assert(neighbors.size == 9 && neighbors.toSet().size == 9)
}
@Test
fun `Given boundary square wraps fields correctly`() {
// South-west corner: AA00 neighbors wrap to RR99 / RA90 etc.
val sw = qthNeighbors("AA00")
assert(sw.size == 9 && sw.toSet().size == 9)
assert(sw[0] == "RA91" && sw[4] == "AA00" && sw[6] == "RR99" && sw[8] == "AR19")
// North-east corner: RR99 wraps to AA00
val ne = qthNeighbors("RR99")
assert(ne.size == 9 && ne.toSet().size == 9)
assert(ne[0] == "RA80" && ne[4] == "RR99" && ne[8] == "AR08")
// Field boundary: IO91's east neighbors cross into J field
val london = qthNeighbors("IO91")
assert(london[2] == "JO02" && london[5] == "JO01")
}
@Test
fun `Given full locator returns square part`() {
assert(qthToSquare("OL42ih45") == "OL42")
assert(qthToSquare("io91VL39FX") == "IO91")
assert(qthToSquare("JN58") == "JN58")
assert(qthToSquare("garbage!!") == "----")
}
}
@@ -21,36 +21,27 @@ import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
@Serializable
sealed class Screen(val iconResId: Int, val titleResId: Int, val screenId: String) : NavKey {
sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
@Serializable
data object Satellites : Screen(R.drawable.ic_satellites, R.string.nav_sat, "Satellites")
data object Satellites : Screen(R.drawable.ic_satellites, R.string.nav_sat)
@Serializable
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass, "Passes")
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
@Serializable
data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar, "Radar")
data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar)
@Serializable
data object Map : Screen(R.drawable.ic_map, R.string.nav_map, "Map")
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
@Serializable
data object Mutual : Screen(R.drawable.ic_match, R.string.nav_mutual, "Mutual")
data object Mutual : Screen(R.drawable.ic_match, R.string.nav_mutual)
@Serializable
data object Roaming : Screen(R.drawable.ic_roaming, R.string.nav_roaming, "Roaming")
@Serializable
data object CwDecode : Screen(R.drawable.ic_cw, R.string.nav_cw, "CwDecode")
@Serializable
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs, "Settings")
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
}
// UI 设置: 默认页面顺序(与 MainScreen navItems 一致)
val defaultScreenOrder = listOf("Satellites", "Passes", "Radar", "Mutual", "Roaming", "CwDecode", "Map", "Settings")
@Serializable
data object RadarDestination : NavKey
@@ -79,4 +70,4 @@ class DeeplinkResolver(private val fallbackDestination: NavKey = Screen.Passes)
matchers.forEach { it.match(deeplink)?.let { match -> return match } }
return fallbackDestination
}
}
}
@@ -1,15 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Morse style icon: a dot above a dash -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M 8 4 L 16 4 L 16 8 L 8 8 Z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M 3 16 L 21 16 L 21 20 L 3 20 Z" />
</vector>
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,6v3l4,-4 -4,-4v3c-4.42,0 -8,3.58 -8,8 0,1.57 0.46,3.03 1.24,4.26L6.7,14.8c-0.45,-0.83 -0.7,-1.79 -0.7,-2.8 0,-3.31 2.69,-6 6,-6zM18.76,7.74L17.3,9.2c0.44,0.84 0.7,1.79 0.7,2.8 0,3.31 -2.69,6 -6,6v-3l-4,4 4,4v-3c4.42,0 8,-3.58 8,-8 0,-1.57 -0.46,-3.03 -1.24,-4.26z" />
</vector>
@@ -1,25 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="96"
android:viewportHeight="96">
<!-- Crosshair / grid-locator style icon: concentric rings + center dot -->
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="6"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M 48 12 L 48 84 M 12 48 L 84 48" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="6"
android:pathData="M 48 26 A 22 22 0 1 0 48.1 26" />
<path
android:fillColor="@android:color/white"
android:pathData="M 44 44 L 52 44 L 52 52 L 44 52 Z" />
</vector>
@@ -10,7 +10,6 @@
<string name="nav_sat">Uydular</string>
<string name="nav_pass">Geçişler</string>
<string name="nav_radar">Radar</string>
<string name="nav_cw">CW Dekoder</string>
<string name="nav_map">Harita</string>
<string name="nav_prefs">Ayarlar</string>
@@ -51,9 +50,13 @@
Ayarlar\'dan GPS, Enlem/Boylam veya QTH kullanarak konumunuzu belirlediğinizden emin olun.
\n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false">
* Transponder CW kod çözücü paneli artık geliştirilmiş kod çözme motorunu canlı şelale (waterfall) görüntüsüyle kullanıyor
* CW kod çözme sayfaları aynı motoru paylaşıyor: gezinme sayfası ve transponder paneli</string>
<string name="pass_whatsnew_message" translatable="false">
* Switched to short-form commands in NetworkReporter, by theojalba
\n* Added CAT radio control for full-duplex SAT operation, by jcmerg
\n* Added normalized time for passes AOS/LOS, fixed refresh state bug
\n* Added multiple performance tweaks, improved passes calculation
\n* Fixed UTC pass time problem mentioned in issue #201
</string>
<!-- Radar screen -->
<string name="radar_back">Geri</string>
@@ -100,7 +103,7 @@
<string name="map_visible">Görünür</string>
<!-- Settings screen -->
<string name="prefs_app_title" translatable="false">Look4Sat Pro v%s</string>
<string name="prefs_app_title" translatable="false">Look4Sat v%s</string>
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
<string name="prefs_donate_title">Bağış yap</string>
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
@@ -134,7 +137,7 @@
<string name="prefs_data_entries">Uydular: %s</string>
<string name="prefs_data_radios">Transceiver\'lar: %s</string>
<string name="prefs_data_update">Güncelle</string>
<string name="prefs_data_import">Özel URL</string>
<string name="prefs_data_import">İçe aktar</string>
<string name="prefs_data_clear">Temizle</string>
<string name="prefs_data_clear_success">Veriler başarıyla temizlendi</string>
<string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string>
@@ -143,7 +146,6 @@
<string name="prefs_data_sources_tle_switch">Özel TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Özel transceiver URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_reset_url">Varsayılan URL\'ye sıfırla</string>
<string name="prefs_data_output_title">Veri aktarımı</string>
<string name="prefs_net_output">Ağ</string>
@@ -179,9 +181,6 @@
<string name="prefs_net_perm_error">Ağ izninizi kontrol edin</string>
<string name="prefs_other_title">Diğer ayarlar</string>
<string name="prefs_ui_title">UI Ayarları</string>
<string name="prefs_ui_order_title">Sayfa sırası</string>
<string name="prefs_ui_order_reset">Sıralamayı sıfırla</string>
<string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string>
<string name="prefs_other_switch_update">Otomatik veri güncellemeyi etkinleştir</string>
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string>
@@ -193,7 +192,7 @@
<string name="prefs_highlight_mid">Orta %1$d-%2$d°</string>
<string name="prefs_highlight_high">Yüksek &gt; %1$d°</string>
<string name="prefs_outro_title">BG7NTA ve orijinal yazar teşekkür eder</string>
<string name="prefs_outro_title">Teşekkürler</string>
<string name="prefs_outro_thanks" translatable="false">
• Look4Sat users and contributors!
\n• David A. B. Johnson (predict4java)
@@ -201,8 +200,7 @@
\n• Alexandru Csete (Gpredict)
\n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS)
\n• BA7OPF (pass matching feature)
\n• BG7NTA</string>
\n• BG7NTA (CW decoding feature)</string>
<string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string>
</resources>
@@ -10,8 +10,6 @@
<string name="nav_pass">过境</string>
<string name="nav_radar">雷达</string>
<string name="nav_mutual">匹配</string>
<string name="nav_roaming">漫游</string>
<string name="nav_cw">CW解码</string>
<string name="nav_map">地图</string>
<string name="nav_prefs">设置</string>
@@ -40,10 +38,6 @@
<string name="pass_error_message">没有即将过境的卫星</string>
<string name="pass_welcome_title">欢迎来到 Look4Sat\!</string>
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
<string name="pass_whatsnew_title">Look4Sat Pro 更新内容</string>
<string name="pass_whatsnew_message" translatable="false">
* 转发器 CW 解码面板已接入增强解码引擎,带实时瀑布图显示
* CW 解码入口共用同一引擎:导航页与转发器面板</string>
<!-- Radar screen -->
<string name="radar_back">后退</string>
@@ -98,7 +92,7 @@
<string name="prefs_updated_title">更新:%s</string>
<string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string>
<string name="prefs_loc_title">站位</string>
<string name="prefs_loc_title">站点位置</string>
<string name="prefs_loc_gps_title">GPS 定位</string>
<string name="prefs_loc_gps_error">检查您的位置权限</string>
<string name="prefs_loc_input_title">输入位置</string>
@@ -106,17 +100,17 @@
<string name="prefs_loc_qth_title">QTH 网格</string>
<string name="prefs_loc_qth_error">无效的 QTH 网格</string>
<string name="prefs_loc_success">位置更新成功</string>
<string name="prefs_station_title">站位设置</string>
<string name="prefs_station_lat_text">输入站位的纬度</string>
<string name="prefs_station_lon_text">输入站位的经度</string>
<string name="prefs_station_title">站点位置设置</string>
<string name="prefs_station_lat_text">输入站点的纬度</string>
<string name="prefs_station_lon_text">输入站点的经度</string>
<string name="prefs_locator_title">QTH 网格</string>
<string name="prefs_locator_text">使用梅登黑德网格设置站位</string>
<string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
<string name="prefs_data_title">卫星数据更新</string>
<string name="prefs_data_entries">卫星:%s</string>
<string name="prefs_data_radios">收发器:%s</string>
<string name="prefs_data_update">在线更新</string>
<string name="prefs_data_import">自定义URL</string>
<string name="prefs_data_import">导入文件</string>
<string name="prefs_data_clear">清空</string>
<string name="prefs_data_clear_success">数据清空成功</string>
<string name="prefs_data_update_success">更新成功</string>
@@ -162,9 +156,6 @@
<string name="rc_enable_switch">启用 CAT 控制</string>
<string name="prefs_other_title">其他设置</string>
<string name="prefs_ui_title">UI设置</string>
<string name="prefs_ui_order_title">页面顺序</string>
<string name="prefs_ui_order_reset">重置顺序</string>
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
@@ -176,7 +167,7 @@
<string name="prefs_highlight_mid">中 %1$d-%2$d°</string>
<string name="prefs_highlight_high">高 &gt; %1$d°</string>
<string name="prefs_outro_title">BG7NTA 与原作者感谢:</string>
<string name="prefs_outro_title">我要感谢:</string>
<string name="prefs_outro_thanks" translatable="false">
• Look4Sat 所有用户及贡献者!
\n• David A. B. Johnson (predict4java)
@@ -185,8 +176,39 @@
\n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS)
\n• xdsopl 和 Robot36 贡献者!
\n• BA7OPF(过境匹配功能)
\n• BG7NTA</string>
\n• BG7NTA(CW解码功能)</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>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Commmon -->
<string name="app_name" translatable="false">Look4Sat Pro</string>
<string name="app_name" translatable="false">Look4Sat</string>
<string name="btn_accept">Accept</string>
<string name="btn_cancel">Cancel</string>
<string name="empty_list_message">No data to show</string>
@@ -11,8 +11,6 @@
<string name="nav_pass">Passes</string>
<string name="nav_radar">Radar</string>
<string name="nav_mutual">Match</string>
<string name="nav_roaming">Roaming</string>
<string name="nav_cw">CW Decoder</string>
<string name="nav_map">Map</string>
<string name="nav_prefs">Settings</string>
@@ -53,9 +51,10 @@
Make sure to set your position via GPS, Lat/Lon or QTH in Settings.
\n\nPlease update the database at least weekly to get accurate predictions.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false">
* Transponder CW decoder panel now uses the enhanced decoding engine with a live waterfall display
* CW decoding pages share the same engine: navigation page and transponder panel</string>
<string name="pass_whatsnew_message" translatable="false">
* Added required tweaks to support Android 17 (API 37)
\n\n* Added ACCESS_LOCAL_NETWORK permission check to support network data output
</string>
<!-- Radar screen -->
<string name="radar_back">Back</string>
@@ -112,7 +111,7 @@
<string name="map_visible">Visible</string>
<!-- Settings screen -->
<string name="prefs_app_title" translatable="false">Look4Sat Pro v%s</string>
<string name="prefs_app_title" translatable="false">Look4Sat v%s</string>
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
<string name="prefs_donate_title">Donate</string>
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
@@ -146,7 +145,7 @@
<string name="prefs_data_entries">Satellites: %s</string>
<string name="prefs_data_radios">Transceivers: %s</string>
<string name="prefs_data_update">Update</string>
<string name="prefs_data_import">Custom URL</string>
<string name="prefs_data_import">Import</string>
<string name="prefs_data_clear">Clear</string>
<string name="prefs_data_clear_success">Data was cleared successfully</string>
<string name="prefs_data_update_success">Update completed successfully</string>
@@ -157,7 +156,6 @@
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_reset_url">Reset to default URL</string>
<string name="prefs_data_output_title">Data output</string>
<string name="prefs_net_output">Network</string>
@@ -193,9 +191,6 @@
<string name="prefs_net_perm_error">Check your network permission</string>
<string name="prefs_other_title">Other settings</string>
<string name="prefs_ui_title">UI Settings</string>
<string name="prefs_ui_order_title">Page order</string>
<string name="prefs_ui_order_reset">Reset order</string>
<string name="prefs_other_switch_utc">Show pass time in UTC</string>
<string name="prefs_other_switch_update">Enable automatic data update</string>
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string>
@@ -207,7 +202,7 @@
<string name="prefs_highlight_mid">Mid %1$d–%2$d°</string>
<string name="prefs_highlight_high">High &gt; %1$d°</string>
<string name="prefs_outro_title">BG7NTA &amp; the original author would like to thank</string>
<string name="prefs_outro_title">I would like to say thanks to</string>
<string name="prefs_outro_thanks" translatable="false">
* Look4Sat users and contributors!
\n* David A. B. Johnson (predict4java)
@@ -216,9 +211,40 @@
\n* Dr T.S. Kelso (Celestrak)
\n* Libre Space Foundation (SatNOGS)
\n* xdsopl and Robot36 contributors!
\n* BA7OPF (pass matching feature)
\n* BG7NTA
\n* BG7NTA (CW decoding feature)
</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>
@@ -139,7 +139,7 @@ public class MainActivity {
public void onResume() {
android.content.SharedPreferences prefs = mActivity.getSharedPreferences(
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;
if (bVar != null) {
bVar.f623s.set(messageType.equals("ham_radio_qso"));
+1 -1
View File
@@ -4,6 +4,6 @@ package g3;
public abstract class d {
/* renamed from: a, reason: collision with root package name */
public static final double[][] f11591a = {new double[]{0.0d, 0.0d}, new double[]{9.760860120877624E-4d, -3.903230345984362E-11d}, new double[]{0.0019512202125042677d, -8.124251825289188E-11d}, new double[]{0.0029254043474793434d, -1.8374207360194882E-11d}, new double[]{0.0038986406289041042d, -2.1324678121885073E-10d}, new double[]{0.004870930686593056d, -4.5199654318611534E-10d}, new double[]{0.00584227591753006d, -2.933016992001806E-10d}, new double[]{0.006812678650021553d, -2.325147219074669E-10d}, new double[]{0.007782140746712685d, -3.046577356838847E-10d}, new double[]{0.008750664070248604d, -5.500631513861575E-10d}, new double[]{0.00971824862062931d, 8.48292035519895E-10d}, new double[]{0.010684899985790253d, 1.1422610134013436E-10d}, new double[]{0.01165061630308628d, 9.168889933128375E-10d}, new double[]{0.012615403160452843d, -5.303786078838E-10d}, new double[]{0.013579258695244789d, -5.688639355498786E-10d}, new double[]{0.01454218477010727d, 7.296670293275653E-10d}, new double[]{0.015504186972975731d, -4.370104767451421E-10d}, new double[]{0.016465261578559875d, 1.43695591408832E-9d}, new double[]{0.01742541790008545d, -1.1862263158849434E-9d}, new double[]{0.018384650349617004d, -9.482976524690715E-10d}, new double[]{0.01934296265244484d, 1.9068609515836638E-10d}, new double[]{0.020300358533859253d, 2.655990315697216E-10d}, new double[]{0.021256837993860245d, 1.0315548713040775E-9d}, new double[]{0.022212404757738113d, 5.13345647019085E-10d}, new double[]{0.02316705882549286d, 4.5604151934208014E-10d}, new double[]{0.02412080392241478d, -1.1255706987475148E-9d}, new double[]{0.025073636323213577d, 1.2289023836765196E-9d}, new double[]{0.02602556347846985d, 1.7990281828096504E-9d}, new double[]{0.026976589113473892d, -1.4152718164638451E-9d}, new double[]{0.02792670577764511d, 7.568772963781632E-10d}, new double[]{0.0288759246468544d, -1.1449998592111558E-9d}, new double[]{0.029824241995811462d, -1.6850976862319495E-9d}, new double[]{0.030771657824516296d, 8.422373919843096E-10d}, new double[]{0.0317181795835495d, 6.872350402175489E-10d}, new double[]{0.03266380727291107d, -4.541194749189272E-10d}, new double[]{0.03360854089260101d, -8.9064764856495E-10d}, new double[]{0.034552380442619324d, 1.0640404096769032E-9d}, new double[]{0.0354953333735466d, -3.5901655945224663E-10d}, new double[]{0.03643739968538284d, -3.4829517943661266E-9d}, new double[]{0.037378571927547455d, 8.149473794244232E-10d}, new double[]{0.03831886500120163d, -6.990650304449166E-10d}, new double[]{0.03925827145576477d, 1.0883076226453258E-9d}, new double[]{0.040196798741817474d, 3.845192807999274E-10d}, new double[]{0.04113444685935974d, -1.1570594692045927E-9d}, new double[]{0.04207121580839157d, -1.8877045166697178E-9d}, new double[]{0.043007105588912964d, -1.6332083257987747E-10d}, new double[]{0.04394212365150452d, -1.7950057534514933E-9d}, new double[]{0.04487626254558563d, 2.302710041648838E-9d}, new double[]{0.045809537172317505d, -1.1410233017161343E-9d}, new double[]{0.04674194008111954d, -3.0498741599744685E-9d}, new double[]{0.04767347127199173d, -1.8026348269183678E-9d}, new double[]{0.04860413819551468d, -3.233204600453039E-9d}, new double[]{0.04953393340110779d, 1.7211688427961583E-9d}, new double[]{0.05046287178993225d, -2.329967807055457E-10d}, new double[]{0.05139094591140747d, -4.191810118556531E-11d}, new double[]{0.052318163216114044d, -3.5574324788328143E-9d}, new double[]{0.053244516253471375d, -1.7346590916458485E-9d}, new double[]{0.05417001247406006d, -4.343048751383674E-10d}, new double[]{0.055094651877880096d, 1.92909364037955E-9d}, new double[]{0.056018441915512085d, -5.139745677199588E-10d}, new double[]{0.05694137513637543d, 1.2637629975129189E-9d}, new double[]{0.05786345899105072d, 1.3840561112481119E-9d}, new double[]{0.058784693479537964d, 1.414889689612056E-9d}, new double[]{0.05970507860183716d, 2.9199191907666474E-9d}, new double[]{0.0606246218085289d, 7.90594243412116E-12d}, new double[]{0.06154331564903259d, 1.6844747839686189E-9d}, new double[]{0.06246116757392883d, 2.0498074572151747E-9d}, new double[]{0.06337818503379822d, -4.800180493433863E-9d}, new double[]{0.06429435312747955d, -2.4220822960064277E-9d}, new double[]{0.06520968675613403d, -4.179048566709334E-9d}, new double[]{0.06612417101860046d, 6.363872957010456E-9d}, new double[]{0.06703783571720123d, 9.339468680056365E-10d}, new double[]{0.06795066595077515d, -4.04226739708981E-9d}, new double[]{0.0688626617193222d, -7.043545052852817E-9d}, new double[]{0.06977382302284241d, -6.552819560439773E-9d}, new double[]{0.07068414986133575d, -1.0571674860370546E-9d}, new double[]{0.07159365713596344d, -3.948954622015801E-9d}, new double[]{0.07250232994556427d, 1.1776625988228244E-9d}, new double[]{0.07341018319129944d, 9.221072639606492E-10d}, new double[]{0.07431721687316895d, -3.219119568928366E-9d}, new double[]{0.0752234160900116d, 5.147575929018918E-9d}, new double[]{0.07612881064414978d, -2.291749683541979E-9d}, new doubleLine truncated
public static final double[][] f11591a = {new double[]{0.0d, 0.0d}, new double[]{9.760860120877624E-4d, -3.903230345984362E-11d}, new double[]{0.0019512202125042677d, -8.124251825289188E-11d}, new double[]{0.0029254043474793434d, -1.8374207360194882E-11d}, new double[]{0.0038986406289041042d, -2.1324678121885073E-10d}, new double[]{0.004870930686593056d, -4.5199654318611534E-10d}, new double[]{0.00584227591753006d, -2.933016992001806E-10d}, new double[]{0.006812678650021553d, -2.325147219074669E-10d}, new double[]{0.007782140746712685d, -3.046577356838847E-10d}, new double[]{0.008750664070248604d, -5.500631513861575E-10d}, new double[]{0.00971824862062931d, 8.48292035519895E-10d}, new double[]{0.010684899985790253d, 1.1422610134013436E-10d}, new double[]{0.01165061630308628d, 9.168889933128375E-10d}, new double[]{0.012615403160452843d, -5.303786078838E-10d}, new double[]{0.013579258695244789d, -5.688639355498786E-10d}, new double[]{0.01454218477010727d, 7.296670293275653E-10d}, new double[]{0.015504186972975731d, -4.370104767451421E-10d}, new double[]{0.016465261578559875d, 1.43695591408832E-9d}, new double[]{0.01742541790008545d, -1.1862263158849434E-9d}, new double[]{0.018384650349617004d, -9.482976524690715E-10d}, new double[]{0.01934296265244484d, 1.9068609515836638E-10d}, new double[]{0.020300358533859253d, 2.655990315697216E-10d}, new double[]{0.021256837993860245d, 1.0315548713040775E-9d}, new double[]{0.022212404757738113d, 5.13345647019085E-10d}, new double[]{0.02316705882549286d, 4.5604151934208014E-10d}, new double[]{0.02412080392241478d, -1.1255706987475148E-9d}, new double[]{0.025073636323213577d, 1.2289023836765196E-9d}, new double[]{0.02602556347846985d, 1.7990281828096504E-9d}, new double[]{0.026976589113473892d, -1.4152718164638451E-9d}, new double[]{0.02792670577764511d, 7.568772963781632E-10d}, new double[]{0.0288759246468544d, -1.1449998592111558E-9d}, new double[]{0.029824241995811462d, -1.6850976862319495E-9d}, new double[]{0.030771657824516296d, 8.422373919843096E-10d}, new double[]{0.0317181795835495d, 6.872350402175489E-10d}, new double[]{0.03266380727291107d, -4.541194749189272E-10d}, new double[]{0.03360854089260101d, -8.9064764856495E-10d}, new double[]{0.034552380442619324d, 1.0640404096769032E-9d}, new double[]{0.0354953333735466d, -3.5901655945224663E-10d}, new double[]{0.03643739968538284d, -3.4829517943661266E-9d}, new double[]{0.037378571927547455d, 8.149473794244232E-10d}, new double[]{0.03831886500120163d, -6.990650304449166E-10d}, new double[]{0.03925827145576477d, 1.0883076226453258E-9d}, new double[]{0.040196798741817474d, 3.845192807999274E-10d}, new double[]{0.04113444685935974d, -1.1570594692045927E-9d}, new double[]{0.04207121580839157d, -1.8877045166697178E-9d}, new double[]{0.043007105588912964d, -1.6332083257987747E-10d}, new double[]{0.04394212365150452d, -1.7950057534514933E-9d}, new double[]{0.04487626254558563d, 2.302710041648838E-9d}, new double[]{0.045809537172317505d, -1.1410233017161343E-9d}, new double[]{0.04674194008111954d, -3.0498741599744685E-9d}, new double[]{0.04767347127199173d, -1.8026348269183678E-9d}, new double[]{0.04860413819551468d, -3.233204600453039E-9d}, new double[]{0.04953393340110779d, 1.7211688427961583E-9d}, new double[]{0.05046287178993225d, -2.329967807055457E-10d}, new double[]{0.05139094591140747d, -4.191810118556531E-11d}, new double[]{0.052318163216114044d, -3.5574324788328143E-9d}, new double[]{0.053244516253471375d, -1.7346590916458485E-9d}, new double[]{0.05417001247406006d, -4.343048751383674E-10d}, new double[]{0.055094651877880096d, 1.92909364037955E-9d}, new double[]{0.056018441915512085d, -5.139745677199588E-10d}, new double[]{0.05694137513637543d, 1.2637629975129189E-9d}, new double[]{0.05786345899105072d, 1.3840561112481119E-9d}, new double[]{0.058784693479537964d, 1.414889689612056E-9d}, new double[]{0.05970507860183716d, 2.9199191907666474E-9d}, new double[]{0.0606246218085289d, 7.90594243412116E-12d}, new double[]{0.06154331564903259d, 1.6844747839686189E-9d}, new double[]{0.06246116757392883d, 2.0498074572151747E-9d}, new double[]{0.06337818503379822d, -4.800180493433863E-9d}, new double[]{0.06429435312747955d, -2.4220822960064277E-9d}, new double[]{0.06520968675613403d, -4.179048566709334E-9d}, new double[]{0.06612417101860046d, 6.363872957010456E-9d}, new double[]{0.06703783571720123d, 9.339468680056365E-10d}, new double[]{0.06795066595077515d, -4.04226739708981E-9d}, new double[]{0.0688626617193222d, -7.043545052852817E-9d}, new double[]{0.06977382302284241d, -6.552819560439773E-9d}, new double[]{0.07068414986133575d, -1.0571674860370546E-9d}, new double[]{0.07159365713596344d, -3.948954622015801E-9d}, new double[]{0.07250232994556427d, 1.1776625988228244E-9d}, new double[]{0.07341018319129944d, 9.221072639606492E-10d}, new double[]{0.07431721687316895d, -3.219119568928366E-9d}, new double[]{0.0752234160900116d, 5.147575929018918E-9d}, new double[]{0.07612881064414978d, -2.291749683541979E-9d}, new doubleLine truncated
new double[]{0.6813591718673706d, 5.294053246347931E-8d}, new double[]{0.681853175163269d, -1.2220826223585654E-9d}, new double[]{0.6823468208312988d, 5.8377876767612725E-8d}, new double[]{0.6828403472900391d, -6.437492120743254E-9d}, new double[]{0.6833335161209106d, 4.2990710043633113E-8d}, new double[]{0.6838265657424927d, -3.1516131027023284E-8d}, new double[]{0.684319257736206d, 8.70017386744679E-9d}, new double[]{0.6848117113113403d, 4.466959125843237E-8d}, new double[]{0.6853040456771851d, -4.25782656420497E-8d}, new double[]{0.6857960224151611d, -1.4386267593671393E-8d}, new double[]{0.6862877607345581d, 1.0274494061148778E-8d}, new double[]{0.686779260635376d, 3.164186629229597E-8d}, new double[]{0.6872705221176147d, 4.995334552140326E-8d}, new double[]{0.687761664390564d, -5.3763211240398744E-8d}, new double[]{0.6882524490356445d, -4.0852427502515625E-8d}, new double[]{0.688742995262146d, -3.0287143914420064E-8d}, new double[]{0.6892333030700684d, -2.183125937905008E-8d}, new double[]{0.6897233724594116d, -1.524901992178814E-8d}, new double[]{0.6902132034301758d, -1.0305018010328949E-8d}, new double[]{0.6907027959823608d, -6.764191876212205E-9d}, new double[]{0.6911921501159668d, -4.391824838015402E-9d}, new double[]{0.6916812658309937d, -2.9535446262017846E-9d}, new double[]{0.6921701431274414d, -2.2153227096187463E-9d}, new double[]{0.6926587820053101d, -1.943473623641502E-9d}};
}
@@ -24,6 +24,7 @@ import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.ui.res.stringResource
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.presentation.R
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -226,8 +228,8 @@ fun ElevationCurveChart(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
Text("● 站点A", color = colorA, fontSize = 12.sp)
Text("● 站点B", color = colorB, fontSize = 12.sp)
Text(stringResource(R.string.mutual_station_a), color = colorA, 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(
trackSamples: List<TrackSample>,
progress: Float = 0.5f,
labelA: String = "站点A",
labelB: String = "站点B",
labelA: String = "Station A",
labelB: String = "Station B",
modifier: Modifier = Modifier
) {
if (trackSamples.isEmpty()) return
@@ -44,6 +44,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -57,6 +58,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
@@ -88,28 +90,35 @@ fun MutualScreen(
},
topInfo = {
Text(
text = "过境匹配",
text = stringResource(R.string.mutual_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
},
bottomInfo = {
if (state.mutualPasses.isNotEmpty()) {
Text(
text = "找到 ${state.mutualPasses.size} 个匹配",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
text = mutualStatusText(state),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
},
endAction = {
if (state.isCalculating) {
CircularProgressIndicator(
modifier = Modifier
.height(24.dp)
.width(24.dp),
strokeWidth = 2.dp
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
if (state.isCalculating) {
CircularProgressIndicator(
modifier = Modifier
.height(24.dp)
.width(24.dp),
strokeWidth = 2.dp
)
}
MutualStatusChip(state)
}
}
)
@@ -187,11 +196,9 @@ private fun MutualContent(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
MatchIntroCard()
if (isVertical) {
StationInputCard(
title = "你的位置",
title = stringResource(R.string.mutual_station_you),
titleColor = MaterialTheme.colorScheme.primary,
lat = state.stationALat,
lon = state.stationALon,
@@ -207,7 +214,7 @@ private fun MutualContent(
currentPositionAction = onUseCurrentPosition
)
StationInputCard(
title = "友台位置",
title = stringResource(R.string.mutual_station_opposite),
titleColor = MaterialTheme.colorScheme.tertiary,
lat = state.stationBLat,
lon = state.stationBLon,
@@ -227,7 +234,7 @@ private fun MutualContent(
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
StationInputCard(
title = "你的位置",
title = stringResource(R.string.mutual_station_you),
titleColor = MaterialTheme.colorScheme.primary,
lat = state.stationALat,
lon = state.stationALon,
@@ -244,7 +251,7 @@ private fun MutualContent(
modifier = Modifier.weight(1f)
)
StationInputCard(
title = "友台位置",
title = stringResource(R.string.mutual_station_opposite),
titleColor = MaterialTheme.colorScheme.tertiary,
lat = state.stationBLat,
lon = state.stationBLon,
@@ -287,8 +294,8 @@ private fun MutualContent(
startTime = pass.startTime,
endTime = pass.endTime,
maxElev = maxOf(pass.maxElevationA, pass.maxElevationB, 10.0),
labelA = "你",
labelB = "友台"
labelA = stringResource(R.string.mutual_label_you),
labelB = stringResource(R.string.mutual_label_opposite)
)
MutualPassCard(
pass = pass,
@@ -304,25 +311,52 @@ private fun MutualContent(
}
@Composable
private fun MatchIntroCard(modifier: Modifier = Modifier) {
ElevatedCard(modifier = modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "位置匹配",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "输入双方位置,查找共同可见的卫星过境窗口",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
private fun MutualStatusChip(state: MutualUiState) {
val colorScheme = MaterialTheme.colorScheme
val containerColor = when {
state.isCalculating -> colorScheme.primaryContainer
state.errorMessage != null -> colorScheme.errorContainer
state.mutualPasses.isNotEmpty() -> colorScheme.primary
state.hasSearched -> colorScheme.surfaceVariant
else -> colorScheme.surfaceVariant
}
val contentColor = when {
state.isCalculating -> colorScheme.onPrimaryContainer
state.errorMessage != null -> colorScheme.onErrorContainer
state.mutualPasses.isNotEmpty() -> colorScheme.onPrimary
else -> colorScheme.onSurfaceVariant
}
Surface(
color = containerColor,
contentColor = contentColor,
shape = MaterialTheme.shapes.small
) {
Text(
text = mutualStatusChipText(state),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Medium,
maxLines = 1,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
)
}
}
@Composable
private fun mutualStatusText(state: MutualUiState): String = when {
state.isCalculating -> stringResource(R.string.mutual_status_calculating)
state.errorMessage != null -> stringResource(R.string.mutual_status_error)
state.mutualPasses.isNotEmpty() -> stringResource(R.string.mutual_status_matches, state.mutualPasses.size)
state.hasSearched -> stringResource(R.string.mutual_status_none)
else -> stringResource(R.string.mutual_status_idle)
}
@Composable
private fun mutualStatusChipText(state: MutualUiState): String = when {
state.isCalculating -> stringResource(R.string.mutual_chip_calculating)
state.errorMessage != null -> stringResource(R.string.mutual_chip_error)
state.mutualPasses.isNotEmpty() -> stringResource(R.string.mutual_chip_matches, state.mutualPasses.size)
state.hasSearched -> stringResource(R.string.mutual_chip_none)
else -> stringResource(R.string.mutual_chip_idle)
}
@Composable
@@ -361,7 +395,7 @@ private fun StationInputCard(
)
if (currentPositionAction != null) {
OutlinedButton(onClick = currentPositionAction) {
Text("填入当前位置", style = MaterialTheme.typography.bodySmall)
Text(stringResource(R.string.mutual_use_current), style = MaterialTheme.typography.bodySmall)
}
}
}
@@ -373,7 +407,7 @@ private fun StationInputCard(
OutlinedTextField(
value = lat,
onValueChange = onLatChange,
label = { Text("纬度") },
label = { Text(stringResource(R.string.mutual_lat)) },
placeholder = { Text(latPlaceholder) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
@@ -382,7 +416,7 @@ private fun StationInputCard(
OutlinedTextField(
value = lon,
onValueChange = onLonChange,
label = { Text("经度") },
label = { Text(stringResource(R.string.mutual_lon)) },
placeholder = { Text(lonPlaceholder) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
@@ -393,15 +427,15 @@ private fun StationInputCard(
OutlinedTextField(
value = grid,
onValueChange = onGridChange,
label = { Text("网格") },
label = { Text(stringResource(R.string.mutual_grid)) },
placeholder = { Text(gridPlaceholder) },
supportingText = { Text("支持 4/6/8 位 Maidenhead 网格;也可直接输入经纬度") },
supportingText = { Text(stringResource(R.string.mutual_grid_hint)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "最小仰角:${minElev.toInt()}°",
text = stringResource(R.string.mutual_min_elevation, minElev.toInt()),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -429,7 +463,7 @@ private fun MatchSearchCard(
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = "时间范围",
text = stringResource(R.string.mutual_time_range),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
@@ -460,7 +494,7 @@ private fun MatchSearchCard(
)
Spacer(Modifier.width(8.dp))
}
Text(if (isCalculating) "计算中..." else "查询匹配")
Text(if (isCalculating) stringResource(R.string.mutual_calculating) else stringResource(R.string.mutual_query))
}
}
}
@@ -526,12 +560,12 @@ private fun MutualPassCard(
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "你: ${pass.maxElevationA}°",
text = stringResource(R.string.mutual_elevation_you, pass.maxElevationA.toInt()),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "友台: ${pass.maxElevationB}°",
text = stringResource(R.string.mutual_elevation_opposite, pass.maxElevationB.toInt()),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary
)
@@ -547,7 +581,7 @@ private fun MutualPassCard(
// Dual-station radar track (polar plot)
if (visibleTracks.isNotEmpty()) {
Text(
text = "双方轨迹",
text = stringResource(R.string.mutual_both_tracks),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant
@@ -555,8 +589,8 @@ private fun MutualPassCard(
MutualRadarView(
trackSamples = visibleTracks,
progress = dragProgress,
labelA = "你",
labelB = "友台"
labelA = stringResource(R.string.mutual_label_you),
labelB = stringResource(R.string.mutual_label_opposite)
)
Spacer(Modifier.height(8.dp))
}
@@ -581,7 +615,7 @@ private fun MutualPassCard(
modifier = Modifier.height(16.dp).width(16.dp)
)
Spacer(Modifier.width(6.dp))
Text("查看雷达")
Text(stringResource(R.string.mutual_open_radar))
}
}
}
@@ -49,6 +49,7 @@ data class MutualUiState(
val hoursAhead: Int = 24,
val mutualPasses: List<MutualPass> = emptyList(),
val isCalculating: Boolean = false,
val hasSearched: Boolean = false,
val selectedPassIndex: Int = -1,
val errorMessage: String? = null
)
@@ -157,17 +158,25 @@ class MutualViewModel(
var posB = resolvePosition(state.stationBLat, state.stationBLon, state.stationBGrid)
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
}
val satellites = satelliteRepo.satellites.value
if (satellites.isEmpty()) {
_uiState.update { it.copy(errorMessage = "没有卫星数据,请先在卫星列表中选择卫星") }
_uiState.update { it.copy(errorMessage = "No satellite data. Select satellites from the list first.") }
return
}
_uiState.update { it.copy(isCalculating = true, errorMessage = null, mutualPasses = emptyList()) }
_uiState.update {
it.copy(
isCalculating = true,
hasSearched = true,
errorMessage = null,
mutualPasses = emptyList(),
selectedPassIndex = -1
)
}
viewModelScope.launch {
val time = System.currentTimeMillis()
@@ -184,9 +193,9 @@ class MutualViewModel(
val passCount = satelliteRepo.passes.value.size
val satCount = satellites.size
when {
passCount == 0 -> "过境列表为空,请先在卫星列表中选择卫星"
satCount == 0 -> "没有已选中的卫星"
else -> "未找到共同过境(${passCount}个过境,${satCount}颗卫星)"
passCount == 0 -> "Pass list is empty. Select satellites from the list first."
satCount == 0 -> "No satellites selected."
else -> "No mutual passes found (${passCount} passes, ${satCount} satellites)."
}
} else null
@@ -38,6 +38,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import java.util.TimeZone
@@ -162,7 +163,10 @@ class PassesViewModel(
return SimpleDateFormat(pattern, Locale.getDefault()).also { it.timeZone = tz }
}
// Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace
// Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace.
// CelestialComputer.findSunRiseSet() returns the next rise/set after the supplied time, so pass
// the calendar day's 00:00 in the same timezone used by the visible date group. Passing a pass's
// AOS time can jump to the following day's sunrise/sunset when that AOS is after the local event.
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
val stationPos = settingsRepo.stationPosition.value
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
@@ -171,7 +175,7 @@ class PassesViewModel(
val result = LinkedHashMap<String, Pair<String, String>>()
// DeepSpace group always shows today's sun times
if (passes.any { it.isDeepSpace }) {
val riseSet = CelestialComputer.findSunRiseSet(stationPos, System.currentTimeMillis())
val riseSet = CelestialComputer.findSunRiseSet(stationPos, startOfDayMillis(System.currentTimeMillis(), tz))
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
result["DeepSpace (period >225min)"] = rise to set
@@ -180,7 +184,7 @@ class PassesViewModel(
if (pass.isDeepSpace) continue
val label = sdfDate.format(Date(pass.aosTime))
if (label in result) continue
val riseSet = CelestialComputer.findSunRiseSet(stationPos, pass.aosTime)
val riseSet = CelestialComputer.findSunRiseSet(stationPos, startOfDayMillis(pass.aosTime, tz))
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
result[label] = rise to set
@@ -188,6 +192,16 @@ class PassesViewModel(
return result
}
private fun startOfDayMillis(timeMillis: Long, tz: TimeZone): Long {
return Calendar.getInstance(tz, Locale.getDefault()).apply {
timeInMillis = timeMillis
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}
private fun groupPasses(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, List<OrbitalPass>> {
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
val sdfDate = dateFormat(tz)
-2
View File
@@ -8,8 +8,6 @@ android {
dependencies {
implementation(project(":feature:mutual"))
// CW 解码面板: 复用 feature:cw 的 Morse Expert 引擎(布局 cw_panel_main + MainActivity 控制器)
implementation(project(":feature:cw"))
// 与 feature:cw 一致(控制器按 ID 递归查找 ConstraintLayout 视图)
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
}
@@ -61,6 +61,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import com.rtbishop.look4sat.core.domain.utility.toDegrees
import com.rtbishop.look4sat.core.presentation.EmptyListCard
import com.rtbishop.look4sat.core.presentation.IconCard
@@ -78,6 +79,7 @@ import kotlin.math.PI
private enum class RadarPage(val title: String) {
Transceivers("Transceivers"),
Calculator("Calculator"),
Sstv("SSTV")
}
@@ -201,16 +203,31 @@ private fun PagerCard(
requestMicPermission: () -> Unit,
modifier: Modifier = Modifier
) {
val pages = RadarPage.entries
val hasCalculatorPage = remember(uiState.transceivers.transmitters) {
uiState.transceivers.transmitters.any(DopplerFrequencyCalculator::isNamedLinearTransponder)
}
val pages = remember(hasCalculatorPage) {
buildList {
add(RadarPage.Transceivers)
if (hasCalculatorPage) add(RadarPage.Calculator)
add(RadarPage.Sstv)
}
}
val pagerState = rememberPagerState(pageCount = { pages.size })
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(pages.size) {
val lastPage = pages.lastIndex
if (pagerState.currentPage > lastPage) pagerState.scrollToPage(lastPage)
}
ElevatedCard(modifier = modifier) {
Column(modifier = Modifier.fillMaxSize()) {
PrimaryTabRow(selectedTabIndex = pagerState.currentPage) {
val selectedTabIndex = pagerState.currentPage.coerceIn(0, pages.lastIndex)
PrimaryTabRow(selectedTabIndex = selectedTabIndex) {
pages.forEachIndexed { index, page ->
Tab(
selected = pagerState.currentPage == index,
selected = selectedTabIndex == index,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
)
@@ -222,14 +239,19 @@ private fun PagerCard(
) { pageIndex ->
when (pages[pageIndex]) {
RadarPage.Transceivers -> TransceiversPage(
transceivers = uiState.transceivers.transmitters,
selectedUuid = uiState.transceivers.selectedUuid,
orbitalPos = uiState.orbitalPos,
cw = uiState.cw,
radioControl = uiState.radioControl,
onAction = onAction,
requestMicPermission = requestMicPermission
)
transceivers = uiState.transceivers.transmitters,
selectedUuid = uiState.transceivers.selectedUuid,
radioControl = uiState.radioControl,
onAction = onAction
)
RadarPage.Calculator -> CalculatorPage(
transceivers = uiState.transceivers.transmitters,
selectedUuid = uiState.transceivers.selectedUuid,
orbitalPos = uiState.orbitalPos,
cw = uiState.cw,
onAction = onAction,
requestMicPermission = requestMicPermission
)
RadarPage.Sstv -> SstvPage(
sstv = uiState.sstv,
dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) },
@@ -94,21 +94,14 @@ fun RadarViewCompose(
animationSpec = infiniteRepeatable(tween(1000)),
label = "animScale"
)
// Drive the sweep from the animation framework to eliminate state mutation inside the draw block.
// Only create the infinite transition when the sweep is enabled — otherwise the canvas would
// be invalidated every frame for an effect that isn't visible, wasting GPU and battery.
val sweepDegrees = if (shouldShowSweep) {
val sweepTransition = rememberInfiniteTransition(label = "sweep")
val animatedSweep by sweepTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(tween(SWEEP_DURATION_MS, easing = LinearEasing)),
label = "sweepDegrees"
)
animatedSweep
} else {
0f
}
// Drive the sweep from the animation framework to eliminate state mutation inside the draw block
val sweepTransition = rememberInfiniteTransition(label = "sweep")
val sweepDegrees by sweepTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(tween(SWEEP_DURATION_MS, easing = LinearEasing)),
label = "sweepDegrees"
)
val measurer = rememberTextMeasurer()
val sunPainter = painterResource(R.drawable.ic_sun)
val moonPainter = painterResource(R.drawable.ic_moon)
@@ -181,7 +181,7 @@ class RadarViewModel(
orbitalPos = pos, sunPosition = cachedSunPos, moonPosition = cachedMoonPos
)
}
processRadios(allRadios, pos)
processRadios(allRadios, pass.orbitalObject, timeNow)
sendPassData(pos)
}
@@ -342,8 +342,8 @@ class RadarViewModel(
}
}
private suspend fun processRadios(radios: List<SatRadio>, satPos: OrbitalPos) {
val transmitters = satelliteRepo.getRadios(satPos, radios)
private suspend fun processRadios(radios: List<SatRadio>, orbitalObject: OrbitalObject, time: Long) {
val transmitters = satelliteRepo.getRadios(orbitalObject, stationPos, radios, time)
_uiState.update { state ->
val freq = if (state.transceivers.selectedUuid != null) {
val selectedRadio = transmitters.firstOrNull { it.uuid == state.transceivers.selectedUuid }
@@ -23,14 +23,18 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -41,31 +45,39 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
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.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
@@ -77,6 +89,7 @@ import androidx.constraintlayout.widget.ConstraintLayout
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.formatFrequency
@@ -90,11 +103,8 @@ import kotlin.time.Duration.Companion.milliseconds
fun TransceiversPage(
transceivers: List<SatRadio>,
selectedUuid: String?,
orbitalPos: OrbitalPos?,
cw: CwSubState,
radioControl: RadioControlSubState,
onAction: (RadarAction) -> Unit,
requestMicPermission: () -> Unit = {},
modifier: Modifier = Modifier
) {
if (transceivers.isEmpty()) {
@@ -117,11 +127,8 @@ fun TransceiversPage(
TransceiverItem(
radio = radio,
isExpanded = isExpanded,
orbitalPos = orbitalPos,
cw = cw,
radioControl = radioControl,
onAction = onAction,
requestMicPermission = requestMicPermission,
onToggle = { onAction(RadarAction.SelectTransmitter(radio.uuid)) }
)
}
@@ -129,6 +136,85 @@ fun TransceiversPage(
}
}
@Composable
fun CalculatorPage(
transceivers: List<SatRadio>,
selectedUuid: String?,
orbitalPos: OrbitalPos?,
cw: CwSubState,
onAction: (RadarAction) -> Unit,
requestMicPermission: () -> Unit = {},
modifier: Modifier = Modifier
) {
val calculatorTransceivers = remember(transceivers) {
transceivers.filter(DopplerFrequencyCalculator::isNamedLinearTransponder)
}
val selectedTransceiver = remember(calculatorTransceivers, selectedUuid) {
calculatorTransceivers.firstOrNull { it.uuid == selectedUuid }
?: calculatorTransceivers.firstOrNull()
}
if (selectedTransceiver == null) {
EmptyTransceiversContent(modifier)
return
}
LazyColumn(
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
if (calculatorTransceivers.size > 1) {
item {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth()
) {
calculatorTransceivers.forEach { radio ->
FilterChip(
selected = radio.uuid == selectedTransceiver.uuid,
onClick = {
if (radio.uuid != selectedUuid) onAction(RadarAction.SelectTransmitter(radio.uuid))
},
label = {
Text(
text = transceiverTitle(radio),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
)
}
}
}
}
item {
DopplerFrequencyCalculator(
transponder = selectedTransceiver,
orbitalPos = orbitalPos,
modifier = Modifier.fillMaxWidth()
)
}
item {
HorizontalDivider(
thickness = 1.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
}
item {
CwDecoderPanel(
cw = cw,
onAction = onAction,
requestMicPermission = requestMicPermission,
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) {
@@ -156,11 +242,8 @@ private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
private fun TransceiverItem(
radio: SatRadio,
isExpanded: Boolean,
orbitalPos: OrbitalPos?,
cw: CwSubState,
radioControl: RadioControlSubState,
onAction: (RadarAction) -> Unit,
requestMicPermission: () -> Unit,
onToggle: () -> Unit
) {
val bgColor = if (isExpanded) MaterialTheme.colorScheme.surfaceContainerHighest
@@ -201,11 +284,8 @@ private fun TransceiverItem(
)
}
// Title with mode
val title = if (radio.isInverted) "INV: ${radio.info}" else radio.info
val mode = "${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"}"
val fullTitle = "$title ($mode)"
Text(
text = fullTitle,
text = transceiverTitle(radio),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
@@ -253,11 +333,8 @@ private fun TransceiverItem(
) {
ExpandedRadioControl(
radio = radio,
orbitalPos = orbitalPos,
cw = cw,
radioControl = radioControl,
onAction = onAction,
requestMicPermission = requestMicPermission
onAction = onAction
)
}
@@ -326,11 +403,8 @@ private fun UnifiedFrequencyRow(
@Composable
private fun ExpandedRadioControl(
radio: SatRadio,
orbitalPos: OrbitalPos?,
cw: CwSubState,
radioControl: RadioControlSubState,
onAction: (RadarAction) -> Unit,
requestMicPermission: () -> Unit = {},
) {
Column(
modifier = Modifier
@@ -439,23 +513,6 @@ private fun ExpandedRadioControl(
}
}
// Doppler frequency calculator (linear transponders only)
DopplerFrequencyCalculator(
transponder = radio,
orbitalPos = orbitalPos,
modifier = Modifier.fillMaxWidth()
)
// CW decoder panel (linear transponders only)
if (DopplerFrequencyCalculator.isLinearTransponder(radio)) {
CwDecoderPanel(
cw = cw,
onAction = onAction,
requestMicPermission = requestMicPermission,
modifier = Modifier.fillMaxWidth()
)
}
// Control buttons
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) {
@@ -487,6 +544,8 @@ private fun ExpandedRadioControl(
}
}
private enum class EditedField { TX, PASSBAND, RX }
@Composable
private fun DopplerFrequencyCalculator(
transponder: SatRadio,
@@ -495,144 +554,351 @@ private fun DopplerFrequencyCalculator(
) {
if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return
var txInputMHz by remember { mutableStateOf("") }
var rxInputMHz by remember { mutableStateOf("") }
var lastEditedField by remember { mutableStateOf(EditedField.TX) }
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 lastEditedBy by remember { mutableStateOf(EditedField.TX) }
var stepSizeKHz by remember { mutableIntStateOf(1) }
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) {
if (lastEditedBy == EditedField.TX) {
val txMHz = txInputMHz.toDoubleOrNull()
if (txMHz != null && txMHz > 0) {
val txHz = (txMHz * 1_000_000).toLong()
val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txHz, transponder, orbitalPos, offsetHz
)
if (rxHz != null) {
rxInputMHz = String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0)
}
if (lastEditedField == EditedField.TX) {
// TX 是锚点,不变;重算 RX
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
} else if (lastEditedField == EditedField.RX) {
// RX 是锚点,不变;重算 TX
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
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 {
val rxMHz = rxInputMHz.toDoubleOrNull()
if (rxMHz != null && rxMHz > 0) {
val rxHz = (rxMHz * 1_000_000).toLong()
val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxHz, transponder, orbitalPos, offsetHz
)
if (txHz != null) {
txInputMHz = String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0)
}
else -> {
txFrequencyHz = newTxHz.coerceIn(txLow, txHigh)
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
}
}
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(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(4.dp)
modifier = modifier.fillMaxWidth().padding(top = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = stringResource(R.string.radar_doppler_calc),
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
Text(
text = stringResource(R.string.radar_doppler_info),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Offset input
OutlinedTextField(
value = offsetKHz,
onValueChange = { newVal ->
offsetKHz = newVal
// Trigger recompute based on last edited field
if (lastEditedBy == EditedField.TX) {
val txMHz = txInputMHz.toDoubleOrNull()
if (txMHz != null && txMHz > 0) {
val txHz = (txMHz * 1_000_000).toLong()
val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txHz, transponder, orbitalPos, offsetHz
)
rxInputMHz = if (rxHz != null) String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0) else ""
}
} else {
val rxMHz = rxInputMHz.toDoubleOrNull()
if (rxMHz != null && rxMHz > 0) {
val rxHz = (rxMHz * 1_000_000).toLong()
val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxHz, transponder, orbitalPos, offsetHz
)
txInputMHz = if (txHz != null) String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0) else ""
}
Row(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
// 左半边: TX + RX 按钮
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
FilterChip(
selected = lastEditedField == EditedField.TX,
onClick = {
when (lastEditedField) {
EditedField.TX -> {
passbandPosition = ((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
lastEditedField = EditedField.PASSBAND
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
EditedField.PASSBAND -> { lastEditedField = EditedField.TX }
else -> { lastEditedField = EditedField.TX }
}
},
label = {
Text("TX", fontSize = 13.sp, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth())
},
modifier = Modifier.weight(1f).fillMaxHeight()
)
FilterChip(
selected = lastEditedField == EditedField.RX,
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()
)
}
},
label = { Text(stringResource(R.string.radar_doppler_offset_hint)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.fillMaxWidth()
)
// 右半边: Offset 输入框(带细线边框)
Box(
modifier = Modifier
.weight(1f)
.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
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
// TX input → compute RX
OutlinedTextField(
value = txInputMHz,
onValueChange = { newVal ->
txInputMHz = newVal
lastEditedBy = EditedField.TX
val mhz = newVal.toDoubleOrNull()
if (mhz != null && mhz > 0) {
val txHz = (mhz * 1_000_000).toLong()
val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txHz, transponder, orbitalPos, offsetHz
// TX 行
Column(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
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) }
Slider(
value = txSliderValue,
onValueChange = { norm ->
val rawFreq = txLow + (txRange * norm).toLong()
val snapped = ((rawFreq + 500) / 1000L) * 1000L
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 = ""
}
},
label = { Text(stringResource(R.string.radar_doppler_tx_hint)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.weight(1f)
Text(
text = String.format(Locale.ENGLISH, "%.4f", txHigh / 1_000_000.0),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
textAlign = TextAlign.End
)
}
}
HorizontalDivider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
// RX input → compute TX
OutlinedTextField(
value = rxInputMHz,
onValueChange = { newVal ->
rxInputMHz = newVal
lastEditedBy = EditedField.RX
val mhz = newVal.toDoubleOrNull()
if (mhz != null && mhz > 0) {
val rxHz = (mhz * 1_000_000).toLong()
val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxHz, transponder, orbitalPos, offsetHz
// RX 行
Column(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
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) }
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 = ""
}
},
label = { Text(stringResource(R.string.radar_doppler_rx_hint)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.weight(1f)
)
Text(
text = String.format(Locale.ENGLISH, "%.4f", rxHigh / 1_000_000.0),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
textAlign = TextAlign.End
)
}
}
}
}
}
private enum class EditedField { TX, RX }
@Composable
private fun CwDecoderPanel(
cw: CwSubState,
@@ -669,39 +935,43 @@ private fun CwDecoderPanel(
}
AnimatedVisibility(visible = cw.isExpanded) {
// Morse Expert 引擎(4.5.0 移植): 小布局 cw_panel_main(瀑布图 + 解码文本)
// 自带 Kotlin 贝叶斯引擎(CwDecoder)不再用于此面板(代码保留作回退)
// PR #1 Morse Expert engine: mini layout with waterfall + decoded text.
// Keep the old Kotlin decoder state/actions as fallback code, but this panel no longer feeds it.
val context = LocalContext.current
val activity = remember { context as? Activity }
val activity = remember(context) {
context as? Activity ?: error("CwDecoderPanel must be hosted in an Activity")
}
val controller = remember { MainActivity() }
val rootView = remember {
LayoutInflater.from(context).inflate(CwR.layout.cw_panel_main, null) as ConstraintLayout
val rootView = remember(context) {
LayoutInflater.from(context).inflate(CwR.layout.activity_main, null) as ConstraintLayout
}
var initialized by remember { mutableStateOf(false) }
var listening by remember { mutableStateOf(false) }
// 生命周期: 展开即绑定视图(不改宿主窗口); 收起/离开自动释放
DisposableEffect(Unit) {
if (activity != null) {
controller.onCreate(activity, rootView, false)
}
onDispose {
controller.onPause()
controller.onDestroy()
}
}
// 权限已授予(首次进入/授权回调统一入口): 初始化引擎 + 启动录音
LaunchedEffect(cw.hasPermission) {
if (cw.hasPermission && !initialized) {
controller.onPermissionGranted() // = v(): 创建音频采集 + 解码核心
controller.onResume() // 立即启动录音
// 重要: 不在展开时初始化控制器(避免"一展开就崩溃")。
// controller.onCreate/onResume/onPermissionGranted 全部推迟到用户点 Start 才执行。
fun startDecoding() {
if (!initialized) {
controller.onCreate(activity, rootView)
controller.onPermissionGranted()
controller.onResume()
initialized = true
listening = true
} else {
controller.onResume()
}
listening = true
}
DisposableEffect(Unit) {
onDispose {
if (initialized) {
controller.onPause()
controller.onDestroy()
}
}
}
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
// Control buttons row(映射到控制器)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
@@ -711,14 +981,8 @@ private fun CwDecoderPanel(
onClick = {
if (!cw.hasPermission) {
requestMicPermission()
} else if (!initialized) {
controller.onPermissionGranted()
controller.onResume()
initialized = true
listening = true
} else {
controller.onResume()
listening = true
startDecoding()
}
},
modifier = Modifier.weight(1f)
@@ -737,20 +1001,23 @@ private fun CwDecoderPanel(
}
}
OutlinedButton(
onClick = { controller.clearDecoded() },
onClick = { if (initialized) controller.clearDecoded() },
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.radar_cw_reset))
}
}
// 瀑布图 + 解码文本(原版布局迷你版, 适配容器)
AndroidView(
factory = { rootView },
Box(
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
)
) {
AndroidView(
factory = { rootView },
modifier = Modifier.fillMaxSize()
)
}
}
}
}
@@ -771,5 +1038,11 @@ private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
)
}
private fun transceiverTitle(radio: SatRadio): String {
val title = if (radio.isInverted) "INV: ${radio.info}" else radio.info
val mode = "${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"}"
return "$title ($mode)"
}
private val FREQ_ADJUSTMENTS =
listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
-11
View File
@@ -1,11 +0,0 @@
plugins {
alias(libs.plugins.convention.featurePlugin)
}
android {
namespace = "com.rtbishop.look4sat.feature.roaming"
}
dependencies {
implementation(project(":core:data"))
}
@@ -1,791 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.roaming
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Bundle
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* 漫游页 —— 原样照搬自 QTH定位器 2.0 (com.us1pm.gridsquarelocator)。
* UI: res/layout/main.xml (结构/尺寸/颜色逐项照搬, 不做任何更改)
* 逻辑: MainActivity.showLocation() / checkEnabled() / GPS 实时监听
* 红线: UI 与逻辑均不更改, 与成品截图逐像素对齐。
*/
@Composable
fun RoamingScreen() {
val context = LocalContext.current
var state by remember { mutableStateOf(RoamingState()) }
// 照搬 onCreate: 日期固定文本 + 小时前缀(定位时拼接分钟)
val dateText = remember { SimpleDateFormat("dd MMM yyyy", Locale.getDefault()).format(Date()) }
var hourPrefix by remember { mutableStateOf("") }
// 照搬 onCreate: 初始 checkEnabled() -> 红点+设置按钮, 日期隐藏
var hasPermission by remember { mutableStateOf(false) }
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { }
LaunchedEffect(Unit) {
hourPrefix = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date()).substring(0, 3)
val fine = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
val coarse = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
hasPermission = fine || coarse
if (!hasPermission) {
permissionLauncher.launch(
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
)
} else {
// 照搬 onResume: 绿点+进度条+提示+日期显示, 红点/按钮隐藏
state = state.copy(gpsOn = true, gpsOff = false, showSettings = false, showProgress = true, showNotice = true, showDate = true)
}
}
// 照搬 onResume/onPause: GPS + network 监听, 10 秒 / 10 米
val locationManager = remember { context.getSystemService(Context.LOCATION_SERVICE) as LocationManager }
DisposableEffect(locationManager, hasPermission) {
if (hasPermission) {
@SuppressLint("MissingPermission")
val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
// 照搬 showLocation 开头: 只接受 gps / network provider
if (location.provider != "gps" && location.provider != "network") return
state = roamingStateFromLocation(location.latitude, location.longitude, location.time, hourPrefix)
.copy(date = dateText)
}
override fun onProviderDisabled(provider: String) {
// 照搬 checkEnabled: 红点+按钮, 日期隐藏
state = state.copy(gpsOn = false, gpsOff = true, showSettings = true, showProgress = false, showNotice = false, showDate = false)
}
override fun onProviderEnabled(provider: String) {
state = state.copy(gpsOn = true, gpsOff = false, showSettings = false, showProgress = true, showNotice = true, showDate = true)
}
@Deprecated("Deprecated in Java")
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) = Unit
}
try {
locationManager.requestLocationUpdates("gps", 10_000L, 10f, listener)
locationManager.requestLocationUpdates("network", 10_000L, 10f, listener)
} catch (_: SecurityException) {
}
onDispose { locationManager.removeUpdates(listener) }
} else {
onDispose { }
}
}
// 照搬 res/layout/main.xml: 浅色页面, 蓝色信息区, 43sp 定位码, 3x3 九宫格, 底部版权
// 按用户要求: 上下往内缩进系统栏安全区, 避免内容被状态栏/导航栏遮挡
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.windowInsetsPadding(WindowInsets.systemBars)
) {
// relativeLayout7: 顶部 GPS 状态条, 25dp 高, holo_blue_bright
Box(
modifier = Modifier
.fillMaxWidth()
.padding(2.dp)
.height(25.dp)
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
Row(modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
// tvTitleGPS: "GPS" 14sp 黑, 左 8dp
Text(
text = "GPS",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp)
)
// imGps: toRightOf tvTitleGPS + marginLeft 15dp (成品 main.xml)
Spacer(modifier = Modifier.width(15.dp))
// imGps: 绿点 (15dp, 主题色 primary -> 夜间滤镜下可见)
if (state.gpsOn) {
Box(
modifier = Modifier
.size(15.dp)
.background(MaterialTheme.colorScheme.primary, CircleShape)
)
}
// imGpsOff: 红点叠层 (15dp, 主题色 error)
if (state.gpsOff) {
Box(
modifier = Modifier
.size(15.dp)
.background(MaterialTheme.colorScheme.error, CircleShape)
)
}
Spacer(modifier = Modifier.weight(1f))
// tvDate: 14sp 黑, 居中
if (state.showDate) {
Text(text = state.date.ifEmpty { dateText }, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Spacer(modifier = Modifier.weight(1f))
// tvTime: 14sp 黑, 右对齐
Text(
text = state.time,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(end = 8.dp)
)
}
// btnLocationSettings: 黄色按钮, 居中
// btnLocationSettings: 成品 #faf8f54a 半透明黄, 14sp 黑字, 居中
if (state.showSettings) {
Box(
modifier = Modifier
.align(Alignment.Center)
.background(MaterialTheme.colorScheme.primary)
.clickable { context.startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) }
.padding(horizontal = 5.dp),
contentAlignment = Alignment.Center
) {
Text(text = "设置启用GPS", fontSize = 14.sp, color = MaterialTheme.colorScheme.onPrimary)
}
}
}
// linearLayout4: 经纬度区, holo_blue_bright
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 2.dp)
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
// 纬度行: tvLati 16sp 黑 左 8dp | tvLat 右对齐 3dp
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) {
Text(text = state.latLabel, fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 8.dp))
Spacer(modifier = Modifier.weight(1f))
Text(text = state.latValue, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 3.dp))
}
// 经度行
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) {
Text(text = state.lonLabel, fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 8.dp))
Spacer(modifier = Modifier.weight(1f))
Text(text = state.lonValue, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 3.dp))
}
}
// relativeLayout6: 定位码区, 43sp bold 黑居中
Box(
modifier = Modifier
.fillMaxWidth()
.padding(2.dp)
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
Text(
text = state.loc,
fontSize = 43.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
// progressBar: 加载中, 居中
if (state.showProgress) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp).align(Alignment.Center),
color = MaterialTheme.colorScheme.onSurfaceVariant,
strokeWidth = 2.dp
)
}
// tvNotice: 提示文字, 居中顶部
if (state.showNotice) {
Text(
text = "请稍等,加载可能需要几分钟的时间。",
fontSize = 16.sp,
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.TopCenter).padding(top = 2.dp)
)
}
}
// relativeLayout: 九宫格, 占满剩余, 底部 15dp
// 用 Box 等价 RelativeLayout 布局: 左格贴左 / 中格水平居中 / 右格贴右 (成品 main.xml)
Column(modifier = Modifier.fillMaxWidth().weight(1f).padding(bottom = 15.dp)) {
// 行1: relativeLayout5 (上排: 纬度 +1)
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
GridCell(text = state.grids[0], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterStart))
GridCell(text = state.grids[1], modifier = Modifier.width(200.dp).fillMaxHeight().align(Alignment.Center))
GridCell(text = state.grids[2], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterEnd))
}
// 行2: relativeLayout4, 205dp 高 (中排: 中央)
Box(modifier = Modifier.fillMaxWidth().height(205.dp)) {
GridCell(text = state.grids[3], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterStart))
// 中央列: tv22 (100x80dp centerInParent) 叠在 linearLayout3 (200x200dp) 之上
Box(modifier = Modifier.width(200.dp).fillMaxHeight().align(Alignment.Center)) {
// linearLayout3: 200x200dp, marginTop 2dp, holo_blue_bright
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.offset(y = 2.dp)
.size(width = 200.dp, height = 200.dp)
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
// iv: 红点 10x10dp, top|left, leftMargin/topMargin 查表绝对定位
Image(
painter = painterResource(R.drawable.roam_pnt),
contentDescription = null,
modifier = Modifier
.size(10.dp)
.offset(x = state.markerLeft.dp, y = state.markerTop.dp)
)
}
// tv22: 100x80dp centerInParent, 30sp bold, textColorHighlight(白)
Text(
text = state.grids[4],
fontSize = 30.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.size(width = 100.dp, height = 80.dp)
.align(Alignment.Center)
)
}
GridCell(text = state.grids[5], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterEnd))
}
// 行3: relativeLayout3 (下排: 纬度 -1)
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
GridCell(text = state.grids[6], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterStart))
GridCell(text = state.grids[7], modifier = Modifier.width(200.dp).fillMaxHeight().align(Alignment.Center))
GridCell(text = state.grids[8], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterEnd))
}
}
// tv4: 底部版权, 10sp 黑 (往上拉, 底部预留空间避免被导航栏遮挡)
Text(
text = "制作:US1PM 汉化:BA7LCE",
fontSize = 10.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp)
)
}
}
// 周边格: 60/200/60dp, #0BACF1, bold 居中, 2dp margin (照搬 tv11/tv13/tv21/tv23/tv31/tv33)
@Composable
private fun GridCell(text: String, modifier: Modifier) {
Box(
modifier = modifier
.padding(2.dp)
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center
) {
Text(
text = text,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
}
}
/**
* 照搬 MainActivity.showLocation() 的状态。
* 网格/九宫格/红点查表与成品逐行一致。
*/
data class RoamingState(
val date: String = "",
val time: String = "",
val latLabel: String = "纬度",
val latValue: String = "",
val lonLabel: String = "经度",
val lonValue: String = "",
val loc: String = "",
val grids: List<String> = List(9) { "" },
/** 红点 leftMargin(dp), 经度第 3 对字符查表 */
val markerLeft: Int = 0,
/** 红点 topMargin(dp), 纬度第 3 对字符查表(屏幕 Y 反转) */
val markerTop: Int = 0,
val gpsOn: Boolean = false,
val gpsOff: Boolean = true,
val showSettings: Boolean = true,
val showProgress: Boolean = false,
val showNotice: Boolean = false,
val showDate: Boolean = false
)
/**
* 照搬 showLocation() 的完整计算: 时间/经纬度 DMS/8 位网格/九宫格/红点查表。
*/
fun roamingStateFromLocation(lat: Double, lon: Double, fixTime: Long, hourPrefix: String): RoamingState {
// 照搬: tvTime = 缓存小时前缀 + 定位时间分钟
val minute = SimpleDateFormat("mm", Locale.getDefault()).format(Date(fixTime))
val time = hourPrefix + minute
// 方向字符
val ew = if (lon >= 0.0) "E" else "W"
val ns = if (lat >= 0.0) "N" else "S"
// 照搬: 纬度 DMS (String.valueOf 无补零)
val latAbs = if (lat < 0.0) -lat else lat
val latDeg = latAbs.toInt()
val latMinFull = (latAbs - latDeg) * 60.0
val latMin = latMinFull.toInt()
val latSec = ((latMinFull - latMin) * 60.0).toInt()
val latLabel = "纬度 $latDeg° $latMin' $latSec\" $ns"
val latValue = "$lat° "
// 照搬: 经度 DMS
val lonAbs = if (lon < 0.0) -lon else lon
val lonDeg = lonAbs.toInt()
val lonMinFull = (lonAbs - lonDeg) * 60.0
val lonMin = lonMinFull.toInt()
val lonSec = ((lonMinFull - lonMin) * 60.0).toInt()
val lonLabel = "经度 $lonDeg° $lonMin' $lonSec\" $ew"
val lonValue = "$lon° "
// 照搬: 8 位网格编码 (区间查表)
val (str, str2, str3, str4) = encodeLon(lon)
val (str5, str6, str38, str9) = encodeLat(lat)
val loc = str + str5 + str2 + str6 + str3 + str38 + str4 + str9
// 照搬: 九宫格 parseInt3 五分支
val grids = buildGrids(str, str5, str2, str6, str3, str38)
// 照搬: 红点查表 (第 3 对字符)
val markerLeft = lonMargin[str3.firstOrNull()] ?: 0
val markerTop = latMargin[str38.firstOrNull()] ?: 0
return RoamingState(
time = time,
latLabel = latLabel,
latValue = latValue,
lonLabel = lonLabel,
lonValue = lonValue,
loc = loc,
grids = grids,
markerLeft = markerLeft,
markerTop = markerTop,
gpsOn = true,
gpsOff = false,
showSettings = false,
showProgress = false,
showNotice = false,
showDate = true
)
}
// 红点 leftMargin 查表 (MainActivity 1286-1386 行): 经度第 3 对 a..x -> -2..190 dp
private val lonMargin = mapOf(
'a' to -2, 'b' to 8, 'c' to 16, 'd' to 24, 'e' to 32, 'f' to 40, 'g' to 48, 'h' to 56,
'i' to 65, 'j' to 74, 'k' to 83, 'l' to 92, 'm' to 101, 'n' to 110, 'o' to 119, 'p' to 128,
'q' to 137, 'r' to 146, 's' to 155, 't' to 163, 'u' to 171, 'v' to 179, 'w' to 184, 'x' to 190
)
// 红点 topMargin 查表 (MainActivity 1315-1363 行): 纬度第 3 对 a..x -> 190..-2 dp (屏幕 Y 反转)
private val latMargin = mapOf(
'a' to 190, 'b' to 182, 'c' to 174, 'd' to 169, 'e' to 163, 'f' to 155, 'g' to 146, 'h' to 137,
'i' to 128, 'j' to 119, 'k' to 110, 'l' to 101, 'm' to 92, 'n' to 83, 'o' to 74, 'p' to 65,
'q' to 56, 'r' to 48, 's' to 40, 't' to 32, 'u' to 24, 'v' to 16, 'w' to 8, 'x' to -2
)
// 照搬 311-611 行: 经度 -> (20°区字母, 2°数字, 2'字母, 30"数字)
private fun encodeLon(inputLon: Double): List<String> {
var longitude = inputLon
var d = 0.0
val str = when {
(longitude >= -180.0) && (longitude <= -160.0) -> { longitude += 180.0; "A" }
(longitude >= -160.0) && (longitude <= -140.0) -> { longitude += 160.0; "B" }
(longitude >= -140.0) && (longitude <= -120.0) -> { longitude += 140.0; "C" }
(longitude >= -120.0) && (longitude <= -100.0) -> { longitude += 120.0; "D" }
(longitude >= -100.0) && (longitude <= -80.0) -> { longitude += 100.0; "E" }
(longitude >= -80.0) && (longitude <= -60.0) -> { longitude += 80.0; "F" }
(longitude >= -60.0) && (longitude <= -40.0) -> { longitude += 60.0; "G" }
(longitude >= -40.0) && (longitude <= -20.0) -> { longitude += 40.0; "H" }
(longitude >= -20.0) && (longitude <= 0.0) -> { longitude += 20.0; "I" }
(longitude >= 0.0) && (longitude <= 20.0) -> "J"
(longitude >= 20.0) && (longitude <= 40.0) -> { longitude -= 20.0; "K" }
(longitude >= 40.0) && (longitude <= 60.0) -> { longitude -= 40.0; "L" }
(longitude >= 60.0) && (longitude <= 80.0) -> { longitude -= 60.0; "M" }
(longitude >= 80.0) && (longitude <= 100.0) -> { longitude -= 80.0; "N" }
(longitude >= 100.0) && (longitude <= 120.0) -> { longitude -= 100.0; "O" }
(longitude >= 120.0) && (longitude <= 140.0) -> { longitude -= 120.0; "P" }
(longitude >= 140.0) && (longitude <= 160.0) -> { longitude -= 140.0; "Q" }
(longitude >= 160.0) && (longitude <= 180.0) -> { longitude -= 160.0; "R" }
else -> { longitude = 0.0; " " }
}
val str2 = when {
((longitude >= 0.0) && (longitude <= 2.0)) || ((longitude >= -20.0) && (longitude <= -18.0)) -> { d = longitude * 60.0; "0" }
((longitude >= 2.0) && (longitude <= 4.0)) || ((longitude >= -18.0) && (longitude <= -16.0)) -> { d = (longitude - 2.0) * 60.0; "1" }
((longitude >= 4.0) && (longitude <= 6.0)) || ((longitude >= -16.0) && (longitude <= -14.0)) -> { d = (longitude - 4.0) * 60.0; "2" }
((longitude >= 6.0) && (longitude <= 8.0)) || ((longitude >= -14.0) && (longitude <= -12.0)) -> { d = (longitude - 6.0) * 60.0; "3" }
((longitude >= 8.0) && (longitude <= 10.0)) || ((longitude >= -12.0) && (longitude <= -10.0)) -> { d = (longitude - 8.0) * 60.0; "4" }
((longitude >= 10.0) && (longitude <= 12.0)) || ((longitude >= -10.0) && (longitude <= -8.0)) -> { d = (longitude - 10.0) * 60.0; "5" }
((longitude >= 12.0) && (longitude <= 14.0)) || ((longitude >= -8.0) && (longitude <= -6.0)) -> { d = (longitude - 12.0) * 60.0; "6" }
((longitude >= 14.0) && (longitude <= 16.0)) || ((longitude >= -6.0) && (longitude <= -4.0)) -> { d = (longitude - 14.0) * 60.0; "7" }
((longitude >= 16.0) && (longitude <= 18.0)) || ((longitude >= -4.0) && (longitude <= -2.0)) -> { d = (longitude - 16.0) * 60.0; "8" }
((longitude >= 18.0) && (longitude <= 20.0)) || ((longitude >= -2.0) && (longitude <= 0.0)) -> { d = (longitude - 18.0) * 60.0; "9" }
else -> { d = 0.0; " " }
}
val str3 = when {
((d >= 0.0) && (d <= 5.0)) || ((d >= -120.0) && (d <= -115.0)) -> { longitude = d * 60.0; "a" }
((d >= 5.0) && (d <= 10.0)) || ((d >= -115.0) && (d <= -110.0)) -> { longitude = (d - 5.0) * 60.0; "b" }
((d >= 10.0) && (d <= 15.0)) || ((d >= -110.0) && (d <= -105.0)) -> { longitude = (d - 10.0) * 60.0; "c" }
((d >= 15.0) && (d <= 20.0)) || ((d >= -105.0) && (d <= -100.0)) -> { longitude = (d - 15.0) * 60.0; "d" }
((d >= 20.0) && (d <= 25.0)) || ((d >= -100.0) && (d <= -95.0)) -> { longitude = (d - 20.0) * 60.0; "e" }
((d >= 25.0) && (d <= 30.0)) || ((d >= -95.0) && (d <= -90.0)) -> { longitude = (d - 25.0) * 60.0; "f" }
((d >= 30.0) && (d <= 35.0)) || ((d >= -90.0) && (d <= -85.0)) -> { longitude = (d - 30.0) * 60.0; "g" }
((d >= 35.0) && (d <= 40.0)) || ((d >= -85.0) && (d <= -80.0)) -> { longitude = (d - 35.0) * 60.0; "h" }
((d >= 40.0) && (d <= 45.0)) || ((d >= -80.0) && (d <= -75.0)) -> { longitude = (d - 40.0) * 60.0; "i" }
((d >= 45.0) && (d <= 50.0)) || ((d >= -75.0) && (d <= -70.0)) -> { longitude = (d - 45.0) * 60.0; "j" }
((d >= 50.0) && (d <= 55.0)) || ((d >= -70.0) && (d <= -65.0)) -> { longitude = (d - 50.0) * 60.0; "k" }
((d >= 55.0) && (d <= 60.0)) || ((d >= -65.0) && (d <= -60.0)) -> { longitude = (d - 55.0) * 60.0; "l" }
((d >= 60.0) && (d <= 65.0)) || ((d >= -60.0) && (d <= -55.0)) -> { longitude = (d - 60.0) * 60.0; "m" }
((d >= 65.0) && (d <= 70.0)) || ((d >= -55.0) && (d <= -50.0)) -> { longitude = (d - 65.0) * 60.0; "n" }
((d >= 70.0) && (d <= 75.0)) || ((d >= -50.0) && (d <= -45.0)) -> { longitude = (d - 70.0) * 60.0; "o" }
((d >= 75.0) && (d <= 80.0)) || ((d >= -45.0) && (d <= -40.0)) -> { longitude = (d - 75.0) * 60.0; "p" }
((d >= 80.0) && (d <= 85.0)) || ((d >= -40.0) && (d <= -35.0)) -> { longitude = (d - 80.0) * 60.0; "q" }
((d >= 85.0) && (d <= 90.0)) || ((d >= -35.0) && (d <= -30.0)) -> { longitude = (d - 85.0) * 60.0; "r" }
((d >= 90.0) && (d <= 95.0)) || ((d >= -30.0) && (d <= -25.0)) -> { longitude = (d - 90.0) * 60.0; "s" }
((d >= 95.0) && (d <= 100.0)) || ((d >= -25.0) && (d <= -20.0)) -> { longitude = (d - 95.0) * 60.0; "t" }
((d >= 100.0) && (d <= 105.0)) || ((d >= -20.0) && (d <= -15.0)) -> { longitude = (d - 100.0) * 60.0; "u" }
((d >= 105.0) && (d <= 110.0)) || ((d >= -15.0) && (d <= -10.0)) -> { longitude = (d - 105.0) * 60.0; "v" }
((d >= 110.0) && (d <= 115.0)) || ((d >= -10.0) && (d <= -5.0)) -> { longitude = (d - 110.0) * 60.0; "w" }
((d >= 115.0) && (d <= 120.0)) || ((d >= -5.0) && (d <= 0.0)) -> { longitude = (d - 115.0) * 60.0; "x" }
else -> { longitude = 0.0; " " }
}
val str4 = when {
((longitude >= 0.0) && (longitude <= 30.0)) || ((longitude >= -300.0) && (longitude <= -270.0)) -> "0"
((longitude >= 30.0) && (longitude <= 60.0)) || ((longitude >= -270.0) && (longitude <= -240.0)) -> "1"
((longitude >= 60.0) && (longitude <= 90.0)) || ((longitude >= -240.0) && (longitude <= -210.0)) -> "2"
((longitude >= 90.0) && (longitude <= 120.0)) || ((longitude >= -210.0) && (longitude <= -180.0)) -> "3"
((longitude >= 120.0) && (longitude <= 150.0)) || ((longitude >= -180.0) && (longitude <= -150.0)) -> "4"
((longitude >= 150.0) && (longitude <= 180.0)) || ((longitude >= -150.0) && (longitude <= -120.0)) -> "5"
((longitude >= 180.0) && (longitude <= 210.0)) || ((longitude >= -120.0) && (longitude <= -90.0)) -> "6"
((longitude >= 210.0) && (longitude <= 240.0)) || ((longitude >= -90.0) && (longitude <= -60.0)) -> "7"
((longitude >= 240.0) && (longitude <= 270.0)) || ((longitude >= -60.0) && (longitude <= -30.0)) -> "8"
((longitude >= 270.0) && (longitude <= 300.0)) || ((longitude >= -30.0) && (longitude <= 0.0)) -> "9"
else -> " "
}
return listOf(str, str2, str3, str4)
}
// 照搬 612-914 行: 纬度 -> (10°区字母, 1°数字, 1'字母, 15"数字)
private fun encodeLat(inputLat: Double): List<String> {
var latitude = inputLat
var d2 = 0.0
var d3 = 0.0
val str5 = when {
(latitude >= -90.0) && (latitude <= -80.0) -> { d2 = latitude + 90.0; "A" }
(latitude >= -80.0) && (latitude <= -70.0) -> { d2 = latitude + 80.0; "B" }
(latitude >= -70.0) && (latitude <= -60.0) -> { d2 = latitude + 70.0; "C" }
(latitude >= -60.0) && (latitude <= -50.0) -> { d2 = latitude + 60.0; "D" }
(latitude >= -50.0) && (latitude <= -40.0) -> { d2 = latitude + 50.0; "E" }
(latitude >= -40.0) && (latitude <= -30.0) -> { d2 = latitude + 40.0; "F" }
(latitude >= -30.0) && (latitude <= -20.0) -> { d2 = latitude + 30.0; "G" }
(latitude >= -20.0) && (latitude <= -10.0) -> { d2 = latitude + 20.0; "H" }
(latitude >= -10.0) && (latitude <= 0.0) -> { d2 = latitude + 10.0; "I" }
(latitude >= 0.0) && (latitude <= 10.0) -> { d2 = latitude; "J" }
(latitude >= 10.0) && (latitude <= 20.0) -> { d2 = latitude - 10.0; "K" }
(latitude >= 20.0) && (latitude <= 30.0) -> { d2 = latitude - 20.0; "L" }
(latitude >= 30.0) && (latitude <= 40.0) -> { d2 = latitude - 30.0; "M" }
(latitude >= 40.0) && (latitude <= 50.0) -> { d2 = latitude - 40.0; "N" }
(latitude >= 50.0) && (latitude <= 60.0) -> { d2 = latitude - 50.0; "O" }
(latitude >= 60.0) && (latitude <= 70.0) -> { d2 = latitude - 60.0; "P" }
(latitude >= 70.0) && (latitude <= 80.0) -> { d2 = latitude - 70.0; "Q" }
(latitude >= 80.0) && (latitude <= 90.0) -> { d2 = latitude - 80.0; "R" }
else -> { d2 = 0.0; " " }
}
var str6 = " "
when {
((d2 >= 0.0) && (d2 <= 1.0)) || ((d2 >= -10.0) && (d2 <= -9.0)) -> { d3 = d2 * 60.0; str6 = "0" }
((d2 >= 1.0) && (d2 <= 2.0)) || ((d2 >= -9.0) && (d2 <= -8.0)) -> { d3 = (d2 - 1.0) * 60.0; str6 = "1" }
((d2 >= 2.0) && (d2 <= 3.0)) || ((d2 >= -8.0) && (d2 <= -7.0)) -> { d3 = (d2 - 2.0) * 60.0; str6 = "2" }
((d2 >= 3.0) && (d2 <= 4.0)) || ((d2 >= -7.0) && (d2 <= -6.0)) -> { d3 = (d2 - 3.0) * 60.0; str6 = "3" }
((d2 >= 4.0) && (d2 <= 5.0)) || ((d2 >= -6.0) && (d2 <= -5.0)) -> { d3 = (d2 - 4.0) * 60.0; str6 = "4" }
((d2 >= 5.0) && (d2 <= 6.0)) || ((d2 >= -5.0) && (d2 <= -4.0)) -> { d3 = (d2 - 5.0) * 60.0; str6 = "5" }
((d2 >= 6.0) && (d2 <= 7.0)) || ((d2 >= -4.0) && (d2 <= -3.0)) -> { d3 = (d2 - 6.0) * 60.0; str6 = "6" }
((d2 >= 7.0) && (d2 <= 8.0)) || ((d2 >= -3.0) && (d2 <= -2.0)) -> { d3 = (d2 - 7.0) * 60.0; str6 = "7" }
((d2 >= 8.0) && (d2 <= 9.0)) || ((d2 >= -2.0) && (d2 <= -1.0)) -> { d3 = (d2 - 8.0) * 60.0; str6 = "8" }
((d2 >= 9.0) && (d2 <= 10.0)) || ((d2 >= -1.0) && (d2 <= 0.0)) -> { d3 = (d2 - 9.0) * 60.0; str6 = "9" }
}
val str38 = when {
((d3 >= 0.0) && (d3 <= 2.5)) || ((d3 >= -60.0) && (d3 <= -57.5)) -> { d2 = d3 * 60.0; "a" }
((d3 >= 2.5) && (d3 <= 5.0)) || ((d3 >= -57.5) && (d3 <= -55.0)) -> { d2 = (d3 - 2.5) * 60.0; "b" }
((d3 >= 5.0) && (d3 <= 7.5)) || ((d3 >= -55.0) && (d3 <= -52.5)) -> { d2 = (d3 - 5.0) * 60.0; "c" }
((d3 >= 7.5) && (d3 <= 10.0)) || ((d3 >= -52.5) && (d3 <= -50.0)) -> { d2 = (d3 - 7.5) * 60.0; "d" }
((d3 >= 10.0) && (d3 <= 12.5)) || ((d3 >= -50.0) && (d3 <= -47.5)) -> { d2 = (d3 - 10.0) * 60.0; "e" }
((d3 >= 12.5) && (d3 <= 15.0)) || ((d3 >= -47.5) && (d3 <= -45.0)) -> { d2 = (d3 - 12.5) * 60.0; "f" }
((d3 >= 15.0) && (d3 <= 17.5)) || ((d3 >= -45.0) && (d3 <= -42.5)) -> { d2 = (d3 - 15.0) * 60.0; "g" }
((d3 >= 17.5) && (d3 <= 20.0)) || ((d3 >= -42.5) && (d3 <= -40.0)) -> { d2 = (d3 - 17.5) * 60.0; "h" }
((d3 >= 20.0) && (d3 <= 22.5)) || ((d3 >= -40.0) && (d3 <= -37.5)) -> { d2 = (d3 - 20.0) * 60.0; "i" }
((d3 >= 22.5) && (d3 <= 25.0)) || ((d3 >= -37.5) && (d3 <= -35.0)) -> { d2 = (d3 - 22.5) * 60.0; "j" }
((d3 >= 25.0) && (d3 <= 27.5)) || ((d3 >= -35.0) && (d3 <= -32.5)) -> { d2 = (d3 - 25.0) * 60.0; "k" }
((d3 >= 27.5) && (d3 <= 30.0)) || ((d3 >= -32.5) && (d3 <= -30.0)) -> { d2 = (d3 - 27.5) * 60.0; "l" }
((d3 >= 30.0) && (d3 <= 32.5)) || ((d3 >= -30.0) && (d3 <= -27.5)) -> { d2 = (d3 - 30.0) * 60.0; "m" }
((d3 >= 32.5) && (d3 <= 35.0)) || ((d3 >= -27.5) && (d3 <= -25.0)) -> { d2 = (d3 - 32.5) * 60.0; "n" }
((d3 >= 35.0) && (d3 <= 37.5)) || ((d3 >= -25.0) && (d3 <= -22.5)) -> { d2 = (d3 - 35.0) * 60.0; "o" }
((d3 >= 37.5) && (d3 <= 40.0)) || ((d3 >= -22.5) && (d3 <= -20.0)) -> { d2 = (d3 - 37.5) * 60.0; "p" }
((d3 >= 40.0) && (d3 <= 42.5)) || ((d3 >= -20.0) && (d3 <= -17.5)) -> { d2 = (d3 - 40.0) * 60.0; "q" }
((d3 >= 42.5) && (d3 <= 45.0)) || ((d3 >= -17.5) && (d3 <= -15.0)) -> { d2 = (d3 - 42.5) * 60.0; "r" }
((d3 >= 45.0) && (d3 <= 47.5)) || ((d3 >= -15.0) && (d3 <= -12.5)) -> { d2 = (d3 - 45.0) * 60.0; "s" }
((d3 >= 47.5) && (d3 <= 50.0)) || ((d3 >= -12.5) && (d3 <= -10.0)) -> { d2 = (d3 - 47.5) * 60.0; "t" }
((d3 >= 50.0) && (d3 <= 52.5)) || ((d3 >= -10.0) && (d3 <= -7.5)) -> { d2 = (d3 - 50.0) * 60.0; "u" }
((d3 >= 52.5) && (d3 <= 55.0)) || ((d3 >= -7.5) && (d3 <= -5.0)) -> { d2 = (d3 - 52.5) * 60.0; "v" }
((d3 >= 55.0) && (d3 <= 57.5)) || ((d3 >= -5.0) && (d3 <= -2.5)) -> { d2 = (d3 - 55.0) * 60.0; "w" }
((d3 >= 57.5) && (d3 <= 60.0)) || ((d3 >= -2.5) && (d3 <= 0.0)) -> { d2 = (d3 - 57.5) * 60.0; "x" }
else -> { d2 = 0.0; " " }
}
val str9 = when {
((d2 >= 0.0) && (d2 <= 15.0)) || ((d2 >= -150.0) && (d2 <= -135.0)) -> "0"
((d2 >= 15.0) && (d2 <= 30.0)) || ((d2 >= -135.0) && (d2 <= -120.0)) -> "1"
((d2 >= 30.0) && (d2 <= 45.0)) || ((d2 >= -120.0) && (d2 <= -105.0)) -> "2"
((d2 >= 45.0) && (d2 <= 60.0)) || ((d2 >= -105.0) && (d2 <= -90.0)) -> "3"
((d2 >= 60.0) && (d2 <= 75.0)) || ((d2 >= -90.0) && (d2 <= -75.0)) -> "4"
((d2 >= 75.0) && (d2 <= 90.0)) || ((d2 >= -75.0) && (d2 <= -60.0)) -> "5"
((d2 >= 90.0) && (d2 <= 105.0)) || ((d2 >= -60.0) && (d2 <= -45.0)) -> "6"
((d2 >= 105.0) && (d2 <= 120.0)) || ((d2 >= -45.0) && (d2 <= -30.0)) -> "7"
((d2 >= 120.0) && (d2 <= 135.0)) || ((d2 >= -30.0) && (d2 <= -15.0)) -> "8"
((d2 >= 135.0) && (d2 <= 150.0)) || ((d2 >= -15.0) && (d2 <= 0.0)) -> "9"
else -> " "
}
return listOf(str5, str6, str38, str9)
}
/**
* 照搬 917-1283 行: 九宫格 parseInt3 五分支(内部/西界/东界/北界/南界 + 四角)。
* 返回 9 格文本: [上左, 上中, 上右, 左中, 中央, 右中, 下左, 下中, 下右]。
*/
private fun buildGrids(str: String, str5: String, str2: String, str6: String, str3: String, str38: String): List<String> {
val parseInt = str2.toInt()
val parseInt2 = str6.toInt()
val str39 = str + str5
val str40 = str2 + str6
val parseInt3 = str40.toInt()
val str41 = str39 + str40
val g = MutableList(9) { "" }
fun fmt2(n: Int) = if (n <= 9) "0$n" else n.toString()
if ((parseInt > 0) && (parseInt < 9) && (parseInt2 > 0) && (parseInt2 < 9)) {
// 内部: 数字对 ±1
val i7 = parseInt3 + 1
val i8 = i7 - 10
val valueOf9 = fmt2(i8)
val str11 = i7.toString()
val valueOf18 = (i7 + 10).toString()
val i9 = parseInt3 - 10
val valueOf10 = fmt2(i9)
val valueOf19 = (parseInt3 + 10).toString()
val i10 = parseInt3 - 1
val i11 = i10 - 10
val valueOf11 = fmt2(i11)
val str14 = i10.toString()
val str15 = (i10 + 10).toString()
g[0] = str39 + valueOf9
g[1] = str39 + str11
g[2] = str39 + valueOf18
g[3] = str39 + valueOf10
g[4] = str41
g[5] = str39 + valueOf19
g[6] = str39 + valueOf11
g[7] = str39 + str14
g[8] = str39 + str15
} else if ((parseInt == 0) && (parseInt2 > 0) && (parseInt2 < 9)) {
// 西边界: 经度 0 -> 9, 经度字母 -1
val str43 = (str[0] - 1).toString() + str5
val str44 = "9" + (parseInt2 + 1)
val i12 = parseInt3 + 1
val valueOf7 = fmt2(i12)
val str12 = (i12 + 10).toString()
val sb4 = "9" + parseInt2
val valueOf20 = (parseInt3 + 10).toString()
val str46 = "9" + (parseInt2 - 1)
val i13 = parseInt3 - 1
val valueOf8 = fmt2(i13)
val str15 = (i13 + 10).toString()
g[0] = str43 + str44
g[1] = str39 + valueOf7
g[2] = str39 + str12
g[3] = str43 + sb4
g[4] = str41
g[5] = str39 + valueOf20
g[6] = str43 + str46
g[7] = str39 + valueOf8
g[8] = str39 + str15
} else if ((parseInt == 9) && (parseInt2 > 0) && (parseInt2 < 9)) {
// 东边界: 经度 9 -> 0, 经度字母 +1
val str47 = (str[0] + 1).toString() + str5
val i14 = parseInt3 + 1
val valueOf21 = (i14 - 10).toString()
val valueOf22 = i14.toString()
val str48 = "0" + (parseInt2 + 1)
val valueOf23 = (parseInt3 - 10).toString()
val str49 = "0" + parseInt2
val i15 = parseInt3 - 1
val valueOf24 = (i15 - 10).toString()
val valueOf25 = i15.toString()
val str15 = "0" + (parseInt2 - 1)
g[0] = str39 + valueOf21
g[1] = str39 + valueOf22
g[2] = str47 + str48
g[3] = str39 + valueOf23
g[4] = str41
g[5] = str47 + str49
g[6] = str39 + valueOf24
g[7] = str39 + valueOf25
g[8] = str47 + str15
} else if ((parseInt < 9) && (parseInt > 0) && (parseInt2 == 9)) {
// 北边界: 纬度 9 -> 0, 纬度字母 +1
val str50 = str + (str5[0] + 1).toString()
val i16 = parseInt3 - 9
val i17 = i16 - 10
val valueOf4 = fmt2(i17)
val valueOf26 = i16.toString()
val valueOf27 = (i16 + 10).toString()
val i18 = parseInt3 - 10
val valueOf5 = fmt2(i18)
val valueOf28 = (parseInt3 + 10).toString()
val i19 = parseInt3 - 1
val i20 = i19 - 10
val valueOf6 = fmt2(i20)
val str14 = i19.toString()
val str15 = (i19 + 10).toString()
g[0] = str39 + valueOf4
g[1] = str50 + valueOf26
g[2] = str39 + valueOf27
g[3] = str39 + valueOf5
g[4] = str41
g[5] = str39 + valueOf28
g[6] = str39 + valueOf6
g[7] = str39 + str14
g[8] = str39 + str15
} else if ((parseInt < 9) && (parseInt > 0) && (parseInt2 == 0)) {
// 南边界: 纬度 0 -> 9, 纬度字母 -1
val str52 = str + (str5[0] - 1).toString()
val i21 = parseInt3 + 1
val i22 = i21 - 10
val valueOf = fmt2(i22)
val valueOf29 = i21.toString()
val valueOf30 = (i21 + 10).toString()
val i23 = parseInt3 - 10
val valueOf2 = fmt2(i23)
val valueOf31 = (parseInt3 + 10).toString()
val i24 = parseInt3 - 1
val valueOf3 = fmt2(i24)
val str14 = (i24 + 10).toString()
val str15 = (i24 + 20).toString()
g[0] = str39 + valueOf
g[1] = str39 + valueOf29
g[2] = str39 + valueOf30
g[3] = str39 + valueOf2
g[4] = str41
g[5] = str39 + valueOf31
g[6] = str52 + valueOf3
g[7] = str52 + str14
g[8] = str52 + str15
} else if (parseInt3 == 0) {
// 四角 00: 经纬度数字均 -1 进位
val v32 = (str[0] - 1).toString()
val v33 = (str5[0] - 1).toString()
g[0] = v32 + str5 + "91"
g[1] = str39 + "01"
g[2] = str39 + "11"
g[3] = v32 + str5 + "90"
g[4] = str41
g[5] = v32 + str5 + "10"
g[6] = v32 + v33 + "99"
g[7] = str + v33 + "09"
g[8] = str + v33 + "19"
} else if (parseInt3 == 9) {
// 四角 09: 经度 -1, 纬度 +1
val v34 = (str[0] - 1).toString()
val v35 = (str5[0] + 1).toString()
g[0] = v34 + v35 + "90"
g[1] = str + v35 + "00"
g[2] = str + v35 + "10"
g[3] = v34 + str5 + "99"
g[4] = str41
g[5] = v34 + str5 + "19"
g[6] = v34 + str5 + "98"
g[7] = v34 + str5 + "08"
g[8] = v34 + str5 + "18"
} else if (parseInt3 == 99) {
// 四角 99: 经纬度数字均 +1 进位
val v36 = (str[0] + 1).toString()
val v37 = (str5[0] + 1).toString()
g[0] = str + v37 + "80"
g[1] = str + v37 + "90"
g[2] = v36 + v37 + "00"
g[3] = str39 + "89"
g[4] = str41
g[5] = v36 + str5 + "09"
g[6] = str39 + "88"
g[7] = str39 + "98"
g[8] = v36 + str5 + "08"
} else if (parseInt3 == 90) {
// 四角 90: 经度 +1, 纬度 -1
val v38 = (str[0] + 1).toString()
val v39 = (str5[0] - 1).toString()
g[0] = str39 + "81"
g[1] = str39 + "91"
g[2] = v38 + str5 + "01"
g[3] = str39 + "80"
g[4] = str41
g[5] = v38 + str5 + "00"
g[6] = str + v39 + "89"
g[7] = str + v39 + "99"
g[8] = v38 + v39 + "09"
}
return g
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 375 B

@@ -1,124 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.roaming
import org.junit.Assert.assertEquals
import org.junit.Test
class RoamingStateTest {
// 用户实测坐标 (QTH定位器 2.0 成品截图): 22.314066 / 108.706575
private fun stateOf(lat: Double, lon: Double) =
roamingStateFromLocation(lat, lon, System.currentTimeMillis(), "21:")
@Test
fun `user coordinates produce OL42ih45`() {
val state = stateOf(22.314066, 108.706575)
assertEquals("OL42ih45", state.loc)
}
@Test
fun `red marker from ih pair matches reference lookup`() {
val state = stateOf(22.314066, 108.706575)
// 成品查表: 经度第3对 'i' -> leftMargin 65dp, 纬度第3对 'h' -> topMargin 137dp
assertEquals(65, state.markerLeft)
assertEquals(137, state.markerTop)
}
@Test
fun `grid nine from OL42 matches reference`() {
val state = stateOf(22.314066, 108.706575)
val expected = listOf(
"OL33", "OL43", "OL53",
"OL32", "OL42", "OL52",
"OL31", "OL41", "OL51"
)
assertEquals(expected, state.grids)
}
@Test
fun `latitude dms formatting matches reference`() {
val state = stateOf(22.314066, 108.706575)
assertEquals("纬度 22° 18' 50\" N", state.latLabel)
assertEquals("22.314066° ", state.latValue)
assertEquals("经度 108° 42' 23\" E", state.lonLabel)
assertEquals("108.706575° ", state.lonValue)
}
@Test
fun `time is hour prefix plus fix minutes`() {
// fixTime = 2026-08-03 21:12:13 UTC+8 -> minutes 12
val fix = 1785820333000L // 2026-08-03 21:12:13 +0800
val state = roamingStateFromLocation(22.314066, 108.706575, fix, "21:")
assertEquals("21:12", state.time)
}
@Test
fun `west edge borrows lon letter`() {
// 经度 0.5 纬度 22.314066: 数字对 02, 西边界 parseInt==0
val state = stateOf(22.314066, 0.5)
assertEquals(8, state.loc.length)
// 左列经度 0->9, 经度字母 J-1=I: IL92
assertEquals("IL92", state.grids[3])
}
@Test
fun `north edge carries lat letter`() {
// 纬度 29.5 经度 108.706575: 数字对 49? -> 纬度数字 9 -> 北边界
val state = stateOf(29.5, 108.706575)
assertEquals(8, state.loc.length)
// 上排纬度 +1 -> 0, 纬度字母 +1 (M)
assertEquals("OM40", state.grids[1])
}
@Test
fun `east edge carries lon letter`() {
// 经度 119.5 纬度 22.314066: 经度数字 9 -> 东边界
val state = stateOf(22.314066, 119.5)
assertEquals(8, state.loc.length)
// 右上 经度 9->0, 经度字母 O+1=P, 纬度 2+1=3: PL03
assertEquals("PL03", state.grids[2])
}
@Test
fun `south edge borrows lat letter`() {
// 纬度 0.5 经度 108.706575: 纬度数字 0 -> 南边界
val state = stateOf(0.5, 108.706575)
assertEquals(8, state.loc.length)
// 下中 纬度 0->9, 纬度字母 J-1=I: OI49
assertEquals("OI49", state.grids[7])
}
@Test
fun `boundary values wrap like reference`() {
// (90, 180) -> RR99xx99 (成品区间查表行为)
val state = stateOf(90.0, 180.0)
assertEquals("RR99xx99", state.loc)
}
@Test
fun `gps state after fix matches showLocation`() {
val state = stateOf(22.314066, 108.706575)
assertEquals(true, state.gpsOn)
assertEquals(false, state.gpsOff)
assertEquals(false, state.showSettings)
assertEquals(false, state.showProgress)
assertEquals(false, state.showNotice)
assertEquals(true, state.showDate)
}
}
@@ -30,8 +30,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
@@ -43,13 +41,11 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.LocalSpacing
@@ -207,17 +203,6 @@ fun DataSourcesDialog(
singleLine = true,
modifier = Modifier.fillMaxWidth(),
enabled = isEnabledCustomTle.value,
trailingIcon = {
IconButton(
onClick = { urlTle.value = Sources.defaultTleUrl },
enabled = isEnabledCustomTle.value
) {
Icon(
painter = painterResource(id = R.drawable.ic_reset),
contentDescription = stringResource(id = R.string.prefs_data_reset_url)
)
}
},
)
Spacer(modifier = Modifier.height(6.dp))
Row(
@@ -247,17 +232,6 @@ fun DataSourcesDialog(
singleLine = true,
modifier = Modifier.fillMaxWidth(),
enabled = isEnabledCustomTransceivers.value,
trailingIcon = {
IconButton(
onClick = { urlTransceivers.value = Sources.defaultTransceiversUrl },
enabled = isEnabledCustomTransceivers.value
) {
Icon(
painter = painterResource(id = R.drawable.ic_reset),
contentDescription = stringResource(id = R.string.prefs_data_reset_url)
)
}
},
)
Spacer(modifier = Modifier.height(12.dp))
}
@@ -22,26 +22,15 @@ import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@@ -51,28 +40,15 @@ import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import kotlin.math.roundToInt
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.zIndex
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
@@ -323,15 +299,6 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
)
}
item { OtherCard(uiState.otherSettings, onAction) }
item {
UiSettingsCard(
hiddenScreens = uiState.otherSettings.hiddenScreens,
screenOrder = uiState.otherSettings.screenOrder,
onToggle = { name -> onAction(SettingsAction.ToggleScreen(name)) },
onReorder = { order -> onAction(SettingsAction.ReorderScreens(order)) },
onResetOrder = { onAction(SettingsAction.ResetScreenOrder) }
)
}
item { CardCredits() }
}
}
@@ -372,8 +339,6 @@ private fun LocationCard(
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = "Lat: ${settings.stationPos.latitude}°")
Text(text = "Lon: ${settings.stationPos.longitude}°")
}
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = "Qth: ${settings.stationPos.qthLocator}")
}
Spacer(modifier = Modifier.height(1.dp))
@@ -519,12 +484,11 @@ private fun OtherCardPreview() = MainTheme {
@Composable
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
ElevatedCard(
modifier = Modifier.fillMaxWidth()
modifier = Modifier
.fillMaxWidth()
.height(268.dp)
) {
Column(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text(
text = stringResource(id = R.string.prefs_other_title),
color = MaterialTheme.colorScheme.primary
@@ -548,152 +512,8 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
}
}
/**
* UI 设置卡片: 控制底部菜单栏各页面显示/隐藏。
* 顺序固定(卫星/过境/雷达/匹配/漫游/地图/设置), 关闭后其余自动靠拢;
* "设置"页固定显示(防止失去设置入口)。
*/
@Composable
private fun UiSettingsCard(
hiddenScreens: List<String>,
screenOrder: List<String>,
onToggle: (String) -> Unit,
onReorder: (List<String>) -> Unit,
onResetOrder: () -> Unit
) {
val screens = listOf(
R.string.nav_sat to "Satellites",
R.string.nav_pass to "Passes",
R.string.nav_radar to "Radar",
R.string.nav_mutual to "Mutual",
R.string.nav_roaming to "Roaming",
R.string.nav_cw to "CwDecode",
R.string.nav_map to "Map",
R.string.nav_prefs to "Settings"
) // name 必须与 Screen.screenId 一致 (R8 安全)
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = stringResource(id = R.string.prefs_ui_title),
color = MaterialTheme.colorScheme.primary
)
screens.forEach { (labelRes, name) ->
if (name == "Settings") {
// 设置页固定显示, 开关禁用
SwitchRow(labelRes, true, null)
} else {
SwitchRow(labelRes, name !in hiddenScreens) {
onToggle(name)
}
}
}
Text(
text = stringResource(id = R.string.prefs_ui_order_title),
color = MaterialTheme.colorScheme.primary
)
DragOrderList(screens = screens, screenOrder = screenOrder, onReorder = onReorder)
TextButton(onClick = onResetOrder) {
Text(text = stringResource(id = R.string.prefs_ui_order_reset))
}
}
}
}
/** 页面顺序拖拽列表: 每行右侧抓手, 按住上下拖动实时换位, 松手持久化 */
@Composable
private fun DragOrderList(
screens: List<Pair<Int, String>>,
screenOrder: List<String>,
onReorder: (List<String>) -> Unit
) {
val itemHeight = 48.dp
val itemHeightPx = with(LocalDensity.current) { itemHeight.toPx() }
// 按 screenOrder 排序(空 = 默认顺序); remember 以 screens 参数为 key, 持久化后重组
val ordered = remember(screens, screenOrder) {
screens.sortedBy { (_, name) ->
// 未知(新页面如 CwDecode 不在旧持久化顺序里): 用默认顺序位置, 再兜底最后
screenOrder.indexOf(name).let {
if (it != -1) it else com.rtbishop.look4sat.core.presentation.defaultScreenOrder.indexOf(name).let {
if (it != -1) it else Int.MAX_VALUE
}
}
}
}
val listState = rememberLazyListState()
val items = remember(ordered) { mutableStateListOf<Pair<Int, String>>().apply { addAll(ordered) } }
var draggingIndex by remember { mutableIntStateOf(-1) }
var dragOffset by remember { mutableStateOf(0f) }
// 固定高度: 嵌套在 LazyVerticalGrid 内必须给有界高度, 否则无限高度约束崩溃
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxWidth()
.height(itemHeight * items.size)
) {
itemsIndexed(items, key = { _, s -> s.second }) { index, screen ->
val isDragging = draggingIndex == index
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.height(itemHeight)
.zIndex(if (isDragging) 1f else 0f)
.graphicsLayer { translationY = if (isDragging) dragOffset else 0f }
.animateItem()
) {
Text(
text = stringResource(id = screen.first),
modifier = Modifier.weight(1f)
)
Box(
modifier = Modifier
.size(48.dp)
.padding(20.dp)
.pointerInput(screen.second) {
detectDragGestures(
onDragStart = { draggingIndex = items.indexOf(screen) },
onDrag = { change, dragAmount ->
change.consume()
dragOffset += dragAmount.y
val currentIndex = draggingIndex
val target = (currentIndex + (dragOffset / itemHeightPx).roundToInt())
.coerceIn(0, items.size - 1)
if (target != currentIndex) {
items.add(target, items.removeAt(currentIndex))
dragOffset += (currentIndex - target) * itemHeightPx
draggingIndex = target
}
},
onDragEnd = {
onReorder(items.map { it.second })
draggingIndex = -1
dragOffset = 0f
},
onDragCancel = {
draggingIndex = -1
dragOffset = 0f
}
)
}
) {
// 小圆点手柄(触控区 padding 20dp 居中 -> 8dp 圆点)
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.onSurfaceVariant, CircleShape)
)
}
}
}
}
}
@Composable
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?) {
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
@@ -736,11 +556,15 @@ private fun CardCreditsPreview() = MainTheme { CardCredits() }
@Composable
private fun CardCredits(modifier: Modifier = Modifier) {
ElevatedCard(
modifier = modifier.fillMaxWidth()
modifier = modifier
.fillMaxWidth()
.height(268.dp)
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.fillMaxHeight()
) {
Text(
text = stringResource(id = R.string.prefs_outro_title),
@@ -749,8 +573,6 @@ private fun CardCredits(modifier: Modifier = Modifier) {
Text(
text = stringResource(id = R.string.prefs_outro_thanks)
)
// 感谢列表与保修声明之间保留约两行间距 (用户要求)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(id = R.string.prefs_outro_license),
color = MaterialTheme.colorScheme.primary
@@ -766,7 +588,7 @@ private fun TopCard(onClick: () -> Unit, modifier: Modifier = Modifier, version:
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.heightIn(min = 48.dp)
.height(48.dp)
.clickable { onClick() }) {
Spacer(Modifier)
Icon(
@@ -781,6 +603,7 @@ private fun TopCard(onClick: () -> Unit, modifier: Modifier = Modifier, version:
modifier = Modifier
.weight(1f)
.padding(end = 6.dp)
.infiniteMarquee()
)
}
}
@@ -64,12 +64,6 @@ sealed interface SettingsAction {
data class ToggleSensor(val value: Boolean) : SettingsAction
data class ToggleLightTheme(val value: Boolean) : SettingsAction
data class ToggleNightMode(val value: Boolean) : SettingsAction
// UI 设置: 切换底部导航栏页面的隐藏状态(Screen simpleName)
data class ToggleScreen(val screenName: String) : SettingsAction
// UI 设置: 更新页面顺序
data class ReorderScreens(val order: List<String>) : SettingsAction
// UI 设置: 重置为默认顺序
data object ResetScreenOrder : SettingsAction
// Remote control
data class UpdateRC(val settings: RCSettings) : SettingsAction
@@ -121,13 +121,6 @@ class SettingsViewModel(
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
is SettingsAction.ToggleScreen -> settingsRepo.updateOtherSettings { current ->
val hidden = current.hiddenScreens.toMutableList()
if (action.screenName in hidden) hidden.remove(action.screenName) else hidden.add(action.screenName)
current.copy(hiddenScreens = hidden)
}
is SettingsAction.ReorderScreens -> settingsRepo.updateOtherSettings { it.copy(screenOrder = action.order) }
SettingsAction.ResetScreenOrder -> settingsRepo.updateOtherSettings { it.copy(screenOrder = emptyList()) }
// Remote control & data sources
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
+2 -7
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "450"
appVersionCode = "443"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.5.0"
appVersionName = "4.4.3"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry
@@ -11,11 +11,6 @@ minSdk = "24"
jdkVersion = "21"
#noinspection UnusedVersionCatalogEntry
packageName = "com.rtbishop.look4sat"
# Fork application ID: official app uses com.rtbishop.look4sat with a different
# signing cert. Installing a fork with the same applicationId would conflict
# with the official build (signature mismatch / cannot overwrite), so the fork
# ships under its own applicationId suffix.
applicationId = "com.rtbishop.look4sat.bg7nta"
android-gradle-plugin = "9.3.1"
-1
View File
@@ -26,7 +26,6 @@ include(
":feature:mutual",
":feature:passes",
":feature:radar",
":feature:roaming",
":feature:satellites",
":feature:settings"
)