mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c73d5ed7a | ||
|
|
a663ef7f1b | ||
|
|
1f1e1fb144 | ||
|
|
5166daa297 | ||
|
|
604c2ec59d | ||
|
|
1904a6b3e6 | ||
|
|
7f390117b8 | ||
|
|
346803fe6e | ||
|
|
47b87a1604 | ||
|
|
935fe6d99f | ||
|
|
69af1c38bb | ||
|
|
5c35f2418d | ||
|
|
2aa9858660 | ||
|
|
bc6e065804 | ||
|
|
7924a6f69e | ||
|
|
9a2a715d1f | ||
|
|
ee32c7a1eb | ||
|
|
1d54ac4718 | ||
|
|
433ce85561 | ||
|
|
cebe3b7ec4 | ||
|
|
d8f60cf270 | ||
|
|
4a3332fdb5 | ||
|
|
9e7fdbfe71 | ||
|
|
463df58eb9 | ||
|
|
58b8801c0c | ||
|
|
2581ba92c0 | ||
|
|
e5131ebb72 | ||
|
|
f9accdd517 | ||
|
|
ee3c1e7058 | ||
|
|
a08a28df39 | ||
|
|
759a6de2c6 | ||
|
|
ba5a984c16 | ||
|
|
083b8a2847 | ||
|
|
e8ebfb1ef3 | ||
|
|
12eed1d9a9 | ||
|
|
b3859fbece | ||
|
|
f40a67e1b7 | ||
|
|
16dce5df2a | ||
|
|
80a58bed61 | ||
|
|
a8afc63bba | ||
|
|
dbf9a2a65f | ||
|
|
ddecaa6017 | ||
|
|
418a05e343 |
No files matched your search
@@ -18,6 +18,7 @@ out/
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
.cxx/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
@@ -67,3 +68,4 @@ fastlane/readme.md
|
||||
/app/release/output-metadata.json
|
||||
/app/release/
|
||||
/.kotlin/sessions/
|
||||
dist/
|
||||
@@ -1,45 +1,38 @@
|
||||
# Look4Sat: Satellite tracker
|
||||
# Look4Sat-BA7OPF
|
||||
|
||||
[](https://github.com/rt-bishop/Look4Sat/actions/workflows/release.yml)
|
||||
[](https://github.com/atsunatsu/Look4Sat/releases)
|
||||
|
||||
[<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>
|
||||
- **双站过境匹配(Mutual Pass)** — 输入友台经纬度/网格,筛选双方同时可见的卫星过境,在地图上同时显示双方仰角曲线和地面轨迹,支持一键跳转雷达页查看详情
|
||||
- **线性卫星转发器频率计算器** — 在雷达页的 Calculator 标签页中,支持 TX/RX 双向多普勒频率计算,以及下行频率偏移(offset)输入,方便操作带偏移的线性卫星
|
||||
- **CW 解码器** — 集成 Morse Expert 解码引擎,支持瀑布图、实时解码文本
|
||||
- **Passband 模式** — 支持通过位置滑块(Passband)自动计算 TX/RX 频率,避免切换时跳变
|
||||
- **AMSAT 状态页** — 实时查看卫星的业余无线电转发器状态(开启/关闭/待机),支持 72 小时历史记录回放
|
||||
- **中文界面优化** — 翻译修正、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.
|
||||
GNU General Public License v3.0。详见 [LICENSE](LICENSE)。
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=rt-bishop%2FLook4Sat&type=timeline&legend=top-left">
|
||||
<a href="https://star-history.dera.page/#atsunatsu/Look4Sat&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" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=atsunatsu/Look4Sat&type=timeline&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=atsunatsu/Look4Sat&type=timeline&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=atsunatsu/Look4Sat&type=timeline&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
+28
-4
@@ -1,12 +1,36 @@
|
||||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.convention.applicationPlugin)
|
||||
}
|
||||
|
||||
// Release signing credentials live in local.properties (gitignored) or
|
||||
// environment variables — never hardcode passwords in VCS.
|
||||
val releaseProps = Properties().apply {
|
||||
val propsFile = rootProject.file("local.properties")
|
||||
if (propsFile.exists()) FileInputStream(propsFile).use { load(it) }
|
||||
}
|
||||
fun releaseCred(name: String): String =
|
||||
releaseProps.getProperty(name) ?: System.getenv(name) ?: ""
|
||||
|
||||
android {
|
||||
// PR #1 CW decoder uses the Morse Expert native decoder, which ships only armeabi-v7a.
|
||||
namespace = libs.versions.packageName.get()
|
||||
defaultConfig {
|
||||
ndk {
|
||||
abiFilters += listOf("armeabi-v7a")
|
||||
applicationId = "cn.ba7opf.look4sat"
|
||||
ndk { abiFilters.add("armeabi-v7a") }
|
||||
}
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = file(System.getProperty("user.home") + "/my-release-key.jks")
|
||||
storePassword = releaseCred("RELEASE_STORE_PASSWORD")
|
||||
keyAlias = releaseCred("RELEASE_KEY_ALIAS").ifEmpty { "look4sat" }
|
||||
keyPassword = releaseCred("RELEASE_KEY_PASSWORD")
|
||||
}
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
@@ -45,5 +46,15 @@
|
||||
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
||||
android:value="com.rtbishop.look4sat" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -49,7 +49,7 @@ class MainActivity : ComponentActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
observeNightFilterState()
|
||||
setContent {
|
||||
MainTheme(isDarkTheme = true) { MainScreen() }
|
||||
MainTheme(isDarkTheme = true) { NavRoot() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,10 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.app.Application.ActivityLifecycleCallbacks
|
||||
import android.os.Bundle
|
||||
import com.rtbishop.look4sat.core.data.injection.MainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
@@ -35,12 +38,37 @@ class MainApplication : Application(), IContainerProvider {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
container = MainContainer(this)
|
||||
clearAmSatCacheOnAppBackground()
|
||||
// trigger automatic update every 48 hours
|
||||
container.appScope.launch { checkAutoUpdate() }
|
||||
// load satellite data on every app start
|
||||
container.appScope.launch { container.satelliteRepo.initRepository() }
|
||||
}
|
||||
|
||||
private fun clearAmSatCacheOnAppBackground() {
|
||||
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
|
||||
private var startedActivityCount = 0
|
||||
|
||||
override fun onActivityStarted(activity: Activity) {
|
||||
if (startedActivityCount == 0) {
|
||||
container.appScope.launch { container.amSatRepo.prefetchStatus() }
|
||||
}
|
||||
startedActivityCount += 1
|
||||
}
|
||||
|
||||
override fun onActivityStopped(activity: Activity) {
|
||||
startedActivityCount = (startedActivityCount - 1).coerceAtLeast(0)
|
||||
if (startedActivityCount == 0) container.amSatRepo.clearStatusCache()
|
||||
}
|
||||
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
|
||||
override fun onActivityResumed(activity: Activity) = Unit
|
||||
override fun onActivityPaused(activity: Activity) = Unit
|
||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
|
||||
override fun onActivityDestroyed(activity: Activity) = Unit
|
||||
})
|
||||
}
|
||||
|
||||
private suspend fun checkAutoUpdate(timeNow: Long = System.currentTimeMillis()) {
|
||||
if (container.settingsRepo.otherSettings.value.stateOfAutoUpdate) {
|
||||
val timeDelta = timeNow - container.settingsRepo.databaseState.value.updateTimestamp
|
||||
|
||||
@@ -25,8 +25,6 @@ import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
@@ -44,7 +42,7 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
||||
@@ -53,6 +51,9 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
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
|
||||
@@ -87,37 +88,53 @@ import com.rtbishop.look4sat.feature.passes.PassesDestination
|
||||
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
||||
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
||||
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
||||
import com.rtbishop.look4sat.feature.status.SatStatusDestination
|
||||
|
||||
@Composable
|
||||
fun NavRoot(deeplink: String? = null) {
|
||||
val rootBackStack = rememberNavBackStack(Screen.Passes)
|
||||
val deeplinkResolver = DeeplinkResolver()
|
||||
var openMapRequest by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(deeplink) {
|
||||
deeplink?.let {
|
||||
val destination = deeplinkResolver.resolve(it) // rootBackStack.clear()
|
||||
rootBackStack.add(destination)
|
||||
}
|
||||
deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
|
||||
}
|
||||
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
||||
val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
|
||||
val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
|
||||
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
|
||||
val navigateToMap: () -> Unit = {
|
||||
while (rootBackStack.size > 1) rootBackStack.removeAt(rootBackStack.size - 1)
|
||||
openMapRequest += 1
|
||||
}
|
||||
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
|
||||
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
|
||||
slideOutHorizontally(tween(300)) { -it / 3 }
|
||||
// Reverse: outgoing slides out to the right, incoming drifts in from the left
|
||||
val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith
|
||||
slideOutHorizontally(tween(300)) { it }
|
||||
NavDisplay(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
backStack = rootBackStack,
|
||||
onBack = navigateBack,
|
||||
transitionSpec = { slideInTransition },
|
||||
popTransitionSpec = { slideOutTransition },
|
||||
predictivePopTransitionSpec = { slideOutTransition },
|
||||
transitionSpec = { pushTransition },
|
||||
popTransitionSpec = { popTransition },
|
||||
predictivePopTransitionSpec = { popTransition },
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(), // Required for saving Compose state per entry
|
||||
rememberViewModelStoreNavEntryDecorator() // Required for ViewModel scoping per entry
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Passes> { MainScreen(navigateToRadar = { rootBackStack.add(RadarDestination) }) }
|
||||
entry<Screen.Passes> {
|
||||
MainScreen(
|
||||
navigateToRadar = navigateToRadar,
|
||||
openMapRequest = openMapRequest,
|
||||
onOpenMapRequestHandled = { openMapRequest = 0 }
|
||||
)
|
||||
}
|
||||
entry<RadarDestination> {
|
||||
Scaffold { innerPadding ->
|
||||
RadarDestination(navigateUp = navigateBack)
|
||||
innerPadding.calculateTopPadding()
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
RadarDestination(navigateUp = navigateBack, navigateToMap = navigateToMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,12 +142,26 @@ fun NavRoot(deeplink: String? = null) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
fun MainScreen(
|
||||
navigateToRadar: () -> Unit = {},
|
||||
openMapRequest: Int = 0,
|
||||
onOpenMapRequestHandled: () -> Unit = {}
|
||||
) {
|
||||
val backStack = rememberNavBackStack(Screen.Passes)
|
||||
val currentKey = backStack.lastOrNull()
|
||||
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
||||
val navigateToMap: () -> Unit = {
|
||||
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||
backStack.add(Screen.Map)
|
||||
}
|
||||
LaunchedEffect(openMapRequest) {
|
||||
if (openMapRequest > 0) {
|
||||
navigateToMap()
|
||||
onOpenMapRequestHandled()
|
||||
}
|
||||
}
|
||||
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 navItems = listOf(Screen.Satellites, Screen.Passes, Screen.AMSAT, Screen.Mutual, Screen.Settings)
|
||||
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
@@ -154,9 +185,9 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
val isSelected = when (currentKey) {
|
||||
is Screen.Satellites -> screen is Screen.Satellites
|
||||
is Screen.Passes -> screen is Screen.Passes
|
||||
is Screen.Radar -> screen is Screen.Radar
|
||||
is Screen.Map -> screen is Screen.Passes
|
||||
is Screen.AMSAT -> screen is Screen.AMSAT
|
||||
is Screen.Mutual -> screen is Screen.Mutual
|
||||
is Screen.Map -> screen is Screen.Map
|
||||
is Screen.Settings -> screen is Screen.Settings
|
||||
else -> false
|
||||
}
|
||||
@@ -165,7 +196,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
label = { Text(stringResource(screen.titleResId)) },
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
if (isSelected) return@item
|
||||
if (isSelected && !(currentKey is Screen.Map && screen is Screen.Passes)) return@item
|
||||
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||
if (screen !is Screen.Passes) backStack.add(screen)
|
||||
}
|
||||
@@ -200,15 +231,14 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
SatellitesDestination(navigateUp = navigateBack)
|
||||
}
|
||||
entry<Screen.Passes> {
|
||||
PassesDestination { catNum, aosTime ->
|
||||
container.setMutualPassData(MutualPassData())
|
||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||
backStack.add(Screen.Radar)
|
||||
// navigateToRadar()
|
||||
}
|
||||
}
|
||||
entry<Screen.Radar> {
|
||||
RadarDestination(navigateUp = navigateBack)
|
||||
PassesDestination(
|
||||
navigateToRadar = { catNum, aosTime ->
|
||||
container.setMutualPassData(MutualPassData())
|
||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||
navigateToRadar()
|
||||
},
|
||||
navigateToMap = navigateToMap
|
||||
)
|
||||
}
|
||||
entry<Screen.Map> {
|
||||
MapDestination()
|
||||
@@ -216,14 +246,14 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
entry<Screen.Mutual> {
|
||||
MutualScreen(
|
||||
viewModel = mutualViewModel,
|
||||
navigateUp = navigateBack,
|
||||
navigateToRadar = { catNum, aosTime, pass ->
|
||||
container.setMutualPassData(pass ?: MutualPassData())
|
||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||
backStack.add(Screen.Radar)
|
||||
navigateToRadar()
|
||||
}
|
||||
)
|
||||
}
|
||||
entry<Screen.AMSAT> { SatStatusDestination() }
|
||||
entry<Screen.Settings> {
|
||||
SettingsDestination()
|
||||
}
|
||||
@@ -249,7 +279,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
if (pass != null) {
|
||||
container.setMutualPassData(MutualPassData())
|
||||
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
||||
backStack.add(Screen.Radar)
|
||||
navigateToRadar()
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<cache-path name="updates" path="." />
|
||||
</paths>
|
||||
+1
@@ -37,6 +37,7 @@ internal class ApplicationPlugin : Plugin<Project> {
|
||||
implementation(project(":feature:radar"))
|
||||
implementation(project(":feature:satellites"))
|
||||
implementation(project(":feature:settings"))
|
||||
implementation(project(":feature:status"))
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
implementation(libs.compose.material3.adaptive)
|
||||
implementation(libs.compose.navigation3)
|
||||
|
||||
+69
-28
@@ -17,8 +17,10 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.framework
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
@@ -31,18 +33,30 @@ class NetworkReporter(
|
||||
private val rotatorServer: String,
|
||||
private val rotatorPort: Int,
|
||||
private val frequencyServer: String,
|
||||
private val frequencyPort: Int
|
||||
private val frequencyPort: Int,
|
||||
private val frequencyOffsetHz: Long = 0L
|
||||
) : IReporter {
|
||||
|
||||
private val writeMutex = Mutex()
|
||||
private val connectionMutex = Mutex()
|
||||
private val frequencyCommands = Channel<String>(Channel.CONFLATED)
|
||||
|
||||
private var rotatorSocket: SocketChannel? = null
|
||||
private var rotatorConnected = false
|
||||
private var rotatorConnecting = false
|
||||
|
||||
private var frequencySocket: SocketChannel? = null
|
||||
private var frequencyConnected = false
|
||||
private var frequencyConnecting = false
|
||||
|
||||
init {
|
||||
// Keep only the latest frequency command to avoid stale backlog and effective lag.
|
||||
reporterScope.launch {
|
||||
for (command in frequencyCommands) {
|
||||
ensureFrequencyConnected()
|
||||
if (!frequencyConnected) continue
|
||||
write(frequencySocket, command) { resetFrequencyConnection() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun reportRotation(format: String, azimuth: Double, elevation: Double) {
|
||||
reporterScope.launch {
|
||||
@@ -53,51 +67,48 @@ class NetworkReporter(
|
||||
.replace($$"$AZ", azimuth.toString())
|
||||
.replace($$"$EL", el.toString())
|
||||
.unescapeControlChars()
|
||||
write(rotatorSocket, command) { rotatorConnected = false }
|
||||
write(rotatorSocket, command) { resetRotatorConnection() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun reportFrequency(format: String, frequency: Long) {
|
||||
reporterScope.launch {
|
||||
ensureFrequencyConnected()
|
||||
if (!frequencyConnected) return@launch
|
||||
val command = format
|
||||
.replace($$"$FREQ", frequency.toString())
|
||||
.unescapeControlChars()
|
||||
write(frequencySocket, command) { frequencyConnected = false }
|
||||
}
|
||||
val clampedOffset = frequencyOffsetHz.coerceIn(
|
||||
Constants.FREQ_OFFSET_MIN_HZ,
|
||||
Constants.FREQ_OFFSET_MAX_HZ
|
||||
)
|
||||
val correctedFreq = frequency.coerceAtLeast(0L).safeAdd(clampedOffset).coerceAtLeast(0L)
|
||||
val command = format
|
||||
.replace($$"$FREQ", correctedFreq.toString())
|
||||
.unescapeControlChars()
|
||||
frequencyCommands.trySend(command)
|
||||
}
|
||||
|
||||
private fun ensureRotatorConnected() {
|
||||
if (rotatorConnected || rotatorConnecting || rotatorServer.isBlank()) return
|
||||
reporterScope.launch {
|
||||
private suspend fun ensureRotatorConnected() {
|
||||
connectionMutex.withLock {
|
||||
if (rotatorConnected || rotatorServer.isBlank()) return
|
||||
try {
|
||||
rotatorConnecting = true
|
||||
resetRotatorConnection()
|
||||
rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
|
||||
rotatorConnected = true
|
||||
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
|
||||
} catch (e: Exception) {
|
||||
println("NetworkReporter rotator connect error: ${e.message}")
|
||||
rotatorConnected = false
|
||||
} finally {
|
||||
rotatorConnecting = false
|
||||
resetRotatorConnection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureFrequencyConnected() {
|
||||
if (frequencyConnected || frequencyConnecting || frequencyServer.isBlank()) return
|
||||
reporterScope.launch {
|
||||
private suspend fun ensureFrequencyConnected() {
|
||||
connectionMutex.withLock {
|
||||
if (frequencyConnected || frequencyServer.isBlank()) return
|
||||
try {
|
||||
frequencyConnecting = true
|
||||
resetFrequencyConnection()
|
||||
frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
|
||||
frequencyConnected = true
|
||||
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
|
||||
} catch (e: Exception) {
|
||||
println("NetworkReporter frequency connect error: ${e.message}")
|
||||
frequencyConnected = false
|
||||
} finally {
|
||||
frequencyConnecting = false
|
||||
resetFrequencyConnection()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,7 +117,9 @@ class NetworkReporter(
|
||||
try {
|
||||
writeMutex.withLock {
|
||||
val buffer = ByteBuffer.wrap("$command\n".toByteArray())
|
||||
socket?.write(buffer)
|
||||
while (buffer.hasRemaining()) {
|
||||
socket?.write(buffer)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("NetworkReporter write error: ${e.message}")
|
||||
@@ -114,6 +127,34 @@ class NetworkReporter(
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetRotatorConnection() {
|
||||
rotatorConnected = false
|
||||
closeQuietly(rotatorSocket)
|
||||
rotatorSocket = null
|
||||
}
|
||||
|
||||
private fun resetFrequencyConnection() {
|
||||
frequencyConnected = false
|
||||
closeQuietly(frequencySocket)
|
||||
frequencySocket = null
|
||||
}
|
||||
|
||||
private fun closeQuietly(socket: SocketChannel?) {
|
||||
try {
|
||||
socket?.close()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
private fun Long.safeAdd(delta: Long): Long {
|
||||
return when {
|
||||
delta > 0 && this > Long.MAX_VALUE - delta -> Long.MAX_VALUE
|
||||
delta < 0 && this < Long.MIN_VALUE - delta -> Long.MIN_VALUE
|
||||
else -> this + delta
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.unescapeControlChars(): String =
|
||||
replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
|
||||
replace("""\r""", 13.toChar().toString())
|
||||
.replace("""\n""", 10.toChar().toString())
|
||||
.replace("""\t""", 9.toChar().toString())
|
||||
}
|
||||
@@ -29,11 +29,13 @@ import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
||||
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
|
||||
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
|
||||
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
|
||||
import com.rtbishop.look4sat.core.data.repository.AmSatRepository
|
||||
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.SensorsRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.SettingsRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.UpdateRepository
|
||||
import com.rtbishop.look4sat.core.data.source.LocalSource
|
||||
import com.rtbishop.look4sat.core.data.source.RemoteSource
|
||||
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
||||
@@ -70,12 +72,15 @@ import okhttp3.OkHttpClient
|
||||
class MainContainer(private val context: Context) : IMainContainer {
|
||||
|
||||
private val localSource = provideLocalSource()
|
||||
private val remoteSource = provideRemoteSource()
|
||||
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
|
||||
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
|
||||
override val settingsRepo = provideSettingsRepo()
|
||||
override val selectionRepo = provideSelectionRepo()
|
||||
override val satelliteRepo = provideSatelliteRepo()
|
||||
override val databaseRepo = provideDatabaseRepo()
|
||||
override val amSatRepo by lazy { AmSatRepository(remoteSource, appScope) }
|
||||
override val updateRepo by lazy { UpdateRepository(remoteSource) }
|
||||
override val radioTrackingService: IRadioTrackingService by lazy {
|
||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
||||
@@ -114,7 +119,8 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
rc.rotatorAddress,
|
||||
rc.rotatorPort.toIntOrNull() ?: 0,
|
||||
rc.frequencyAddress,
|
||||
rc.frequencyPort.toIntOrNull() ?: 0
|
||||
rc.frequencyPort.toIntOrNull() ?: 0,
|
||||
rc.frequencyOffsetHz
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
||||
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmitResult
|
||||
import com.rtbishop.look4sat.core.domain.model.SatDay
|
||||
import com.rtbishop.look4sat.core.domain.model.SatReport
|
||||
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/** One report from the AMSAT API (data layer model). */
|
||||
private data class ApiReport(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val callsign: String,
|
||||
val report: String,
|
||||
val gridSquare: String,
|
||||
val reportedTimeUtcSec: Long
|
||||
)
|
||||
|
||||
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
|
||||
class AmSatRepository(
|
||||
private val remoteSource: IRemoteSource,
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
) : IAmSatRepository {
|
||||
|
||||
private val statusCacheMutex = Mutex()
|
||||
|
||||
@Volatile
|
||||
private var statusCache: SatStatusPage? = null
|
||||
|
||||
@Volatile
|
||||
private var statusFetchInFlight: Deferred<SatStatusPage?>? = null
|
||||
|
||||
@Volatile
|
||||
private var cacheGeneration = 0
|
||||
|
||||
private val isoUtcFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
|
||||
override fun getCachedStatus(): SatStatusPage? = statusCache
|
||||
|
||||
override suspend fun fetchStatus(forceRefresh: Boolean): SatStatusPage? {
|
||||
if (!forceRefresh) statusCache?.let { return it }
|
||||
|
||||
var cachedPage: SatStatusPage? = null
|
||||
val inFlightFetch = statusCacheMutex.withLock {
|
||||
if (!forceRefresh) {
|
||||
val cached = statusCache
|
||||
if (cached != null) {
|
||||
cachedPage = cached
|
||||
return@withLock null
|
||||
}
|
||||
}
|
||||
|
||||
statusFetchInFlight ?: scope.async(Dispatchers.IO) {
|
||||
fetchStatusFromRemote(cacheGeneration)
|
||||
}.also { statusFetchInFlight = it }
|
||||
}
|
||||
|
||||
cachedPage?.let { return it }
|
||||
val fetch = inFlightFetch ?: return null
|
||||
return try {
|
||||
fetch.await()
|
||||
} finally {
|
||||
statusCacheMutex.withLock {
|
||||
if (statusFetchInFlight === fetch && fetch.isCompleted) statusFetchInFlight = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchStatusFromRemote(generation: Int): SatStatusPage? {
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
val catalogJson = remoteSource.getAmSatCatalog() ?: return null
|
||||
// 72h = 3 days; API hard cap is limit=500 regardless of what we send.
|
||||
// 500 records across ~100 catalog satellites ≈ ~1-5 reports/satellite/day — enough for 3 days.
|
||||
// Upgrade path: paginate or request AMSAT to raise the cap if catalog grows beyond ~200 sats.
|
||||
val reportsJson = remoteSource.getAmSatReports(hours = 72, limit = 500) ?: return null
|
||||
|
||||
val names = parseCatalog(catalogJson)
|
||||
val reports = parseReports(reportsJson)
|
||||
|
||||
if (names.isEmpty() && reports.isEmpty()) return null
|
||||
|
||||
val statuses = buildStatuses(names, reports, nowSec)
|
||||
val reportMap = reports.associate { it.id to toSatReport(it) }
|
||||
return SatStatusPage(System.currentTimeMillis(), statuses, reportMap).also { page ->
|
||||
if (generation == cacheGeneration) statusCache = page
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun prefetchStatus() {
|
||||
fetchStatus(forceRefresh = false)
|
||||
}
|
||||
|
||||
override fun clearStatusCache() {
|
||||
cacheGeneration += 1
|
||||
statusCache = null
|
||||
statusFetchInFlight = null
|
||||
}
|
||||
|
||||
override suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult = withContext(Dispatchers.IO) {
|
||||
val payload = JSONObject().apply {
|
||||
put("name", submission.name)
|
||||
put("report", submission.report)
|
||||
put("callsign", submission.callsign)
|
||||
put("reported_at", isoUtcFormat.format(Date(submission.reportedAtUtcMillis)))
|
||||
if (submission.gridSquare.isNotBlank()) put("grid_square", submission.gridSquare)
|
||||
}
|
||||
val response = remoteSource.submitAmSatReport(payload.toString())
|
||||
?: return@withContext AmSatReportSubmitResult(success = false, message = "Network request failed")
|
||||
val (code, body) = response
|
||||
val errorMessage = parseSubmitError(body)
|
||||
return@withContext if (code in 200..299 && errorMessage.isBlank()) {
|
||||
AmSatReportSubmitResult(success = true, reportId = parseSubmitReportId(body))
|
||||
} else {
|
||||
AmSatReportSubmitResult(
|
||||
success = false,
|
||||
message = errorMessage.ifBlank { "HTTP $code" }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSubmitError(json: String): String {
|
||||
return try {
|
||||
JSONObject(json).optJSONObject("error")?.optString("message").orEmpty()
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSubmitReportId(json: String): String? {
|
||||
return try {
|
||||
val obj = JSONObject(json)
|
||||
obj.optJSONObject("data")?.optString("id")?.takeIf { it.isNotBlank() }
|
||||
?: obj.optString("id").takeIf { it.isNotBlank() }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse catalog JSON to list of satellite names */
|
||||
private fun parseCatalog(json: String): List<String> {
|
||||
return try {
|
||||
val arr = JSONObject(json).getJSONArray("data")
|
||||
(0 until arr.length()).map { arr.getJSONObject(it).getString("name") }
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse reports JSON to list of ApiReport domain objects */
|
||||
private fun parseReports(json: String): List<ApiReport> {
|
||||
return try {
|
||||
val arr = JSONObject(json).getJSONArray("data")
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
val iso = o.optString("reported_time", "")
|
||||
if (iso.isEmpty()) null else ApiReport(
|
||||
id = o.optString("id", ""),
|
||||
name = o.optString("name", ""),
|
||||
callsign = o.optString("callsign", ""),
|
||||
report = o.optString("report", ""),
|
||||
gridSquare = o.optString("grid_square", ""),
|
||||
reportedTimeUtcSec = parseIsoUtcSec(iso)
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse ISO 8601 UTC timestamp to epoch seconds (e.g., "2026-08-05T07:30:00Z") */
|
||||
private fun parseIsoUtcSec(iso: String): Long {
|
||||
return try {
|
||||
(isoUtcFormat.parse(iso)?.time ?: 0L) / 1000
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
/** Build one SatStatus (3 days x 12 slots) per catalog satellite, slotting reports by age. */
|
||||
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
|
||||
val byName = reports.groupBy { it.name }
|
||||
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
val labels = (0 until 3).map { d ->
|
||||
utc.timeInMillis = (nowSec - d * 86400L) * 1000
|
||||
formatDayLabel(utc)
|
||||
}
|
||||
return names.map { name ->
|
||||
val slots = (0 until 36).map { slotIdx ->
|
||||
val slotStart = nowSec - (slotIdx + 1) * 7200L
|
||||
val slotEnd = nowSec - slotIdx * 7200L
|
||||
val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd }
|
||||
if (inSlot.isEmpty()) {
|
||||
SatSlot(statusColor = NO_REPORT_GRAY, count = 0)
|
||||
} else {
|
||||
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
|
||||
SatSlot(
|
||||
statusColor = statusColorOf(newest.report),
|
||||
count = inSlot.size,
|
||||
reportIds = inSlot.map { it.id }
|
||||
)
|
||||
}
|
||||
}
|
||||
val days = (0 until 3).map { d ->
|
||||
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
|
||||
}
|
||||
SatStatus(name = name, days = days)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDayLabel(calendar: Calendar): String {
|
||||
return if (Locale.getDefault().language == Locale.CHINESE.language) {
|
||||
"${calendar.get(Calendar.MONTH) + 1}月${calendar.get(Calendar.DAY_OF_MONTH)}日"
|
||||
} else {
|
||||
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||
"${monthAbbr[calendar.get(Calendar.MONTH)]} ${calendar.get(Calendar.DAY_OF_MONTH)}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun toSatReport(r: ApiReport): SatReport {
|
||||
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
cal.timeInMillis = r.reportedTimeUtcSec * 1000
|
||||
val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
|
||||
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
|
||||
val y = cal.get(Calendar.YEAR)
|
||||
val mo = (cal.get(Calendar.MONTH) + 1).toString().padStart(2, '0')
|
||||
val d = cal.get(Calendar.DAY_OF_MONTH).toString().padStart(2, '0')
|
||||
return SatReport(
|
||||
id = r.id,
|
||||
statusText = r.report,
|
||||
call = r.callsign,
|
||||
grid = r.gridSquare,
|
||||
dateUtc = "$y-$mo-$d",
|
||||
timeUtc = "$hh:$mm UTC"
|
||||
)
|
||||
}
|
||||
|
||||
/** Map status text to color value (for UI rendering). */
|
||||
private fun statusColorOf(report: String): Long = when (report.lowercase()) {
|
||||
"heard", "crew active" -> ACTIVE_BLUE
|
||||
"telemetry only" -> TLM_ORANGE
|
||||
"not heard" -> NOT_HEARD_PINK
|
||||
else -> CONFLICT_DEEP_ORANGE
|
||||
}
|
||||
|
||||
companion object {
|
||||
// AMSAT official status colors (from amsat.org/status)
|
||||
private const val ACTIVE_BLUE = 0xFF648FFF
|
||||
private const val TLM_ORANGE = 0xFFFFB000
|
||||
private const val NOT_HEARD_PINK = 0xFFDC267F
|
||||
private const val CONFLICT_DEEP_ORANGE = 0xFFFE6100
|
||||
private const val NO_REPORT_GRAY = 0xFFC0C0C0
|
||||
}
|
||||
}
|
||||
+42
-22
@@ -66,28 +66,45 @@ class DatabaseRepo(
|
||||
}
|
||||
|
||||
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
||||
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
||||
val tleUrls = buildMap {
|
||||
putAll(Sources.satelliteDataUrls)
|
||||
if (dataSourcesSettings.useCustomTLE) put(customSourceType, dataSourcesSettings.tleUrl)
|
||||
}.filterValues { it.isNotBlank() }
|
||||
val radioUrls = buildMap {
|
||||
putAll(Sources.transceiversDataUrls)
|
||||
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) } }
|
||||
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||
// parse fetched data concurrently and associate with types
|
||||
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
|
||||
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
|
||||
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
|
||||
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
|
||||
}
|
||||
}
|
||||
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
|
||||
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
||||
}
|
||||
val settings = settingsRepo.dataSourcesSettings.value
|
||||
// Keep the raw URL as the status key (matches what the settings UI
|
||||
// displays) while requesting with the normalized URL.
|
||||
val tleUrls = settings.satelliteUrls
|
||||
.filterIndexed { i, url -> url.isNotBlank() && settings.isSatelliteEnabled(i) }
|
||||
.map { it to normalizeUrl(it) }
|
||||
.distinctBy { it.second }
|
||||
val radioUrls = settings.transceiversUrls
|
||||
.filterIndexed { i, url -> url.isNotBlank() && settings.isTransceiverEnabled(i) }
|
||||
.map { it to normalizeUrl(it) }
|
||||
.distinctBy { it.second }
|
||||
val builtinTypesByUrl = Sources.satelliteDataUrls
|
||||
.filterValues { it.isNotBlank() }
|
||||
.mapValues { normalizeUrl(it.value) }
|
||||
.entries
|
||||
.associate { (type, url) -> url to type }
|
||||
val importedTypeIds = mutableMapOf<String, MutableList<Int>>()
|
||||
// launch all network requests concurrently, keeping the raw url as key for status reporting
|
||||
val tleJobs = tleUrls.map { (raw, norm) -> async { raw to remoteSource.getNetworkStream(norm) } }
|
||||
val radioJobs = radioUrls.map { (raw, norm) -> async { raw to remoteSource.getNetworkStream(norm) } }
|
||||
val tleResults = tleJobs.awaitAll()
|
||||
val radioResults = radioJobs.awaitAll()
|
||||
// report the HTTP status code of every source (200, 404, ...)
|
||||
settingsRepo.updateDataSourcesStatus(
|
||||
(tleResults + radioResults).associate { (url, result) -> url to result.code }
|
||||
)
|
||||
// parse fetched data concurrently and associate known built-in URLs with existing type filters.
|
||||
val importedEntries = tleResults.flatMap { (rawUrl, result) ->
|
||||
val normUrl = normalizeUrl(rawUrl)
|
||||
val entries = result.stream?.let { parseSatelliteStream(normUrl, unwrapIfZipped(normUrl, it)) }.orEmpty()
|
||||
val type = builtinTypesByUrl[normUrl] ?: customSourceType
|
||||
importedTypeIds.getOrPut(type) { mutableListOf() }.addAll(entries.map { it.catnum })
|
||||
entries
|
||||
}.distinctBy { it.catnum }
|
||||
importedTypeIds.forEach { (type, ids) -> settingsRepo.setSatelliteTypeIds(type, ids.distinct()) }
|
||||
val importedRadios = radioResults.flatMap { (rawUrl, result) ->
|
||||
val normUrl = normalizeUrl(rawUrl)
|
||||
result.stream?.let { dataParser.parseJSONStream(unwrapIfZipped(normUrl, it)) }.orEmpty()
|
||||
}.filter { it.uuid.isNotBlank() }.distinctBy { it.uuid }
|
||||
// insert parsed data into the database
|
||||
localSource.insertEntries(importedEntries)
|
||||
localSource.insertRadios(importedRadios)
|
||||
@@ -100,6 +117,9 @@ class DatabaseRepo(
|
||||
setUpdateSuccessful(0L)
|
||||
}
|
||||
|
||||
private fun normalizeUrl(url: String): String =
|
||||
if (url.startsWith("http", ignoreCase = true)) url else "https://$url"
|
||||
|
||||
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> {
|
||||
val bufferedStream = stream.buffered()
|
||||
return when {
|
||||
|
||||
+18
-13
@@ -34,6 +34,7 @@ import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.TimeZone
|
||||
@@ -63,19 +64,23 @@ class SatelliteRepo(
|
||||
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
|
||||
|
||||
override suspend fun initRepository() = withContext(dispatcher) {
|
||||
settingsRepo.selectedIds.collect { selectedIds ->
|
||||
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
||||
val settings = settingsRepo.passesSettings.value
|
||||
calculatePasses(
|
||||
time = System.currentTimeMillis(),
|
||||
hoursAhead = settings.hoursAhead,
|
||||
minElevation = settings.minElevation,
|
||||
aosStartMinute = settings.aosStartMinute,
|
||||
aosEndMinute = settings.aosEndMinute,
|
||||
invertAosTimeWindow = settings.invertAosTimeWindow,
|
||||
modes = settings.selectedModes
|
||||
)
|
||||
}
|
||||
combine(
|
||||
settingsRepo.selectedIds,
|
||||
settingsRepo.stationPosition
|
||||
) { selectedIds, _ -> selectedIds }
|
||||
.collect { selectedIds ->
|
||||
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
||||
val settings = settingsRepo.passesSettings.value
|
||||
calculatePasses(
|
||||
time = System.currentTimeMillis(),
|
||||
hoursAhead = settings.hoursAhead,
|
||||
minElevation = settings.minElevation,
|
||||
aosStartMinute = settings.aosStartMinute,
|
||||
aosEndMinute = settings.aosEndMinute,
|
||||
invertAosTimeWindow = settings.invertAosTimeWindow,
|
||||
modes = settings.selectedModes
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos {
|
||||
|
||||
+168
-10
@@ -37,6 +37,10 @@ import com.rtbishop.look4sat.core.domain.utility.round
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.json.JSONObject
|
||||
import java.util.Locale
|
||||
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
|
||||
class SettingsRepo(
|
||||
private val locationManager: LocationManager,
|
||||
@@ -67,6 +71,7 @@ class SettingsRepo(
|
||||
private val keyFrequencyAddress = "frequencyAddress"
|
||||
private val keyFrequencyPort = "frequencyPort"
|
||||
private val keyFrequencyFormat = "frequencyFormat"
|
||||
private val keyFrequencyOffsetHz = "frequencyOffsetHz"
|
||||
private val keySelectedIds = "selectedIds"
|
||||
private val keySelectedTypes = "selectedTypes"
|
||||
private val keySelectedModes = "selectedModes"
|
||||
@@ -91,7 +96,15 @@ class SettingsRepo(
|
||||
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
||||
private val keyTleUrl = "tleUrl"
|
||||
private val keyTransceiversUrl = "transceiversUrl"
|
||||
private val keySatelliteUrls = "satelliteUrls"
|
||||
private val keyTransceiversUrls = "transceiversUrls"
|
||||
private val keySatelliteEnabled = "satelliteEnabled"
|
||||
private val keyTransceiversEnabled = "transceiversEnabled"
|
||||
private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration"
|
||||
private val separatorComma = ","
|
||||
private val separatorUrl = "\n"
|
||||
private val legacyCelestrakSatnogsUrl =
|
||||
"https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv"
|
||||
|
||||
//region # Satellites selection settings
|
||||
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
||||
@@ -298,6 +311,10 @@ class SettingsRepo(
|
||||
override val rcSettings: StateFlow<RCSettings> = _rcSettings
|
||||
|
||||
override fun updateRCSettings(settings: RCSettings) {
|
||||
val clampedFreqOffsetHz = settings.frequencyOffsetHz.coerceIn(
|
||||
Constants.FREQ_OFFSET_MIN_HZ,
|
||||
Constants.FREQ_OFFSET_MAX_HZ
|
||||
)
|
||||
preferences.edit {
|
||||
putBoolean(keyRotatorState, settings.rotatorState)
|
||||
putString(keyRotatorAddress, settings.rotatorAddress)
|
||||
@@ -307,6 +324,7 @@ class SettingsRepo(
|
||||
putString(keyFrequencyAddress, settings.frequencyAddress)
|
||||
putString(keyFrequencyPort, settings.frequencyPort)
|
||||
putString(keyFrequencyFormat, settings.frequencyFormat)
|
||||
putLong(keyFrequencyOffsetHz, clampedFreqOffsetHz)
|
||||
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
|
||||
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
|
||||
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
|
||||
@@ -315,7 +333,7 @@ class SettingsRepo(
|
||||
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
|
||||
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
|
||||
}
|
||||
_rcSettings.value = settings
|
||||
_rcSettings.value = settings.copy(frequencyOffsetHz = clampedFreqOffsetHz)
|
||||
}
|
||||
|
||||
private fun getRCSettings(): RCSettings = RCSettings(
|
||||
@@ -327,6 +345,8 @@ class SettingsRepo(
|
||||
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
|
||||
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
|
||||
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ",
|
||||
frequencyOffsetHz = preferences.getLong(keyFrequencyOffsetHz, 0L)
|
||||
.coerceIn(Constants.FREQ_OFFSET_MIN_HZ, Constants.FREQ_OFFSET_MAX_HZ),
|
||||
bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
|
||||
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
|
||||
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
|
||||
@@ -381,21 +401,108 @@ class SettingsRepo(
|
||||
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings
|
||||
|
||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
|
||||
// Normalize the enabled lists so they are positionally aligned with the URL lists.
|
||||
// Missing entries default to enabled (true), keeping the persisted "one flag per URL"
|
||||
// invariant intact even when a default empty list is used to construct the model.
|
||||
val normalized = settings.copy(
|
||||
satelliteEnabled = alignFlags(settings.satelliteUrls, settings.satelliteEnabled),
|
||||
transceiversEnabled = alignFlags(settings.transceiversUrls, settings.transceiversEnabled)
|
||||
)
|
||||
preferences.edit {
|
||||
putBoolean(keyUseCustomTle, settings.useCustomTLE)
|
||||
putBoolean(keyUseCustomTransceivers, settings.useCustomTransceivers)
|
||||
putString(keyTleUrl, settings.tleUrl)
|
||||
putString(keyTransceiversUrl, settings.transceiversUrl)
|
||||
putString(keySatelliteUrls, normalized.satelliteUrls.joinToString(separatorUrl))
|
||||
putString(keyTransceiversUrls, normalized.transceiversUrls.joinToString(separatorUrl))
|
||||
putString(keySatelliteEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
|
||||
putString(keyTransceiversEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
|
||||
putBoolean(keySatnogsTleSourceMigration, true)
|
||||
}
|
||||
_dataSourcesSettings.value = settings
|
||||
_dataSourcesSettings.value = normalized
|
||||
}
|
||||
|
||||
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") ?: ""
|
||||
satelliteUrls = getDataSourceUrls(
|
||||
key = keySatelliteUrls,
|
||||
defaultUrls = Sources.satelliteDataUrls.values.filter { it.isNotBlank() },
|
||||
legacyEnabledKey = keyUseCustomTle,
|
||||
legacyUrlKey = keyTleUrl
|
||||
).migrateSatnogsTleSource(),
|
||||
transceiversUrls = getDataSourceUrls(
|
||||
key = keyTransceiversUrls,
|
||||
defaultUrls = Sources.transceiversDataUrls.values.filter { it.isNotBlank() },
|
||||
legacyEnabledKey = keyUseCustomTransceivers,
|
||||
legacyUrlKey = keyTransceiversUrl
|
||||
),
|
||||
satelliteEnabled = readEnabledFlags(keySatelliteEnabled),
|
||||
transceiversEnabled = readEnabledFlags(keyTransceiversEnabled)
|
||||
)
|
||||
|
||||
/** Read the persisted per-source enabled flags (empty when never stored). */
|
||||
private fun readEnabledFlags(key: String): List<Boolean> {
|
||||
return preferences.getString(key, null)
|
||||
?.split(separatorComma)
|
||||
?.mapNotNull { it.trim() }
|
||||
?.filter { it == "true" || it == "false" }
|
||||
?.map { it == "true" }
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
/** Keep the flags positionally aligned with the URL list, defaulting to enabled. */
|
||||
private fun alignFlags(urls: List<String>, flags: List<Boolean>): List<Boolean> {
|
||||
if (flags.size >= urls.size) return flags.take(urls.size)
|
||||
return flags + List(urls.size - flags.size) { true }
|
||||
}
|
||||
|
||||
private fun getDataSourceUrls(
|
||||
key: String,
|
||||
defaultUrls: List<String>,
|
||||
legacyEnabledKey: String,
|
||||
legacyUrlKey: String
|
||||
): List<String> {
|
||||
val stored = preferences.getString(key, null)
|
||||
?.split(separatorUrl)
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotBlank() }
|
||||
if (stored != null) return stored
|
||||
val legacyCustomUrl = preferences.getString(legacyUrlKey, null)?.trim().orEmpty()
|
||||
return if (preferences.getBoolean(legacyEnabledKey, false) && legacyCustomUrl.isNotBlank()) {
|
||||
(defaultUrls + legacyCustomUrl).distinct()
|
||||
} else {
|
||||
defaultUrls
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<String>.migrateSatnogsTleSource(): List<String> {
|
||||
if (preferences.getBoolean(keySatnogsTleSourceMigration, false)) return this
|
||||
val satnogsTleUrl = Sources.satelliteDataUrls["SatNOGS"].orEmpty()
|
||||
if (satnogsTleUrl.isBlank() || containsSourceUrl(satnogsTleUrl)) return this
|
||||
val celestrakSatnogsIndex = indexOfFirst { isSameSourceUrl(it, legacyCelestrakSatnogsUrl) }
|
||||
if (celestrakSatnogsIndex < 0) return this
|
||||
|
||||
val migrated = toMutableList().apply { add(celestrakSatnogsIndex + 1, satnogsTleUrl) }
|
||||
preferences.edit {
|
||||
putString(keySatelliteUrls, migrated.joinToString(separatorUrl))
|
||||
putBoolean(keySatnogsTleSourceMigration, true)
|
||||
}
|
||||
return migrated
|
||||
}
|
||||
|
||||
private fun List<String>.containsSourceUrl(url: String): Boolean = any { isSameSourceUrl(it, url) }
|
||||
|
||||
private fun isSameSourceUrl(first: String, second: String): Boolean =
|
||||
normalizeSourceUrl(first).equals(normalizeSourceUrl(second), ignoreCase = true)
|
||||
|
||||
private fun normalizeSourceUrl(url: String): String {
|
||||
val trimmed = url.trim()
|
||||
return if (trimmed.startsWith("http", ignoreCase = true)) trimmed else "https://$trimmed"
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region # Data sources status
|
||||
private val _dataSourcesStatus = MutableStateFlow<Map<String, Int>>(emptyMap())
|
||||
override val dataSourcesStatus: StateFlow<Map<String, Int>> = _dataSourcesStatus
|
||||
|
||||
override fun updateDataSourcesStatus(status: Map<String, Int>) {
|
||||
_dataSourcesStatus.value = status
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region # Radio control settings
|
||||
@@ -436,4 +543,55 @@ class SettingsRepo(
|
||||
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
|
||||
)
|
||||
//endregion
|
||||
|
||||
//region # Per-satellite calculator offset settings
|
||||
private val keySatelliteOffsets = "satelliteOffsets"
|
||||
private val keyLegacySatelliteOffsetPrefix = "offset_khz_"
|
||||
|
||||
override fun getSatelliteOffset(catnum: Int): String {
|
||||
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
|
||||
val stored = try {
|
||||
JSONObject(json).optString(catnum.toString(), "")
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
if (stored.isNotEmpty()) return stored
|
||||
|
||||
// Lazy migration from the fork's old UI-layer implementation, which stored
|
||||
// one SharedPreferences entry per satellite directly from TransceiversPage.
|
||||
val legacyKey = "$keyLegacySatelliteOffsetPrefix$catnum"
|
||||
val legacy = preferences.getString(legacyKey, "").orEmpty()
|
||||
if (legacy.isNotEmpty()) {
|
||||
setSatelliteOffset(catnum, legacy)
|
||||
}
|
||||
return legacy
|
||||
}
|
||||
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) {
|
||||
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
|
||||
val updated = try {
|
||||
val obj = JSONObject(json)
|
||||
if (offset.isEmpty()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), offset)
|
||||
obj.toString()
|
||||
} catch (_: Exception) {
|
||||
if (offset.isEmpty()) "{}" else """{"$catnum": "$offset"}"""
|
||||
}
|
||||
preferences.edit {
|
||||
putString(keySatelliteOffsets, updated)
|
||||
remove("$keyLegacySatelliteOffsetPrefix$catnum")
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region # AMSAT status report settings
|
||||
private val keyAmSatCallsign = "amSatCallsign"
|
||||
|
||||
override fun getAmSatCallsign(): String {
|
||||
return preferences.getString(keyAmSatCallsign, "").orEmpty()
|
||||
}
|
||||
|
||||
override fun setAmSatCallsign(callsign: String) {
|
||||
preferences.edit { putString(keyAmSatCallsign, callsign.trim().uppercase(Locale.US)) }
|
||||
}
|
||||
//endregion
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.LatestRelease
|
||||
import com.rtbishop.look4sat.core.domain.repository.IUpdateRepository
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
class UpdateRepository(
|
||||
private val remoteSource: IRemoteSource
|
||||
) : IUpdateRepository {
|
||||
|
||||
override suspend fun getLatestRelease(): LatestRelease? = withContext(Dispatchers.IO) {
|
||||
val result = remoteSource.getNetworkStream(LATEST_RELEASE_URL)
|
||||
val stream = result.stream ?: return@withContext null
|
||||
try {
|
||||
val json = stream.bufferedReader().use { it.readText() }
|
||||
parseRelease(json)
|
||||
} catch (e: Exception) {
|
||||
println("UpdateRepository parse failure: $e")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun downloadApk(url: String, dest: File): Boolean = withContext(Dispatchers.IO) {
|
||||
if (url.isBlank()) return@withContext false
|
||||
val result = remoteSource.getNetworkStream(url)
|
||||
val stream = result.stream ?: return@withContext false
|
||||
try {
|
||||
dest.outputStream().use { out -> stream.use { it.copyTo(out) } }
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
println("UpdateRepository download failure: $e")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseRelease(json: String): LatestRelease? {
|
||||
val obj = JSONObject(json)
|
||||
val tag = obj.optString("tag_name")
|
||||
if (tag.isBlank()) return null
|
||||
val assets = obj.optJSONArray("assets")
|
||||
var apkUrl: String? = null
|
||||
if (assets != null) {
|
||||
for (i in 0 until assets.length()) {
|
||||
val asset = assets.optJSONObject(i) ?: continue
|
||||
val name = asset.optString("name", "")
|
||||
if (name.endsWith(".apk")) {
|
||||
apkUrl = asset.optString("browser_download_url", "").ifBlank { null }
|
||||
if (apkUrl != null) break
|
||||
}
|
||||
}
|
||||
}
|
||||
return LatestRelease(
|
||||
versionTag = tag,
|
||||
title = obj.optString("name", ""),
|
||||
body = obj.optString("body", ""),
|
||||
apkUrl = apkUrl
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val LATEST_RELEASE_URL = "https://api.github.com/repos/atsunatsu/Look4Sat/releases/latest"
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,13 @@ package com.rtbishop.look4sat.core.data.source
|
||||
import android.content.ContentResolver
|
||||
import androidx.core.net.toUri
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.ByteArrayInputStream
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.InputStream
|
||||
|
||||
class RemoteSource(
|
||||
@@ -43,15 +45,74 @@ class RemoteSource(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
||||
override suspend fun getNetworkStream(url: String): NetworkResult = withContext(dispatcher) {
|
||||
try {
|
||||
val networkRequest = Request.Builder().url(url).build()
|
||||
httpClient.newCall(networkRequest).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext null
|
||||
ByteArrayInputStream(response.body.bytes())
|
||||
val response = httpClient.newCall(networkRequest).execute()
|
||||
if (!response.isSuccessful) {
|
||||
val code = response.code
|
||||
response.close()
|
||||
return@withContext NetworkResult(code, null)
|
||||
}
|
||||
// Return the body stream directly as the caller is responsible for closing it.
|
||||
// Closing the stream returns the connection to OkHttp's pool.
|
||||
val body = response.body
|
||||
if (body == null) {
|
||||
response.close()
|
||||
return@withContext NetworkResult(response.code, null)
|
||||
}
|
||||
NetworkResult(response.code, body.byteStream().buffered())
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource network stream exception: $exception")
|
||||
NetworkResult(NetworkResult.CONNECTION_ERROR, null)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getAmSatCatalog(): String? = withContext(dispatcher) {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url("https://www.amsat.org/status/api/v1/catalog.php")
|
||||
.header("User-Agent", "Look4Sat")
|
||||
.build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@use null
|
||||
response.body.string()
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource getAmSatCatalog exception: $exception")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = withContext(dispatcher) {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url("https://www.amsat.org/status/api/v1/reports.php?hours=$hours&limit=$limit")
|
||||
.header("User-Agent", "Look4Sat")
|
||||
.build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@use null
|
||||
response.body.string()
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource getAmSatReports exception: $exception")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = withContext(dispatcher) {
|
||||
try {
|
||||
val body = payloadJson.toRequestBody("application/json; charset=utf-8".toMediaType())
|
||||
val request = Request.Builder()
|
||||
.url("https://www.amsat.org/status/api/v1/reports.php")
|
||||
.header("User-Agent", "Look4Sat")
|
||||
.post(body)
|
||||
.build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
response.code to response.body.string()
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource submitAmSatReport exception: $exception")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
import java.io.InputStream
|
||||
|
||||
class AmSatRepositoryTest {
|
||||
|
||||
@Test
|
||||
fun fetchStatusReturnsSeededCacheUntilCacheIsCleared() = runTest {
|
||||
val remoteSource = FakeAmSatRemoteSource()
|
||||
val repository = AmSatRepository(remoteSource)
|
||||
val cachedPage = SatStatusPage(
|
||||
fetchedAtUtcMs = 123L,
|
||||
statuses = listOf(SatStatus(name = "AO-7", days = emptyList())),
|
||||
reports = emptyMap()
|
||||
)
|
||||
repository.seedStatusCache(cachedPage)
|
||||
|
||||
val firstPage = repository.fetchStatus()
|
||||
val secondPage = repository.fetchStatus()
|
||||
|
||||
assertSame(cachedPage, firstPage)
|
||||
assertSame(cachedPage, secondPage)
|
||||
assertSame(cachedPage, repository.getCachedStatus())
|
||||
assertEquals(0, remoteSource.catalogRequests)
|
||||
assertEquals(0, remoteSource.reportRequests)
|
||||
|
||||
repository.clearStatusCache()
|
||||
assertNull(repository.getCachedStatus())
|
||||
repository.fetchStatus()
|
||||
|
||||
assertEquals(1, remoteSource.catalogRequests)
|
||||
assertEquals(1, remoteSource.reportRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forceRefreshBypassesCachedPage() = runTest {
|
||||
val remoteSource = FakeAmSatRemoteSource()
|
||||
val repository = AmSatRepository(remoteSource)
|
||||
repository.seedStatusCache(
|
||||
SatStatusPage(
|
||||
fetchedAtUtcMs = 123L,
|
||||
statuses = listOf(SatStatus(name = "AO-7", days = emptyList())),
|
||||
reports = emptyMap()
|
||||
)
|
||||
)
|
||||
|
||||
repository.fetchStatus(forceRefresh = true)
|
||||
|
||||
assertEquals(1, remoteSource.catalogRequests)
|
||||
assertEquals(1, remoteSource.reportRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchStatusSharesStartupPrefetchRequest() = runTest {
|
||||
val remoteSource = FakeAmSatRemoteSource(responseDelayMillis = 50)
|
||||
val repository = AmSatRepository(remoteSource)
|
||||
|
||||
val prefetch = async { repository.prefetchStatus() }
|
||||
val pageFetch = async { repository.fetchStatus() }
|
||||
prefetch.await()
|
||||
val page = pageFetch.await()
|
||||
|
||||
assertSame(page, repository.getCachedStatus())
|
||||
assertEquals(1, remoteSource.catalogRequests)
|
||||
assertEquals(1, remoteSource.reportRequests)
|
||||
}
|
||||
}
|
||||
|
||||
private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
|
||||
val cacheField = AmSatRepository::class.java.getDeclaredField("statusCache")
|
||||
cacheField.isAccessible = true
|
||||
cacheField.set(this, page)
|
||||
}
|
||||
|
||||
private class FakeAmSatRemoteSource(private val responseDelayMillis: Long = 0L) : IRemoteSource {
|
||||
var catalogRequests = 0
|
||||
var reportRequests = 0
|
||||
|
||||
override suspend fun getFileStream(uri: String): InputStream? = null
|
||||
|
||||
override suspend fun getNetworkStream(url: String): NetworkResult = NetworkResult(404, null)
|
||||
|
||||
override suspend fun getAmSatCatalog(): String? {
|
||||
if (responseDelayMillis > 0L) delay(responseDelayMillis)
|
||||
catalogRequests += 1
|
||||
return """{"data":[{"name":"AO-7"}]}"""
|
||||
}
|
||||
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? {
|
||||
if (responseDelayMillis > 0L) delay(responseDelayMillis)
|
||||
reportRequests += 1
|
||||
return """{"data":[]}"""
|
||||
}
|
||||
|
||||
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
|
||||
}
|
||||
+56
-10
@@ -31,6 +31,8 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -91,10 +93,8 @@ class DatabaseRepoTest {
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
useCustomTLE = true,
|
||||
useCustomTransceivers = false,
|
||||
tleUrl = customCsvUrl,
|
||||
transceiversUrl = ""
|
||||
satelliteUrls = listOf(customCsvUrl),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
)
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
@@ -105,6 +105,27 @@ class DatabaseRepoTest {
|
||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remote update imports satellites from real SatNOGS TLE source`() = runTest(dispatcher) {
|
||||
val satnogsUrl = Sources.satelliteDataUrls.getValue("SatNOGS")
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
networkStreams[satnogsUrl] = { jamxTleStream() }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
satelliteUrls = listOf(satnogsUrl),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
)
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
|
||||
repository.updateFromRemote()
|
||||
|
||||
assertTrue(localSource.insertedEntries.any { it.name == "JAMX-0825b" && it.catnum == 98248 })
|
||||
assertEquals(listOf(98248), settingsRepo.satelliteTypeIdsByType["SatNOGS"])
|
||||
}
|
||||
|
||||
private fun validCsvStream(): InputStream = """
|
||||
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||
@@ -115,6 +136,12 @@ class DatabaseRepoTest {
|
||||
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
||||
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||
""".trimIndent().byteInputStream()
|
||||
|
||||
private fun jamxTleStream(): InputStream = """
|
||||
JAMX-0825b
|
||||
1 98248U 26237.16675926 .00015724 00000-0 97477-3 0 00013
|
||||
2 98248 097.5373 310.9694 0011309 278.1232 340.7230 15.09766181000012
|
||||
""".trimIndent().byteInputStream()
|
||||
}
|
||||
|
||||
private class FakeRemoteSource : IRemoteSource {
|
||||
@@ -123,7 +150,14 @@ private class FakeRemoteSource : IRemoteSource {
|
||||
|
||||
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
|
||||
|
||||
override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke()
|
||||
override suspend fun getNetworkStream(url: String): NetworkResult =
|
||||
networkStreams[url]?.let { NetworkResult(200, it()) } ?: NetworkResult(404, null)
|
||||
|
||||
override suspend fun getAmSatCatalog(): String? = null
|
||||
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
||||
|
||||
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
|
||||
}
|
||||
|
||||
private class FakeLocalSource : ILocalSource {
|
||||
@@ -176,7 +210,7 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
||||
|
||||
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
||||
RCSettings(false, "", "", "", false, "", "", "", false, "", "", "", false, "", "")
|
||||
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
||||
)
|
||||
|
||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||
@@ -221,14 +255,26 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
dataSourcesSettings.value = settings
|
||||
}
|
||||
|
||||
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||
|
||||
override fun updateDataSourcesStatus(status: Map<String, Int>) {
|
||||
(dataSourcesStatus as? MutableStateFlow)?.value = status
|
||||
}
|
||||
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||
|
||||
override fun getAmSatCallsign(): String = ""
|
||||
|
||||
override fun setAmSatCallsign(callsign: String) = Unit
|
||||
}
|
||||
|
||||
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
||||
return DataSourcesSettings(
|
||||
useCustomTLE = false,
|
||||
useCustomTransceivers = false,
|
||||
tleUrl = "",
|
||||
transceiversUrl = ""
|
||||
satelliteUrls = emptyList(),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.rtbishop.look4sat.core.domain.model
|
||||
|
||||
/** One public AMSAT satellite status report submission. */
|
||||
data class AmSatReportSubmission(
|
||||
val name: String,
|
||||
val report: String,
|
||||
val callsign: String,
|
||||
val gridSquare: String,
|
||||
val reportedAtUtcMillis: Long
|
||||
)
|
||||
|
||||
/** Result of submitting an AMSAT satellite status report. */
|
||||
data class AmSatReportSubmitResult(
|
||||
val success: Boolean,
|
||||
val message: String = "",
|
||||
val reportId: String? = null
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* 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.core.domain.model
|
||||
|
||||
object Constants {
|
||||
const val FREQ_OFFSET_MIN_HZ = -50_000L
|
||||
const val FREQ_OFFSET_MAX_HZ = 50_000L
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.rtbishop.look4sat.core.domain.model
|
||||
|
||||
/** Latest release info fetched from the fork's GitHub releases API. */
|
||||
data class LatestRelease(
|
||||
val versionTag: String, // e.g. "v4.4.6-ba7opf.6"
|
||||
val title: String, // release name, may be empty
|
||||
val body: String, // release notes / update description
|
||||
val apkUrl: String? // first .apk asset download URL, null if absent
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.rtbishop.look4sat.core.domain.model
|
||||
|
||||
/** One satellite status report (AMSAT site tooltip data) */
|
||||
data class SatReport(
|
||||
val id: String, // 报告 ID(a885153)
|
||||
val statusText: String, // Heard / Telemetry Only / Not Heard ...
|
||||
val call: String, // 呼号
|
||||
val grid: String, // 网格坐标(可为空)
|
||||
val dateUtc: String, // 2026-08-04
|
||||
val timeUtc: String // 2:46-:59 UTC
|
||||
)
|
||||
|
||||
/** State of one 2-hour slot */
|
||||
data class SatSlot(
|
||||
val statusColor: Long, // ARGB 状态色(-1 = 无报告)
|
||||
val count: Int, // 报告数量(0 = 无)
|
||||
val reportIds: List<String> = emptyList() // 该槽报告 ID 列表
|
||||
)
|
||||
|
||||
/** One satellite day (12 two-hour slots) */
|
||||
data class SatDay(
|
||||
val dateLabel: String, // "Aug 4"
|
||||
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
|
||||
)
|
||||
|
||||
/** One satellite, 3 days of state */
|
||||
data class SatStatus(
|
||||
val name: String, // "AO-123_[FM]"
|
||||
val days: List<SatDay> // 3 days (newest first)
|
||||
)
|
||||
|
||||
/** Overall page parse result */
|
||||
data class SatStatusPage(
|
||||
val fetchedAtUtcMs: Long,
|
||||
val statuses: List<SatStatus>,
|
||||
val reports: Map<String, SatReport> // id → 报告
|
||||
)
|
||||
@@ -42,6 +42,7 @@ data class RCSettings(
|
||||
val frequencyAddress: String,
|
||||
val frequencyPort: String,
|
||||
val frequencyFormat: String,
|
||||
val frequencyOffsetHz: Long = 0L,
|
||||
val bluetoothRotatorState: Boolean,
|
||||
val bluetoothRotatorFormat: String,
|
||||
val bluetoothRotatorName: String,
|
||||
@@ -66,11 +67,14 @@ data class OtherSettings(
|
||||
)
|
||||
|
||||
data class DataSourcesSettings(
|
||||
val useCustomTLE: Boolean,
|
||||
val useCustomTransceivers: Boolean,
|
||||
val tleUrl: String,
|
||||
val transceiversUrl: String
|
||||
)
|
||||
val satelliteUrls: List<String>,
|
||||
val transceiversUrls: List<String>,
|
||||
val satelliteEnabled: List<Boolean> = emptyList(),
|
||||
val transceiversEnabled: List<Boolean> = emptyList()
|
||||
) {
|
||||
fun isSatelliteEnabled(index: Int): Boolean = satelliteEnabled.getOrElse(index) { true }
|
||||
fun isTransceiverEnabled(index: Int): Boolean = transceiversEnabled.getOrElse(index) { true }
|
||||
}
|
||||
|
||||
data class RadioControlSettings(
|
||||
val enabled: Boolean,
|
||||
|
||||
+7
-3
@@ -424,11 +424,15 @@ object CelestialComputer {
|
||||
}
|
||||
if (sunrise == 0.0) sunrise = daynum
|
||||
|
||||
// Phase 4: fast-forward through the day until sun drops back below threshold
|
||||
daynum = sunrise
|
||||
// Phase 4: fast-forward through the day until sun drops back below threshold.
|
||||
// Start from just after sunrise (small offset) so the sun is clearly above
|
||||
// the threshold. This prevents a bug where Phase 3 converges to a point
|
||||
// slightly below -threshold, causing Phase 4 to skip and Phase 5 to converge
|
||||
// to the same time as sunrise, producing identical sunrise/sunset times.
|
||||
daynum = sunrise + 0.001
|
||||
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||
guard = 0
|
||||
while (sunPos.elevation > -threshold && guard++ < 500) {
|
||||
while (sunPos.elevation >= -threshold && guard++ < 500) {
|
||||
daynum += 0.008
|
||||
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||
}
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
||||
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmitResult
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||
|
||||
/** AMSAT satellite status data source */
|
||||
interface IAmSatRepository {
|
||||
/** Cached AMSAT status page for the current foreground session, if any. */
|
||||
fun getCachedStatus(): SatStatusPage?
|
||||
|
||||
/** Fetch and parse the AMSAT status page; null on failure. */
|
||||
suspend fun fetchStatus(forceRefresh: Boolean = false): SatStatusPage?
|
||||
|
||||
/** Warm the foreground-session cache without forcing a network reload. */
|
||||
suspend fun prefetchStatus()
|
||||
|
||||
/** Clear the foreground-session status cache. */
|
||||
fun clearStatusCache()
|
||||
|
||||
/** Submit a public AMSAT satellite status report. */
|
||||
suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult
|
||||
}
|
||||
+2
@@ -30,6 +30,8 @@ interface IMainContainer {
|
||||
val selectionRepo: ISelectionRepo
|
||||
val satelliteRepo: ISatelliteRepo
|
||||
val databaseRepo: IDatabaseRepo
|
||||
val amSatRepo: IAmSatRepository
|
||||
val updateRepo: IUpdateRepository
|
||||
val radioTrackingService: IRadioTrackingService
|
||||
val mutualPassData: StateFlow<MutualPassData>
|
||||
fun setMutualPassData(data: MutualPassData)
|
||||
|
||||
+15
@@ -73,8 +73,23 @@ interface ISettingsRepo {
|
||||
fun updateDataSourcesSettings(settings: DataSourcesSettings)
|
||||
//endregion
|
||||
|
||||
//region # Data sources status
|
||||
val dataSourcesStatus: StateFlow<Map<String, Int>>
|
||||
fun updateDataSourcesStatus(status: Map<String, Int>)
|
||||
//endregion
|
||||
|
||||
//region # Radio control settings
|
||||
val radioControlSettings: StateFlow<RadioControlSettings>
|
||||
fun updateRadioControlSettings(settings: RadioControlSettings)
|
||||
//endregion
|
||||
|
||||
//region # Per-satellite calculator offset settings
|
||||
fun getSatelliteOffset(catnum: Int): String
|
||||
fun setSatelliteOffset(catnum: Int, offset: String)
|
||||
//endregion
|
||||
|
||||
//region # AMSAT status report settings
|
||||
fun getAmSatCallsign(): String
|
||||
fun setAmSatCallsign(callsign: String)
|
||||
//endregion
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.LatestRelease
|
||||
import java.io.File
|
||||
|
||||
interface IUpdateRepository {
|
||||
/** Fetch the latest release of the fork repo from GitHub; null on failure. */
|
||||
suspend fun getLatestRelease(): LatestRelease?
|
||||
|
||||
/** Download the release APK from [url] into [dest]; true on success. */
|
||||
suspend fun downloadApk(url: String, dest: File): Boolean
|
||||
}
|
||||
+12
-1
@@ -19,7 +19,18 @@ package com.rtbishop.look4sat.core.domain.source
|
||||
|
||||
import java.io.InputStream
|
||||
|
||||
/** Result of a network download: the HTTP status code and the response body stream. */
|
||||
data class NetworkResult(val code: Int, val stream: InputStream?) {
|
||||
companion object {
|
||||
/** Code used when a request fails before any HTTP response is received. */
|
||||
const val CONNECTION_ERROR = -1
|
||||
}
|
||||
}
|
||||
|
||||
interface IRemoteSource {
|
||||
suspend fun getFileStream(uri: String): InputStream?
|
||||
suspend fun getNetworkStream(url: String): InputStream?
|
||||
suspend fun getNetworkStream(url: String): NetworkResult
|
||||
suspend fun getAmSatCatalog(): String?
|
||||
suspend fun getAmSatReports(hours: Int, limit: Int): String?
|
||||
suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>?
|
||||
}
|
||||
@@ -19,7 +19,10 @@ package com.rtbishop.look4sat.core.domain.source
|
||||
|
||||
object Sources {
|
||||
val satelliteDataUrls = mapOf(
|
||||
"LAPAN-A2" to "https://www.kaggle.com/api/v1/datasets/download/muazamnugroho/lapan-a2-satellite-two-line-element-tle-dataset/LAPAN-A2_TLE_latest.txt",
|
||||
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
||||
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||
"SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le",
|
||||
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
|
||||
"Brightest" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=visual&FORMAT=csv",
|
||||
"Cubesat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=cubesat&FORMAT=csv",
|
||||
@@ -35,7 +38,7 @@ object Sources {
|
||||
"OneWeb" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=oneweb&FORMAT=csv",
|
||||
"Orbcomm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=orbcomm&FORMAT=csv",
|
||||
"Resource" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=resource&FORMAT=csv",
|
||||
"SatNOGS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv",
|
||||
"CelesTrak SatNOGS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv",
|
||||
"Science" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=science&FORMAT=csv",
|
||||
"Spire" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=spire&FORMAT=csv",
|
||||
"Starlink" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=starlink&FORMAT=csv",
|
||||
@@ -45,10 +48,11 @@ object Sources {
|
||||
"Amsat" to "https://amsat.org/tle/current/nasabare.txt",
|
||||
"Classified" to "https://www.mmccants.org/tles/classfd.zip",
|
||||
"McCants" to "https://www.mmccants.org/tles/inttles.zip",
|
||||
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
||||
"ARISS" to "https://live.ariss.org/iss.txt",
|
||||
"Other" to "" // key for sats filter
|
||||
)
|
||||
val transceiversDataUrls = mapOf(
|
||||
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active"
|
||||
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active",
|
||||
"R4UAB" to "https://r4uab.ru/transmitters.json"
|
||||
)
|
||||
}
|
||||
+75
-26
@@ -16,20 +16,28 @@ 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,
|
||||
@@ -37,17 +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 mapping the downlink passband position back to the uplink.
|
||||
* it before the inverse downlink Doppler.
|
||||
*/
|
||||
fun computeUplinkFromDownlinkWithOffset(
|
||||
downlinkHz: Long,
|
||||
@@ -56,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,
|
||||
@@ -70,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,
|
||||
@@ -86,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. */
|
||||
@@ -114,11 +144,30 @@ object DopplerFrequencyCalculator {
|
||||
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
|
||||
.joinToString(separator = " ")
|
||||
.lowercase(Locale.ENGLISH)
|
||||
val hasLinearName = info.contains("linear")
|
||||
val hasLinearName = info.contains("linear") || info.contains(" lin") || info.startsWith("lin")
|
||||
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)
|
||||
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode) ||
|
||||
(hasLinearName && hasLinearMode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes duplicate transponder entries that describe the same physical
|
||||
* transponder with different mode labels (e.g. SatNOGS lists AO-7's Mode A
|
||||
* as both "Lin SSB" and "Lin CW", and JO-97's U/V transponder as both
|
||||
* "CW Transponder" and "SSB Transponder").
|
||||
*
|
||||
* Entries sharing the same uplink/downlink frequency range are considered
|
||||
* the same transponder. The non-CW entry is preferred because its invert
|
||||
* flag is more reliable (e.g. JO-97's CW entry wrongly has invert=false).
|
||||
*/
|
||||
fun deduplicateTransponders(radios: List<SatRadio>): List<SatRadio> {
|
||||
return radios.groupBy { radio ->
|
||||
listOf(radio.uplinkLow, radio.uplinkHigh, radio.downlinkLow, radio.downlinkHigh)
|
||||
}.values.map { group ->
|
||||
group.firstOrNull { it.downlinkMode?.equals("CW", ignoreCase = true) != true } ?: group.first()
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
-12
@@ -19,20 +19,43 @@ package com.rtbishop.look4sat.core.domain.utility
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
|
||||
/**
|
||||
* Convert a Maidenhead locator (4, 6 or 8 chars) to the position of its
|
||||
* square / subsquare / extended-square centre. Longer input is truncated to
|
||||
* 8 chars (the Maidenhead maximum); malformed or out-of-range input yields
|
||||
* null.
|
||||
*/
|
||||
fun qthToPosition(locator: String): GeoPos? {
|
||||
val trimmedQth = locator.take(6)
|
||||
if (!isValidLocator(trimmedQth)) return null
|
||||
val lonFirst = (trimmedQth[0].uppercaseChar().code - 65) * 20
|
||||
val latFirst = (trimmedQth[1].uppercaseChar().code - 65) * 10
|
||||
val trimmedQth = locator.trim().uppercase().take(8)
|
||||
if (trimmedQth.length !in listOf(4, 6, 8) || !isValidLocator(trimmedQth)) return null
|
||||
val lonFirst = (trimmedQth[0].code - 65) * 20
|
||||
val latFirst = (trimmedQth[1].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) + (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)
|
||||
// Start from the 4-char square centre, refine for 6/8-char subsquares.
|
||||
var longitude = lonFirst + lonSecond + 1.0
|
||||
var latitude = latFirst + latSecond + 0.5
|
||||
if (trimmedQth.length >= 6) {
|
||||
val lonSub = (trimmedQth[4].code - 65) * 5.0 / 60.0
|
||||
val latSub = (trimmedQth[5].code - 65) * 2.5 / 60.0
|
||||
if (lonSub < 0.0 || lonSub > 115.0 / 60.0 || latSub < 0.0 || latSub > 57.5 / 60.0) return null
|
||||
longitude = lonFirst + lonSecond + lonSub + 2.5 / 60.0
|
||||
latitude = latFirst + latSecond + latSub + 1.25 / 60.0
|
||||
if (trimmedQth.length >= 8) {
|
||||
val lonExt = (trimmedQth[6].code - 48) * 30.0 / 3600.0
|
||||
val latExt = (trimmedQth[7].code - 48) * 15.0 / 3600.0
|
||||
if (lonExt < 0.0 || lonExt > 270.0 / 3600.0 || latExt < 0.0 || latExt > 135.0 / 3600.0) return null
|
||||
longitude += lonExt + 15.0 / 3600.0
|
||||
latitude += latExt + 7.5 / 3600.0
|
||||
}
|
||||
}
|
||||
return GeoPos((latitude - 90.0).round(4), (longitude - 180.0).round(4))
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a position to its 6-char Maidenhead locator, upper-cased (field,
|
||||
* square and subsquare letters). Returns null for out-of-range positions.
|
||||
*/
|
||||
fun positionToQth(latitude: Double, longitude: Double): String? {
|
||||
if (!isValidPosition(latitude, longitude)) return null
|
||||
val newLongitude = if (longitude > 180.0) longitude else longitude + 180
|
||||
@@ -41,8 +64,8 @@ fun positionToQth(latitude: Double, longitude: Double): String? {
|
||||
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 lonThird = (65 + (newLongitude % 2) * 12).toInt().toChar().uppercaseChar()
|
||||
val latThird = (65 + (newLatitude % 1) * 24).toInt().toChar().uppercaseChar()
|
||||
return "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
|
||||
}
|
||||
|
||||
@@ -51,5 +74,11 @@ private fun isValidPosition(lat: Double, lon: Double): Boolean {
|
||||
}
|
||||
|
||||
private fun isValidLocator(locator: String): Boolean {
|
||||
return locator.matches("[a-xA-X][a-xA-X]\\d\\d[a-xA-X][a-xA-X]".toRegex())
|
||||
// 4 chars: [A-X][A-X]\d\d ; 6 chars: + [A-X][A-X] ; 8 chars: + \d\d
|
||||
return when (locator.length) {
|
||||
4 -> Regex("[A-X]{2}\\d{2}").matches(locator)
|
||||
6 -> Regex("[A-X]{2}\\d{2}[A-X]{2}").matches(locator)
|
||||
8 -> Regex("[A-X]{2}\\d{2}[A-X]{2}\\d{2}").matches(locator)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.rtbishop.look4sat.core.domain.utility
|
||||
|
||||
/**
|
||||
* Compares two version strings of the form "<major>.<minor>.<patch>[-<build>]" used by the
|
||||
* BA7OPF fork releases (e.g. "4.4.6-ba7opf.6" or "v4.4.6-ba7opf.7").
|
||||
*
|
||||
* The comparison is done on the numeric version segments first; when the base versions are
|
||||
* equal, the trailing build number (if any) decides. This lets a "4.4.6-ba7opf.7" release be
|
||||
* detected as newer than "4.4.6-ba7opf.6" while "4.5.0" trumps any "4.4.x" build.
|
||||
*/
|
||||
object VersionComparator {
|
||||
|
||||
/** True when [candidate] is a newer version than [current]. */
|
||||
fun isNewer(candidate: String, current: String): Boolean {
|
||||
val cand = parse(candidate)
|
||||
val curr = parse(current)
|
||||
val maxLen = maxOf(cand.first.size, curr.first.size)
|
||||
for (i in 0 until maxLen) {
|
||||
val c = cand.first.getOrElse(i) { 0 }
|
||||
val k = curr.first.getOrElse(i) { 0 }
|
||||
if (c != k) return c > k
|
||||
}
|
||||
return cand.second > curr.second
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a version string into (numeric base segments, trailing build number).
|
||||
* "v4.4.6-ba7opf.7" -> ([4,4,6], 7); "4.4.6" -> ([4,4,6], 0); "4.4.6-ba7opf" -> ([4,4,6], 0).
|
||||
*/
|
||||
fun parse(version: String): Pair<List<Int>, Int> {
|
||||
val cleaned = version.trim().removePrefix("v")
|
||||
val dashIndex = cleaned.indexOf('-')
|
||||
val basePart = if (dashIndex >= 0) cleaned.substring(0, dashIndex) else cleaned
|
||||
val suffixPart = if (dashIndex >= 0) cleaned.substring(dashIndex + 1) else ""
|
||||
val base = basePart.split('.').mapNotNull { it.toIntOrNull() }
|
||||
// The build number is the trailing numeric segment of the suffix (e.g. "ba7opf.7" -> 7).
|
||||
val build = suffixPart
|
||||
.split('.')
|
||||
.lastOrNull()
|
||||
?.toIntOrNull()
|
||||
?: 0
|
||||
return base to build
|
||||
}
|
||||
}
|
||||
+84
-1
@@ -9,6 +9,7 @@ import org.junit.Test
|
||||
class DopplerFrequencyCalculatorTest {
|
||||
|
||||
private fun linearTransponder(
|
||||
uuid: String = "linear",
|
||||
upLow: Long = 145_000_000L,
|
||||
upHigh: Long = 145_500_000L,
|
||||
downLow: Long = 435_000_000L,
|
||||
@@ -18,7 +19,7 @@ class DopplerFrequencyCalculatorTest {
|
||||
downlinkMode: String? = "USB",
|
||||
uplinkMode: String? = "LSB"
|
||||
) = SatRadio(
|
||||
uuid = "linear", info = info, isAlive = true,
|
||||
uuid = uuid, info = info, isAlive = true,
|
||||
downlinkLow = downLow, downlinkHigh = downHigh,
|
||||
downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
|
||||
uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
|
||||
@@ -68,11 +69,93 @@ class DopplerFrequencyCalculatorTest {
|
||||
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isNamedLinearTransponder_returnsTrueForAbbreviatedLinName() {
|
||||
// AO-7 style: "Mode V/A (A) Lin SSB" — "Lin" abbreviation, no "transponder" word
|
||||
val ao7Entry = linearTransponder(info = "Mode V/A (A) Lin SSB", downlinkMode = "USB", uplinkMode = "USB")
|
||||
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7Entry))
|
||||
val ao7CwEntry = linearTransponder(info = "Mode V/A (A) Lin CW", downlinkMode = "CW", uplinkMode = "CW")
|
||||
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7CwEntry))
|
||||
val ao7ModeBEntry = linearTransponder(info = "Mode U/V (B) Lin", downlinkMode = "USB", uplinkMode = "LSB")
|
||||
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7ModeBEntry))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isNamedLinearTransponder_returnsTrueForLinearWithoutTransponderWord() {
|
||||
// AO-73 style: "Mode U/V Linear" — has "Linear" but no "transponder"
|
||||
val ao73Entry = linearTransponder(info = "Mode U/V Linear", downlinkMode = "USB", uplinkMode = "LSB")
|
||||
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao73Entry))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isNamedLinearTransponder_returnsFalseForDownlinkContainingLinInsideWord() {
|
||||
// "Downlink" contains "lin" but is not a linear-transponder name
|
||||
val downlinkEntry = linearTransponder(info = "Mode U Downlink", downlinkMode = "FM", uplinkMode = "FM")
|
||||
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(downlinkEntry))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isNamedLinearTransponder_returnsFalseForFmRepeater() {
|
||||
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deduplicateTransponders_mergesSameFrequencyRange() {
|
||||
// AO-7's Mode A: same range, SSB and CW entries
|
||||
val ssb = linearTransponder(
|
||||
uuid = "ssb-uuid", info = "Mode V/A (A) Lin SSB",
|
||||
downlinkMode = "USB", uplinkMode = "USB"
|
||||
)
|
||||
val cw = linearTransponder(
|
||||
uuid = "cw-uuid", info = "Mode V/A (A) Lin CW",
|
||||
downlinkMode = "CW", uplinkMode = "CW"
|
||||
)
|
||||
val modeB = linearTransponder(
|
||||
uuid = "modeb-uuid", info = "Mode U/V (B) Lin",
|
||||
upLow = 432_125_000L, upHigh = 432_175_000L,
|
||||
downLow = 145_925_000L, downHigh = 145_975_000L,
|
||||
downlinkMode = "USB", uplinkMode = "LSB"
|
||||
)
|
||||
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(ssb, cw, modeB))
|
||||
assertEquals(2, result.size)
|
||||
// SSB entry should be preferred over CW (same range)
|
||||
assertEquals("ssb-uuid", result[0].uuid)
|
||||
assertEquals("modeb-uuid", result[1].uuid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deduplicateTransponders_prefersNonCwEntry() {
|
||||
// JO-97: CW entry has invert=false (wrong), SSB has invert=true (correct)
|
||||
val cw = linearTransponder(
|
||||
uuid = "cw-uuid", info = "U/V CW Transponder",
|
||||
downlinkMode = "CW", uplinkMode = "CW",
|
||||
upLow = 435_100_000L, upHigh = 435_120_000L,
|
||||
downLow = 145_855_000L, downHigh = 145_875_000L
|
||||
)
|
||||
val ssb = linearTransponder(
|
||||
uuid = "ssb-uuid", info = "U/V SSB Transponder",
|
||||
downlinkMode = "USB", uplinkMode = "LSB",
|
||||
upLow = 435_100_000L, upHigh = 435_120_000L,
|
||||
downLow = 145_855_000L, downHigh = 145_875_000L,
|
||||
inverted = true
|
||||
)
|
||||
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(cw, ssb))
|
||||
assertEquals(1, result.size)
|
||||
assertEquals("ssb-uuid", result[0].uuid)
|
||||
// Verify the correct invert flag is preserved
|
||||
assertTrue(result[0].isInverted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deduplicateTransponders_preservesUniqueEntries() {
|
||||
val t1 = linearTransponder(uuid = "t1", upLow = 145_000_000L, upHigh = 145_500_000L,
|
||||
downLow = 435_000_000L, downHigh = 435_500_000L)
|
||||
val t2 = linearTransponder(uuid = "t2", upLow = 435_000_000L, upHigh = 435_500_000L,
|
||||
downLow = 145_000_000L, downHigh = 145_500_000L)
|
||||
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(t1, t2))
|
||||
assertEquals(2, result.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeUplinkFromDownlink_linear_noDoppler() {
|
||||
val xpdr = linearTransponder()
|
||||
|
||||
@@ -1,20 +1,3 @@
|
||||
/*
|
||||
* 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.core.domain
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||
@@ -22,25 +5,34 @@ import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
||||
import org.junit.Test
|
||||
|
||||
class QthConverterTest {
|
||||
|
||||
@Test
|
||||
fun `Given valid QTH returns correct POS`() {
|
||||
// 8-char locator -> centre of the extended square (higher precision)
|
||||
var result = qthToPosition("io91VL39FX")
|
||||
assert(result?.latitude == 51.4792 && result.longitude == -0.2083)
|
||||
assert(result?.latitude == 51.5188 && result.longitude == -0.1792)
|
||||
result = qthToPosition("gf15vc")
|
||||
assert(result?.latitude == -34.8958 && result.longitude == -56.2083)
|
||||
// 4-char locator -> centre of the 2deg x 1deg square
|
||||
result = qthToPosition("JN58")
|
||||
assert(result?.latitude == 48.5 && result.longitude == 11.0)
|
||||
// lowercase input is accepted and normalised
|
||||
result = qthToPosition("ol63pd")
|
||||
assert(result?.latitude == 23.1458 && result.longitude == 113.2917)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given invalid QTH returns null`() {
|
||||
assert(qthToPosition("ZZ00zz") == null)
|
||||
assert(qthToPosition("JN58") == null)
|
||||
assert(qthToPosition("JN5") == null) // odd length
|
||||
assert(qthToPosition("JN58Z") == null) // 5 chars
|
||||
assert(qthToPosition("JN58ZA") == null) // Z out of A-X range
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given valid POS returns correct QTH`() {
|
||||
assert(positionToQth(51.4878, -0.2146) == "IO91vl")
|
||||
assert(positionToQth(48.1466, 11.6083) == "JN58td")
|
||||
assert(positionToQth(51.4878, -0.2146) == "IO91VL")
|
||||
assert(positionToQth(48.1466, 11.6083) == "JN58TD")
|
||||
assert(positionToQth(23.13, 113.26) == "OL63PD")
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.rtbishop.look4sat.core.domain
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class VersionComparatorTest {
|
||||
|
||||
@Test
|
||||
fun `newer build number wins when base version equal`() {
|
||||
assertTrue(VersionComparator.isNewer("4.4.6-ba7opf.7", "4.4.6-ba7opf.6"))
|
||||
assertTrue(VersionComparator.isNewer("v4.4.6-ba7opf.6", "4.4.6-ba7opf.5"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `newer base version wins regardless of build number`() {
|
||||
assertTrue(VersionComparator.isNewer("4.5.0", "4.4.6-ba7opf.99"))
|
||||
assertTrue(VersionComparator.isNewer("4.4.7-ba7opf.1", "4.4.6-ba7opf.99"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `older versions are not newer`() {
|
||||
assertFalse(VersionComparator.isNewer("4.4.6-ba7opf.5", "4.4.6-ba7opf.6"))
|
||||
assertFalse(VersionComparator.isNewer("4.4.5", "4.4.6-ba7opf.1"))
|
||||
assertFalse(VersionComparator.isNewer("4.4.6-ba7opf.6", "4.4.6-ba7opf.6"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `v prefix is ignored`() {
|
||||
assertTrue(VersionComparator.isNewer("v4.4.7", "4.4.6"))
|
||||
assertFalse(VersionComparator.isNewer("v4.4.6", "4.4.6-ba7opf.1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non numeric suffix without number treated as zero build`() {
|
||||
assertTrue(VersionComparator.isNewer("4.4.6-ba7opf.1", "4.4.6-ba7opf"))
|
||||
assertFalse(VersionComparator.isNewer("4.4.6-ba7opf", "4.4.6-ba7opf.1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse extracts base and build number`() {
|
||||
assertEquals(listOf(4, 4, 6) to 7, VersionComparator.parse("v4.4.6-ba7opf.7"))
|
||||
assertEquals(listOf(4, 4, 6) to 0, VersionComparator.parse("4.4.6"))
|
||||
assertEquals(listOf(4, 4, 6) to 0, VersionComparator.parse("4.4.6-ba7opf"))
|
||||
}
|
||||
}
|
||||
@@ -280,6 +280,40 @@ fun getDefaultPass(): OrbitalPass = OrbitalPass(
|
||||
altitude = 0, maxElevation = 0.0, orbitalObject = NearEarthObject(defaultOrbitalData), progress = 0f
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun InfoDialog(
|
||||
title: String,
|
||||
onDismiss: () -> Unit,
|
||||
onAccept: () -> Unit,
|
||||
extraAction: (@Composable () -> Unit)? = null,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
DialogShell(onDismissRequest = onDismiss) { padding ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = padding, top = padding, end = padding)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
extraAction?.let {
|
||||
it()
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedDialog(
|
||||
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
|
||||
|
||||
+8
-4
@@ -79,6 +79,10 @@ private val lightScheme = lightColorScheme(
|
||||
onSecondary = Color(0xFFFFFFFF),
|
||||
secondaryContainer = Color(0xFFF1E1BB),
|
||||
onSecondaryContainer = Color(0xFF221B04),
|
||||
tertiary = Color(0xFF3C6FE0), // AMSAT Active (darker for light theme)
|
||||
onTertiary = Color(0xFFFFFFFF),
|
||||
tertiaryContainer = Color(0xFFE09800), // AMSAT Telemetry (darker)
|
||||
onTertiaryContainer = Color(0xFF000000),
|
||||
background = Color(0xFFFFF8F0),
|
||||
onBackground = Color(0xFF1E1B13),
|
||||
surface = Color(0xFFFFF8F0),
|
||||
@@ -101,10 +105,10 @@ private val darkScheme = darkColorScheme(
|
||||
onSecondary = Color(0xFF000000),
|
||||
secondaryContainer = Color(0xFF404040), // navBar indicator,
|
||||
onSecondaryContainer = Color(0xFFE0E0E0), // navBar active icon
|
||||
// tertiary = Color(0xFF121212),
|
||||
// onTertiary = Color(0xFF121212),
|
||||
// tertiaryContainer = Color(0xFF121212),
|
||||
// onTertiaryContainer = Color(0xFF121212),
|
||||
tertiary = Color(0xFF648FFF), // AMSAT Active (from amsat.org/status)
|
||||
onTertiary = Color(0xFF000000),
|
||||
tertiaryContainer = Color(0xFFFFB000), // AMSAT Telemetry
|
||||
onTertiaryContainer = Color(0xFF000000),
|
||||
background = Color(0xFF121212),
|
||||
onBackground = Color(0xFFE0E0E0),
|
||||
surface = Color(0xFF202020), // card background
|
||||
|
||||
+4
-4
@@ -29,14 +29,14 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
||||
@Serializable
|
||||
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)
|
||||
|
||||
@Serializable
|
||||
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)
|
||||
data object Mutual : Screen(R.drawable.ic_radio_tower, R.string.nav_mutual)
|
||||
|
||||
@Serializable
|
||||
data object AMSAT : Screen(R.drawable.ic_satellite_alt, R.string.nav_amsat)
|
||||
|
||||
@Serializable
|
||||
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<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="M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?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="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M240,320q-33,0 -56.5,-23.5T160,240q0,-33 23.5,-56.5T240,160q33,0 56.5,23.5T320,240q0,33 -23.5,56.5T240,320zM240,560q-33,0 -56.5,-23.5T160,480q0,-33 23.5,-56.5T240,400q33,0 56.5,23.5T320,480q0,33 -23.5,56.5T240,560zM240,800q-33,0 -56.5,-23.5T160,720q0,-33 23.5,-56.5T240,640q33,0 56.5,23.5T320,720q0,33 -23.5,56.5T240,800zM720,320q-33,0 -56.5,-23.5T640,240q0,-33 23.5,-56.5T720,160q33,0 56.5,23.5T800,240q0,33 -23.5,56.5T720,320zM720,560q-33,0 -56.5,-23.5T640,480q0,-33 23.5,-56.5T720,400q33,0 56.5,23.5T800,480q0,33 -23.5,56.5T720,560zM720,800q-33,0 -56.5,-23.5T640,720q0,-33 23.5,-56.5T720,640q33,0 56.5,23.5T800,720q0,33 -23.5,56.5T720,800z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?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="#FF000000" android:pathData="M12,10A2,2 0 0,1 14,12C14,12.5 13.82,12.94 13.53,13.29L16.7,22H14.57L12,14.93L9.43,22H7.3L10.47,13.29C10.18,12.94 10,12.5 10,12A2,2 0 0,1 12,10M12,8A4,4 0 0,0 8,12C8,12.5 8.1,13 8.28,13.46L7.4,15.86C6.53,14.81 6,13.47 6,12A6,6 0 0,1 12,6A6,6 0 0,1 18,12C18,13.47 17.47,14.81 16.6,15.86L15.72,13.46C15.9,13 16,12.5 16,12A4,4 0 0,0 12,8M12,4A8,8 0 0,0 4,12C4,14.36 5,16.5 6.64,17.94L5.92,19.94C3.54,18.11 2,15.23 2,12A10,10 0 0,1 12,2A10,10 0 0,1 22,12C22,15.23 20.46,18.11 18.08,19.94L17.36,17.94C19,16.5 20,14.36 20,12A8,8 0 0,0 12,4Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Standard Material refresh icon (24dp viewport), centered geometry -->
|
||||
<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="#FF000000"
|
||||
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- AMSAT tab icon provided by rt-bishop (upstream author), original SVG asset -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<group
|
||||
android:scaleX="0.242"
|
||||
android:scaleY="0.242"
|
||||
android:translateX="0.3"
|
||||
android:translateY="-230.5">
|
||||
<path android:fillColor="#FF000000" android:pathData="m 21.275553,967.35933 a 2.0000048,2.0000048 0 0 0 -1.1875,0.59375 l -13.5000003,13.5 a 2.0000048,2.0000048 0 0 0 0,2.8125 l 18.5625003,18.59382 a 2.0000048,2.0000048 0 0 0 1.2813,0.5625 l 5.5312,0.4375 -4.0312,4.0312 a 2.0000048,2.0000048 0 0 0 0,2.8126 l 13.6874,13.7187 a 2.0000048,2.0000048 0 0 0 2.8438,0 l 4.0312,-4.0312 0.4376,5.5312 a 2.0000048,2.0000048 0 0 0 0.5624,1.2813 l 18.5938,18.5625 a 2.0000048,2.0000048 0 0 0 2.8438,0 l 13.468705,-13.4688 a 2.0000048,2.0000048 0 0 0 0,-2.8437 l -18.562505,-18.5938 a 2.0000048,2.0000048 0 0 0 -1.2812,-0.5625 l -5.5313,-0.4375 4.0313,-4.0313 a 2.0000048,2.0000048 0 0 0 0,-2.8437 l -13.6876,-13.68757 a 2.0000048,2.0000048 0 0 0 -2.8437,0 l -4.0313,4.03125 -0.4374,-5.53125 a 2.0000048,2.0000048 0 0 0 -0.5626,-1.28125 l -18.5937,-18.5625 a 2.0000048,2.0000048 0 0 0 -1.4375,-0.59375 2.0000048,2.0000048 0 0 0 -0.1875,0 z m 0.2187,4.8125 16.6563,16.65625 0.5625,7.46875 -3.7812,3.78127 -7.4688,-0.59377 -16.6562,-16.625 z m 34.5626,41.46887 7.4687,0.5937 16.656305,16.625 -10.687605,10.6875 -16.625,-16.6562 -0.5937,-7.4688 z" />
|
||||
<path android:fillColor="#FF000000" android:pathData="m 50.940553,966.18283 a 2.000005,2.000005 0 1 0 0,3.99969 c 7.9999,0 15.9734,3.03851 22.0884,9.15344 6.103605,6.10359 9.143805,14.08909 9.143805,22.07844 a 2.000005,2.000005 0 1 0 3.9994,0 c 0,-9.00692 -3.4462,-18.03775 -10.315,-24.90657 -6.877605,-6.8775 -15.900305,-10.325 -24.916605,-10.325 z" />
|
||||
<path android:fillColor="#FF000000" android:pathData="m 50.940553,958.36533 a 2.000005,2.000005 0 1 0 0,3.99969 c 10.0021,0 19.9781,3.79987 27.613705,11.43562 7.6352,7.63517 11.4357,17.62207 11.4357,27.61376 a 2.000005,2.000005 0 1 0 3.9997,0 c 0,-11.00842 -4.207,-22.04171 -12.6072,-30.44188 -8.399505,-8.3996 -19.424005,-12.60719 -30.441905,-12.60719 z" />
|
||||
<path android:fillColor="#FF000000" android:pathData="m 50.940553,973.94033 a 2.000005,2.000005 0 1 0 0,3.99938 c 6.0191,0 12.0105,2.279 16.6031,6.87156 4.5926,4.59255 6.881605,10.59683 6.881605,16.60313 a 2.000005,2.000005 0 1 0 3.9997,0 c 0,-7.02618 -2.6961,-14.07429 -8.053105,-19.43126 -5.3571,-5.35696 -12.398,-8.04281 -19.4313,-8.04281 z" />
|
||||
<path android:fillColor="#FF000000" android:pathData="m 50.940553,981.77783 a 2.000005,2.000005 0 1 0 0,3.99938 c 4.0058,0 7.9968,1.51836 11.0578,4.57937 3.061,3.06099 4.5794,7.05193 4.5794,11.05782 a 2.000005,2.000005 0 1 0 3.9994,0 c 0,-5.02276 -1.9251,-10.06035 -5.7507,-13.88594 -3.8256,-3.82559 -8.8631,-5.75063 -13.8859,-5.75063 z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -112,6 +112,13 @@
|
||||
<string name="prefs_data_update_success">Actualización completada con éxito</string>
|
||||
|
||||
<string name="prefs_data_sources_title">Custom data sources</string>
|
||||
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
||||
<string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string>
|
||||
<string name="prefs_data_sources_transceivers_label" translatable="false">Transceivers data</string>
|
||||
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string>
|
||||
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
|
||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
||||
|
||||
|
||||
@@ -111,6 +111,13 @@
|
||||
<string name="prefs_data_update_success">Обновление прошло успешно</string>
|
||||
|
||||
<string name="prefs_data_sources_title">Свои источники данных</string>
|
||||
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
||||
<string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string>
|
||||
<string name="prefs_data_sources_transceivers_label" translatable="false">Transceivers data</string>
|
||||
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string>
|
||||
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
|
||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">Custom Transceivers URL</string>
|
||||
|
||||
|
||||
@@ -112,6 +112,13 @@
|
||||
<string name="prefs_data_update_success">යාවත්කාලීනය සම්පූර්ණ කිරීම සාර්ථකයි</string>
|
||||
|
||||
<string name="prefs_data_sources_title">Custom data sources</string>
|
||||
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
||||
<string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string>
|
||||
<string name="prefs_data_sources_transceivers_label" translatable="false">Transceivers data</string>
|
||||
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string>
|
||||
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
|
||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
||||
|
||||
|
||||
@@ -143,6 +143,12 @@
|
||||
<string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string>
|
||||
|
||||
<string name="prefs_data_sources_title">Özel veri kaynakları</string>
|
||||
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||
<string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string>
|
||||
<string name="prefs_data_sources_transceivers_label" translatable="false">Transceivers data</string>
|
||||
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string>
|
||||
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
|
||||
<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>
|
||||
@@ -199,7 +205,8 @@
|
||||
\n• Dave Moten (predict4java)
|
||||
\n• Alexandru Csete (Gpredict)
|
||||
\n• Dr T.S. Kelso (Celestrak)
|
||||
\n• Libre Space Foundation (SatNOGS)</string>
|
||||
\n• Libre Space Foundation (SatNOGS)
|
||||
\n• BG7NTA (CW decoding feature)</string>
|
||||
<string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string>
|
||||
|
||||
</resources>
|
||||
@@ -112,6 +112,13 @@
|
||||
<string name="prefs_data_update_success">Оновлення успішне</string>
|
||||
|
||||
<string name="prefs_data_sources_title">Custom data sources</string>
|
||||
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
||||
<string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string>
|
||||
<string name="prefs_data_sources_transceivers_label" translatable="false">Transceivers data</string>
|
||||
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string>
|
||||
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
|
||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
||||
|
||||
|
||||
@@ -39,6 +39,41 @@
|
||||
<string name="pass_welcome_title">欢迎来到 Look4Sat\!</string>
|
||||
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
|
||||
|
||||
<!-- AMSAT Status screen -->
|
||||
<string name="nav_amsat">AMSAT</string>
|
||||
<string name="amsat_title">AMSAT 卫星状态</string>
|
||||
<string name="amsat_refresh">刷新</string>
|
||||
<string name="amsat_retry">重试</string>
|
||||
<string name="amsat_updated">更新于:</string>
|
||||
<string name="amsat_active">活跃</string>
|
||||
<string name="amsat_tlm">遥测/信标</string>
|
||||
<string name="amsat_not_heard">未听到</string>
|
||||
<string name="amsat_conflict">冲突</string>
|
||||
<string name="amsat_name">名称</string>
|
||||
<string name="amsat_load_failed">加载失败 - 检查网络后重试</string>
|
||||
<string name="amsat_no_reports">此时间段无报告</string>
|
||||
<string name="amsat_close">关闭</string>
|
||||
<string name="amsat_upload">上传</string>
|
||||
<string name="amsat_upload_back">返回</string>
|
||||
<string name="amsat_upload_report">上传报告到 AMSAT</string>
|
||||
<string name="amsat_upload_status">状态</string>
|
||||
<string name="amsat_upload_callsign">呼号</string>
|
||||
<string name="amsat_upload_grid">网格</string>
|
||||
<string name="amsat_upload_time_now">时间:当前 UTC %s</string>
|
||||
<string name="amsat_upload_submit">提交到 AMSAT</string>
|
||||
<string name="amsat_upload_confirm">确认上传</string>
|
||||
<string name="amsat_upload_confirm_title">确认上传到 AMSAT</string>
|
||||
<string name="amsat_upload_confirm_message">这条公开报告将上传到 AMSAT。\n卫星:%1$s\n状态:%2$s\n呼号:%3$s\n网格:%4$s</string>
|
||||
<string name="amsat_upload_grid_none">未填写</string>
|
||||
<string name="amsat_upload_success">报告已上传</string>
|
||||
<string name="amsat_upload_failed">上传失败:%s</string>
|
||||
<string name="amsat_upload_callsign_required">请填写呼号</string>
|
||||
<string name="amsat_upload_grid_invalid">网格应为 4 或 6 位 Maidenhead 定位符</string>
|
||||
<string name="amsat_upload_active">活跃</string>
|
||||
<string name="amsat_upload_tlm">遥测/信标</string>
|
||||
<string name="amsat_upload_not_heard">未听到</string>
|
||||
<string name="amsat_upload_crew_active">ISS 语音</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">后退</string>
|
||||
<string name="radar_notify">通知</string>
|
||||
@@ -92,7 +127,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>
|
||||
@@ -100,11 +135,11 @@
|
||||
<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>
|
||||
@@ -119,8 +154,12 @@
|
||||
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
|
||||
|
||||
<string name="prefs_data_sources_title">自定义数据源</string>
|
||||
<string name="prefs_data_sources_restore">恢复默认数据源</string>
|
||||
<string name="prefs_data_sources_tle_switch">自定义TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">自定义收发器URL</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
||||
<string name="prefs_data_sources_satellites_label">卫星数据</string>
|
||||
<string name="prefs_data_sources_transceivers_label">收发器数据</string>
|
||||
|
||||
<string name="prefs_data_output_title">数据输出</string>
|
||||
<string name="prefs_net_output">网络</string>
|
||||
@@ -134,6 +173,8 @@
|
||||
<string name="prefs_net_frequency_switch">启用多普勒频率输出</string>
|
||||
<string name="prefs_net_frequency_address_hint">IP:端口</string>
|
||||
<string name="prefs_net_frequency_format_hint">数据格式</string>
|
||||
<string name="prefs_net_frequency_offset_hint" translatable="false">频率偏移 (Hz)</string>
|
||||
<string name="prefs_net_frequency_offset_help" translatable="false">范围:-50000 到 50000 Hz。正值提高上报频率,负值降低上报频率。</string>
|
||||
|
||||
<string name="prefs_bt_title">蓝牙数据输出</string>
|
||||
<string name="prefs_bt_rotator_switch">启用方位俯仰角输出</string>
|
||||
@@ -175,7 +216,52 @@
|
||||
\n• Alexandru Csete (Gpredict)
|
||||
\n• Dr T.S. Kelso (Celestrak)
|
||||
\n• Libre Space Foundation (SatNOGS)
|
||||
\n• xdsopl 和 Robot36 贡献者!</string>
|
||||
\n• xdsopl 和 Robot36 贡献者!
|
||||
\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>
|
||||
<string name="update_check_title">检查更新</string>
|
||||
<string name="update_check_button">检查更新</string>
|
||||
<string name="update_check_current">当前版本:%s</string>
|
||||
<string name="update_check_latest">最新版本:%s</string>
|
||||
<string name="update_check_up_to_date">已是最新版本</string>
|
||||
<string name="update_check_available">发现新版本</string>
|
||||
<string name="update_check_release_notes">更新内容</string>
|
||||
<string name="update_check_download">下载并安装</string>
|
||||
<string name="update_check_downloading">正在下载…</string>
|
||||
<string name="update_check_error">检查更新失败,请检查网络连接。</string>
|
||||
<string name="update_check_download_error">下载失败,请重试。</string>
|
||||
<string name="update_check_no_apk">该版本未附带 APK 安装包。</string>
|
||||
|
||||
</resources>
|
||||
@@ -56,6 +56,41 @@
|
||||
\n\n* Added ACCESS_LOCAL_NETWORK permission check to support network data output
|
||||
</string>
|
||||
|
||||
<!-- AMSAT Status screen -->
|
||||
<string name="nav_amsat">AMSAT</string>
|
||||
<string name="amsat_title">AMSAT Satellite Status</string>
|
||||
<string name="amsat_refresh">Refresh</string>
|
||||
<string name="amsat_retry">Retry</string>
|
||||
<string name="amsat_updated">Updated:</string>
|
||||
<string name="amsat_active">Active</string>
|
||||
<string name="amsat_tlm">TLM/Beacon</string>
|
||||
<string name="amsat_not_heard">Not Heard</string>
|
||||
<string name="amsat_conflict">Conflicting</string>
|
||||
<string name="amsat_name">Name</string>
|
||||
<string name="amsat_load_failed">Load failed - check network and retry</string>
|
||||
<string name="amsat_no_reports">No reports for this period</string>
|
||||
<string name="amsat_close">Close</string>
|
||||
<string name="amsat_upload">Upload</string>
|
||||
<string name="amsat_upload_back">Back</string>
|
||||
<string name="amsat_upload_report">Upload report to AMSAT</string>
|
||||
<string name="amsat_upload_status">Status</string>
|
||||
<string name="amsat_upload_callsign">Callsign</string>
|
||||
<string name="amsat_upload_grid">Grid</string>
|
||||
<string name="amsat_upload_time_now">Time: current UTC %s</string>
|
||||
<string name="amsat_upload_submit">Submit to AMSAT</string>
|
||||
<string name="amsat_upload_confirm">Upload</string>
|
||||
<string name="amsat_upload_confirm_title">Confirm AMSAT upload</string>
|
||||
<string name="amsat_upload_confirm_message">This public report will be uploaded to AMSAT.\nSatellite: %1$s\nStatus: %2$s\nCallsign: %3$s\nGrid: %4$s</string>
|
||||
<string name="amsat_upload_grid_none">Not provided</string>
|
||||
<string name="amsat_upload_success">Report uploaded</string>
|
||||
<string name="amsat_upload_failed">Upload failed: %s</string>
|
||||
<string name="amsat_upload_callsign_required">Callsign is required</string>
|
||||
<string name="amsat_upload_grid_invalid">Grid must be a 4 or 6 character Maidenhead locator</string>
|
||||
<string name="amsat_upload_active">Active</string>
|
||||
<string name="amsat_upload_tlm">TLM/Beacon</string>
|
||||
<string name="amsat_upload_not_heard">Not Heard</string>
|
||||
<string name="amsat_upload_crew_active">Crew Active</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">Back</string>
|
||||
<string name="radar_notify">Notify</string>
|
||||
@@ -153,9 +188,12 @@
|
||||
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||
|
||||
<string name="prefs_data_sources_title">Custom data sources</string>
|
||||
<string name="prefs_data_sources_restore">Restore default sources</string>
|
||||
<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_sources_satellites_label">Satellites data</string>
|
||||
<string name="prefs_data_sources_transceivers_label">Transceivers data</string>
|
||||
|
||||
<string name="prefs_data_output_title">Data output</string>
|
||||
<string name="prefs_net_output">Network</string>
|
||||
@@ -179,6 +217,8 @@
|
||||
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
||||
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
||||
<string name="prefs_net_frequency_format_hint">Format</string>
|
||||
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string>
|
||||
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
|
||||
|
||||
<string name="prefs_bt_title">Bluetooth data output</string>
|
||||
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
|
||||
@@ -211,7 +251,52 @@
|
||||
\n* Dr T.S. Kelso (Celestrak)
|
||||
\n* Libre Space Foundation (SatNOGS)
|
||||
\n* xdsopl and Robot36 contributors!
|
||||
\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>
|
||||
<string name="update_check_title">Check for updates</string>
|
||||
<string name="update_check_button">Check for updates</string>
|
||||
<string name="update_check_current">Current version: %s</string>
|
||||
<string name="update_check_latest">Latest version: %s</string>
|
||||
<string name="update_check_up_to_date">You are up to date</string>
|
||||
<string name="update_check_available">New version available</string>
|
||||
<string name="update_check_release_notes">Release notes</string>
|
||||
<string name="update_check_download">Download & install</string>
|
||||
<string name="update_check_downloading">Downloading…</string>
|
||||
<string name="update_check_error">Failed to check for updates. Check your connection.</string>
|
||||
<string name="update_check_download_error">Download failed. Try again.</string>
|
||||
<string name="update_check_no_apk">No APK attached to this release.</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"));
|
||||
|
||||
+4
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -63,7 +63,6 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||
import com.rtbishop.look4sat.core.domain.repository.TrackSampleData
|
||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||
@@ -75,7 +74,6 @@ import java.util.Locale
|
||||
@Composable
|
||||
fun MutualScreen(
|
||||
viewModel: MutualViewModel,
|
||||
navigateUp: () -> Unit = {},
|
||||
navigateToRadar: (Int, Long, MutualPassData?) -> Unit = { _, _, _ -> },
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
@@ -85,12 +83,10 @@ fun MutualScreen(
|
||||
topBar = { isVertical ->
|
||||
TopBar(
|
||||
isVerticalLayout = isVertical,
|
||||
startAction = {
|
||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||
},
|
||||
startAction = {},
|
||||
topInfo = {
|
||||
Text(
|
||||
text = "过境匹配",
|
||||
text = stringResource(R.string.mutual_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
@@ -198,7 +194,7 @@ private fun MutualContent(
|
||||
) {
|
||||
if (isVertical) {
|
||||
StationInputCard(
|
||||
title = "你的位置",
|
||||
title = stringResource(R.string.mutual_station_you),
|
||||
titleColor = MaterialTheme.colorScheme.primary,
|
||||
lat = state.stationALat,
|
||||
lon = state.stationALon,
|
||||
@@ -214,7 +210,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,
|
||||
@@ -234,7 +230,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,
|
||||
@@ -251,7 +247,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,
|
||||
@@ -294,8 +290,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,
|
||||
@@ -341,20 +337,22 @@ private fun MutualStatusChip(state: MutualUiState) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun mutualStatusText(state: MutualUiState): String = when {
|
||||
state.isCalculating -> "正在计算双方共同可见窗口…"
|
||||
state.errorMessage != null -> "请修正提示后重新查询"
|
||||
state.mutualPasses.isNotEmpty() -> "找到 ${state.mutualPasses.size} 个匹配 · 点击卡片展开曲线"
|
||||
state.hasSearched -> "未找到共同可见窗口,调整时间或仰角后重试"
|
||||
else -> "输入双方位置后点击查询"
|
||||
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 -> "计算中"
|
||||
state.errorMessage != null -> "有错误"
|
||||
state.mutualPasses.isNotEmpty() -> "${state.mutualPasses.size}个匹配"
|
||||
state.hasSearched -> "无匹配"
|
||||
else -> "待查询"
|
||||
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
|
||||
@@ -393,7 +391,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -405,7 +403,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,
|
||||
@@ -414,7 +412,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,
|
||||
@@ -425,15 +423,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
|
||||
)
|
||||
@@ -461,7 +459,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
|
||||
)
|
||||
@@ -492,7 +490,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -558,12 +556,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
|
||||
)
|
||||
@@ -579,7 +577,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
|
||||
@@ -587,8 +585,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))
|
||||
}
|
||||
@@ -613,7 +611,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
-91
@@ -26,6 +26,9 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -34,7 +37,6 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
data class MutualUiState(
|
||||
@@ -56,7 +58,10 @@ data class MutualUiState(
|
||||
|
||||
class MutualViewModel(
|
||||
private val satelliteRepo: ISatelliteRepo,
|
||||
private val settingsRepo: ISettingsRepo
|
||||
private val settingsRepo: ISettingsRepo,
|
||||
// Injected so unit tests can run the pass computation on a test dispatcher;
|
||||
// production callers keep the CPU-bound pool default.
|
||||
private val computeDispatcher: CoroutineDispatcher = Dispatchers.Default
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(MutualUiState())
|
||||
@@ -66,7 +71,7 @@ class MutualViewModel(
|
||||
// Pre-fill station A with the user's current station position (as grid),
|
||||
// and default min elevation to the same value used by the main radar passes
|
||||
val pos = settingsRepo.stationPosition.value
|
||||
val grid = latLonToGrid(pos.latitude, pos.longitude)
|
||||
val grid = positionToQth(pos.latitude, pos.longitude) ?: ""
|
||||
_uiState.update { it.copy(
|
||||
stationAGrid = grid,
|
||||
stationALat = "%.4f".format(pos.latitude),
|
||||
@@ -81,7 +86,9 @@ class MutualViewModel(
|
||||
val lat = value.toDoubleOrNull()
|
||||
val lon = _uiState.value.stationALon.toDoubleOrNull()
|
||||
if (lat != null && lon != null) {
|
||||
_uiState.update { it.copy(stationAGrid = latLonToGrid(lat, lon)) }
|
||||
positionToQth(lat, lon)?.let { grid ->
|
||||
_uiState.update { it.copy(stationAGrid = grid) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,15 +97,18 @@ class MutualViewModel(
|
||||
val lat = _uiState.value.stationALat.toDoubleOrNull()
|
||||
val lon = value.toDoubleOrNull()
|
||||
if (lat != null && lon != null) {
|
||||
_uiState.update { it.copy(stationAGrid = latLonToGrid(lat, lon)) }
|
||||
positionToQth(lat, lon)?.let { grid ->
|
||||
_uiState.update { it.copy(stationAGrid = grid) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onStationAGrid(value: String) {
|
||||
val old = _uiState.value.stationAGrid
|
||||
_uiState.update { it.copy(stationAGrid = value) }
|
||||
if (value.trim().uppercase() == old.trim().uppercase()) return
|
||||
val pos = gridToLatLon(value.trim().uppercase())
|
||||
val newGrid = value.trim().uppercase()
|
||||
_uiState.update { it.copy(stationAGrid = newGrid) }
|
||||
if (newGrid == old.trim().uppercase()) return
|
||||
val pos = qthToPosition(newGrid)
|
||||
if (pos != null) {
|
||||
_uiState.update { it.copy(
|
||||
stationALat = "%.4f".format(pos.latitude),
|
||||
@@ -112,7 +122,9 @@ class MutualViewModel(
|
||||
val lat = value.toDoubleOrNull()
|
||||
val lon = _uiState.value.stationBLon.toDoubleOrNull()
|
||||
if (lat != null && lon != null) {
|
||||
_uiState.update { it.copy(stationBGrid = latLonToGrid(lat, lon)) }
|
||||
positionToQth(lat, lon)?.let { grid ->
|
||||
_uiState.update { it.copy(stationBGrid = grid) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,15 +133,18 @@ class MutualViewModel(
|
||||
val lat = _uiState.value.stationBLat.toDoubleOrNull()
|
||||
val lon = value.toDoubleOrNull()
|
||||
if (lat != null && lon != null) {
|
||||
_uiState.update { it.copy(stationBGrid = latLonToGrid(lat, lon)) }
|
||||
positionToQth(lat, lon)?.let { grid ->
|
||||
_uiState.update { it.copy(stationBGrid = grid) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onStationBGrid(value: String) {
|
||||
val old = _uiState.value.stationBGrid
|
||||
_uiState.update { it.copy(stationBGrid = value) }
|
||||
if (value.trim().uppercase() == old.trim().uppercase()) return
|
||||
val pos = gridToLatLon(value.trim().uppercase())
|
||||
val newGrid = value.trim().uppercase()
|
||||
_uiState.update { it.copy(stationBGrid = newGrid) }
|
||||
if (newGrid == old.trim().uppercase()) return
|
||||
val pos = qthToPosition(newGrid)
|
||||
if (pos != null) {
|
||||
_uiState.update { it.copy(
|
||||
stationBLat = "%.4f".format(pos.latitude),
|
||||
@@ -144,7 +159,7 @@ class MutualViewModel(
|
||||
_uiState.update { it.copy(
|
||||
stationALat = "%.4f".format(pos.latitude),
|
||||
stationALon = "%.4f".format(pos.longitude),
|
||||
stationAGrid = latLonToGrid(pos.latitude, pos.longitude)
|
||||
stationAGrid = positionToQth(pos.latitude, pos.longitude) ?: ""
|
||||
) }
|
||||
}
|
||||
fun onHoursAhead(value: Int) = _uiState.update { it.copy(hoursAhead = value) }
|
||||
@@ -158,13 +173,13 @@ 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
|
||||
}
|
||||
|
||||
@@ -184,7 +199,7 @@ class MutualViewModel(
|
||||
val minElevB = state.stationBMinElev
|
||||
val hours = state.hoursAhead
|
||||
|
||||
val results = withContext(Dispatchers.Default) {
|
||||
val results = withContext(computeDispatcher) {
|
||||
findMutualPasses(satellites, posA, posB, minElevA, minElevB, time, hours)
|
||||
}
|
||||
|
||||
@@ -193,9 +208,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
|
||||
|
||||
@@ -213,83 +228,13 @@ class MutualViewModel(
|
||||
private fun resolvePosition(latText: String, lonText: String, gridText: String): GeoPos? {
|
||||
val trimmed = gridText.trim().uppercase()
|
||||
if (trimmed.length in listOf(4, 6, 8) && trimmed.all { it.isLetterOrDigit() }) {
|
||||
return gridToLatLon(trimmed)
|
||||
return qthToPosition(trimmed)
|
||||
}
|
||||
val lat = latText.toDoubleOrNull()
|
||||
val lon = lonText.toDoubleOrNull()
|
||||
return if (lat != null && lon != null) GeoPos(lat, lon) else null
|
||||
}
|
||||
|
||||
/** Convert lat/lon to Maidenhead grid (6-char). */
|
||||
private fun latLonToGrid(lat: Double, lon: Double): String {
|
||||
var adjLon = (lon + 180.0) % 360.0
|
||||
var adjLat = (lat + 90.0) % 180.0
|
||||
|
||||
val fieldLon = (adjLon / 20.0).toInt()
|
||||
val fieldLat = (adjLat / 10.0).toInt()
|
||||
adjLon -= fieldLon * 20.0
|
||||
adjLat -= fieldLat * 10.0
|
||||
|
||||
val squareLon = (adjLon / 2.0).toInt()
|
||||
val squareLat = (adjLat / 1.0).toInt()
|
||||
adjLon -= squareLon * 2.0
|
||||
adjLat -= squareLat * 1.0
|
||||
|
||||
val subLon = (adjLon * 60.0 / 5.0).toInt()
|
||||
val subLat = (adjLat * 60.0 / 2.5).toInt()
|
||||
|
||||
return buildString {
|
||||
append('A' + fieldLon)
|
||||
append('A' + fieldLat)
|
||||
append('0' + squareLon)
|
||||
append('0' + squareLat)
|
||||
append('A' + subLon)
|
||||
append('A' + subLat)
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert Maidenhead grid (4, 6, or 8 chars) to lat/lon center of the square. */
|
||||
private fun gridToLatLon(grid: String): GeoPos? {
|
||||
val g = grid.uppercase()
|
||||
if (g.length < 4) return null
|
||||
val lonField = (g[0] - 'A').toDouble() * 20.0
|
||||
val latField = (g[1] - 'A').toDouble() * 10.0
|
||||
if (lonField < 0 || lonField > 340 || latField < 0 || latField > 170) return null
|
||||
|
||||
val lonSquare = (g[2] - '0').toDouble() * 2.0
|
||||
val latSquare = (g[3] - '0').toDouble() * 1.0
|
||||
if (lonSquare < 0 || lonSquare > 18 || latSquare < 0 || latSquare > 9) return null
|
||||
|
||||
var lon = lonField + lonSquare
|
||||
var lat = latField + latSquare
|
||||
|
||||
if (g.length >= 6) {
|
||||
val lonSub = (g[4] - 'A').toDouble() * 5.0 / 60.0
|
||||
val latSub = (g[5] - 'A').toDouble() * 2.5 / 60.0
|
||||
if (lonSub < 0 || lonSub > 115.0 / 60.0 || latSub < 0 || latSub > 57.5 / 60.0) return null
|
||||
lon += lonSub
|
||||
lat += latSub
|
||||
|
||||
if (g.length >= 8) {
|
||||
val lonExt = (g[6] - '0').toDouble() * 30.0 / 3600.0
|
||||
val latExt = (g[7] - '0').toDouble() * 15.0 / 3600.0
|
||||
if (lonExt < 0 || lonExt > 270.0 / 3600.0 || latExt < 0 || latExt > 135.0 / 3600.0) return null
|
||||
lon += lonExt + 15.0 / 3600.0
|
||||
lat += latExt + 7.5 / 3600.0
|
||||
} else {
|
||||
lon += 2.5 / 60.0
|
||||
lat += 1.25 / 60.0
|
||||
}
|
||||
} else {
|
||||
lon += 1.0
|
||||
lat += 0.5
|
||||
}
|
||||
|
||||
lon = lon - 180.0
|
||||
lat = lat - 90.0
|
||||
return GeoPos(lat, lon)
|
||||
}
|
||||
|
||||
private fun findMutualPasses(
|
||||
satellites: List<OrbitalObject>,
|
||||
posA: GeoPos, posB: GeoPos,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.rtbishop.look4sat.feature.mutual
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||
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.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Test fake for ISatelliteRepo. Only `satellites` and `passes` are backed by
|
||||
* mutable state; everything else the ViewModel never touches fails loudly.
|
||||
*/
|
||||
class FakeSatelliteRepo(
|
||||
satellites: List<OrbitalObject> = emptyList(),
|
||||
passes: List<OrbitalPass> = emptyList()
|
||||
) : ISatelliteRepo {
|
||||
|
||||
override val satellites = MutableStateFlow(satellites)
|
||||
override val passes = MutableStateFlow(passes)
|
||||
override val isCalculating = MutableStateFlow(false)
|
||||
override val selectedPass = MutableStateFlow(Pair(0, 0L))
|
||||
|
||||
override fun selectPass(catNum: Int, aosTime: Long) = TODO()
|
||||
override suspend fun initRepository() = TODO()
|
||||
override suspend fun calculatePasses(
|
||||
time: Long, hoursAhead: Int, minElevation: Double,
|
||||
aosStartMinute: Int, aosEndMinute: Int,
|
||||
invertAosTimeWindow: Boolean, modes: List<String>
|
||||
) = TODO()
|
||||
override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos = TODO()
|
||||
override suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos> = TODO()
|
||||
override suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List<SatRadio>, time: Long): List<SatRadio> = TODO()
|
||||
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = TODO()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test fake for ISettingsRepo. Only [stationPosition] is backed by mutable
|
||||
* state; everything else the ViewModel never touches fails loudly.
|
||||
*/
|
||||
class FakeSettingsRepo(initialPosition: GeoPos = GeoPos(23.13, 113.26)) : ISettingsRepo {
|
||||
|
||||
override val stationPosition = MutableStateFlow(initialPosition)
|
||||
|
||||
override val appVersionName: String = "test"
|
||||
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
||||
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
||||
PassesSettings(hoursAhead = 24, minElevation = 0.0, selectedModes = emptyList())
|
||||
)
|
||||
override val databaseState: StateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
||||
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
||||
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
||||
)
|
||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||
OtherSettings(false, false, false, false, false, false, false, false)
|
||||
)
|
||||
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
|
||||
DataSourcesSettings(satelliteUrls = emptyList(), transceiversUrls = emptyList())
|
||||
)
|
||||
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||
)
|
||||
|
||||
override fun setSelectedIds(ids: List<Int>) = TODO()
|
||||
override fun setSelectedTypes(types: List<String>) = TODO()
|
||||
override fun setPassesSettings(settings: PassesSettings) = TODO()
|
||||
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = TODO()
|
||||
override fun setStationPosition(): Boolean = TODO()
|
||||
override fun setStationPosition(locator: String): Boolean = TODO()
|
||||
override fun getSatelliteTypesIds(types: List<String>): List<Int> = TODO()
|
||||
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO()
|
||||
override fun updateDatabaseState(state: DatabaseState) = TODO()
|
||||
override fun updateRCSettings(settings: RCSettings) = TODO()
|
||||
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = TODO()
|
||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = TODO()
|
||||
|
||||
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||
|
||||
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
|
||||
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
|
||||
override fun getAmSatCallsign(): String = ""
|
||||
override fun setAmSatCallsign(callsign: String) = TODO()
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.rtbishop.look4sat.feature.mutual
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestDispatcher
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.rules.TestWatcher
|
||||
import org.junit.runner.Description
|
||||
|
||||
/**
|
||||
* Swaps the Main dispatcher for a [StandardTestDispatcher] so that
|
||||
* `viewModelScope.launch` work can be driven deterministically with
|
||||
* `runTest` / `advanceUntilIdle()` instead of racing real threads.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MainDispatcherRule(
|
||||
val dispatcher: TestDispatcher = StandardTestDispatcher()
|
||||
) : TestWatcher() {
|
||||
|
||||
override fun starting(description: Description) = Dispatchers.setMain(dispatcher)
|
||||
|
||||
override fun finished(description: Description) = Dispatchers.resetMain()
|
||||
}
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
package com.rtbishop.look4sat.feature.mutual
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.random.Random
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MutualViewModelTest {
|
||||
|
||||
@get:Rule
|
||||
val mainDispatcherRule = MainDispatcherRule()
|
||||
|
||||
/**
|
||||
* ViewModel wired to fakes and a test dispatcher that shares the runTest
|
||||
* scheduler, so [advanceUntilIdle] deterministically drains both the
|
||||
* viewModelScope (Main) queue and the compute dispatcher.
|
||||
*/
|
||||
private fun TestScope.createVm(
|
||||
satellites: List<OrbitalObject> = emptyList(),
|
||||
passes: List<OrbitalPass> = emptyList(),
|
||||
position: GeoPos = TestOrbits.GUANGZHOU
|
||||
): MutualViewModel = MutualViewModel(
|
||||
satelliteRepo = FakeSatelliteRepo(satellites, passes),
|
||||
settingsRepo = FakeSettingsRepo(position),
|
||||
computeDispatcher = StandardTestDispatcher(mainDispatcherRule.dispatcher.scheduler)
|
||||
)
|
||||
|
||||
private fun TestScope.queryAndSettle(vm: MutualViewModel) {
|
||||
vm.queryMutualPasses()
|
||||
advanceUntilIdle()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- init
|
||||
|
||||
@Test
|
||||
fun `init prefills station A with the current position`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val vm = createVm(position = TestOrbits.GUANGZHOU)
|
||||
val state = vm.uiState.value
|
||||
|
||||
assertEquals("23.1300", state.stationALat)
|
||||
assertEquals("113.2600", state.stationALon)
|
||||
assertEquals(6, state.stationAGrid.length)
|
||||
assertTrue(state.stationAGrid.startsWith("OL63"))
|
||||
assertTrue(state.stationAGrid.uppercase() == state.stationAGrid)
|
||||
assertEquals(0.0, state.stationAMinElev, 0.0)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- station input
|
||||
|
||||
@Test
|
||||
fun `entering latitude recomputes the maidenhead grid`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val vm = createVm(position = TestOrbits.SHENZHEN) // station A pre-filled with SZ
|
||||
vm.onStationALat("23.5")
|
||||
|
||||
// 23.5N 114.06E -> field OL, square 73 (Shenzhen), subsquare AM
|
||||
assertEquals("OL73AM", vm.uiState.value.stationAGrid)
|
||||
// raw input string is stored verbatim, not reformatted
|
||||
assertEquals("23.5", vm.uiState.value.stationALat)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `entering longitude recomputes the maidenhead grid`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val vm = createVm(position = TestOrbits.GUANGZHOU)
|
||||
vm.onStationALon("113.5")
|
||||
|
||||
// 23.13N 113.5E -> Guangzhou field OL63, subsquare SD
|
||||
assertEquals("OL63SD", vm.uiState.value.stationAGrid)
|
||||
// raw input string is stored verbatim, not reformatted
|
||||
assertEquals("113.5", vm.uiState.value.stationALon)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `entering a lowercase grid normalises to uppercase and updates lat lon`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val vm = createVm(position = TestOrbits.NEW_YORK)
|
||||
vm.onStationAGrid("ol63")
|
||||
|
||||
val state = vm.uiState.value
|
||||
// The grid is normalised to upper case on input (Bug fix: raw input
|
||||
// used to be stored verbatim, e.g. "ol63" stayed lowercase).
|
||||
assertEquals("OL63", state.stationAGrid)
|
||||
// 4-char grid resolves to the square centre
|
||||
assertEquals(23.5, state.stationALat.toDouble(), 1e-9)
|
||||
assertEquals(113.0, state.stationALon.toDouble(), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid grid leaves lat and lon untouched`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val vm = createVm(position = TestOrbits.GUANGZHOU)
|
||||
val before = vm.uiState.value.stationALat
|
||||
vm.onStationAGrid("ZZ99") // valid shape, out-of-range letters
|
||||
|
||||
assertEquals("ZZ99", vm.uiState.value.stationAGrid)
|
||||
assertEquals(before, vm.uiState.value.stationALat)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `use current position restores station A from settings`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val vm = createVm(position = TestOrbits.GUANGZHOU)
|
||||
vm.onStationALat("10.0")
|
||||
vm.onStationALon("20.0")
|
||||
assertNotEquals("23.1300", vm.uiState.value.stationALat)
|
||||
|
||||
vm.onUseCurrentPosition()
|
||||
|
||||
val state = vm.uiState.value
|
||||
assertEquals("23.1300", state.stationALat)
|
||||
assertEquals("113.2600", state.stationALon)
|
||||
assertTrue(state.stationAGrid.startsWith("OL63"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `station B independent from station A`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val vm = createVm()
|
||||
vm.onStationBLat("22.54")
|
||||
vm.onStationBLon("114.06")
|
||||
|
||||
// 22.54N 114.06E is Shenzhen: OL72 field (lon square 7, lat square 2)
|
||||
assertEquals("OL72", vm.uiState.value.stationBGrid.take(4))
|
||||
assertTrue(vm.uiState.value.stationBGrid.length == 6)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ query: errors
|
||||
|
||||
@Test
|
||||
fun `query with invalid coordinates sets an error and does not search`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val vm = createVm(satellites = listOf(TestOrbits.ISS), passes = TestOrbits.findPassWindows())
|
||||
vm.onStationAGrid("ZZ99")
|
||||
vm.onStationBGrid("ZZ99")
|
||||
|
||||
queryAndSettle(vm)
|
||||
|
||||
val state = vm.uiState.value
|
||||
assertTrue(state.errorMessage != null)
|
||||
assertFalse(state.hasSearched)
|
||||
assertFalse(state.isCalculating)
|
||||
assertTrue(state.mutualPasses.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query with no satellites sets an error`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val vm = createVm(satellites = emptyList())
|
||||
vm.onStationBGrid("OL62")
|
||||
|
||||
queryAndSettle(vm)
|
||||
|
||||
assertEquals(
|
||||
"No satellite data. Select satellites from the list first.",
|
||||
vm.uiState.value.errorMessage
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query with everything empty reports empty pass list`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val vm = createVm(satellites = listOf(TestOrbits.ISS), passes = emptyList())
|
||||
vm.onStationBGrid("OL62")
|
||||
vm.onStationAMinElev(89.0)
|
||||
vm.onStationBMinElev(89.0)
|
||||
vm.onHoursAhead(2)
|
||||
|
||||
queryAndSettle(vm)
|
||||
|
||||
val state = vm.uiState.value
|
||||
assertFalse(state.isCalculating)
|
||||
assertEquals("Pass list is empty. Select satellites from the list first.", state.errorMessage)
|
||||
}
|
||||
|
||||
// --------------------------------------------------- query: happy path
|
||||
|
||||
@Test
|
||||
fun `query finds mutual passes reusing the main pass list`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val windows = TestOrbits.findPassWindows(hoursAhead = 12)
|
||||
assertTrue("test fixture must produce ISS passes over Guangzhou", windows.isNotEmpty())
|
||||
|
||||
val vm = createVm(
|
||||
satellites = listOf(TestOrbits.ISS),
|
||||
passes = windows
|
||||
)
|
||||
vm.onStationBGrid("OL62")
|
||||
vm.onHoursAhead(12)
|
||||
|
||||
queryAndSettle(vm)
|
||||
|
||||
val state = vm.uiState.value
|
||||
assertNull(state.errorMessage)
|
||||
assertTrue(state.hasSearched)
|
||||
assertFalse(state.isCalculating)
|
||||
assertTrue("expected at least one mutual pass", state.mutualPasses.isNotEmpty())
|
||||
assertTrue(state.mutualPasses.all { it.catNum == 25544 })
|
||||
assertTrue(state.mutualPasses.all { it.name == "ISS (ZARYA)" })
|
||||
// AOS/LOS within the searched horizon
|
||||
val now = System.currentTimeMillis()
|
||||
assertTrue(state.mutualPasses.all { it.startTime >= now - 120_000L })
|
||||
assertTrue(state.mutualPasses.all { it.endTime <= now + 12 * 3600_000L + 120_000L })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query results are sorted by start time even when input is shuffled`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
// A 24h horizon reliably yields several windows per satellite (ISS +
|
||||
// staggered variant), guaranteeing >= 2 distinct passes.
|
||||
val windows = TestOrbits.findPassWindows(hoursAhead = 24) +
|
||||
TestOrbits.findPassWindows(sat = TestOrbits.ISS_VARIANT, hoursAhead = 24)
|
||||
assertTrue("fixture must produce at least two windows", windows.size >= 2)
|
||||
|
||||
val shuffled = windows.shuffled(Random(42))
|
||||
val vm = createVm(
|
||||
satellites = listOf(TestOrbits.ISS, TestOrbits.ISS_VARIANT),
|
||||
passes = shuffled
|
||||
)
|
||||
vm.onStationBGrid("OL62")
|
||||
vm.onHoursAhead(24)
|
||||
|
||||
queryAndSettle(vm)
|
||||
|
||||
val starts = vm.uiState.value.mutualPasses.map { it.startTime }
|
||||
assertEquals(starts.sorted(), starts)
|
||||
// Refined AOS must not drift far from the coarse window
|
||||
val byAos = windows.associateBy { it.aosTime }
|
||||
vm.uiState.value.mutualPasses.forEach { mp ->
|
||||
assertTrue("refined AOS ${mp.startTime} near a coarse AOS", byAos.keys.any { kotlin.math.abs(it - mp.startTime) < 5 * 60_000L })
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------- query: fallback
|
||||
|
||||
@Test
|
||||
fun `query falls back to independent search when pass list is empty`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
// Two satellites (ISS + staggered variant) raise the odds of a window
|
||||
// within the fallback horizon while keeping the search realistic.
|
||||
val vm = createVm(
|
||||
satellites = listOf(TestOrbits.ISS, TestOrbits.ISS_VARIANT),
|
||||
passes = emptyList()
|
||||
)
|
||||
vm.onStationBGrid("OL62")
|
||||
vm.onHoursAhead(12)
|
||||
|
||||
queryAndSettle(vm)
|
||||
|
||||
val state = vm.uiState.value
|
||||
assertFalse(state.isCalculating)
|
||||
assertTrue(state.hasSearched)
|
||||
// GZ + SZ are ~100km apart: a 12h ISS window must yield at least one mutual pass
|
||||
assertNull(state.errorMessage)
|
||||
assertTrue("fallback should find mutual passes", state.mutualPasses.isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query over antipodal stations finds no mutual passes`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val vm = createVm(satellites = listOf(TestOrbits.ISS), passes = emptyList())
|
||||
vm.onStationBLat("40.71")
|
||||
vm.onStationBLon("-74.01")
|
||||
vm.onHoursAhead(2)
|
||||
|
||||
queryAndSettle(vm)
|
||||
|
||||
val state = vm.uiState.value
|
||||
assertFalse(state.isCalculating)
|
||||
assertTrue(state.mutualPasses.isEmpty())
|
||||
assertTrue(state.errorMessage != null)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- misc
|
||||
|
||||
@Test
|
||||
fun `clearError resets the error message`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val vm = createVm()
|
||||
vm.onStationAGrid("ZZ99")
|
||||
vm.onStationBGrid("ZZ99")
|
||||
queryAndSettle(vm)
|
||||
assertTrue(vm.uiState.value.errorMessage != null)
|
||||
|
||||
vm.clearError()
|
||||
|
||||
assertNull(vm.uiState.value.errorMessage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.rtbishop.look4sat.feature.mutual
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.NearEarthObject
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
import kotlin.math.PI
|
||||
|
||||
/**
|
||||
* Deterministic orbital test fixtures: real ISS TLE elements (2024-03-09,
|
||||
* same source as DataParserTest) so SGP4 propagation produces genuine
|
||||
* visibility windows instead of fabricated numbers.
|
||||
*/
|
||||
object TestOrbits {
|
||||
|
||||
/** ISS (ZARYA) from the 2024-03-09 TLE used in DataParserTest. */
|
||||
val ISS: OrbitalObject = NearEarthObject(
|
||||
OrbitalData(
|
||||
name = "ISS (ZARYA)",
|
||||
epoch = 24069.23963816,
|
||||
meanmo = 15.49756209,
|
||||
eccn = 0.0005741,
|
||||
incl = 51.6418,
|
||||
raan = 90.7424,
|
||||
argper = 343.9724,
|
||||
meanan = 92.8274,
|
||||
catnum = 25544,
|
||||
bstar = 0.00025016
|
||||
)
|
||||
)
|
||||
|
||||
/** Guangzhou (default BA7OPF grid OL62/OL63 area). */
|
||||
val GUANGZHOU = GeoPos(23.13, 113.26)
|
||||
|
||||
/** Shenzhen, ~100 km from Guangzhou — nearly every LEO pass is mutual. */
|
||||
val SHENZHEN = GeoPos(22.54, 114.06)
|
||||
|
||||
/** New York — on the opposite side of the globe, never mutual with GZ. */
|
||||
val NEW_YORK = GeoPos(40.71, -74.01)
|
||||
|
||||
/**
|
||||
* Second LEO with the same ISS elements but a different argument of
|
||||
* perigee, so its visibility windows land at different times. Gives tests
|
||||
* two distinct passes to exercise ordering/aggregation logic.
|
||||
*/
|
||||
val ISS_VARIANT: OrbitalObject = NearEarthObject(
|
||||
OrbitalData(
|
||||
name = "ISS-V2",
|
||||
epoch = 24069.23963816,
|
||||
meanmo = 15.49756209,
|
||||
eccn = 0.0005741,
|
||||
incl = 51.6418,
|
||||
raan = 90.7424,
|
||||
argper = 100.0,
|
||||
meanan = 92.8274,
|
||||
catnum = 25545,
|
||||
bstar = 0.00025016
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* Coarse pass-window search (30s steps) producing OrbitalPass entries with
|
||||
* honest AOS/LOS times. Mirrors the coarse phase of SatelliteRepo.getLeoPass;
|
||||
* the ViewModel under test re-refines the edges to ~1s itself.
|
||||
*/
|
||||
fun findPassWindows(
|
||||
sat: OrbitalObject = ISS,
|
||||
pos: GeoPos = GUANGZHOU,
|
||||
time: Long = System.currentTimeMillis(),
|
||||
hoursAhead: Int = 12
|
||||
): List<OrbitalPass> {
|
||||
if (!sat.willBeSeen(pos)) return emptyList()
|
||||
val passes = mutableListOf<OrbitalPass>()
|
||||
val step = 30_000L
|
||||
val end = time + hoursAhead * 3600_000L
|
||||
var t = time
|
||||
var inWindow = false
|
||||
var aos = 0L
|
||||
var maxElev = 0.0
|
||||
while (t < end) {
|
||||
val elev = sat.getElevation(pos, t) * 180.0 / PI
|
||||
if (elev > 0.0 && !inWindow) {
|
||||
inWindow = true
|
||||
aos = t
|
||||
maxElev = 0.0
|
||||
}
|
||||
if (inWindow && elev > maxElev) maxElev = elev
|
||||
if (elev <= 0.0 && inWindow) {
|
||||
inWindow = false
|
||||
passes.add(
|
||||
OrbitalPass(
|
||||
aosTime = aos,
|
||||
losTime = t,
|
||||
maxElevation = maxElev,
|
||||
orbitalObject = sat
|
||||
)
|
||||
)
|
||||
}
|
||||
t += step
|
||||
}
|
||||
return passes
|
||||
}
|
||||
}
|
||||
@@ -84,19 +84,23 @@ import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
@Composable
|
||||
fun PassesDestination(navigateToRadar: (Int, Long) -> Unit) {
|
||||
fun PassesDestination(
|
||||
navigateToRadar: (Int, Long) -> Unit,
|
||||
navigateToMap: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val viewModel: PassesViewModel = viewModel(factory = PassesViewModel.factory(container))
|
||||
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
|
||||
PassesScreen(uiState, viewModel::onAction, navigateToRadar)
|
||||
PassesScreen(uiState, viewModel::onAction, navigateToRadar, navigateToMap)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PassesScreen(
|
||||
uiState: PassesState,
|
||||
onAction: (PassesAction) -> Unit,
|
||||
navigateToRadar: (Int, Long) -> Unit
|
||||
navigateToRadar: (Int, Long) -> Unit,
|
||||
navigateToMap: () -> Unit
|
||||
) {
|
||||
if (uiState.isPassesDialogShown) {
|
||||
PassesFilterDialog(
|
||||
@@ -163,7 +167,7 @@ private fun PassesScreen(
|
||||
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
|
||||
},
|
||||
endAction = {
|
||||
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
|
||||
IconCard(action = navigateToMap, resId = R.drawable.ic_map)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
+31
-2
@@ -23,6 +23,7 @@ import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||
@@ -167,6 +168,7 @@ class PassesViewModel(
|
||||
// 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.
|
||||
// For white nights / polar day edge cases, we fall back to searching from the previous day.
|
||||
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()
|
||||
@@ -175,7 +177,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, startOfDayMillis(System.currentTimeMillis(), tz))
|
||||
val riseSet = findTodaySunRiseSet(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
|
||||
@@ -184,7 +186,7 @@ class PassesViewModel(
|
||||
if (pass.isDeepSpace) continue
|
||||
val label = sdfDate.format(Date(pass.aosTime))
|
||||
if (label in result) continue
|
||||
val riseSet = CelestialComputer.findSunRiseSet(stationPos, startOfDayMillis(pass.aosTime, tz))
|
||||
val riseSet = findTodaySunRiseSet(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
|
||||
@@ -192,6 +194,33 @@ class PassesViewModel(
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Find today's sunrise and sunset by starting from local midnight.
|
||||
* Handles the white nights / polar day edge case where the sun is still above
|
||||
* the -0.8333° threshold at midnight: in that case we search from the previous
|
||||
* day's midnight to find a valid sunrise/sunset pair within the target day.
|
||||
*/
|
||||
private fun findTodaySunRiseSet(observer: GeoPos, localMidnightMillis: Long): CelestialComputer.RiseSetTimes {
|
||||
val threshold = 0.8333
|
||||
val sunPos = CelestialComputer.getSunPosition(observer, localMidnightMillis)
|
||||
if (sunPos.elevation > -threshold) {
|
||||
// White nights / polar day: the sun is too high at midnight.
|
||||
// Search from earlier days so the algorithm goes through a full
|
||||
// sunset → sunrise → sunset cycle, landing in the target day.
|
||||
val dayEnd = localMidnightMillis + 86400000L
|
||||
for (offset in 1..3) {
|
||||
val result = CelestialComputer.findSunRiseSet(observer, localMidnightMillis - offset * 86400000L)
|
||||
if (result.riseTimeMillis in localMidnightMillis..dayEnd &&
|
||||
result.setTimeMillis in localMidnightMillis..dayEnd) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
// No valid pair found (midnight sun / polar day)
|
||||
return CelestialComputer.RiseSetTimes(0L, 0L)
|
||||
}
|
||||
return CelestialComputer.findSunRiseSet(observer, localMidnightMillis)
|
||||
}
|
||||
|
||||
private fun startOfDayMillis(timeMillis: Long, tz: TimeZone): Long {
|
||||
return Calendar.getInstance(tz, Locale.getDefault()).apply {
|
||||
timeInMillis = timeMillis
|
||||
|
||||
@@ -84,7 +84,7 @@ private enum class RadarPage(val title: String) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RadarDestination(navigateUp: () -> Unit) {
|
||||
fun RadarDestination(navigateUp: () -> Unit, navigateToMap: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
|
||||
@@ -96,6 +96,12 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
||||
}
|
||||
navigateUp()
|
||||
}
|
||||
val navigateToMapAndClearMutual = {
|
||||
if (container.mutualPassData.value.endTime > 0L) {
|
||||
container.setMutualPassData(MutualPassData())
|
||||
}
|
||||
navigateToMap()
|
||||
}
|
||||
LaunchedEffect(mutualData.endTime) {
|
||||
if (mutualData.endTime <= 0L) return@LaunchedEffect
|
||||
while (true) {
|
||||
@@ -121,7 +127,7 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
||||
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
|
||||
viewModel.onAction(RadarAction.CwPermissionResult(granted))
|
||||
}
|
||||
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, requestMicPermission = {
|
||||
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, navigateToMapAndClearMutual, mutualData, requestMicPermission = {
|
||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
})
|
||||
}
|
||||
@@ -131,13 +137,11 @@ private fun RadarScreen(
|
||||
uiState: RadarState,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
navigateUp: () -> Unit,
|
||||
navigateToMap: () -> Unit,
|
||||
mutualData: MutualPassData,
|
||||
requestMicPermission: () -> Unit
|
||||
) {
|
||||
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
||||
val addToCalendar: () -> Unit = {
|
||||
uiState.currentPass?.let { onAction(RadarAction.AddToCalendar(it.name, it.aosTime, it.losTime)) }
|
||||
}
|
||||
// Station-B overlay: full track line (only where B's elevation > 0) + live position dot
|
||||
// at the current moment, same display mode as the local station.
|
||||
val trackB = remember(mutualData.trackSamples) {
|
||||
@@ -173,7 +177,7 @@ private fun RadarScreen(
|
||||
TopBar {
|
||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
||||
IconCard(action = navigateToMap, resId = R.drawable.ic_map)
|
||||
}
|
||||
TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
|
||||
} else {
|
||||
@@ -181,7 +185,7 @@ private fun RadarScreen(
|
||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
||||
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
|
||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
||||
IconCard(action = navigateToMap, resId = R.drawable.ic_map)
|
||||
}
|
||||
}
|
||||
if (isVertical) {
|
||||
@@ -249,6 +253,7 @@ private fun PagerCard(
|
||||
selectedUuid = uiState.transceivers.selectedUuid,
|
||||
orbitalPos = uiState.orbitalPos,
|
||||
cw = uiState.cw,
|
||||
calculatorOffsetKHz = uiState.calculatorOffsetKHz,
|
||||
onAction = onAction,
|
||||
requestMicPermission = requestMicPermission
|
||||
)
|
||||
|
||||
@@ -69,7 +69,8 @@ data class RadarState(
|
||||
val mutualEndTime: Long = 0L,
|
||||
val mutualMaxElev: Double = 10.0,
|
||||
val mutualLabelA: String = "你",
|
||||
val mutualLabelB: String = "友台"
|
||||
val mutualLabelB: String = "友台",
|
||||
val calculatorOffsetKHz: String = ""
|
||||
)
|
||||
|
||||
enum class SstvStatus { Idle, Recording }
|
||||
@@ -124,4 +125,7 @@ sealed interface RadarAction {
|
||||
data class CwSetToneFreq(val freq: Float) : RadarAction
|
||||
data class CwToggleExpanded(val expanded: Boolean) : RadarAction
|
||||
data class CwPermissionResult(val granted: Boolean) : RadarAction
|
||||
|
||||
// Calculator actions
|
||||
data class ChangeCalculatorOffset(val offsetKHz: String) : RadarAction
|
||||
}
|
||||
@@ -151,7 +151,13 @@ class RadarViewModel(
|
||||
transponders = allRadios.filter { it.downlinkLow != null }
|
||||
if (allRadios.isNotEmpty()) {
|
||||
val firstUuid = allRadios.first().uuid
|
||||
_uiState.update { it.copy(transceivers = it.transceivers.copy(selectedUuid = firstUuid)) }
|
||||
val offsetKHz = allRadios.first().catnum?.let { settingsRepo.getSatelliteOffset(it) } ?: ""
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
transceivers = it.transceivers.copy(selectedUuid = firstUuid),
|
||||
calculatorOffsetKHz = offsetKHz
|
||||
)
|
||||
}
|
||||
transponders.find { it.uuid == firstUuid }?.let { trackingService.setTransponder(it) }
|
||||
}
|
||||
if (!pass.isDeepSpace) {
|
||||
@@ -228,7 +234,16 @@ class RadarViewModel(
|
||||
// Compute toggle state before the update so we don't read post-update value
|
||||
val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid
|
||||
val newUuid = if (isTogglingOff) null else action.uuid
|
||||
_uiState.update { it.copy(transceivers = it.transceivers.copy(selectedUuid = newUuid)) }
|
||||
val offsetKHz = if (!isTogglingOff) {
|
||||
transponders.find { it.uuid == action.uuid }
|
||||
?.catnum?.let { settingsRepo.getSatelliteOffset(it) } ?: ""
|
||||
} else ""
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
transceivers = it.transceivers.copy(selectedUuid = newUuid),
|
||||
calculatorOffsetKHz = offsetKHz
|
||||
)
|
||||
}
|
||||
// Only update the tracking service when selecting a different transponder to
|
||||
// avoid resetting a user-adjusted TX base on re-expand
|
||||
if (!isTogglingOff) {
|
||||
@@ -296,6 +311,15 @@ class RadarViewModel(
|
||||
is RadarAction.CwToggleExpanded -> {
|
||||
_uiState.update { it.copy(cw = it.cw.copy(isExpanded = action.expanded)) }
|
||||
}
|
||||
is RadarAction.ChangeCalculatorOffset -> {
|
||||
val catnum = _uiState.value.transceivers.selectedUuid?.let { uuid ->
|
||||
transponders.find { it.uuid == uuid }?.catnum
|
||||
}
|
||||
if (catnum != null) {
|
||||
settingsRepo.setSatelliteOffset(catnum, action.offsetKHz)
|
||||
}
|
||||
_uiState.update { it.copy(calculatorOffsetKHz = action.offsetKHz) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+414
-185
@@ -23,14 +23,20 @@ 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.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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,6 +47,7 @@ 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.FilterChip
|
||||
@@ -49,22 +56,31 @@ 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.saveable.rememberSaveable
|
||||
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
|
||||
@@ -128,12 +144,14 @@ fun CalculatorPage(
|
||||
selectedUuid: String?,
|
||||
orbitalPos: OrbitalPos?,
|
||||
cw: CwSubState,
|
||||
calculatorOffsetKHz: String = "",
|
||||
onAction: (RadarAction) -> Unit,
|
||||
requestMicPermission: () -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val calculatorTransceivers = remember(transceivers) {
|
||||
transceivers.filter(DopplerFrequencyCalculator::isNamedLinearTransponder)
|
||||
.let(DopplerFrequencyCalculator::deduplicateTransponders)
|
||||
}
|
||||
val selectedTransceiver = remember(calculatorTransceivers, selectedUuid) {
|
||||
calculatorTransceivers.firstOrNull { it.uuid == selectedUuid }
|
||||
@@ -145,61 +163,55 @@ fun CalculatorPage(
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp),
|
||||
.verticalScroll(rememberScrollState()),
|
||||
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
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
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()
|
||||
)
|
||||
}
|
||||
DopplerFrequencyCalculator(
|
||||
transponder = selectedTransceiver,
|
||||
orbitalPos = orbitalPos,
|
||||
offsetKHz = calculatorOffsetKHz,
|
||||
onOffsetChange = { onAction(RadarAction.ChangeCalculatorOffset(it)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
item {
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
|
||||
)
|
||||
|
||||
item {
|
||||
CwDecoderPanel(
|
||||
cw = cw,
|
||||
onAction = onAction,
|
||||
requestMicPermission = requestMicPermission,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
CwDecoderPanel(
|
||||
cw = cw,
|
||||
onAction = onAction,
|
||||
requestMicPermission = requestMicPermission,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -532,152 +544,365 @@ private fun ExpandedRadioControl(
|
||||
}
|
||||
}
|
||||
|
||||
private enum class EditedField { TX, PASSBAND, RX }
|
||||
|
||||
@Composable
|
||||
private fun DopplerFrequencyCalculator(
|
||||
transponder: SatRadio,
|
||||
orbitalPos: OrbitalPos?,
|
||||
offsetKHz: String = "",
|
||||
onOffsetChange: (String) -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return
|
||||
|
||||
var txInputMHz by remember { mutableStateOf("") }
|
||||
var rxInputMHz by remember { mutableStateOf("") }
|
||||
var offsetKHz by remember { mutableStateOf("") }
|
||||
var lastEditedBy by remember { mutableStateOf(EditedField.TX) }
|
||||
var lastEditedField by rememberSaveable(transponder.uuid) { mutableStateOf(EditedField.TX) }
|
||||
var txFrequencyHz by rememberSaveable(transponder.uuid) { mutableStateOf(0L) }
|
||||
var rxFrequencyHz by rememberSaveable(transponder.uuid) { mutableStateOf(0L) }
|
||||
var passbandPosition by rememberSaveable(transponder.uuid) { mutableStateOf(0.5f) }
|
||||
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
|
||||
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 位置
|
||||
val txFromRx = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||
newRxHz, rawTransponder, orbitalPos, offsetHz
|
||||
)
|
||||
if (txHz != null) {
|
||||
txInputMHz = String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0)
|
||||
passbandPosition = if (txFromRx != null) {
|
||||
((txFromRx - txLow).toFloat() / txRange).coerceIn(0f, 1f)
|
||||
} else {
|
||||
((rxFrequencyHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
|
||||
}
|
||||
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.Text),
|
||||
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 = onOffsetChange,
|
||||
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,
|
||||
@@ -717,30 +942,36 @@ private fun CwDecoderPanel(
|
||||
// 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.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -754,14 +985,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)
|
||||
@@ -780,19 +1005,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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+473
-105
@@ -19,39 +19,83 @@ package com.rtbishop.look4sat.feature.settings
|
||||
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.content.Context
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableFloatState
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
@@ -116,11 +160,19 @@ fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Uni
|
||||
private fun TransceiversDialogPreview() {
|
||||
MainTheme {
|
||||
DataSourcesDialog(
|
||||
useCustomTle = true,
|
||||
useCustomTransceivers = true,
|
||||
tleUrl = "https://example.com/tle.txt",
|
||||
transceiversUrl = "https://example.com/tx.json",
|
||||
requestCustomSourcesPermission = { onGranted, _ -> onGranted() },
|
||||
satelliteUrls = listOf(
|
||||
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||
"amsat.org/tle/current/nasabare.txt"
|
||||
),
|
||||
transceiversUrls = listOf(
|
||||
"db.satnogs.org/api/transmitters/?format=json&status=active"
|
||||
),
|
||||
satelliteEnabled = listOf(true, false),
|
||||
transceiversEnabled = listOf(true),
|
||||
statusCodes = mapOf(
|
||||
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv" to 200,
|
||||
"amsat.org/tle/current/nasabare.txt" to 404
|
||||
),
|
||||
onImportTle = {},
|
||||
onImportTransceivers = {},
|
||||
onDismiss = {},
|
||||
@@ -131,111 +183,404 @@ private fun TransceiversDialogPreview() {
|
||||
|
||||
@Composable
|
||||
fun DataSourcesDialog(
|
||||
useCustomTle: Boolean,
|
||||
useCustomTransceivers: Boolean,
|
||||
tleUrl: String,
|
||||
transceiversUrl: String,
|
||||
requestCustomSourcesPermission: (onGranted: () -> Unit, onDenied: () -> Unit) -> Unit,
|
||||
satelliteUrls: List<String>,
|
||||
transceiversUrls: List<String>,
|
||||
satelliteEnabled: List<Boolean>,
|
||||
transceiversEnabled: List<Boolean>,
|
||||
statusCodes: Map<String, Int>,
|
||||
onImportTle: () -> Unit,
|
||||
onImportTransceivers: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (Boolean, Boolean, String, String) -> Unit
|
||||
onSave: (List<String>, List<String>, List<Boolean>, List<Boolean>) -> Unit
|
||||
) {
|
||||
val padding = LocalSpacing.current.large
|
||||
val isEnabledCustomTle = rememberSaveable { mutableStateOf(useCustomTle) }
|
||||
val isEnabledCustomTransceivers = rememberSaveable { mutableStateOf(useCustomTransceivers) }
|
||||
val urlTle = rememberSaveable { mutableStateOf(tleUrl) }
|
||||
val urlTransceivers = rememberSaveable { mutableStateOf(transceiversUrl) }
|
||||
val onAccept = {
|
||||
onSave(isEnabledCustomTle.value, isEnabledCustomTransceivers.value, urlTle.value, urlTransceivers.value)
|
||||
onDismiss()
|
||||
// Stable Long IDs avoid key collisions when several entries are empty or duplicated.
|
||||
val nextId = remember { mutableLongStateOf((satelliteUrls.size + transceiversUrls.size).toLong()) }
|
||||
val satUrls = remember {
|
||||
satelliteUrls.mapIndexed { i, url -> i.toLong() to url }.toMutableStateList()
|
||||
}
|
||||
val onCancel = { onDismiss() }
|
||||
SharedDialog(
|
||||
title = stringResource(id = R.string.prefs_data_sources_title),
|
||||
onCancel = onCancel,
|
||||
onAccept = onAccept,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = padding)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
CardButton(
|
||||
onClick = {
|
||||
onImportTle()
|
||||
onDismiss()
|
||||
},
|
||||
text = "TLE/3LE (.txt)\nOMM (.csv)",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
CardButton(
|
||||
onClick = {
|
||||
onImportTransceivers()
|
||||
onDismiss()
|
||||
},
|
||||
text = "Transceivers\nSatNOGS (.json)",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.prefs_data_sources_tle_switch))
|
||||
Switch(
|
||||
checked = isEnabledCustomTle.value,
|
||||
onCheckedChange = { enabled ->
|
||||
if (!enabled) {
|
||||
isEnabledCustomTle.value = false
|
||||
} else {
|
||||
requestCustomSourcesPermission(
|
||||
{ isEnabledCustomTle.value = true },
|
||||
{ isEnabledCustomTle.value = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = urlTle.value,
|
||||
onValueChange = { urlTle.value = it },
|
||||
label = { Text(text = stringResource(id = R.string.prefs_data_sources_url_title)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = isEnabledCustomTle.value,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.prefs_data_sources_transceivers_switch))
|
||||
Switch(
|
||||
checked = isEnabledCustomTransceivers.value,
|
||||
onCheckedChange = { enabled ->
|
||||
if (!enabled) {
|
||||
isEnabledCustomTransceivers.value = false
|
||||
} else {
|
||||
requestCustomSourcesPermission(
|
||||
{ isEnabledCustomTransceivers.value = true },
|
||||
{ isEnabledCustomTransceivers.value = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = urlTransceivers.value,
|
||||
onValueChange = { urlTransceivers.value = it },
|
||||
label = { Text(text = stringResource(id = R.string.prefs_data_sources_url_title)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = isEnabledCustomTransceivers.value,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
val txUrls = remember {
|
||||
transceiversUrls.mapIndexed { i, url -> (satelliteUrls.size + i).toLong() to url }.toMutableStateList()
|
||||
}
|
||||
val satEnabled = remember {
|
||||
mutableStateMapOf<Long, Boolean>().apply {
|
||||
satUrls.forEachIndexed { i, (id, _) -> this[id] = satelliteEnabled.getOrElse(i) { true } }
|
||||
}
|
||||
}
|
||||
val txEnabled = remember {
|
||||
mutableStateMapOf<Long, Boolean>().apply {
|
||||
txUrls.forEachIndexed { i, (id, _) -> this[id] = transceiversEnabled.getOrElse(i) { true } }
|
||||
}
|
||||
}
|
||||
val onRestoreDefaults = {
|
||||
val satDefaults = Sources.satelliteDataUrls.values.filter { it.isNotBlank() }
|
||||
val txDefaults = Sources.transceiversDataUrls.values.filter { it.isNotBlank() }
|
||||
nextId.longValue = (satDefaults.size + txDefaults.size).toLong()
|
||||
satUrls.clear()
|
||||
satUrls.addAll(satDefaults.mapIndexed { i, url -> i.toLong() to url })
|
||||
txUrls.clear()
|
||||
txUrls.addAll(txDefaults.mapIndexed { i, url -> (satDefaults.size + i).toLong() to url })
|
||||
satEnabled.clear()
|
||||
txEnabled.clear()
|
||||
Unit
|
||||
}
|
||||
val listState = rememberLazyListState()
|
||||
val satDraggedId = remember { mutableStateOf(-1L) }
|
||||
val txDraggedId = remember { mutableStateOf(-1L) }
|
||||
val onAccept = {
|
||||
val satFiltered = satUrls.filter { it.second.trim().isNotBlank() }
|
||||
val txFiltered = txUrls.filter { it.second.trim().isNotBlank() }
|
||||
onSave(
|
||||
satFiltered.map { it.second.trim() },
|
||||
txFiltered.map { it.second.trim() },
|
||||
satFiltered.map { satEnabled[it.first] ?: true },
|
||||
txFiltered.map { txEnabled[it.first] ?: true }
|
||||
)
|
||||
onDismiss()
|
||||
}
|
||||
SharedDialog(
|
||||
title = stringResource(id = R.string.prefs_data_sources_title),
|
||||
onCancel = onDismiss,
|
||||
onAccept = onAccept,
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight(0.84f)
|
||||
.padding(horizontal = padding),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
contentPadding = PaddingValues(vertical = 6.dp)
|
||||
) {
|
||||
item {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
CardButton(
|
||||
onClick = { onImportTle(); onDismiss() },
|
||||
text = "TLE/3LE (.txt)\nOMM (.csv)",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
CardButton(
|
||||
onClick = { onImportTransceivers(); onDismiss() },
|
||||
text = "Transceivers\nSatNOGS (.json)",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
CardButton(
|
||||
onClick = onRestoreDefaults,
|
||||
text = stringResource(R.string.prefs_data_sources_restore),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
sourceSection(
|
||||
sectionKey = "sat",
|
||||
labelResId = R.string.prefs_data_sources_satellites_label,
|
||||
urls = satUrls,
|
||||
listState = listState,
|
||||
draggedId = satDraggedId,
|
||||
statusCodes = statusCodes,
|
||||
enabledMap = satEnabled,
|
||||
onToggle = { id -> satEnabled[id] = !(satEnabled[id] ?: true) },
|
||||
onAdd = { satUrls.add(nextId.longValue++ to "") },
|
||||
onMove = { from, to -> satUrls.add(to, satUrls.removeAt(from)) },
|
||||
onRemove = { i -> satUrls.removeAt(i) },
|
||||
onUrlChange = { i, v -> satUrls[i] = satUrls[i].first to v }
|
||||
)
|
||||
sourceSection(
|
||||
sectionKey = "tx",
|
||||
labelResId = R.string.prefs_data_sources_transceivers_label,
|
||||
urls = txUrls,
|
||||
listState = listState,
|
||||
draggedId = txDraggedId,
|
||||
statusCodes = statusCodes,
|
||||
enabledMap = txEnabled,
|
||||
onToggle = { id -> txEnabled[id] = !(txEnabled[id] ?: true) },
|
||||
onAdd = { txUrls.add(nextId.longValue++ to "") },
|
||||
onMove = { from, to -> txUrls.add(to, txUrls.removeAt(from)) },
|
||||
onRemove = { i -> txUrls.removeAt(i) },
|
||||
onUrlChange = { i, v -> txUrls[i] = txUrls[i].first to v }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.sourceSection(
|
||||
sectionKey: String,
|
||||
labelResId: Int,
|
||||
urls: List<Pair<Long, String>>,
|
||||
listState: LazyListState,
|
||||
draggedId: MutableState<Long>,
|
||||
statusCodes: Map<String, Int>,
|
||||
enabledMap: Map<Long, Boolean>,
|
||||
onToggle: (Long) -> Unit,
|
||||
onAdd: () -> Unit,
|
||||
onMove: (Int, Int) -> Unit,
|
||||
onRemove: (Int) -> Unit,
|
||||
onUrlChange: (Int, String) -> Unit
|
||||
) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(labelResId),
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconCard(action = onAdd, resId = R.drawable.ic_add)
|
||||
}
|
||||
}
|
||||
itemsIndexed(urls, key = { _, entry -> "$sectionKey-${entry.first}" }) { index, (id, url) ->
|
||||
val enabledTint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val enabled = enabledMap[id] ?: true
|
||||
val rowState = remember { DragRowState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
val isDragging = draggedId.value == id
|
||||
val isLifted = isDragging || rowState.isSettling.value
|
||||
LaunchedEffect(isDragging) {
|
||||
if (!isDragging) return@LaunchedEffect
|
||||
autoScroll(listState, rowState.startCenterY, rowState.fingerOffset, rowState.scrollComp)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.draggedVisual(
|
||||
isLifted = isLifted,
|
||||
translationY = if (rowState.isSettling.value) {
|
||||
rowState.settleAnim.value
|
||||
} else {
|
||||
rowState.offsetY.floatValue + rowState.scrollComp.floatValue
|
||||
}
|
||||
)
|
||||
.animateItem(
|
||||
fadeInSpec = spring(),
|
||||
// The dragged row repositions instantly, while its neighbours spring
|
||||
// out of the way (the "squeeze" effect).
|
||||
placementSpec = if (isDragging) {
|
||||
tween<IntOffset>(durationMillis = 0)
|
||||
} else {
|
||||
spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow
|
||||
)
|
||||
},
|
||||
fadeOutSpec = spring()
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.dragHandle(listState, sectionKey, id, urls, draggedId, rowState, scope, onMove)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_drag_handle),
|
||||
contentDescription = null,
|
||||
tint = enabledTint
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = url,
|
||||
onValueChange = { onUrlChange(index, it) },
|
||||
label = { Text(stringResource(R.string.prefs_data_sources_url_title)) },
|
||||
supportingText = statusCodes[url]?.let { code ->
|
||||
{ Text(statusLabel(code), color = statusColor(code), fontSize = 12.sp) }
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { onRemove(index) }) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_delete),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Checkbox(
|
||||
checked = enabled,
|
||||
onCheckedChange = { onToggle(id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-row drag state, kept in one object to keep the drag-handle modifier signature small.
|
||||
*
|
||||
* [offsetY] is the compensated visual displacement during a drag (finger travel minus the
|
||||
* heights of already-swapped neighbours), so the row stays glued to the finger. [fingerOffset]
|
||||
* tracks the raw finger travel for edge auto-scroll and swap detection. [settleAnim] smoothly
|
||||
* flies the lifted row back into its slot once the finger is released.
|
||||
*/
|
||||
private class DragRowState {
|
||||
val offsetY = mutableFloatStateOf(0f)
|
||||
val fingerOffset = mutableFloatStateOf(0f)
|
||||
val scrollComp = mutableFloatStateOf(0f)
|
||||
val startCenterY = mutableFloatStateOf(0f)
|
||||
val settleAnim = Animatable(0f)
|
||||
val isSettling = mutableStateOf(false)
|
||||
}
|
||||
|
||||
/** Spring shared by neighbour "squeeze" and the settle-back animation: soft and slightly bouncy. */
|
||||
private val reorderSpring = spring<Float>(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow
|
||||
)
|
||||
|
||||
/**
|
||||
* Drag handle gesture that performs live reordering while dragging.
|
||||
*/
|
||||
@Composable
|
||||
private fun Modifier.dragHandle(
|
||||
listState: LazyListState,
|
||||
sectionKey: String,
|
||||
entryId: Long,
|
||||
urls: List<Pair<Long, String>>,
|
||||
draggedId: MutableState<Long>,
|
||||
rowState: DragRowState,
|
||||
scope: CoroutineScope,
|
||||
onMove: (from: Int, to: Int) -> Unit
|
||||
): Modifier = pointerInput(entryId, sectionKey) {
|
||||
fun reorderLive() {
|
||||
val myIndex = urls.indexOfFirst { it.first == entryId }
|
||||
if (myIndex !in urls.indices) return
|
||||
val myCenter = rowState.startCenterY.floatValue + rowState.fingerOffset.floatValue
|
||||
val visible = listState.layoutInfo.visibleItemsInfo
|
||||
// Dragging down: swap when the dragged centre passes the next row's midpoint.
|
||||
if (myIndex < urls.lastIndex) {
|
||||
val next = visible.firstOrNull { it.key == "$sectionKey-${urls[myIndex + 1].first}" }
|
||||
if (next != null && myCenter > next.offset + next.size / 2f) {
|
||||
onMove(myIndex, myIndex + 1)
|
||||
rowState.offsetY.floatValue -= next.size.toFloat()
|
||||
return
|
||||
}
|
||||
}
|
||||
// Dragging up: swap when the dragged centre passes the previous row's midpoint.
|
||||
if (myIndex > 0) {
|
||||
val prev = visible.firstOrNull { it.key == "$sectionKey-${urls[myIndex - 1].first}" }
|
||||
if (prev != null && myCenter < prev.offset + prev.size / 2f) {
|
||||
onMove(myIndex, myIndex - 1)
|
||||
rowState.offsetY.floatValue += prev.size.toFloat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the drag bookkeeping and fly the lifted row back into its slot.
|
||||
fun finishDrag() {
|
||||
val lastOffset = rowState.offsetY.floatValue + rowState.scrollComp.floatValue
|
||||
if (kotlin.math.abs(lastOffset) < 1f) {
|
||||
rowState.fingerOffset.floatValue = 0f
|
||||
rowState.offsetY.floatValue = 0f
|
||||
rowState.scrollComp.floatValue = 0f
|
||||
draggedId.value = -1L
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
rowState.settleAnim.snapTo(lastOffset)
|
||||
rowState.isSettling.value = true
|
||||
rowState.fingerOffset.floatValue = 0f
|
||||
rowState.offsetY.floatValue = 0f
|
||||
rowState.scrollComp.floatValue = 0f
|
||||
draggedId.value = -1L
|
||||
rowState.settleAnim.animateTo(0f, reorderSpring)
|
||||
rowState.isSettling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = {
|
||||
rowState.isSettling.value = false
|
||||
val layout = listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == "$sectionKey-$entryId" }
|
||||
rowState.startCenterY.floatValue = (layout?.offset ?: 0) + (layout?.size ?: 0) / 2f
|
||||
rowState.fingerOffset.floatValue = 0f
|
||||
rowState.offsetY.floatValue = 0f
|
||||
rowState.scrollComp.floatValue = 0f
|
||||
draggedId.value = entryId
|
||||
},
|
||||
onDragEnd = ::finishDrag,
|
||||
onDragCancel = ::finishDrag
|
||||
) { change, dragAmount ->
|
||||
change.consume()
|
||||
if (draggedId.value != entryId) return@detectDragGesturesAfterLongPress
|
||||
rowState.fingerOffset.floatValue += dragAmount.y
|
||||
rowState.offsetY.floatValue += dragAmount.y
|
||||
reorderLive()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Modifier.draggedVisual(isLifted: Boolean, translationY: Float): Modifier {
|
||||
val shape = MaterialTheme.shapes.small
|
||||
// Smoothly scale the row up/down as the lifted card appears and disappears.
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isLifted) 1.02f else 1f,
|
||||
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
|
||||
label = "dragScale"
|
||||
)
|
||||
return this
|
||||
.graphicsLayer {
|
||||
if (isLifted) {
|
||||
this.translationY = translationY
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
}
|
||||
.then(
|
||||
if (isLifted) {
|
||||
// Solid card on top so the lifted row fully covers the row beneath it
|
||||
// instead of showing a translucent overlap of both rows.
|
||||
Modifier
|
||||
.zIndex(1f)
|
||||
.shadow(8.dp, shape, clip = false)
|
||||
.background(MaterialTheme.colorScheme.surface, shape)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls the list while dragging so the entry follows the finger past the viewport edges.
|
||||
* The visual centre is tracked independently of the entry's layout slot (which can scroll out
|
||||
* of [LazyListState.layoutInfo.visibleItemsInfo] during a long drag); [startCenterY] is the
|
||||
* entry's viewport centre captured at drag start and [fingerOffset] is the raw finger delta.
|
||||
*/
|
||||
private suspend fun autoScroll(
|
||||
listState: LazyListState,
|
||||
startCenterY: MutableFloatState,
|
||||
fingerOffset: MutableFloatState,
|
||||
scrollComp: MutableFloatState
|
||||
) {
|
||||
val threshold = 48f
|
||||
val maxSpeed = 24f
|
||||
while (true) {
|
||||
val info = listState.layoutInfo
|
||||
val center = startCenterY.floatValue + fingerOffset.floatValue
|
||||
val top = info.viewportStartOffset + threshold
|
||||
val bottom = info.viewportEndOffset - threshold
|
||||
val delta = when {
|
||||
center < top -> -(top - center).coerceAtMost(maxSpeed)
|
||||
center > bottom -> (center - bottom).coerceAtMost(maxSpeed)
|
||||
else -> 0f
|
||||
}
|
||||
if (delta != 0f) scrollComp.floatValue += listState.scrollBy(delta)
|
||||
delay(16L)
|
||||
}
|
||||
}
|
||||
|
||||
private fun statusLabel(code: Int): String = if (code == NetworkResult.CONNECTION_ERROR) "ERR" else code.toString()
|
||||
|
||||
@Composable
|
||||
private fun statusColor(code: Int): Color = when {
|
||||
code == NetworkResult.CONNECTION_ERROR -> MaterialTheme.colorScheme.error
|
||||
code in 200..299 -> Color(0xFF66BB6A)
|
||||
else -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@@ -252,6 +597,7 @@ fun PreviewNetworkOutputDialog() {
|
||||
frequencyAddress = "127.0.0.1",
|
||||
frequencyPort = "4532",
|
||||
frequencyFormat = $$"F $FREQ",
|
||||
frequencyOffsetHz = 0L,
|
||||
bluetoothRotatorState = false,
|
||||
bluetoothRotatorFormat = $$"P $AZ $EL",
|
||||
bluetoothRotatorName = "Default",
|
||||
@@ -261,7 +607,7 @@ fun PreviewNetworkOutputDialog() {
|
||||
bluetoothFrequencyFormat = $$"F $FREQ"
|
||||
),
|
||||
onDismiss = {},
|
||||
onSave = { _, _, _, _, _, _, _, _ -> }
|
||||
onSave = { _, _, _, _, _, _, _, _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -272,7 +618,7 @@ fun NetworkOutputDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (
|
||||
Boolean, String, String, String,
|
||||
Boolean, String, String, String
|
||||
Boolean, String, String, String, Long
|
||||
) -> Unit
|
||||
) {
|
||||
val padding = LocalSpacing.current.large
|
||||
@@ -286,12 +632,15 @@ fun NetworkOutputDialog(
|
||||
mutableStateOf("${initialSettings.frequencyAddress}:${initialSettings.frequencyPort}")
|
||||
}
|
||||
val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.frequencyFormat) }
|
||||
val frequencyOffsetHz = rememberSaveable { mutableStateOf(initialSettings.frequencyOffsetHz.toString()) }
|
||||
val onAccept = {
|
||||
val (rotIp, rotPort) = splitAddress(rotatorAddress.value)
|
||||
val (freqIp, freqPort) = splitAddress(frequencyAddress.value)
|
||||
val offsetHz = (frequencyOffsetHz.value.trim().toLongOrNull() ?: 0L)
|
||||
.coerceIn(Constants.FREQ_OFFSET_MIN_HZ, Constants.FREQ_OFFSET_MAX_HZ)
|
||||
onSave(
|
||||
rotatorState.value, rotIp, rotPort, rotatorFormat.value,
|
||||
frequencyState.value, freqIp, freqPort, frequencyFormat.value
|
||||
frequencyState.value, freqIp, freqPort, frequencyFormat.value, offsetHz
|
||||
)
|
||||
onDismiss()
|
||||
}
|
||||
@@ -324,6 +673,24 @@ fun NetworkOutputDialog(
|
||||
onFormatChange = { frequencyFormat.value = it },
|
||||
formatLabel = stringResource(R.string.prefs_net_frequency_format_hint)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
OutlinedTextField(
|
||||
value = frequencyOffsetHz.value,
|
||||
onValueChange = { frequencyOffsetHz.value = it },
|
||||
singleLine = true,
|
||||
label = { Text(stringResource(R.string.prefs_net_frequency_offset_hint)) },
|
||||
supportingText = { Text(stringResource(R.string.prefs_net_frequency_offset_help)) },
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
onClick = { frequencyOffsetHz.value = "0" },
|
||||
enabled = frequencyState.value && frequencyOffsetHz.value != "0"
|
||||
) {
|
||||
Icon(painter = painterResource(R.drawable.ic_close), contentDescription = null)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = frequencyState.value
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
@@ -352,6 +719,7 @@ fun PreviewBluetoothOutputDialog() {
|
||||
frequencyAddress = "127.0.0.1",
|
||||
frequencyPort = "4532",
|
||||
frequencyFormat = $$"F $FREQ",
|
||||
frequencyOffsetHz = 0L,
|
||||
bluetoothRotatorState = false,
|
||||
bluetoothRotatorFormat = $$"P $AZ $EL",
|
||||
bluetoothRotatorName = "Default",
|
||||
|
||||
+37
-23
@@ -61,6 +61,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
@@ -81,13 +82,25 @@ import java.util.Locale
|
||||
fun SettingsDestination() {
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container))
|
||||
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container, context))
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
SettingsScreen(uiState, viewModel::onAction)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) {
|
||||
var showUpdateChecker by rememberSaveable { mutableStateOf(false) }
|
||||
if (showUpdateChecker) {
|
||||
UpdateCheckerScreen(
|
||||
currentVersion = uiState.appVersionName,
|
||||
state = uiState.updateChecker,
|
||||
onBack = { showUpdateChecker = false },
|
||||
onCheck = { onAction(SettingsAction.CheckForUpdate) },
|
||||
onDownload = { onAction(SettingsAction.DownloadUpdate) },
|
||||
onConsumeApk = { onAction(SettingsAction.ConsumeDownloadedApk) }
|
||||
)
|
||||
return
|
||||
}
|
||||
val dialogs = rememberDialogVisibility()
|
||||
val pendingCustomSourcesGrant = remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||
val pendingCustomSourcesDeny = remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||
@@ -125,30 +138,23 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
}
|
||||
if (dialogs.dataSources) {
|
||||
DataSourcesDialog(
|
||||
useCustomTle = uiState.dataSourcesSettings.useCustomTLE,
|
||||
useCustomTransceivers = uiState.dataSourcesSettings.useCustomTransceivers,
|
||||
tleUrl = uiState.dataSourcesSettings.tleUrl,
|
||||
transceiversUrl = uiState.dataSourcesSettings.transceiversUrl,
|
||||
requestCustomSourcesPermission = { onGranted, onDenied ->
|
||||
pendingCustomSourcesGrant.value = onGranted
|
||||
pendingCustomSourcesDeny.value = onDenied
|
||||
permissions.launchCustomSourcesPermission()
|
||||
},
|
||||
satelliteUrls = uiState.dataSourcesSettings.satelliteUrls,
|
||||
transceiversUrls = uiState.dataSourcesSettings.transceiversUrls,
|
||||
satelliteEnabled = uiState.dataSourcesSettings.satelliteEnabled,
|
||||
transceiversEnabled = uiState.dataSourcesSettings.transceiversEnabled,
|
||||
statusCodes = uiState.dataSourcesStatus,
|
||||
onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false },
|
||||
onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false },
|
||||
onDismiss = { dialogs.dataSources = false },
|
||||
onSave = { useCustomTle, useCustomTransceivers, tleUrl, transceiversUrl ->
|
||||
val current = uiState.dataSourcesSettings
|
||||
val newSettings = current.copy(
|
||||
useCustomTLE = if (!useCustomTle || tleUrl.isNotBlank()) useCustomTle else current.useCustomTLE,
|
||||
tleUrl = if (!useCustomTle || tleUrl.isNotBlank()) tleUrl else current.tleUrl,
|
||||
useCustomTransceivers = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) useCustomTransceivers else current.useCustomTransceivers,
|
||||
transceiversUrl = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) transceiversUrl else current.transceiversUrl
|
||||
onSave = { satUrls, txUrls, satEnabled, txEnabled ->
|
||||
val newSettings = DataSourcesSettings(
|
||||
satelliteUrls = satUrls,
|
||||
transceiversUrls = txUrls,
|
||||
satelliteEnabled = satEnabled,
|
||||
transceiversEnabled = txEnabled
|
||||
)
|
||||
if (newSettings != current) onAction(SettingsAction.UpdateDataSources(newSettings))
|
||||
if (newSettings.useCustomTLE || newSettings.useCustomTransceivers) {
|
||||
onAction(SettingsAction.UpdateFromWeb)
|
||||
}
|
||||
if (newSettings != uiState.dataSourcesSettings) onAction(SettingsAction.UpdateDataSources(newSettings))
|
||||
onAction(SettingsAction.UpdateFromWeb)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -156,14 +162,15 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
NetworkOutputDialog(
|
||||
initialSettings = uiState.rcSettings,
|
||||
onDismiss = { dialogs.network = false },
|
||||
onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt ->
|
||||
onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt, freqOffsetHz ->
|
||||
onAction(
|
||||
SettingsAction.UpdateRC(
|
||||
uiState.rcSettings.copy(
|
||||
rotatorState = rotState, rotatorAddress = rotAddr,
|
||||
rotatorPort = rotPort, rotatorFormat = rotFmt,
|
||||
frequencyState = freqState, frequencyAddress = freqAddr,
|
||||
frequencyPort = freqPort, frequencyFormat = freqFmt
|
||||
frequencyPort = freqPort, frequencyFormat = freqFmt,
|
||||
frequencyOffsetHz = freqOffsetHz
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -300,6 +307,13 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
}
|
||||
item { OtherCard(uiState.otherSettings, onAction) }
|
||||
item { CardCredits() }
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
CardButton(
|
||||
onClick = { showUpdateChecker = true },
|
||||
text = stringResource(R.string.update_check_button),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-1
@@ -18,10 +18,12 @@
|
||||
package com.rtbishop.look4sat.feature.settings
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.LatestRelease
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import java.io.File
|
||||
|
||||
data class PositionSettings(
|
||||
val isUpdating: Boolean, val stationPos: GeoPos, val messageResId: Int
|
||||
@@ -34,6 +36,15 @@ data class DataSettings(
|
||||
val timestamp: Long
|
||||
)
|
||||
|
||||
data class UpdateCheckerState(
|
||||
val isChecking: Boolean = false,
|
||||
val release: LatestRelease? = null,
|
||||
val hasUpdate: Boolean = false,
|
||||
val isDownloading: Boolean = false,
|
||||
val apkFile: File? = null,
|
||||
val errorResId: Int? = null
|
||||
)
|
||||
|
||||
data class SettingsState(
|
||||
val appVersionName: String,
|
||||
val positionSettings: PositionSettings,
|
||||
@@ -41,7 +52,9 @@ data class SettingsState(
|
||||
val otherSettings: OtherSettings,
|
||||
val rcSettings: RCSettings,
|
||||
val radioControlSettings: RadioControlSettings,
|
||||
val dataSourcesSettings: DataSourcesSettings
|
||||
val dataSourcesSettings: DataSourcesSettings,
|
||||
val dataSourcesStatus: Map<String, Int> = emptyMap(),
|
||||
val updateChecker: UpdateCheckerState = UpdateCheckerState()
|
||||
)
|
||||
|
||||
sealed interface SettingsAction {
|
||||
@@ -72,6 +85,11 @@ sealed interface SettingsAction {
|
||||
// Data sources
|
||||
data class UpdateDataSources(val settings: DataSourcesSettings) : SettingsAction
|
||||
|
||||
// Update checker
|
||||
data object CheckForUpdate : SettingsAction
|
||||
data object DownloadUpdate : SettingsAction
|
||||
data object ConsumeDownloadedApk : SettingsAction
|
||||
|
||||
// System
|
||||
data class ShowToast(val message: String) : SettingsAction
|
||||
}
|
||||
+64
-2
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
@@ -24,16 +25,21 @@ import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.IUpdateRepository
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
||||
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
class SettingsViewModel(
|
||||
private val databaseRepo: IDatabaseRepo,
|
||||
private val settingsRepo: ISettingsRepo,
|
||||
private val updateRepo: IUpdateRepository,
|
||||
private val apkFile: File,
|
||||
private val showToast: IShowToast
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -47,7 +53,8 @@ class SettingsViewModel(
|
||||
otherSettings = settingsRepo.otherSettings.value,
|
||||
rcSettings = settingsRepo.rcSettings.value,
|
||||
radioControlSettings = settingsRepo.radioControlSettings.value,
|
||||
dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
||||
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
|
||||
dataSourcesStatus = settingsRepo.dataSourcesStatus.value
|
||||
)
|
||||
)
|
||||
|
||||
@@ -90,6 +97,11 @@ class SettingsViewModel(
|
||||
_uiState.update { it.copy(dataSourcesSettings = settings) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
settingsRepo.dataSourcesStatus.collect { status ->
|
||||
_uiState.update { it.copy(dataSourcesStatus = status) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
settingsRepo.radioControlSettings.collect { settings ->
|
||||
_uiState.update { it.copy(radioControlSettings = settings) }
|
||||
@@ -125,11 +137,59 @@ class SettingsViewModel(
|
||||
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
|
||||
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
|
||||
is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(action.settings)
|
||||
// Update checker
|
||||
SettingsAction.CheckForUpdate -> checkForUpdate()
|
||||
SettingsAction.DownloadUpdate -> downloadUpdate()
|
||||
SettingsAction.ConsumeDownloadedApk -> consumeDownloadedApk()
|
||||
// System
|
||||
is SettingsAction.ShowToast -> showToast(action.message)
|
||||
}
|
||||
}
|
||||
|
||||
// region Update checker
|
||||
|
||||
private fun checkForUpdate() {
|
||||
_uiState.update { it.copy(updateChecker = it.updateChecker.copy(isChecking = true, errorResId = null)) }
|
||||
viewModelScope.launch {
|
||||
val release = updateRepo.getLatestRelease()
|
||||
val hasUpdate = release != null &&
|
||||
VersionComparator.isNewer(release.versionTag, settingsRepo.appVersionName)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
updateChecker = it.updateChecker.copy(
|
||||
isChecking = false,
|
||||
release = release,
|
||||
hasUpdate = hasUpdate,
|
||||
errorResId = if (release == null) R.string.update_check_error else null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadUpdate() {
|
||||
val url = _uiState.value.updateChecker.release?.apkUrl ?: return
|
||||
_uiState.update { it.copy(updateChecker = it.updateChecker.copy(isDownloading = true, errorResId = null)) }
|
||||
viewModelScope.launch {
|
||||
val success = updateRepo.downloadApk(url, apkFile)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
updateChecker = it.updateChecker.copy(
|
||||
isDownloading = false,
|
||||
apkFile = if (success) apkFile else null,
|
||||
errorResId = if (success) null else R.string.update_check_download_error
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun consumeDownloadedApk() {
|
||||
_uiState.update { it.copy(updateChecker = it.updateChecker.copy(apkFile = null)) }
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Position helpers — consolidated from 3 near-identical functions
|
||||
|
||||
private fun setGpsPosition() {
|
||||
@@ -201,11 +261,13 @@ class SettingsViewModel(
|
||||
|
||||
companion object {
|
||||
|
||||
fun factory(container: IMainContainer) = viewModelFactory {
|
||||
fun factory(container: IMainContainer, context: Context) = viewModelFactory {
|
||||
initializer {
|
||||
SettingsViewModel(
|
||||
databaseRepo = container.databaseRepo,
|
||||
settingsRepo = container.settingsRepo,
|
||||
updateRepo = container.updateRepo,
|
||||
apkFile = File(context.cacheDir, "look4sat-update.apk"),
|
||||
showToast = container.provideShowToast()
|
||||
)
|
||||
}
|
||||
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
/*
|
||||
* 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.settings
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.FileProvider
|
||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||
|
||||
@Composable
|
||||
fun UpdateCheckerScreen(
|
||||
currentVersion: String,
|
||||
state: UpdateCheckerState,
|
||||
onBack: () -> Unit,
|
||||
onCheck: () -> Unit,
|
||||
onDownload: () -> Unit,
|
||||
onConsumeApk: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
// When the APK finishes downloading, hand it to the system installer.
|
||||
LaunchedEffect(state.apkFile) {
|
||||
val file = state.apkFile ?: return@LaunchedEffect
|
||||
val uri: Uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
onConsumeApk()
|
||||
}
|
||||
|
||||
ScreenColumn(
|
||||
topBar = { isVerticalLayout ->
|
||||
TopBar {
|
||||
IconCard(
|
||||
action = onBack,
|
||||
resId = R.drawable.ic_arrow,
|
||||
modifier = Modifier.rotate(180f)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.update_check_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(10.dp)
|
||||
) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.update_check_current, currentVersion),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
CardButton(
|
||||
onClick = onCheck,
|
||||
text = stringResource(R.string.update_check_button),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
state.isChecking -> StatusRow(showSpinner = true)
|
||||
state.errorResId != null -> StatusRow(showSpinner = false, message = stringResource(state.errorResId))
|
||||
state.release == null -> Unit // not checked yet
|
||||
!state.hasUpdate -> StatusRow(showSpinner = false, message = stringResource(R.string.update_check_up_to_date))
|
||||
else -> ReleaseCard(
|
||||
state = state,
|
||||
onDownload = onDownload,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusRow(showSpinner: Boolean, message: String? = null) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (showSpinner) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
if (message != null) {
|
||||
Text(
|
||||
text = message,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReleaseCard(state: UpdateCheckerState, onDownload: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val release = state.release ?: return
|
||||
ElevatedCard(modifier = modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.update_check_available),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.update_check_latest, release.versionTag.removePrefix("v")),
|
||||
fontSize = 14.sp
|
||||
)
|
||||
if (release.title.isNotBlank() && release.title != release.versionTag) {
|
||||
Text(text = release.title, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
if (release.body.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.update_check_release_notes),
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.padding(top = 4.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f))
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = release.body,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
when {
|
||||
release.apkUrl == null -> Text(
|
||||
text = stringResource(R.string.update_check_no_apk),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
state.isDownloading -> Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = stringResource(R.string.update_check_downloading))
|
||||
}
|
||||
else -> CardButton(
|
||||
onClick = onDownload,
|
||||
text = stringResource(R.string.update_check_download),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import com.android.build.api.dsl.LibraryExtension
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.convention.featurePlugin)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.rtbishop.look4sat.feature.status"
|
||||
compileOptions {
|
||||
encoding = "UTF-8"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:domain"))
|
||||
implementation(project(":core:presentation"))
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
package com.rtbishop.look4sat.feature.status
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.rtbishop.look4sat.core.domain.model.SatDay
|
||||
import com.rtbishop.look4sat.core.domain.model.SatReport
|
||||
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||
import com.rtbishop.look4sat.core.presentation.InfoDialog
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/** Fixed width per day tile — tablet-safe; name column absorbs remaining space. */
|
||||
private val TILE_WIDTH: Dp = 64.dp
|
||||
|
||||
private data class UploadOption(
|
||||
val apiValue: String,
|
||||
val labelResId: Int,
|
||||
val color: Color
|
||||
)
|
||||
|
||||
private val UPLOAD_OPTIONS = listOf(
|
||||
UploadOption(AMSAT_REPORT_HEARD, R.string.amsat_upload_active, Color(0xFF648FFF)),
|
||||
UploadOption(AMSAT_REPORT_TELEMETRY_ONLY, R.string.amsat_upload_tlm, Color(0xFFFFB000)),
|
||||
UploadOption(AMSAT_REPORT_NOT_HEARD, R.string.amsat_upload_not_heard, Color(0xFFDC267F)),
|
||||
UploadOption(AMSAT_REPORT_CREW_ACTIVE, R.string.amsat_upload_crew_active, Color(0xFFFE6100))
|
||||
)
|
||||
|
||||
/**
|
||||
* Map AMSAT status text to Material3 colorScheme colors.
|
||||
* Addresses PR #233 review: use colorScheme instead of hardcoded Color() constants.
|
||||
*/
|
||||
@Composable
|
||||
private fun statusColorOf(statusText: String): Color {
|
||||
return when {
|
||||
statusText.contains("Heard", ignoreCase = true) && !statusText.contains("Not", ignoreCase = true) ->
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
statusText.contains("Telemetry", ignoreCase = true) || statusText.contains("Beacon", ignoreCase = true) ->
|
||||
MaterialTheme.colorScheme.tertiaryContainer
|
||||
statusText.contains("Not Heard", ignoreCase = true) ->
|
||||
Color(0xFFDC267F)
|
||||
else ->
|
||||
MaterialTheme.colorScheme.error
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SatStatusDestination() {
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container))
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
DisposableEffect(lifecycleOwner, viewModel) {
|
||||
var skipInitialStart = true
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_START) {
|
||||
if (skipInitialStart) {
|
||||
skipInitialStart = false
|
||||
} else {
|
||||
viewModel.fetch()
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
SatStatusScreen(
|
||||
uiState = uiState,
|
||||
refresh = viewModel::refresh,
|
||||
onToggleUpload = viewModel::toggleUploadPanel,
|
||||
onUploadReportChange = viewModel::setUploadReport,
|
||||
onUploadCallsignChange = viewModel::setUploadCallsign,
|
||||
onUploadGridChange = viewModel::setUploadGrid,
|
||||
onRequestSubmitUpload = viewModel::requestSubmitConfirmation,
|
||||
onConfirmSubmitUpload = viewModel::confirmSubmitReport,
|
||||
onDismissSubmitConfirmation = viewModel::dismissSubmitConfirmation,
|
||||
onDismissUpload = viewModel::collapseUploadPanel
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SatStatusScreen(
|
||||
uiState: SatStatusUiState,
|
||||
refresh: () -> Unit,
|
||||
onToggleUpload: () -> Unit,
|
||||
onUploadReportChange: (String) -> Unit,
|
||||
onUploadCallsignChange: (String) -> Unit,
|
||||
onUploadGridChange: (String) -> Unit,
|
||||
onRequestSubmitUpload: () -> Unit,
|
||||
onConfirmSubmitUpload: (String) -> Unit,
|
||||
onDismissSubmitConfirmation: () -> Unit,
|
||||
onDismissUpload: () -> Unit
|
||||
) {
|
||||
var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) }
|
||||
Column(modifier = Modifier.fillMaxSize().layoutPadding()) {
|
||||
StatusHeader(fetchedAtUtcMs = uiState.fetchedAtUtcMs, isRefreshing = uiState.isRefreshing, onRefresh = refresh)
|
||||
LegendRow()
|
||||
|
||||
when {
|
||||
uiState.isLoading -> {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
uiState.error != null && uiState.statuses.isEmpty() -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.amsat_load_failed), color = MaterialTheme.colorScheme.error)
|
||||
TextButton(onClick = refresh) { Text(text = stringResource(id = R.string.amsat_retry)) }
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
HeaderRow(statuses = uiState.statuses)
|
||||
HorizontalDivider(thickness = 1.dp)
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(uiState.statuses, key = { it.name }) { status ->
|
||||
StatusRow(status = status, onClickDay = { day -> selectedDay = status to day })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectedDay?.let { (status, day) ->
|
||||
ReportDialog(
|
||||
statusName = status.name,
|
||||
day = day,
|
||||
reports = uiState.reports,
|
||||
upload = uiState.upload,
|
||||
onToggleUpload = onToggleUpload,
|
||||
onReportChange = onUploadReportChange,
|
||||
onCallsignChange = onUploadCallsignChange,
|
||||
onGridChange = onUploadGridChange,
|
||||
onRequestSubmitReport = onRequestSubmitUpload,
|
||||
onConfirmSubmitReport = { onConfirmSubmitUpload(status.name) },
|
||||
onDismissSubmitConfirmation = onDismissSubmitConfirmation,
|
||||
onDismiss = {
|
||||
selectedDay = null
|
||||
onDismissUpload()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Top: update time + refresh button (spinner while loading) */
|
||||
@Composable
|
||||
private fun StatusHeader(fetchedAtUtcMs: Long, isRefreshing: Boolean, onRefresh: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = if (fetchedAtUtcMs > 0)
|
||||
stringResource(id = R.string.amsat_updated) + " " + formatFetchedAt(fetchedAtUtcMs)
|
||||
else
|
||||
stringResource(id = R.string.amsat_title),
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable(onClick = onRefresh),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (isRefreshing) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_refresh),
|
||||
contentDescription = stringResource(id = R.string.amsat_refresh),
|
||||
modifier = Modifier.size(16.dp),
|
||||
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Legend: FlowRow of colored chips — wraps to two lines on narrow screens, stays one line when wide. */
|
||||
@Composable
|
||||
private fun LegendRow() {
|
||||
val legend = listOf(
|
||||
stringResource(id = R.string.amsat_active) to Color(0xFF648FFF),
|
||||
stringResource(id = R.string.amsat_tlm) to Color(0xFFFFB000),
|
||||
stringResource(id = R.string.amsat_not_heard) to Color(0xFFDC267F),
|
||||
stringResource(id = R.string.amsat_conflict) to Color(0xFFFE6100)
|
||||
)
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
legend.forEach { (label, color) ->
|
||||
val alphaColor = color.copy(alpha = 0.25f)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(32.dp))
|
||||
.background(alphaColor)
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(color)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Header: satellite name column + fixed-width date labels aligned to tiles. */
|
||||
@Composable
|
||||
private fun HeaderRow(statuses: List<SatStatus>) {
|
||||
val dates = statuses.firstOrNull()?.days?.map { it.dateLabel } ?: emptyList()
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.amsat_name),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f).padding(start = 4.dp)
|
||||
)
|
||||
dates.forEach { date ->
|
||||
Text(
|
||||
text = date,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.width(TILE_WIDTH)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Satellite row: name takes remaining width; day tiles are fixed-width (tablet-safe). */
|
||||
@Composable
|
||||
private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
|
||||
val noReportGray = 0xFFC0C0C0L
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = status.name,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f).padding(end = 4.dp)
|
||||
)
|
||||
status.days.forEach { day ->
|
||||
val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first()
|
||||
DayCell(
|
||||
slot = slot,
|
||||
modifier = Modifier.width(TILE_WIDTH).padding(horizontal = 2.dp),
|
||||
onClick = { onClickDay(day) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Day block: newest reported status among the day's 12 slots; gray when none. */
|
||||
@Composable
|
||||
private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
|
||||
val color = Color(slot.statusColor)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(28.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(color)
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (slot.count > 0) {
|
||||
Text(text = slot.count.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Report detail dialog (callsign / date / time / grid) */
|
||||
@Composable
|
||||
private fun ReportDialog(
|
||||
statusName: String,
|
||||
day: SatDay,
|
||||
reports: Map<String, SatReport>,
|
||||
upload: AmSatUploadUiState,
|
||||
onToggleUpload: () -> Unit,
|
||||
onReportChange: (String) -> Unit,
|
||||
onCallsignChange: (String) -> Unit,
|
||||
onGridChange: (String) -> Unit,
|
||||
onRequestSubmitReport: () -> Unit,
|
||||
onConfirmSubmitReport: () -> Unit,
|
||||
onDismissSubmitConfirmation: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] }
|
||||
InfoDialog(
|
||||
title = "$statusName · ${day.dateLabel}",
|
||||
onDismiss = onDismiss,
|
||||
onAccept = onDismiss,
|
||||
extraAction = {
|
||||
CardButton(
|
||||
onClick = onToggleUpload,
|
||||
text = stringResource(if (upload.isExpanded) R.string.amsat_upload_back else R.string.amsat_upload)
|
||||
)
|
||||
}
|
||||
) {
|
||||
AnimatedVisibility(visible = upload.isExpanded) {
|
||||
AmSatUploadPanel(
|
||||
statusName = statusName,
|
||||
upload = upload,
|
||||
onReportChange = onReportChange,
|
||||
onCallsignChange = onCallsignChange,
|
||||
onGridChange = onGridChange,
|
||||
onRequestSubmitReport = onRequestSubmitReport
|
||||
)
|
||||
}
|
||||
if (upload.isConfirmingSubmit) {
|
||||
AmSatUploadConfirmDialog(
|
||||
statusName = statusName,
|
||||
upload = upload,
|
||||
onConfirm = onConfirmSubmitReport,
|
||||
onDismiss = onDismissSubmitConfirmation
|
||||
)
|
||||
}
|
||||
if (dayReports.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.amsat_no_reports),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.heightIn(max = 480.dp)) {
|
||||
items(dayReports) { report ->
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(statusColorOf(report.statusText))
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(text = report.statusText, fontSize = 14.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Text(
|
||||
text = "${report.call} ${report.dateUtc} ${report.timeUtc}" +
|
||||
if (report.grid.isNotBlank() && report.grid != "-") " ${report.grid}" else "",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
HorizontalDivider(thickness = 0.5.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AmSatUploadPanel(
|
||||
statusName: String,
|
||||
upload: AmSatUploadUiState,
|
||||
onReportChange: (String) -> Unit,
|
||||
onCallsignChange: (String) -> Unit,
|
||||
onGridChange: (String) -> Unit,
|
||||
onRequestSubmitReport: () -> Unit
|
||||
) {
|
||||
val options = remember(statusName) {
|
||||
if (statusName.startsWith("ISS")) UPLOAD_OPTIONS else UPLOAD_OPTIONS.filter { it.apiValue != AMSAT_REPORT_CREW_ACTIVE }
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f))
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.amsat_upload_report),
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.amsat_upload_time_now, formatUtcNowForUpload(System.currentTimeMillis())),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.amsat_upload_status),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
options.forEach { option ->
|
||||
val selected = upload.selectedReport == option.apiValue
|
||||
FilterChip(
|
||||
selected = selected,
|
||||
onClick = { onReportChange(option.apiValue) },
|
||||
label = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (selected) Color.White else option.color)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(stringResource(option.labelResId), maxLines = 1)
|
||||
}
|
||||
},
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
containerColor = option.color.copy(alpha = 0.10f),
|
||||
labelColor = MaterialTheme.colorScheme.onSurface,
|
||||
selectedContainerColor = option.color,
|
||||
selectedLabelColor = Color.White
|
||||
),
|
||||
border = FilterChipDefaults.filterChipBorder(
|
||||
enabled = true,
|
||||
selected = selected,
|
||||
borderColor = option.color.copy(alpha = 0.60f),
|
||||
selectedBorderColor = option.color
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedTextField(
|
||||
value = upload.callsign,
|
||||
onValueChange = onCallsignChange,
|
||||
label = { Text(stringResource(R.string.amsat_upload_callsign)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = upload.gridSquare,
|
||||
onValueChange = onGridChange,
|
||||
label = { Text(stringResource(R.string.amsat_upload_grid)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = onRequestSubmitReport,
|
||||
enabled = !upload.isSubmitting && upload.callsign.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (upload.isSubmitting) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Text(stringResource(R.string.amsat_upload_submit))
|
||||
}
|
||||
}
|
||||
upload.message?.let { message ->
|
||||
if (message == UPLOAD_MESSAGE_SUCCESS) {
|
||||
Text(
|
||||
text = stringResource(R.string.amsat_upload_success),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
upload.error?.let { error ->
|
||||
Text(
|
||||
text = uploadErrorText(error),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AmSatUploadConfirmDialog(
|
||||
statusName: String,
|
||||
upload: AmSatUploadUiState,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val option = UPLOAD_OPTIONS.firstOrNull { it.apiValue == upload.selectedReport } ?: UPLOAD_OPTIONS.first()
|
||||
val statusLabel = stringResource(option.labelResId)
|
||||
val grid = upload.gridSquare.ifBlank { stringResource(R.string.amsat_upload_grid_none) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.amsat_upload_confirm_title)) },
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.amsat_upload_confirm_message,
|
||||
statusName,
|
||||
statusLabel,
|
||||
upload.callsign,
|
||||
grid
|
||||
)
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm, enabled = !upload.isSubmitting) {
|
||||
Text(stringResource(R.string.amsat_upload_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss, enabled = !upload.isSubmitting) {
|
||||
Text(stringResource(R.string.btn_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun uploadErrorText(error: String): String {
|
||||
return when (error) {
|
||||
UPLOAD_ERROR_CALLSIGN_REQUIRED -> stringResource(R.string.amsat_upload_callsign_required)
|
||||
UPLOAD_ERROR_GRID_INVALID -> stringResource(R.string.amsat_upload_grid_invalid)
|
||||
else -> stringResource(R.string.amsat_upload_failed, error)
|
||||
}
|
||||
}
|
||||
|
||||
private val MONTH_ABBR = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||
|
||||
private fun formatFetchedAt(utcMs: Long): String {
|
||||
val cal = Calendar.getInstance()
|
||||
cal.timeInMillis = utcMs
|
||||
val day = cal.get(Calendar.DAY_OF_MONTH)
|
||||
val monthIndex = cal.get(Calendar.MONTH)
|
||||
val year = cal.get(Calendar.YEAR)
|
||||
val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
|
||||
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
|
||||
val ss = cal.get(Calendar.SECOND).toString().padStart(2, '0')
|
||||
return if (Locale.getDefault().language == Locale.CHINESE.language) {
|
||||
"${year}年${monthIndex + 1}月${day}日 - $hh:$mm:$ss"
|
||||
} else {
|
||||
"$day${MONTH_ABBR[monthIndex]} $year - $hh:$mm:$ss"
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatUtcNowForUpload(utcMs: Long): String {
|
||||
val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
return formatter.format(Date(utcMs))
|
||||
}
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
package com.rtbishop.look4sat.feature.status
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
||||
import com.rtbishop.look4sat.core.domain.model.SatReport
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
|
||||
const val AMSAT_REPORT_HEARD = "Heard"
|
||||
const val AMSAT_REPORT_TELEMETRY_ONLY = "Telemetry Only"
|
||||
const val AMSAT_REPORT_NOT_HEARD = "Not Heard"
|
||||
const val AMSAT_REPORT_CREW_ACTIVE = "Crew Active"
|
||||
|
||||
const val UPLOAD_ERROR_CALLSIGN_REQUIRED = "callsign_required"
|
||||
const val UPLOAD_ERROR_GRID_INVALID = "grid_invalid"
|
||||
const val UPLOAD_MESSAGE_SUCCESS = "success"
|
||||
|
||||
data class AmSatUploadUiState(
|
||||
val isExpanded: Boolean = false,
|
||||
val selectedReport: String = AMSAT_REPORT_HEARD,
|
||||
val callsign: String = "",
|
||||
val gridSquare: String = "",
|
||||
val isSubmitting: Boolean = false,
|
||||
val isConfirmingSubmit: Boolean = false,
|
||||
val message: String? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
data class SatStatusUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val isRefreshing: Boolean = false,
|
||||
val statuses: List<SatStatus> = emptyList(),
|
||||
val reports: Map<String, SatReport> = emptyMap(),
|
||||
val fetchedAtUtcMs: Long = 0L,
|
||||
val error: String? = null,
|
||||
val upload: AmSatUploadUiState = AmSatUploadUiState()
|
||||
)
|
||||
|
||||
class SatStatusViewModel(
|
||||
private val amSatRepo: IAmSatRepository,
|
||||
private val settingsRepo: ISettingsRepo
|
||||
) : ViewModel() {
|
||||
|
||||
private val cachedStatusPage = amSatRepo.getCachedStatus()
|
||||
private val _uiState = MutableStateFlow(cachedStatusPage?.toUiState() ?: SatStatusUiState(isLoading = true))
|
||||
val uiState: StateFlow<SatStatusUiState> = _uiState
|
||||
|
||||
init {
|
||||
if (cachedStatusPage == null) fetch()
|
||||
}
|
||||
|
||||
fun fetch() {
|
||||
amSatRepo.getCachedStatus()?.let { page ->
|
||||
applyStatusPage(page)
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
try {
|
||||
val page = amSatRepo.fetchStatus()
|
||||
if (page != null && page.statuses.isNotEmpty()) {
|
||||
applyStatusPage(page)
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, isRefreshing = false, error = "fetch_failed")
|
||||
}
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, isRefreshing = false, error = exception.message ?: "fetch_failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
if (_uiState.value.isRefreshing) return
|
||||
_uiState.update { it.copy(isRefreshing = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val page = amSatRepo.fetchStatus(forceRefresh = true)
|
||||
if (page != null && page.statuses.isNotEmpty()) {
|
||||
applyStatusPage(page)
|
||||
} else {
|
||||
_uiState.update { it.copy(isRefreshing = false, error = "fetch_failed") }
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
_uiState.update { it.copy(isRefreshing = false, error = exception.message ?: "fetch_failed") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyStatusPage(page: SatStatusPage) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isRefreshing = false,
|
||||
statuses = page.statuses,
|
||||
reports = page.reports,
|
||||
fetchedAtUtcMs = page.fetchedAtUtcMs,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleUploadPanel() {
|
||||
val storedCallsign = settingsRepo.getAmSatCallsign()
|
||||
val defaultGrid = defaultGridSquare()
|
||||
_uiState.update { state ->
|
||||
val upload = state.upload
|
||||
val expanded = !upload.isExpanded
|
||||
state.copy(
|
||||
upload = if (expanded) {
|
||||
upload.copy(
|
||||
isExpanded = true,
|
||||
callsign = upload.callsign.ifBlank { storedCallsign },
|
||||
gridSquare = upload.gridSquare.ifBlank { defaultGrid },
|
||||
message = null,
|
||||
error = null
|
||||
)
|
||||
} else {
|
||||
upload.copy(isExpanded = false, isConfirmingSubmit = false, message = null, error = null)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun collapseUploadPanel() {
|
||||
_uiState.update { it.copy(upload = it.upload.copy(isExpanded = false, isConfirmingSubmit = false, message = null, error = null)) }
|
||||
}
|
||||
|
||||
fun setUploadReport(report: String) {
|
||||
_uiState.update { it.copy(upload = it.upload.copy(selectedReport = report, isConfirmingSubmit = false, message = null, error = null)) }
|
||||
}
|
||||
|
||||
fun setUploadCallsign(callsign: String) {
|
||||
val normalized = callsign.trim().uppercase(Locale.US)
|
||||
settingsRepo.setAmSatCallsign(normalized)
|
||||
_uiState.update { it.copy(upload = it.upload.copy(callsign = normalized, isConfirmingSubmit = false, message = null, error = null)) }
|
||||
}
|
||||
|
||||
fun setUploadGrid(grid: String) {
|
||||
val normalized = grid.trim().uppercase(Locale.US)
|
||||
_uiState.update { it.copy(upload = it.upload.copy(gridSquare = normalized, isConfirmingSubmit = false, message = null, error = null)) }
|
||||
}
|
||||
|
||||
fun requestSubmitConfirmation() {
|
||||
val prepared = validateUpload(_uiState.value.upload) ?: return
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
upload = it.upload.copy(
|
||||
callsign = prepared.callsign,
|
||||
gridSquare = prepared.grid,
|
||||
isConfirmingSubmit = true,
|
||||
message = null,
|
||||
error = null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissSubmitConfirmation() {
|
||||
_uiState.update { it.copy(upload = it.upload.copy(isConfirmingSubmit = false)) }
|
||||
}
|
||||
|
||||
fun confirmSubmitReport(satelliteName: String) {
|
||||
val upload = _uiState.value.upload
|
||||
if (upload.isSubmitting) return
|
||||
val prepared = validateUpload(upload) ?: return
|
||||
|
||||
settingsRepo.setAmSatCallsign(prepared.callsign)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
upload = it.upload.copy(
|
||||
callsign = prepared.callsign,
|
||||
gridSquare = prepared.grid,
|
||||
isSubmitting = true,
|
||||
isConfirmingSubmit = false,
|
||||
error = null,
|
||||
message = null
|
||||
)
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val result = amSatRepo.submitReport(
|
||||
AmSatReportSubmission(
|
||||
name = satelliteName,
|
||||
report = prepared.report,
|
||||
callsign = prepared.callsign,
|
||||
gridSquare = prepared.grid,
|
||||
reportedAtUtcMillis = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
if (result.success) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
upload = it.upload.copy(
|
||||
isSubmitting = false,
|
||||
callsign = prepared.callsign,
|
||||
gridSquare = prepared.grid,
|
||||
message = UPLOAD_MESSAGE_SUCCESS,
|
||||
error = null
|
||||
)
|
||||
)
|
||||
}
|
||||
refresh()
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
upload = it.upload.copy(
|
||||
isSubmitting = false,
|
||||
message = null,
|
||||
error = result.message.ifBlank { "Upload failed" }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateUpload(upload: AmSatUploadUiState): PreparedUpload? {
|
||||
val callsign = upload.callsign.trim().uppercase(Locale.US)
|
||||
val grid = upload.gridSquare.trim().uppercase(Locale.US)
|
||||
return when {
|
||||
callsign.isBlank() -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
upload = it.upload.copy(
|
||||
isConfirmingSubmit = false,
|
||||
error = UPLOAD_ERROR_CALLSIGN_REQUIRED,
|
||||
message = null
|
||||
)
|
||||
)
|
||||
}
|
||||
null
|
||||
}
|
||||
grid.isNotBlank() && !MAIDENHEAD_GRID.matches(grid) -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
upload = it.upload.copy(
|
||||
isConfirmingSubmit = false,
|
||||
error = UPLOAD_ERROR_GRID_INVALID,
|
||||
message = null
|
||||
)
|
||||
)
|
||||
}
|
||||
null
|
||||
}
|
||||
else -> PreparedUpload(
|
||||
report = upload.selectedReport,
|
||||
callsign = callsign,
|
||||
grid = grid
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun defaultGridSquare(): String {
|
||||
val grid = settingsRepo.stationPosition.value.qthLocator.trim().uppercase(Locale.US)
|
||||
return grid.takeUnless { it == "JJ00AA" }.orEmpty()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun factory(container: IMainContainer) = viewModelFactory {
|
||||
initializer {
|
||||
SatStatusViewModel(container.amSatRepo, container.settingsRepo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun SatStatusPage.toUiState() = SatStatusUiState(
|
||||
statuses = statuses,
|
||||
reports = reports,
|
||||
fetchedAtUtcMs = fetchedAtUtcMs
|
||||
)
|
||||
|
||||
private data class PreparedUpload(
|
||||
val report: String,
|
||||
val callsign: String,
|
||||
val grid: String
|
||||
)
|
||||
|
||||
private val MAIDENHEAD_GRID = Regex("^[A-R]{2}[0-9]{2}([A-X]{2})?$")
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "443"
|
||||
appVersionCode = "451"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.3"
|
||||
appVersionName = "4.4.6-ba7opf.7"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
@@ -23,7 +23,7 @@ compose-activity = "1.13.0"
|
||||
compose-lifecycle = "2.11.0"
|
||||
compose-navigation3 = "1.1.5"
|
||||
|
||||
google-ksp = "2.3.8"
|
||||
google-ksp = "2.3.10"
|
||||
|
||||
kotlin = "2.4.10"
|
||||
kotlin-coroutines = "1.11.0"
|
||||
|
||||
+2
-1
@@ -27,5 +27,6 @@ include(
|
||||
":feature:passes",
|
||||
":feature:radar",
|
||||
":feature:satellites",
|
||||
":feature:settings"
|
||||
":feature:settings",
|
||||
":feature:status"
|
||||
)
|
||||
Reference in new issue
Block a user