mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Compare commits
24
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 |
No files matched your search
@@ -68,3 +68,4 @@ fastlane/readme.md
|
|||||||
/app/release/output-metadata.json
|
/app/release/output-metadata.json
|
||||||
/app/release/
|
/app/release/
|
||||||
/.kotlin/sessions/
|
/.kotlin/sessions/
|
||||||
|
dist/
|
||||||
@@ -6,10 +6,12 @@
|
|||||||
|
|
||||||
## 本仓库特色功能
|
## 本仓库特色功能
|
||||||
|
|
||||||
|
- **双站过境匹配(Mutual Pass)** — 输入友台经纬度/网格,筛选双方同时可见的卫星过境,在地图上同时显示双方仰角曲线和地面轨迹,支持一键跳转雷达页查看详情
|
||||||
- **线性卫星转发器频率计算器** — 在雷达页的 Calculator 标签页中,支持 TX/RX 双向多普勒频率计算,以及下行频率偏移(offset)输入,方便操作带偏移的线性卫星
|
- **线性卫星转发器频率计算器** — 在雷达页的 Calculator 标签页中,支持 TX/RX 双向多普勒频率计算,以及下行频率偏移(offset)输入,方便操作带偏移的线性卫星
|
||||||
- **CW 解码器** — 集成 Morse Expert 解码引擎,支持瀑布图、实时解码文本
|
- **CW 解码器** — 集成 Morse Expert 解码引擎,支持瀑布图、实时解码文本
|
||||||
- **Passband 模式** — 支持通过位置滑块(Passband)自动计算 TX/RX 频率,避免切换时跳变
|
- **Passband 模式** — 支持通过位置滑块(Passband)自动计算 TX/RX 频率,避免切换时跳变
|
||||||
- **中文界面优化** — 翻译修正、UI 布局调整
|
- **AMSAT 状态页** — 实时查看卫星的业余无线电转发器状态(开启/关闭/待机),支持 72 小时历史记录回放
|
||||||
|
- **中文界面优化** — 翻译修正、UI 布局调整,系统语言自动切换
|
||||||
|
|
||||||
## 上游仓库
|
## 上游仓库
|
||||||
|
|
||||||
@@ -23,4 +25,14 @@
|
|||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
GNU General Public License v3.0。详见 [LICENSE](LICENSE)。
|
GNU General Public License v3.0。详见 [LICENSE](LICENSE)。
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
<a href="https://star-history.dera.page/#atsunatsu/Look4Sat&type=timeline&legend=top-left">
|
||||||
|
<picture>
|
||||||
|
<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>
|
||||||
+15
-3
@@ -1,7 +1,19 @@
|
|||||||
|
import java.io.FileInputStream
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.convention.applicationPlugin)
|
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 {
|
android {
|
||||||
namespace = libs.versions.packageName.get()
|
namespace = libs.versions.packageName.get()
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
@@ -11,9 +23,9 @@ android {
|
|||||||
signingConfigs {
|
signingConfigs {
|
||||||
create("release") {
|
create("release") {
|
||||||
storeFile = file(System.getProperty("user.home") + "/my-release-key.jks")
|
storeFile = file(System.getProperty("user.home") + "/my-release-key.jks")
|
||||||
storePassword = "look4sat123"
|
storePassword = releaseCred("RELEASE_STORE_PASSWORD")
|
||||||
keyAlias = "look4sat"
|
keyAlias = releaseCred("RELEASE_KEY_ALIAS").ifEmpty { "look4sat" }
|
||||||
keyPassword = "look4sat123"
|
keyPassword = releaseCred("RELEASE_KEY_PASSWORD")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".MainApplication"
|
android:name=".MainApplication"
|
||||||
@@ -45,5 +46,15 @@
|
|||||||
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
||||||
android:value="com.rtbishop.look4sat" />
|
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>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -49,7 +49,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
observeNightFilterState()
|
observeNightFilterState()
|
||||||
setContent {
|
setContent {
|
||||||
MainTheme(isDarkTheme = true) { MainScreen() }
|
MainTheme(isDarkTheme = true) { NavRoot() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,10 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat
|
package com.rtbishop.look4sat
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.app.Application
|
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.data.injection.MainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
@@ -35,12 +38,37 @@ class MainApplication : Application(), IContainerProvider {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
container = MainContainer(this)
|
container = MainContainer(this)
|
||||||
|
clearAmSatCacheOnAppBackground()
|
||||||
// trigger automatic update every 48 hours
|
// trigger automatic update every 48 hours
|
||||||
container.appScope.launch { checkAutoUpdate() }
|
container.appScope.launch { checkAutoUpdate() }
|
||||||
// load satellite data on every app start
|
// load satellite data on every app start
|
||||||
container.appScope.launch { container.satelliteRepo.initRepository() }
|
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()) {
|
private suspend fun checkAutoUpdate(timeNow: Long = System.currentTimeMillis()) {
|
||||||
if (container.settingsRepo.otherSettings.value.stateOfAutoUpdate) {
|
if (container.settingsRepo.otherSettings.value.stateOfAutoUpdate) {
|
||||||
val timeDelta = timeNow - container.settingsRepo.databaseState.value.updateTimestamp
|
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.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.scaleIn
|
|
||||||
import androidx.compose.animation.scaleOut
|
|
||||||
import androidx.compose.animation.slideInHorizontally
|
import androidx.compose.animation.slideInHorizontally
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
@@ -44,7 +42,7 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
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.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -87,38 +88,53 @@ import com.rtbishop.look4sat.feature.passes.PassesDestination
|
|||||||
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
||||||
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
||||||
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
||||||
import com.rtbishop.look4sat.feature.status.SatStatusScreen
|
import com.rtbishop.look4sat.feature.status.SatStatusDestination
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NavRoot(deeplink: String? = null) {
|
fun NavRoot(deeplink: String? = null) {
|
||||||
val rootBackStack = rememberNavBackStack(Screen.Passes)
|
val rootBackStack = rememberNavBackStack(Screen.Passes)
|
||||||
val deeplinkResolver = DeeplinkResolver()
|
val deeplinkResolver = DeeplinkResolver()
|
||||||
|
var openMapRequest by remember { mutableIntStateOf(0) }
|
||||||
LaunchedEffect(deeplink) {
|
LaunchedEffect(deeplink) {
|
||||||
deeplink?.let {
|
deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
|
||||||
val destination = deeplinkResolver.resolve(it) // rootBackStack.clear()
|
|
||||||
rootBackStack.add(destination)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
||||||
val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
|
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
|
||||||
val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
|
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(
|
NavDisplay(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
backStack = rootBackStack,
|
backStack = rootBackStack,
|
||||||
onBack = navigateBack,
|
onBack = navigateBack,
|
||||||
transitionSpec = { slideInTransition },
|
transitionSpec = { pushTransition },
|
||||||
popTransitionSpec = { slideOutTransition },
|
popTransitionSpec = { popTransition },
|
||||||
predictivePopTransitionSpec = { slideOutTransition },
|
predictivePopTransitionSpec = { popTransition },
|
||||||
entryDecorators = listOf(
|
entryDecorators = listOf(
|
||||||
rememberSaveableStateHolderNavEntryDecorator(), // Required for saving Compose state per entry
|
rememberSaveableStateHolderNavEntryDecorator(),
|
||||||
rememberViewModelStoreNavEntryDecorator() // Required for ViewModel scoping per entry
|
rememberViewModelStoreNavEntryDecorator()
|
||||||
),
|
),
|
||||||
entryProvider = entryProvider {
|
entryProvider = entryProvider {
|
||||||
entry<Screen.Passes> { MainScreen(navigateToRadar = { rootBackStack.add(RadarDestination) }) }
|
entry<Screen.Passes> {
|
||||||
|
MainScreen(
|
||||||
|
navigateToRadar = navigateToRadar,
|
||||||
|
openMapRequest = openMapRequest,
|
||||||
|
onOpenMapRequestHandled = { openMapRequest = 0 }
|
||||||
|
)
|
||||||
|
}
|
||||||
entry<RadarDestination> {
|
entry<RadarDestination> {
|
||||||
Scaffold { innerPadding ->
|
Surface(
|
||||||
RadarDestination(navigateUp = navigateBack)
|
modifier = Modifier.fillMaxSize(),
|
||||||
innerPadding.calculateTopPadding()
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
RadarDestination(navigateUp = navigateBack, navigateToMap = navigateToMap)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,12 +142,26 @@ fun NavRoot(deeplink: String? = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
fun MainScreen(
|
||||||
|
navigateToRadar: () -> Unit = {},
|
||||||
|
openMapRequest: Int = 0,
|
||||||
|
onOpenMapRequestHandled: () -> Unit = {}
|
||||||
|
) {
|
||||||
val backStack = rememberNavBackStack(Screen.Passes)
|
val backStack = rememberNavBackStack(Screen.Passes)
|
||||||
val currentKey = backStack.lastOrNull()
|
val currentKey = backStack.lastOrNull()
|
||||||
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
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 fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
|
||||||
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Map, Screen.AMSAT, Screen.Settings)
|
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.AMSAT, Screen.Mutual, Screen.Settings)
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
@@ -155,10 +185,9 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
val isSelected = when (currentKey) {
|
val isSelected = when (currentKey) {
|
||||||
is Screen.Satellites -> screen is Screen.Satellites
|
is Screen.Satellites -> screen is Screen.Satellites
|
||||||
is Screen.Passes -> screen is Screen.Passes
|
is Screen.Passes -> screen is Screen.Passes
|
||||||
is Screen.Radar -> screen is Screen.Radar
|
is Screen.Map -> screen is Screen.Passes
|
||||||
is Screen.Mutual -> screen is Screen.Mutual
|
|
||||||
is Screen.Map -> screen is Screen.Map
|
|
||||||
is Screen.AMSAT -> screen is Screen.AMSAT
|
is Screen.AMSAT -> screen is Screen.AMSAT
|
||||||
|
is Screen.Mutual -> screen is Screen.Mutual
|
||||||
is Screen.Settings -> screen is Screen.Settings
|
is Screen.Settings -> screen is Screen.Settings
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
@@ -167,7 +196,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
label = { Text(stringResource(screen.titleResId)) },
|
label = { Text(stringResource(screen.titleResId)) },
|
||||||
selected = isSelected,
|
selected = isSelected,
|
||||||
onClick = {
|
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)
|
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||||
if (screen !is Screen.Passes) backStack.add(screen)
|
if (screen !is Screen.Passes) backStack.add(screen)
|
||||||
}
|
}
|
||||||
@@ -202,15 +231,14 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
SatellitesDestination(navigateUp = navigateBack)
|
SatellitesDestination(navigateUp = navigateBack)
|
||||||
}
|
}
|
||||||
entry<Screen.Passes> {
|
entry<Screen.Passes> {
|
||||||
PassesDestination { catNum, aosTime ->
|
PassesDestination(
|
||||||
container.setMutualPassData(MutualPassData())
|
navigateToRadar = { catNum, aosTime ->
|
||||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
container.setMutualPassData(MutualPassData())
|
||||||
backStack.add(Screen.Radar)
|
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||||
// navigateToRadar()
|
navigateToRadar()
|
||||||
}
|
},
|
||||||
}
|
navigateToMap = navigateToMap
|
||||||
entry<Screen.Radar> {
|
)
|
||||||
RadarDestination(navigateUp = navigateBack)
|
|
||||||
}
|
}
|
||||||
entry<Screen.Map> {
|
entry<Screen.Map> {
|
||||||
MapDestination()
|
MapDestination()
|
||||||
@@ -218,17 +246,14 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
entry<Screen.Mutual> {
|
entry<Screen.Mutual> {
|
||||||
MutualScreen(
|
MutualScreen(
|
||||||
viewModel = mutualViewModel,
|
viewModel = mutualViewModel,
|
||||||
navigateUp = navigateBack,
|
|
||||||
navigateToRadar = { catNum, aosTime, pass ->
|
navigateToRadar = { catNum, aosTime, pass ->
|
||||||
container.setMutualPassData(pass ?: MutualPassData())
|
container.setMutualPassData(pass ?: MutualPassData())
|
||||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||||
backStack.add(Screen.Radar)
|
navigateToRadar()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
entry<Screen.AMSAT> {
|
entry<Screen.AMSAT> { SatStatusDestination() }
|
||||||
SatStatusScreen(container = container)
|
|
||||||
}
|
|
||||||
entry<Screen.Settings> {
|
entry<Screen.Settings> {
|
||||||
SettingsDestination()
|
SettingsDestination()
|
||||||
}
|
}
|
||||||
@@ -254,7 +279,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
if (pass != null) {
|
if (pass != null) {
|
||||||
container.setMutualPassData(MutualPassData())
|
container.setMutualPassData(MutualPassData())
|
||||||
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
||||||
backStack.add(Screen.Radar)
|
navigateToRadar()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
.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>
|
||||||
+69
-28
@@ -17,8 +17,10 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.core.data.framework
|
package com.rtbishop.look4sat.core.data.framework
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
@@ -31,18 +33,30 @@ class NetworkReporter(
|
|||||||
private val rotatorServer: String,
|
private val rotatorServer: String,
|
||||||
private val rotatorPort: Int,
|
private val rotatorPort: Int,
|
||||||
private val frequencyServer: String,
|
private val frequencyServer: String,
|
||||||
private val frequencyPort: Int
|
private val frequencyPort: Int,
|
||||||
|
private val frequencyOffsetHz: Long = 0L
|
||||||
) : IReporter {
|
) : IReporter {
|
||||||
|
|
||||||
private val writeMutex = Mutex()
|
private val writeMutex = Mutex()
|
||||||
|
private val connectionMutex = Mutex()
|
||||||
|
private val frequencyCommands = Channel<String>(Channel.CONFLATED)
|
||||||
|
|
||||||
private var rotatorSocket: SocketChannel? = null
|
private var rotatorSocket: SocketChannel? = null
|
||||||
private var rotatorConnected = false
|
private var rotatorConnected = false
|
||||||
private var rotatorConnecting = false
|
|
||||||
|
|
||||||
private var frequencySocket: SocketChannel? = null
|
private var frequencySocket: SocketChannel? = null
|
||||||
private var frequencyConnected = false
|
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) {
|
override fun reportRotation(format: String, azimuth: Double, elevation: Double) {
|
||||||
reporterScope.launch {
|
reporterScope.launch {
|
||||||
@@ -53,51 +67,48 @@ class NetworkReporter(
|
|||||||
.replace($$"$AZ", azimuth.toString())
|
.replace($$"$AZ", azimuth.toString())
|
||||||
.replace($$"$EL", el.toString())
|
.replace($$"$EL", el.toString())
|
||||||
.unescapeControlChars()
|
.unescapeControlChars()
|
||||||
write(rotatorSocket, command) { rotatorConnected = false }
|
write(rotatorSocket, command) { resetRotatorConnection() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun reportFrequency(format: String, frequency: Long) {
|
override fun reportFrequency(format: String, frequency: Long) {
|
||||||
reporterScope.launch {
|
val clampedOffset = frequencyOffsetHz.coerceIn(
|
||||||
ensureFrequencyConnected()
|
Constants.FREQ_OFFSET_MIN_HZ,
|
||||||
if (!frequencyConnected) return@launch
|
Constants.FREQ_OFFSET_MAX_HZ
|
||||||
val command = format
|
)
|
||||||
.replace($$"$FREQ", frequency.toString())
|
val correctedFreq = frequency.coerceAtLeast(0L).safeAdd(clampedOffset).coerceAtLeast(0L)
|
||||||
.unescapeControlChars()
|
val command = format
|
||||||
write(frequencySocket, command) { frequencyConnected = false }
|
.replace($$"$FREQ", correctedFreq.toString())
|
||||||
}
|
.unescapeControlChars()
|
||||||
|
frequencyCommands.trySend(command)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureRotatorConnected() {
|
private suspend fun ensureRotatorConnected() {
|
||||||
if (rotatorConnected || rotatorConnecting || rotatorServer.isBlank()) return
|
connectionMutex.withLock {
|
||||||
reporterScope.launch {
|
if (rotatorConnected || rotatorServer.isBlank()) return
|
||||||
try {
|
try {
|
||||||
rotatorConnecting = true
|
resetRotatorConnection()
|
||||||
rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
|
rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
|
||||||
rotatorConnected = true
|
rotatorConnected = true
|
||||||
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
|
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("NetworkReporter rotator connect error: ${e.message}")
|
println("NetworkReporter rotator connect error: ${e.message}")
|
||||||
rotatorConnected = false
|
resetRotatorConnection()
|
||||||
} finally {
|
|
||||||
rotatorConnecting = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureFrequencyConnected() {
|
private suspend fun ensureFrequencyConnected() {
|
||||||
if (frequencyConnected || frequencyConnecting || frequencyServer.isBlank()) return
|
connectionMutex.withLock {
|
||||||
reporterScope.launch {
|
if (frequencyConnected || frequencyServer.isBlank()) return
|
||||||
try {
|
try {
|
||||||
frequencyConnecting = true
|
resetFrequencyConnection()
|
||||||
frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
|
frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
|
||||||
frequencyConnected = true
|
frequencyConnected = true
|
||||||
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
|
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("NetworkReporter frequency connect error: ${e.message}")
|
println("NetworkReporter frequency connect error: ${e.message}")
|
||||||
frequencyConnected = false
|
resetFrequencyConnection()
|
||||||
} finally {
|
|
||||||
frequencyConnecting = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,7 +117,9 @@ class NetworkReporter(
|
|||||||
try {
|
try {
|
||||||
writeMutex.withLock {
|
writeMutex.withLock {
|
||||||
val buffer = ByteBuffer.wrap("$command\n".toByteArray())
|
val buffer = ByteBuffer.wrap("$command\n".toByteArray())
|
||||||
socket?.write(buffer)
|
while (buffer.hasRemaining()) {
|
||||||
|
socket?.write(buffer)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("NetworkReporter write error: ${e.message}")
|
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 =
|
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())
|
||||||
}
|
}
|
||||||
@@ -35,6 +35,7 @@ import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
|
|||||||
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.SensorsRepo
|
import com.rtbishop.look4sat.core.data.repository.SensorsRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.SettingsRepo
|
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.LocalSource
|
||||||
import com.rtbishop.look4sat.core.data.source.RemoteSource
|
import com.rtbishop.look4sat.core.data.source.RemoteSource
|
||||||
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
||||||
@@ -78,7 +79,8 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
override val selectionRepo = provideSelectionRepo()
|
override val selectionRepo = provideSelectionRepo()
|
||||||
override val satelliteRepo = provideSatelliteRepo()
|
override val satelliteRepo = provideSatelliteRepo()
|
||||||
override val databaseRepo = provideDatabaseRepo()
|
override val databaseRepo = provideDatabaseRepo()
|
||||||
override val amSatRepo by lazy { AmSatRepository(remoteSource) }
|
override val amSatRepo by lazy { AmSatRepository(remoteSource, appScope) }
|
||||||
|
override val updateRepo by lazy { UpdateRepository(remoteSource) }
|
||||||
override val radioTrackingService: IRadioTrackingService by lazy {
|
override val radioTrackingService: IRadioTrackingService by lazy {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
||||||
@@ -117,7 +119,8 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
rc.rotatorAddress,
|
rc.rotatorAddress,
|
||||||
rc.rotatorPort.toIntOrNull() ?: 0,
|
rc.rotatorPort.toIntOrNull() ?: 0,
|
||||||
rc.frequencyAddress,
|
rc.frequencyAddress,
|
||||||
rc.frequencyPort.toIntOrNull() ?: 0
|
rc.frequencyPort.toIntOrNull() ?: 0,
|
||||||
|
rc.frequencyOffsetHz
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+135
-18
@@ -1,5 +1,7 @@
|
|||||||
package com.rtbishop.look4sat.core.data.repository
|
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.SatDay
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatReport
|
import com.rtbishop.look4sat.core.domain.model.SatReport
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
||||||
@@ -7,11 +9,18 @@ import com.rtbishop.look4sat.core.domain.model.SatStatus
|
|||||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
|
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Deferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
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 kotlinx.coroutines.withContext
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
|
|
||||||
@@ -26,25 +35,125 @@ private data class ApiReport(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
|
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
|
||||||
class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepository {
|
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 {
|
private val isoUtcFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
|
||||||
timeZone = TimeZone.getTimeZone("UTC")
|
timeZone = TimeZone.getTimeZone("UTC")
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) {
|
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 nowSec = System.currentTimeMillis() / 1000
|
||||||
val catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext null
|
val catalogJson = remoteSource.getAmSatCatalog() ?: return null
|
||||||
val reportsJson = remoteSource.getAmSatReports(hours = 168, limit = 500) ?: return@withContext 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 names = parseCatalog(catalogJson)
|
||||||
val reports = parseReports(reportsJson)
|
val reports = parseReports(reportsJson)
|
||||||
|
|
||||||
if (names.isEmpty() && reports.isEmpty()) return@withContext null
|
if (names.isEmpty() && reports.isEmpty()) return null
|
||||||
|
|
||||||
val statuses = buildStatuses(names, reports, nowSec)
|
val statuses = buildStatuses(names, reports, nowSec)
|
||||||
val reportMap = reports.associate { it.id to toSatReport(it) }
|
val reportMap = reports.associate { it.id to toSatReport(it) }
|
||||||
SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
|
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 */
|
/** Parse catalog JSON to list of satellite names */
|
||||||
@@ -52,7 +161,7 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
|||||||
return try {
|
return try {
|
||||||
val arr = JSONObject(json).getJSONArray("data")
|
val arr = JSONObject(json).getJSONArray("data")
|
||||||
(0 until arr.length()).map { arr.getJSONObject(it).getString("name") }
|
(0 until arr.length()).map { arr.getJSONObject(it).getString("name") }
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,7 +182,7 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
|||||||
reportedTimeUtcSec = parseIsoUtcSec(iso)
|
reportedTimeUtcSec = parseIsoUtcSec(iso)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,22 +191,21 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
|||||||
private fun parseIsoUtcSec(iso: String): Long {
|
private fun parseIsoUtcSec(iso: String): Long {
|
||||||
return try {
|
return try {
|
||||||
(isoUtcFormat.parse(iso)?.time ?: 0L) / 1000
|
(isoUtcFormat.parse(iso)?.time ?: 0L) / 1000
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
0L
|
0L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build one SatStatus (6 days x 12 slots) per catalog satellite, slotting reports by age. */
|
/** 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> {
|
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
|
||||||
val byName = reports.groupBy { it.name }
|
val byName = reports.groupBy { it.name }
|
||||||
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
|
||||||
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||||
val labels = (0 until 6).map { d ->
|
val labels = (0 until 3).map { d ->
|
||||||
utc.timeInMillis = (nowSec - d * 86400L) * 1000
|
utc.timeInMillis = (nowSec - d * 86400L) * 1000
|
||||||
"${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}"
|
formatDayLabel(utc)
|
||||||
}
|
}
|
||||||
return names.map { name ->
|
return names.map { name ->
|
||||||
val slots = (0 until 72).map { slotIdx ->
|
val slots = (0 until 36).map { slotIdx ->
|
||||||
val slotStart = nowSec - (slotIdx + 1) * 7200L
|
val slotStart = nowSec - (slotIdx + 1) * 7200L
|
||||||
val slotEnd = nowSec - slotIdx * 7200L
|
val slotEnd = nowSec - slotIdx * 7200L
|
||||||
val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd }
|
val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd }
|
||||||
@@ -112,13 +220,22 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val days = (0 until 6).map { d ->
|
val days = (0 until 3).map { d ->
|
||||||
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
|
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
|
||||||
}
|
}
|
||||||
SatStatus(name = name, days = days)
|
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 {
|
private fun toSatReport(r: ApiReport): SatReport {
|
||||||
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||||
cal.timeInMillis = r.reportedTimeUtcSec * 1000
|
cal.timeInMillis = r.reportedTimeUtcSec * 1000
|
||||||
|
|||||||
+42
-22
@@ -66,28 +66,45 @@ class DatabaseRepo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
||||||
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
val settings = settingsRepo.dataSourcesSettings.value
|
||||||
val tleUrls = buildMap {
|
// Keep the raw URL as the status key (matches what the settings UI
|
||||||
putAll(Sources.satelliteDataUrls)
|
// displays) while requesting with the normalized URL.
|
||||||
if (dataSourcesSettings.useCustomTLE) put(customSourceType, dataSourcesSettings.tleUrl)
|
val tleUrls = settings.satelliteUrls
|
||||||
}.filterValues { it.isNotBlank() }
|
.filterIndexed { i, url -> url.isNotBlank() && settings.isSatelliteEnabled(i) }
|
||||||
val radioUrls = buildMap {
|
.map { it to normalizeUrl(it) }
|
||||||
putAll(Sources.transceiversDataUrls)
|
.distinctBy { it.second }
|
||||||
if (dataSourcesSettings.useCustomTransceivers) put(customSourceType, dataSourcesSettings.transceiversUrl)
|
val radioUrls = settings.transceiversUrls
|
||||||
}.filterValues { it.isNotBlank() }
|
.filterIndexed { i, url -> url.isNotBlank() && settings.isTransceiverEnabled(i) }
|
||||||
// launch all network requests concurrently
|
.map { it to normalizeUrl(it) }
|
||||||
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
.distinctBy { it.second }
|
||||||
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
val builtinTypesByUrl = Sources.satelliteDataUrls
|
||||||
// parse fetched data concurrently and associate with types
|
.filterValues { it.isNotBlank() }
|
||||||
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
|
.mapValues { normalizeUrl(it.value) }
|
||||||
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
|
.entries
|
||||||
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
|
.associate { (type, url) -> url to type }
|
||||||
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
|
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 importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
|
val radioJobs = radioUrls.map { (raw, norm) -> async { raw to remoteSource.getNetworkStream(norm) } }
|
||||||
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
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
|
// insert parsed data into the database
|
||||||
localSource.insertEntries(importedEntries)
|
localSource.insertEntries(importedEntries)
|
||||||
localSource.insertRadios(importedRadios)
|
localSource.insertRadios(importedRadios)
|
||||||
@@ -100,6 +117,9 @@ class DatabaseRepo(
|
|||||||
setUpdateSuccessful(0L)
|
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> {
|
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> {
|
||||||
val bufferedStream = stream.buffered()
|
val bufferedStream = stream.buffered()
|
||||||
return when {
|
return when {
|
||||||
|
|||||||
+168
-10
@@ -37,6 +37,10 @@ import com.rtbishop.look4sat.core.domain.utility.round
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
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(
|
class SettingsRepo(
|
||||||
private val locationManager: LocationManager,
|
private val locationManager: LocationManager,
|
||||||
@@ -67,6 +71,7 @@ class SettingsRepo(
|
|||||||
private val keyFrequencyAddress = "frequencyAddress"
|
private val keyFrequencyAddress = "frequencyAddress"
|
||||||
private val keyFrequencyPort = "frequencyPort"
|
private val keyFrequencyPort = "frequencyPort"
|
||||||
private val keyFrequencyFormat = "frequencyFormat"
|
private val keyFrequencyFormat = "frequencyFormat"
|
||||||
|
private val keyFrequencyOffsetHz = "frequencyOffsetHz"
|
||||||
private val keySelectedIds = "selectedIds"
|
private val keySelectedIds = "selectedIds"
|
||||||
private val keySelectedTypes = "selectedTypes"
|
private val keySelectedTypes = "selectedTypes"
|
||||||
private val keySelectedModes = "selectedModes"
|
private val keySelectedModes = "selectedModes"
|
||||||
@@ -91,7 +96,15 @@ class SettingsRepo(
|
|||||||
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
||||||
private val keyTleUrl = "tleUrl"
|
private val keyTleUrl = "tleUrl"
|
||||||
private val keyTransceiversUrl = "transceiversUrl"
|
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 separatorComma = ","
|
||||||
|
private val separatorUrl = "\n"
|
||||||
|
private val legacyCelestrakSatnogsUrl =
|
||||||
|
"https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv"
|
||||||
|
|
||||||
//region # Satellites selection settings
|
//region # Satellites selection settings
|
||||||
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
||||||
@@ -298,6 +311,10 @@ class SettingsRepo(
|
|||||||
override val rcSettings: StateFlow<RCSettings> = _rcSettings
|
override val rcSettings: StateFlow<RCSettings> = _rcSettings
|
||||||
|
|
||||||
override fun updateRCSettings(settings: RCSettings) {
|
override fun updateRCSettings(settings: RCSettings) {
|
||||||
|
val clampedFreqOffsetHz = settings.frequencyOffsetHz.coerceIn(
|
||||||
|
Constants.FREQ_OFFSET_MIN_HZ,
|
||||||
|
Constants.FREQ_OFFSET_MAX_HZ
|
||||||
|
)
|
||||||
preferences.edit {
|
preferences.edit {
|
||||||
putBoolean(keyRotatorState, settings.rotatorState)
|
putBoolean(keyRotatorState, settings.rotatorState)
|
||||||
putString(keyRotatorAddress, settings.rotatorAddress)
|
putString(keyRotatorAddress, settings.rotatorAddress)
|
||||||
@@ -307,6 +324,7 @@ class SettingsRepo(
|
|||||||
putString(keyFrequencyAddress, settings.frequencyAddress)
|
putString(keyFrequencyAddress, settings.frequencyAddress)
|
||||||
putString(keyFrequencyPort, settings.frequencyPort)
|
putString(keyFrequencyPort, settings.frequencyPort)
|
||||||
putString(keyFrequencyFormat, settings.frequencyFormat)
|
putString(keyFrequencyFormat, settings.frequencyFormat)
|
||||||
|
putLong(keyFrequencyOffsetHz, clampedFreqOffsetHz)
|
||||||
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
|
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
|
||||||
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
|
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
|
||||||
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
|
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
|
||||||
@@ -315,7 +333,7 @@ class SettingsRepo(
|
|||||||
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
|
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
|
||||||
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
|
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
|
||||||
}
|
}
|
||||||
_rcSettings.value = settings
|
_rcSettings.value = settings.copy(frequencyOffsetHz = clampedFreqOffsetHz)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getRCSettings(): RCSettings = RCSettings(
|
private fun getRCSettings(): RCSettings = RCSettings(
|
||||||
@@ -327,6 +345,8 @@ class SettingsRepo(
|
|||||||
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
|
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
|
||||||
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
|
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
|
||||||
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ",
|
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),
|
bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
|
||||||
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
|
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
|
||||||
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
|
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
|
||||||
@@ -381,21 +401,108 @@ class SettingsRepo(
|
|||||||
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings
|
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings
|
||||||
|
|
||||||
override fun updateDataSourcesSettings(settings: 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 {
|
preferences.edit {
|
||||||
putBoolean(keyUseCustomTle, settings.useCustomTLE)
|
putString(keySatelliteUrls, normalized.satelliteUrls.joinToString(separatorUrl))
|
||||||
putBoolean(keyUseCustomTransceivers, settings.useCustomTransceivers)
|
putString(keyTransceiversUrls, normalized.transceiversUrls.joinToString(separatorUrl))
|
||||||
putString(keyTleUrl, settings.tleUrl)
|
putString(keySatelliteEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
|
||||||
putString(keyTransceiversUrl, settings.transceiversUrl)
|
putString(keyTransceiversEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
|
||||||
|
putBoolean(keySatnogsTleSourceMigration, true)
|
||||||
}
|
}
|
||||||
_dataSourcesSettings.value = settings
|
_dataSourcesSettings.value = normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
|
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
|
||||||
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false),
|
satelliteUrls = getDataSourceUrls(
|
||||||
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false),
|
key = keySatelliteUrls,
|
||||||
tleUrl = preferences.getString(keyTleUrl, "https://example.com/tle.txt") ?: "",
|
defaultUrls = Sources.satelliteDataUrls.values.filter { it.isNotBlank() },
|
||||||
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: ""
|
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
|
//endregion
|
||||||
|
|
||||||
//region # Radio control settings
|
//region # Radio control settings
|
||||||
@@ -436,4 +543,55 @@ class SettingsRepo(
|
|||||||
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
|
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
|
||||||
)
|
)
|
||||||
//endregion
|
//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 android.content.ContentResolver
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import java.io.ByteArrayInputStream
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
class RemoteSource(
|
class RemoteSource(
|
||||||
@@ -43,16 +45,26 @@ class RemoteSource(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
override suspend fun getNetworkStream(url: String): NetworkResult = withContext(dispatcher) {
|
||||||
try {
|
try {
|
||||||
val networkRequest = Request.Builder().url(url).build()
|
val networkRequest = Request.Builder().url(url).build()
|
||||||
httpClient.newCall(networkRequest).execute().use { response ->
|
val response = httpClient.newCall(networkRequest).execute()
|
||||||
if (!response.isSuccessful) return@withContext null
|
if (!response.isSuccessful) {
|
||||||
ByteArrayInputStream(response.body.bytes())
|
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) {
|
} catch (exception: Exception) {
|
||||||
println("RemoteSource network stream exception: $exception")
|
println("RemoteSource network stream exception: $exception")
|
||||||
null
|
NetworkResult(NetworkResult.CONNECTION_ERROR, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,4 +99,21 @@ class RemoteSource(
|
|||||||
null
|
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.repository.ISettingsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
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 com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -91,10 +93,8 @@ class DatabaseRepoTest {
|
|||||||
}
|
}
|
||||||
val settingsRepo = FakeSettingsRepo(
|
val settingsRepo = FakeSettingsRepo(
|
||||||
dataSources = DataSourcesSettings(
|
dataSources = DataSourcesSettings(
|
||||||
useCustomTLE = true,
|
satelliteUrls = listOf(customCsvUrl),
|
||||||
useCustomTransceivers = false,
|
transceiversUrls = emptyList()
|
||||||
tleUrl = customCsvUrl,
|
|
||||||
transceiversUrl = ""
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
@@ -105,6 +105,27 @@ class DatabaseRepoTest {
|
|||||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
|
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 = """
|
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
|
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
|
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
|
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
|
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||||
""".trimIndent().byteInputStream()
|
""".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 {
|
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 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 {
|
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 databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
||||||
|
|
||||||
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
||||||
RCSettings(false, "", "", "", false, "", "", "", false, "", "", "", false, "", "")
|
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
||||||
)
|
)
|
||||||
|
|
||||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||||
@@ -221,14 +255,26 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
|||||||
dataSourcesSettings.value = settings
|
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 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 {
|
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
||||||
return DataSourcesSettings(
|
return DataSourcesSettings(
|
||||||
useCustomTLE = false,
|
satelliteUrls = emptyList(),
|
||||||
useCustomTransceivers = false,
|
transceiversUrls = emptyList()
|
||||||
tleUrl = "",
|
|
||||||
transceiversUrl = ""
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+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
|
||||||
|
)
|
||||||
@@ -23,10 +23,10 @@ data class SatDay(
|
|||||||
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
|
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
|
||||||
)
|
)
|
||||||
|
|
||||||
/** One satellite, 6 days of state */
|
/** One satellite, 3 days of state */
|
||||||
data class SatStatus(
|
data class SatStatus(
|
||||||
val name: String, // "AO-123_[FM]"
|
val name: String, // "AO-123_[FM]"
|
||||||
val days: List<SatDay> // 6 天(新→旧)
|
val days: List<SatDay> // 3 days (newest first)
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Overall page parse result */
|
/** Overall page parse result */
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ data class RCSettings(
|
|||||||
val frequencyAddress: String,
|
val frequencyAddress: String,
|
||||||
val frequencyPort: String,
|
val frequencyPort: String,
|
||||||
val frequencyFormat: String,
|
val frequencyFormat: String,
|
||||||
|
val frequencyOffsetHz: Long = 0L,
|
||||||
val bluetoothRotatorState: Boolean,
|
val bluetoothRotatorState: Boolean,
|
||||||
val bluetoothRotatorFormat: String,
|
val bluetoothRotatorFormat: String,
|
||||||
val bluetoothRotatorName: String,
|
val bluetoothRotatorName: String,
|
||||||
@@ -66,11 +67,14 @@ data class OtherSettings(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class DataSourcesSettings(
|
data class DataSourcesSettings(
|
||||||
val useCustomTLE: Boolean,
|
val satelliteUrls: List<String>,
|
||||||
val useCustomTransceivers: Boolean,
|
val transceiversUrls: List<String>,
|
||||||
val tleUrl: String,
|
val satelliteEnabled: List<Boolean> = emptyList(),
|
||||||
val transceiversUrl: String
|
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(
|
data class RadioControlSettings(
|
||||||
val enabled: Boolean,
|
val enabled: Boolean,
|
||||||
|
|||||||
+7
-3
@@ -424,11 +424,15 @@ object CelestialComputer {
|
|||||||
}
|
}
|
||||||
if (sunrise == 0.0) sunrise = daynum
|
if (sunrise == 0.0) sunrise = daynum
|
||||||
|
|
||||||
// Phase 4: fast-forward through the day until sun drops back below threshold
|
// Phase 4: fast-forward through the day until sun drops back below threshold.
|
||||||
daynum = sunrise
|
// 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))
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
guard = 0
|
guard = 0
|
||||||
while (sunPos.elevation > -threshold && guard++ < 500) {
|
while (sunPos.elevation >= -threshold && guard++ < 500) {
|
||||||
daynum += 0.008
|
daynum += 0.008
|
||||||
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-2
@@ -1,9 +1,23 @@
|
|||||||
package com.rtbishop.look4sat.core.domain.repository
|
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
|
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||||
|
|
||||||
/** AMSAT satellite status data source */
|
/** AMSAT satellite status data source */
|
||||||
interface IAmSatRepository {
|
interface IAmSatRepository {
|
||||||
/** Fetch and parse the AMSAT status page; null on failure */
|
/** Cached AMSAT status page for the current foreground session, if any. */
|
||||||
suspend fun fetchStatus(): SatStatusPage?
|
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
|
||||||
}
|
}
|
||||||
+1
@@ -31,6 +31,7 @@ interface IMainContainer {
|
|||||||
val satelliteRepo: ISatelliteRepo
|
val satelliteRepo: ISatelliteRepo
|
||||||
val databaseRepo: IDatabaseRepo
|
val databaseRepo: IDatabaseRepo
|
||||||
val amSatRepo: IAmSatRepository
|
val amSatRepo: IAmSatRepository
|
||||||
|
val updateRepo: IUpdateRepository
|
||||||
val radioTrackingService: IRadioTrackingService
|
val radioTrackingService: IRadioTrackingService
|
||||||
val mutualPassData: StateFlow<MutualPassData>
|
val mutualPassData: StateFlow<MutualPassData>
|
||||||
fun setMutualPassData(data: MutualPassData)
|
fun setMutualPassData(data: MutualPassData)
|
||||||
|
|||||||
+15
@@ -73,8 +73,23 @@ interface ISettingsRepo {
|
|||||||
fun updateDataSourcesSettings(settings: DataSourcesSettings)
|
fun updateDataSourcesSettings(settings: DataSourcesSettings)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
|
//region # Data sources status
|
||||||
|
val dataSourcesStatus: StateFlow<Map<String, Int>>
|
||||||
|
fun updateDataSourcesStatus(status: Map<String, Int>)
|
||||||
|
//endregion
|
||||||
|
|
||||||
//region # Radio control settings
|
//region # Radio control settings
|
||||||
val radioControlSettings: StateFlow<RadioControlSettings>
|
val radioControlSettings: StateFlow<RadioControlSettings>
|
||||||
fun updateRadioControlSettings(settings: RadioControlSettings)
|
fun updateRadioControlSettings(settings: RadioControlSettings)
|
||||||
//endregion
|
//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
|
||||||
|
}
|
||||||
+10
-1
@@ -19,9 +19,18 @@ package com.rtbishop.look4sat.core.domain.source
|
|||||||
|
|
||||||
import java.io.InputStream
|
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 {
|
interface IRemoteSource {
|
||||||
suspend fun getFileStream(uri: String): InputStream?
|
suspend fun getFileStream(uri: String): InputStream?
|
||||||
suspend fun getNetworkStream(url: String): InputStream?
|
suspend fun getNetworkStream(url: String): NetworkResult
|
||||||
suspend fun getAmSatCatalog(): String?
|
suspend fun getAmSatCatalog(): String?
|
||||||
suspend fun getAmSatReports(hours: Int, limit: Int): 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 {
|
object Sources {
|
||||||
val satelliteDataUrls = mapOf(
|
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",
|
"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",
|
"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",
|
"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",
|
"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",
|
"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",
|
"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",
|
"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",
|
"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",
|
"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",
|
"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",
|
"Amsat" to "https://amsat.org/tle/current/nasabare.txt",
|
||||||
"Classified" to "https://www.mmccants.org/tles/classfd.zip",
|
"Classified" to "https://www.mmccants.org/tles/classfd.zip",
|
||||||
"McCants" to "https://www.mmccants.org/tles/inttles.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
|
"Other" to "" // key for sats filter
|
||||||
)
|
)
|
||||||
val transceiversDataUrls = mapOf(
|
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"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+41
-12
@@ -19,20 +19,43 @@ package com.rtbishop.look4sat.core.domain.utility
|
|||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
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? {
|
fun qthToPosition(locator: String): GeoPos? {
|
||||||
val trimmedQth = locator.take(6)
|
val trimmedQth = locator.trim().uppercase().take(8)
|
||||||
if (!isValidLocator(trimmedQth)) return null
|
if (trimmedQth.length !in listOf(4, 6, 8) || !isValidLocator(trimmedQth)) return null
|
||||||
val lonFirst = (trimmedQth[0].uppercaseChar().code - 65) * 20
|
val lonFirst = (trimmedQth[0].code - 65) * 20
|
||||||
val latFirst = (trimmedQth[1].uppercaseChar().code - 65) * 10
|
val latFirst = (trimmedQth[1].code - 65) * 10
|
||||||
val lonSecond = trimmedQth[2].toString().toInt() * 2
|
val lonSecond = trimmedQth[2].toString().toInt() * 2
|
||||||
val latSecond = trimmedQth[3].toString().toInt()
|
val latSecond = trimmedQth[3].toString().toInt()
|
||||||
val lonThird = (((trimmedQth[4].lowercaseChar().code - 97) / 12.0) + (1.0 / 24.0)) - 180
|
// Start from the 4-char square centre, refine for 6/8-char subsquares.
|
||||||
val latThird = (((trimmedQth[5].lowercaseChar().code - 97) / 24.0) + (1.0 / 48.0)) - 90
|
var longitude = lonFirst + lonSecond + 1.0
|
||||||
val longitude = (lonFirst + lonSecond + lonThird).round(4)
|
var latitude = latFirst + latSecond + 0.5
|
||||||
val latitude = (latFirst + latSecond + latThird).round(4)
|
if (trimmedQth.length >= 6) {
|
||||||
return GeoPos(latitude, longitude)
|
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? {
|
fun positionToQth(latitude: Double, longitude: Double): String? {
|
||||||
if (!isValidPosition(latitude, longitude)) return null
|
if (!isValidPosition(latitude, longitude)) return null
|
||||||
val newLongitude = if (longitude > 180.0) longitude else longitude + 180
|
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 latFirst = (65 + (newLatitude / 10)).toInt().toChar()
|
||||||
val lonSecond = ((newLongitude / 2) % 10).toInt()
|
val lonSecond = ((newLongitude / 2) % 10).toInt()
|
||||||
val latSecond = (newLatitude % 10).toInt()
|
val latSecond = (newLatitude % 10).toInt()
|
||||||
val lonThird = (65 + (newLongitude % 2) * 12).toInt().toChar().lowercaseChar()
|
val lonThird = (65 + (newLongitude % 2) * 12).toInt().toChar().uppercaseChar()
|
||||||
val latThird = (65 + (newLatitude % 1) * 24).toInt().toChar().lowercaseChar()
|
val latThird = (65 + (newLatitude % 1) * 24).toInt().toChar().uppercaseChar()
|
||||||
return "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
|
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 {
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
package com.rtbishop.look4sat.core.domain
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||||
@@ -22,25 +5,34 @@ import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
|||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class QthConverterTest {
|
class QthConverterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Given valid QTH returns correct POS`() {
|
fun `Given valid QTH returns correct POS`() {
|
||||||
|
// 8-char locator -> centre of the extended square (higher precision)
|
||||||
var result = qthToPosition("io91VL39FX")
|
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")
|
result = qthToPosition("gf15vc")
|
||||||
assert(result?.latitude == -34.8958 && result.longitude == -56.2083)
|
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
|
@Test
|
||||||
fun `Given invalid QTH returns null`() {
|
fun `Given invalid QTH returns null`() {
|
||||||
assert(qthToPosition("ZZ00zz") == 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
|
@Test
|
||||||
fun `Given valid POS returns correct QTH`() {
|
fun `Given valid POS returns correct QTH`() {
|
||||||
assert(positionToQth(51.4878, -0.2146) == "IO91vl")
|
assert(positionToQth(51.4878, -0.2146) == "IO91VL")
|
||||||
assert(positionToQth(48.1466, 11.6083) == "JN58td")
|
assert(positionToQth(48.1466, 11.6083) == "JN58TD")
|
||||||
|
assert(positionToQth(23.13, 113.26) == "OL63PD")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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
|
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
|
@Composable
|
||||||
fun SharedDialog(
|
fun SharedDialog(
|
||||||
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
|
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
|||||||
@Serializable
|
@Serializable
|
||||||
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
|
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
|
@Serializable
|
||||||
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
|
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
+1
-1
@@ -9,4 +9,4 @@
|
|||||||
<path
|
<path
|
||||||
android:fillColor="#FF000000"
|
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" />
|
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>
|
</vector>
|
||||||
@@ -112,6 +112,13 @@
|
|||||||
<string name="prefs_data_update_success">Actualización completada con éxito</string>
|
<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_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_tle_switch">Custom TLE URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers 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_update_success">Обновление прошло успешно</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Свои источники данных</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_tle_switch">Custom TLE URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Custom Transceivers 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_update_success">යාවත්කාලීනය සම්පූර්ණ කිරීම සාර්ථකයි</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Custom data sources</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_tle_switch">Custom TLE URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers 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_update_success">Güncelleme başarıyla tamamlandı</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Özel veri kaynakları</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_tle_switch">Özel TLE URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Özel transceiver URL</string>
|
<string name="prefs_data_sources_transceivers_switch">Özel transceiver URL</string>
|
||||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||||
|
|||||||
@@ -112,6 +112,13 @@
|
|||||||
<string name="prefs_data_update_success">Оновлення успішне</string>
|
<string name="prefs_data_update_success">Оновлення успішне</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Custom data sources</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_tle_switch">Custom TLE URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,26 @@
|
|||||||
<string name="amsat_load_failed">加载失败 - 检查网络后重试</string>
|
<string name="amsat_load_failed">加载失败 - 检查网络后重试</string>
|
||||||
<string name="amsat_no_reports">此时间段无报告</string>
|
<string name="amsat_no_reports">此时间段无报告</string>
|
||||||
<string name="amsat_close">关闭</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 -->
|
<!-- Radar screen -->
|
||||||
<string name="radar_back">后退</string>
|
<string name="radar_back">后退</string>
|
||||||
@@ -134,8 +154,12 @@
|
|||||||
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
|
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">自定义数据源</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_tle_switch">自定义TLE URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">自定义收发器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_data_output_title">数据输出</string>
|
||||||
<string name="prefs_net_output">网络</string>
|
<string name="prefs_net_output">网络</string>
|
||||||
@@ -149,6 +173,8 @@
|
|||||||
<string name="prefs_net_frequency_switch">启用多普勒频率输出</string>
|
<string name="prefs_net_frequency_switch">启用多普勒频率输出</string>
|
||||||
<string name="prefs_net_frequency_address_hint">IP:端口</string>
|
<string name="prefs_net_frequency_address_hint">IP:端口</string>
|
||||||
<string name="prefs_net_frequency_format_hint">数据格式</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_title">蓝牙数据输出</string>
|
||||||
<string name="prefs_bt_rotator_switch">启用方位俯仰角输出</string>
|
<string name="prefs_bt_rotator_switch">启用方位俯仰角输出</string>
|
||||||
@@ -225,5 +251,17 @@
|
|||||||
<string name="mutual_open_radar">查看雷达</string>
|
<string name="mutual_open_radar">查看雷达</string>
|
||||||
<string name="mutual_station_a">站点A</string>
|
<string name="mutual_station_a">站点A</string>
|
||||||
<string name="mutual_station_b">站点B</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>
|
</resources>
|
||||||
@@ -70,6 +70,26 @@
|
|||||||
<string name="amsat_load_failed">Load failed - check network and retry</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_no_reports">No reports for this period</string>
|
||||||
<string name="amsat_close">Close</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 -->
|
<!-- Radar screen -->
|
||||||
<string name="radar_back">Back</string>
|
<string name="radar_back">Back</string>
|
||||||
@@ -168,9 +188,12 @@
|
|||||||
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
<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_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_tle_switch">Custom TLE URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers 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_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_data_output_title">Data output</string>
|
||||||
<string name="prefs_net_output">Network</string>
|
<string name="prefs_net_output">Network</string>
|
||||||
@@ -194,6 +217,8 @@
|
|||||||
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
<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_address_hint">IP:Port</string>
|
||||||
<string name="prefs_net_frequency_format_hint">Format</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_title">Bluetooth data output</string>
|
||||||
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
|
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
|
||||||
@@ -261,5 +286,17 @@
|
|||||||
<string name="mutual_open_radar">Open Radar</string>
|
<string name="mutual_open_radar">Open Radar</string>
|
||||||
<string name="mutual_station_a">Station A</string>
|
<string name="mutual_station_a">Station A</string>
|
||||||
<string name="mutual_station_b">Station B</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>
|
</resources>
|
||||||
@@ -63,7 +63,6 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||||
import com.rtbishop.look4sat.core.domain.repository.TrackSampleData
|
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.R
|
||||||
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
||||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
@@ -75,7 +74,6 @@ import java.util.Locale
|
|||||||
@Composable
|
@Composable
|
||||||
fun MutualScreen(
|
fun MutualScreen(
|
||||||
viewModel: MutualViewModel,
|
viewModel: MutualViewModel,
|
||||||
navigateUp: () -> Unit = {},
|
|
||||||
navigateToRadar: (Int, Long, MutualPassData?) -> Unit = { _, _, _ -> },
|
navigateToRadar: (Int, Long, MutualPassData?) -> Unit = { _, _, _ -> },
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
@@ -85,9 +83,7 @@ fun MutualScreen(
|
|||||||
topBar = { isVertical ->
|
topBar = { isVertical ->
|
||||||
TopBar(
|
TopBar(
|
||||||
isVerticalLayout = isVertical,
|
isVerticalLayout = isVertical,
|
||||||
startAction = {
|
startAction = {},
|
||||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
|
||||||
},
|
|
||||||
topInfo = {
|
topInfo = {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.mutual_title),
|
text = stringResource(R.string.mutual_title),
|
||||||
|
|||||||
+31
-86
@@ -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.IMainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -34,7 +37,6 @@ import kotlinx.coroutines.flow.update
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlin.math.PI
|
import kotlin.math.PI
|
||||||
import kotlin.math.floor
|
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
data class MutualUiState(
|
data class MutualUiState(
|
||||||
@@ -56,7 +58,10 @@ data class MutualUiState(
|
|||||||
|
|
||||||
class MutualViewModel(
|
class MutualViewModel(
|
||||||
private val satelliteRepo: ISatelliteRepo,
|
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() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(MutualUiState())
|
private val _uiState = MutableStateFlow(MutualUiState())
|
||||||
@@ -66,7 +71,7 @@ class MutualViewModel(
|
|||||||
// Pre-fill station A with the user's current station position (as grid),
|
// 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
|
// and default min elevation to the same value used by the main radar passes
|
||||||
val pos = settingsRepo.stationPosition.value
|
val pos = settingsRepo.stationPosition.value
|
||||||
val grid = latLonToGrid(pos.latitude, pos.longitude)
|
val grid = positionToQth(pos.latitude, pos.longitude) ?: ""
|
||||||
_uiState.update { it.copy(
|
_uiState.update { it.copy(
|
||||||
stationAGrid = grid,
|
stationAGrid = grid,
|
||||||
stationALat = "%.4f".format(pos.latitude),
|
stationALat = "%.4f".format(pos.latitude),
|
||||||
@@ -81,7 +86,9 @@ class MutualViewModel(
|
|||||||
val lat = value.toDoubleOrNull()
|
val lat = value.toDoubleOrNull()
|
||||||
val lon = _uiState.value.stationALon.toDoubleOrNull()
|
val lon = _uiState.value.stationALon.toDoubleOrNull()
|
||||||
if (lat != null && lon != null) {
|
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 lat = _uiState.value.stationALat.toDoubleOrNull()
|
||||||
val lon = value.toDoubleOrNull()
|
val lon = value.toDoubleOrNull()
|
||||||
if (lat != null && lon != null) {
|
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) {
|
fun onStationAGrid(value: String) {
|
||||||
val old = _uiState.value.stationAGrid
|
val old = _uiState.value.stationAGrid
|
||||||
_uiState.update { it.copy(stationAGrid = value) }
|
val newGrid = value.trim().uppercase()
|
||||||
if (value.trim().uppercase() == old.trim().uppercase()) return
|
_uiState.update { it.copy(stationAGrid = newGrid) }
|
||||||
val pos = gridToLatLon(value.trim().uppercase())
|
if (newGrid == old.trim().uppercase()) return
|
||||||
|
val pos = qthToPosition(newGrid)
|
||||||
if (pos != null) {
|
if (pos != null) {
|
||||||
_uiState.update { it.copy(
|
_uiState.update { it.copy(
|
||||||
stationALat = "%.4f".format(pos.latitude),
|
stationALat = "%.4f".format(pos.latitude),
|
||||||
@@ -112,7 +122,9 @@ class MutualViewModel(
|
|||||||
val lat = value.toDoubleOrNull()
|
val lat = value.toDoubleOrNull()
|
||||||
val lon = _uiState.value.stationBLon.toDoubleOrNull()
|
val lon = _uiState.value.stationBLon.toDoubleOrNull()
|
||||||
if (lat != null && lon != null) {
|
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 lat = _uiState.value.stationBLat.toDoubleOrNull()
|
||||||
val lon = value.toDoubleOrNull()
|
val lon = value.toDoubleOrNull()
|
||||||
if (lat != null && lon != null) {
|
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) {
|
fun onStationBGrid(value: String) {
|
||||||
val old = _uiState.value.stationBGrid
|
val old = _uiState.value.stationBGrid
|
||||||
_uiState.update { it.copy(stationBGrid = value) }
|
val newGrid = value.trim().uppercase()
|
||||||
if (value.trim().uppercase() == old.trim().uppercase()) return
|
_uiState.update { it.copy(stationBGrid = newGrid) }
|
||||||
val pos = gridToLatLon(value.trim().uppercase())
|
if (newGrid == old.trim().uppercase()) return
|
||||||
|
val pos = qthToPosition(newGrid)
|
||||||
if (pos != null) {
|
if (pos != null) {
|
||||||
_uiState.update { it.copy(
|
_uiState.update { it.copy(
|
||||||
stationBLat = "%.4f".format(pos.latitude),
|
stationBLat = "%.4f".format(pos.latitude),
|
||||||
@@ -144,7 +159,7 @@ class MutualViewModel(
|
|||||||
_uiState.update { it.copy(
|
_uiState.update { it.copy(
|
||||||
stationALat = "%.4f".format(pos.latitude),
|
stationALat = "%.4f".format(pos.latitude),
|
||||||
stationALon = "%.4f".format(pos.longitude),
|
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) }
|
fun onHoursAhead(value: Int) = _uiState.update { it.copy(hoursAhead = value) }
|
||||||
@@ -184,7 +199,7 @@ class MutualViewModel(
|
|||||||
val minElevB = state.stationBMinElev
|
val minElevB = state.stationBMinElev
|
||||||
val hours = state.hoursAhead
|
val hours = state.hoursAhead
|
||||||
|
|
||||||
val results = withContext(Dispatchers.Default) {
|
val results = withContext(computeDispatcher) {
|
||||||
findMutualPasses(satellites, posA, posB, minElevA, minElevB, time, hours)
|
findMutualPasses(satellites, posA, posB, minElevA, minElevB, time, hours)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,83 +228,13 @@ class MutualViewModel(
|
|||||||
private fun resolvePosition(latText: String, lonText: String, gridText: String): GeoPos? {
|
private fun resolvePosition(latText: String, lonText: String, gridText: String): GeoPos? {
|
||||||
val trimmed = gridText.trim().uppercase()
|
val trimmed = gridText.trim().uppercase()
|
||||||
if (trimmed.length in listOf(4, 6, 8) && trimmed.all { it.isLetterOrDigit() }) {
|
if (trimmed.length in listOf(4, 6, 8) && trimmed.all { it.isLetterOrDigit() }) {
|
||||||
return gridToLatLon(trimmed)
|
return qthToPosition(trimmed)
|
||||||
}
|
}
|
||||||
val lat = latText.toDoubleOrNull()
|
val lat = latText.toDoubleOrNull()
|
||||||
val lon = lonText.toDoubleOrNull()
|
val lon = lonText.toDoubleOrNull()
|
||||||
return if (lat != null && lon != null) GeoPos(lat, lon) else null
|
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(
|
private fun findMutualPasses(
|
||||||
satellites: List<OrbitalObject>,
|
satellites: List<OrbitalObject>,
|
||||||
posA: GeoPos, posB: GeoPos,
|
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
|
import java.util.TimeZone
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PassesDestination(navigateToRadar: (Int, Long) -> Unit) {
|
fun PassesDestination(
|
||||||
|
navigateToRadar: (Int, Long) -> Unit,
|
||||||
|
navigateToMap: () -> Unit
|
||||||
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val viewModel: PassesViewModel = viewModel(factory = PassesViewModel.factory(container))
|
val viewModel: PassesViewModel = viewModel(factory = PassesViewModel.factory(container))
|
||||||
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
|
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
|
||||||
PassesScreen(uiState, viewModel::onAction, navigateToRadar)
|
PassesScreen(uiState, viewModel::onAction, navigateToRadar, navigateToMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PassesScreen(
|
private fun PassesScreen(
|
||||||
uiState: PassesState,
|
uiState: PassesState,
|
||||||
onAction: (PassesAction) -> Unit,
|
onAction: (PassesAction) -> Unit,
|
||||||
navigateToRadar: (Int, Long) -> Unit
|
navigateToRadar: (Int, Long) -> Unit,
|
||||||
|
navigateToMap: () -> Unit
|
||||||
) {
|
) {
|
||||||
if (uiState.isPassesDialogShown) {
|
if (uiState.isPassesDialogShown) {
|
||||||
PassesFilterDialog(
|
PassesFilterDialog(
|
||||||
@@ -163,7 +167,7 @@ private fun PassesScreen(
|
|||||||
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
|
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
|
||||||
},
|
},
|
||||||
endAction = {
|
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 androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||||
import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
|
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.predict.OrbitalPass
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
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
|
// 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
|
// 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.
|
// 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>> {
|
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
|
||||||
val stationPos = settingsRepo.stationPosition.value
|
val stationPos = settingsRepo.stationPosition.value
|
||||||
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
@@ -175,7 +177,7 @@ class PassesViewModel(
|
|||||||
val result = LinkedHashMap<String, Pair<String, String>>()
|
val result = LinkedHashMap<String, Pair<String, String>>()
|
||||||
// DeepSpace group always shows today's sun times
|
// DeepSpace group always shows today's sun times
|
||||||
if (passes.any { it.isDeepSpace }) {
|
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 rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
|
||||||
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
|
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
|
||||||
result["DeepSpace (period >225min)"] = rise to set
|
result["DeepSpace (period >225min)"] = rise to set
|
||||||
@@ -184,7 +186,7 @@ class PassesViewModel(
|
|||||||
if (pass.isDeepSpace) continue
|
if (pass.isDeepSpace) continue
|
||||||
val label = sdfDate.format(Date(pass.aosTime))
|
val label = sdfDate.format(Date(pass.aosTime))
|
||||||
if (label in result) continue
|
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 rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
|
||||||
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
|
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
|
||||||
result[label] = rise to set
|
result[label] = rise to set
|
||||||
@@ -192,6 +194,33 @@ class PassesViewModel(
|
|||||||
return result
|
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 {
|
private fun startOfDayMillis(timeMillis: Long, tz: TimeZone): Long {
|
||||||
return Calendar.getInstance(tz, Locale.getDefault()).apply {
|
return Calendar.getInstance(tz, Locale.getDefault()).apply {
|
||||||
timeInMillis = timeMillis
|
timeInMillis = timeMillis
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ private enum class RadarPage(val title: String) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RadarDestination(navigateUp: () -> Unit) {
|
fun RadarDestination(navigateUp: () -> Unit, navigateToMap: () -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
|
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
|
||||||
@@ -96,6 +96,12 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
|||||||
}
|
}
|
||||||
navigateUp()
|
navigateUp()
|
||||||
}
|
}
|
||||||
|
val navigateToMapAndClearMutual = {
|
||||||
|
if (container.mutualPassData.value.endTime > 0L) {
|
||||||
|
container.setMutualPassData(MutualPassData())
|
||||||
|
}
|
||||||
|
navigateToMap()
|
||||||
|
}
|
||||||
LaunchedEffect(mutualData.endTime) {
|
LaunchedEffect(mutualData.endTime) {
|
||||||
if (mutualData.endTime <= 0L) return@LaunchedEffect
|
if (mutualData.endTime <= 0L) return@LaunchedEffect
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -121,7 +127,7 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
|||||||
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
|
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
|
||||||
viewModel.onAction(RadarAction.CwPermissionResult(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)
|
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -131,13 +137,11 @@ private fun RadarScreen(
|
|||||||
uiState: RadarState,
|
uiState: RadarState,
|
||||||
onAction: (RadarAction) -> Unit,
|
onAction: (RadarAction) -> Unit,
|
||||||
navigateUp: () -> Unit,
|
navigateUp: () -> Unit,
|
||||||
|
navigateToMap: () -> Unit,
|
||||||
mutualData: MutualPassData,
|
mutualData: MutualPassData,
|
||||||
requestMicPermission: () -> Unit
|
requestMicPermission: () -> Unit
|
||||||
) {
|
) {
|
||||||
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
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
|
// 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.
|
// at the current moment, same display mode as the local station.
|
||||||
val trackB = remember(mutualData.trackSamples) {
|
val trackB = remember(mutualData.trackSamples) {
|
||||||
@@ -173,7 +177,7 @@ private fun RadarScreen(
|
|||||||
TopBar {
|
TopBar {
|
||||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
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) }
|
TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
|
||||||
} else {
|
} else {
|
||||||
@@ -181,7 +185,7 @@ private fun RadarScreen(
|
|||||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
||||||
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
|
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) {
|
if (isVertical) {
|
||||||
@@ -249,6 +253,7 @@ private fun PagerCard(
|
|||||||
selectedUuid = uiState.transceivers.selectedUuid,
|
selectedUuid = uiState.transceivers.selectedUuid,
|
||||||
orbitalPos = uiState.orbitalPos,
|
orbitalPos = uiState.orbitalPos,
|
||||||
cw = uiState.cw,
|
cw = uiState.cw,
|
||||||
|
calculatorOffsetKHz = uiState.calculatorOffsetKHz,
|
||||||
onAction = onAction,
|
onAction = onAction,
|
||||||
requestMicPermission = requestMicPermission
|
requestMicPermission = requestMicPermission
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ data class RadarState(
|
|||||||
val mutualEndTime: Long = 0L,
|
val mutualEndTime: Long = 0L,
|
||||||
val mutualMaxElev: Double = 10.0,
|
val mutualMaxElev: Double = 10.0,
|
||||||
val mutualLabelA: String = "你",
|
val mutualLabelA: String = "你",
|
||||||
val mutualLabelB: String = "友台"
|
val mutualLabelB: String = "友台",
|
||||||
|
val calculatorOffsetKHz: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class SstvStatus { Idle, Recording }
|
enum class SstvStatus { Idle, Recording }
|
||||||
@@ -124,4 +125,7 @@ sealed interface RadarAction {
|
|||||||
data class CwSetToneFreq(val freq: Float) : RadarAction
|
data class CwSetToneFreq(val freq: Float) : RadarAction
|
||||||
data class CwToggleExpanded(val expanded: Boolean) : RadarAction
|
data class CwToggleExpanded(val expanded: Boolean) : RadarAction
|
||||||
data class CwPermissionResult(val granted: 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 }
|
transponders = allRadios.filter { it.downlinkLow != null }
|
||||||
if (allRadios.isNotEmpty()) {
|
if (allRadios.isNotEmpty()) {
|
||||||
val firstUuid = allRadios.first().uuid
|
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) }
|
transponders.find { it.uuid == firstUuid }?.let { trackingService.setTransponder(it) }
|
||||||
}
|
}
|
||||||
if (!pass.isDeepSpace) {
|
if (!pass.isDeepSpace) {
|
||||||
@@ -228,7 +234,16 @@ class RadarViewModel(
|
|||||||
// Compute toggle state before the update so we don't read post-update value
|
// Compute toggle state before the update so we don't read post-update value
|
||||||
val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid
|
val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid
|
||||||
val newUuid = if (isTogglingOff) null else 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
|
// Only update the tracking service when selecting a different transponder to
|
||||||
// avoid resetting a user-adjusted TX base on re-expand
|
// avoid resetting a user-adjusted TX base on re-expand
|
||||||
if (!isTogglingOff) {
|
if (!isTogglingOff) {
|
||||||
@@ -296,6 +311,15 @@ class RadarViewModel(
|
|||||||
is RadarAction.CwToggleExpanded -> {
|
is RadarAction.CwToggleExpanded -> {
|
||||||
_uiState.update { it.copy(cw = it.cw.copy(isExpanded = action.expanded)) }
|
_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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+54
-71
@@ -18,7 +18,6 @@
|
|||||||
package com.rtbishop.look4sat.feature.radar
|
package com.rtbishop.look4sat.feature.radar
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Context
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.expandVertically
|
import androidx.compose.animation.expandVertically
|
||||||
@@ -27,6 +26,8 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
@@ -91,7 +92,6 @@ import androidx.constraintlayout.widget.ConstraintLayout
|
|||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
||||||
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
|
|
||||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.formatFrequency
|
import com.rtbishop.look4sat.core.presentation.formatFrequency
|
||||||
@@ -144,6 +144,7 @@ fun CalculatorPage(
|
|||||||
selectedUuid: String?,
|
selectedUuid: String?,
|
||||||
orbitalPos: OrbitalPos?,
|
orbitalPos: OrbitalPos?,
|
||||||
cw: CwSubState,
|
cw: CwSubState,
|
||||||
|
calculatorOffsetKHz: String = "",
|
||||||
onAction: (RadarAction) -> Unit,
|
onAction: (RadarAction) -> Unit,
|
||||||
requestMicPermission: () -> Unit = {},
|
requestMicPermission: () -> Unit = {},
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
@@ -162,59 +163,55 @@ fun CalculatorPage(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(
|
Column(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
) {
|
) {
|
||||||
if (calculatorTransceivers.size > 1) {
|
if (calculatorTransceivers.size > 1) {
|
||||||
item {
|
FlowRow(
|
||||||
FlowRow(
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
modifier = Modifier.fillMaxWidth()
|
||||||
modifier = Modifier.fillMaxWidth()
|
) {
|
||||||
) {
|
calculatorTransceivers.forEach { radio ->
|
||||||
calculatorTransceivers.forEach { radio ->
|
FilterChip(
|
||||||
FilterChip(
|
selected = radio.uuid == selectedTransceiver.uuid,
|
||||||
selected = radio.uuid == selectedTransceiver.uuid,
|
onClick = {
|
||||||
onClick = {
|
if (radio.uuid != selectedUuid) onAction(RadarAction.SelectTransmitter(radio.uuid))
|
||||||
if (radio.uuid != selectedUuid) onAction(RadarAction.SelectTransmitter(radio.uuid))
|
},
|
||||||
},
|
label = {
|
||||||
label = {
|
Text(
|
||||||
Text(
|
text = transceiverTitle(radio),
|
||||||
text = transceiverTitle(radio),
|
maxLines = 1,
|
||||||
maxLines = 1,
|
overflow = TextOverflow.Ellipsis
|
||||||
overflow = TextOverflow.Ellipsis
|
)
|
||||||
)
|
}
|
||||||
}
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
item(key = "calculator_${selectedTransceiver.uuid}") {
|
DopplerFrequencyCalculator(
|
||||||
DopplerFrequencyCalculator(
|
transponder = selectedTransceiver,
|
||||||
transponder = selectedTransceiver,
|
orbitalPos = orbitalPos,
|
||||||
orbitalPos = orbitalPos,
|
offsetKHz = calculatorOffsetKHz,
|
||||||
modifier = Modifier.fillMaxWidth()
|
onOffsetChange = { onAction(RadarAction.ChangeCalculatorOffset(it)) },
|
||||||
)
|
modifier = Modifier.fillMaxWidth()
|
||||||
}
|
)
|
||||||
|
|
||||||
item {
|
HorizontalDivider(
|
||||||
HorizontalDivider(
|
thickness = 1.dp,
|
||||||
thickness = 1.dp,
|
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
|
||||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
item {
|
CwDecoderPanel(
|
||||||
CwDecoderPanel(
|
cw = cw,
|
||||||
cw = cw,
|
onAction = onAction,
|
||||||
onAction = onAction,
|
requestMicPermission = requestMicPermission,
|
||||||
requestMicPermission = requestMicPermission,
|
modifier = Modifier.fillMaxWidth()
|
||||||
modifier = Modifier.fillMaxWidth()
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,6 +550,8 @@ private enum class EditedField { TX, PASSBAND, RX }
|
|||||||
private fun DopplerFrequencyCalculator(
|
private fun DopplerFrequencyCalculator(
|
||||||
transponder: SatRadio,
|
transponder: SatRadio,
|
||||||
orbitalPos: OrbitalPos?,
|
orbitalPos: OrbitalPos?,
|
||||||
|
offsetKHz: String = "",
|
||||||
|
onOffsetChange: (String) -> Unit = {},
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return
|
if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return
|
||||||
@@ -561,17 +560,6 @@ private fun DopplerFrequencyCalculator(
|
|||||||
var txFrequencyHz by rememberSaveable(transponder.uuid) { mutableStateOf(0L) }
|
var txFrequencyHz by rememberSaveable(transponder.uuid) { mutableStateOf(0L) }
|
||||||
var rxFrequencyHz by rememberSaveable(transponder.uuid) { mutableStateOf(0L) }
|
var rxFrequencyHz by rememberSaveable(transponder.uuid) { mutableStateOf(0L) }
|
||||||
var passbandPosition by rememberSaveable(transponder.uuid) { mutableStateOf(0.5f) }
|
var passbandPosition by rememberSaveable(transponder.uuid) { mutableStateOf(0.5f) }
|
||||||
// 每个卫星独立记忆 offset:以 NORAD 编号为 key,存到应用 SharedPreferences
|
|
||||||
val context = LocalContext.current
|
|
||||||
val catnum = transponder.catnum
|
|
||||||
var offsetKHz by remember(catnum) {
|
|
||||||
mutableStateOf(
|
|
||||||
catnum?.let { cn ->
|
|
||||||
context.getSharedPreferences(context.packageName + "_preferences", Context.MODE_PRIVATE)
|
|
||||||
.getString("offset_khz_$cn", "") ?: ""
|
|
||||||
} ?: ""
|
|
||||||
)
|
|
||||||
}
|
|
||||||
var stepSizeKHz by remember { mutableIntStateOf(1) }
|
var stepSizeKHz by remember { mutableIntStateOf(1) }
|
||||||
|
|
||||||
val offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L
|
val offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L
|
||||||
@@ -661,12 +649,15 @@ private fun DopplerFrequencyCalculator(
|
|||||||
fun updateRx(newRxHz: Long) {
|
fun updateRx(newRxHz: Long) {
|
||||||
when (lastEditedField) {
|
when (lastEditedField) {
|
||||||
EditedField.PASSBAND -> {
|
EditedField.PASSBAND -> {
|
||||||
// Passband 模式:从 RX 反推 TX 位置,TX 用地面频率
|
// Passband 模式:从 RX 经完整往返路径(含多普勒补偿)反推 TX 位置
|
||||||
val pos = ((newRxHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
|
val txFromRx = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||||
val txFromRx = TransponderMapper.mapDownlinkToUplink(newRxHz, rawTransponder)
|
newRxHz, rawTransponder, orbitalPos, offsetHz
|
||||||
|
)
|
||||||
passbandPosition = if (txFromRx != null) {
|
passbandPosition = if (txFromRx != null) {
|
||||||
((txFromRx - rawTxLow).toFloat() / rawTxRange).coerceIn(0f, 1f)
|
((txFromRx - txLow).toFloat() / txRange).coerceIn(0f, 1f)
|
||||||
} else pos
|
} else {
|
||||||
|
((rxFrequencyHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
txFrequencyHz = txLow + (passbandPosition * txRange).toLong()
|
txFrequencyHz = txLow + (passbandPosition * txRange).toLong()
|
||||||
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||||
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
|
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
|
||||||
@@ -754,15 +745,7 @@ private fun DopplerFrequencyCalculator(
|
|||||||
) {
|
) {
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = offsetKHz,
|
value = offsetKHz,
|
||||||
onValueChange = { newValue ->
|
onValueChange = onOffsetChange,
|
||||||
offsetKHz = newValue
|
|
||||||
catnum?.let { cn ->
|
|
||||||
context.getSharedPreferences(
|
|
||||||
context.packageName + "_preferences",
|
|
||||||
Context.MODE_PRIVATE
|
|
||||||
).edit().putString("offset_khz_$cn", newValue).apply()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
textStyle = TextStyle(
|
textStyle = TextStyle(
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
|
|||||||
+473
-105
@@ -19,39 +19,83 @@ package com.rtbishop.look4sat.feature.settings
|
|||||||
|
|
||||||
import android.bluetooth.BluetoothManager
|
import android.bluetooth.BluetoothManager
|
||||||
import android.content.Context
|
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.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||||
|
import androidx.compose.foundation.gestures.scrollBy
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
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.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.OutlinedTextField
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
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.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.toMutableStateList
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.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.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import 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.RCSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
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.CardButton
|
||||||
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
||||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -116,11 +160,19 @@ fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Uni
|
|||||||
private fun TransceiversDialogPreview() {
|
private fun TransceiversDialogPreview() {
|
||||||
MainTheme {
|
MainTheme {
|
||||||
DataSourcesDialog(
|
DataSourcesDialog(
|
||||||
useCustomTle = true,
|
satelliteUrls = listOf(
|
||||||
useCustomTransceivers = true,
|
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||||
tleUrl = "https://example.com/tle.txt",
|
"amsat.org/tle/current/nasabare.txt"
|
||||||
transceiversUrl = "https://example.com/tx.json",
|
),
|
||||||
requestCustomSourcesPermission = { onGranted, _ -> onGranted() },
|
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 = {},
|
onImportTle = {},
|
||||||
onImportTransceivers = {},
|
onImportTransceivers = {},
|
||||||
onDismiss = {},
|
onDismiss = {},
|
||||||
@@ -131,111 +183,404 @@ private fun TransceiversDialogPreview() {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DataSourcesDialog(
|
fun DataSourcesDialog(
|
||||||
useCustomTle: Boolean,
|
satelliteUrls: List<String>,
|
||||||
useCustomTransceivers: Boolean,
|
transceiversUrls: List<String>,
|
||||||
tleUrl: String,
|
satelliteEnabled: List<Boolean>,
|
||||||
transceiversUrl: String,
|
transceiversEnabled: List<Boolean>,
|
||||||
requestCustomSourcesPermission: (onGranted: () -> Unit, onDenied: () -> Unit) -> Unit,
|
statusCodes: Map<String, Int>,
|
||||||
onImportTle: () -> Unit,
|
onImportTle: () -> Unit,
|
||||||
onImportTransceivers: () -> Unit,
|
onImportTransceivers: () -> Unit,
|
||||||
onDismiss: () -> 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 padding = LocalSpacing.current.large
|
||||||
val isEnabledCustomTle = rememberSaveable { mutableStateOf(useCustomTle) }
|
// Stable Long IDs avoid key collisions when several entries are empty or duplicated.
|
||||||
val isEnabledCustomTransceivers = rememberSaveable { mutableStateOf(useCustomTransceivers) }
|
val nextId = remember { mutableLongStateOf((satelliteUrls.size + transceiversUrls.size).toLong()) }
|
||||||
val urlTle = rememberSaveable { mutableStateOf(tleUrl) }
|
val satUrls = remember {
|
||||||
val urlTransceivers = rememberSaveable { mutableStateOf(transceiversUrl) }
|
satelliteUrls.mapIndexed { i, url -> i.toLong() to url }.toMutableStateList()
|
||||||
val onAccept = {
|
|
||||||
onSave(isEnabledCustomTle.value, isEnabledCustomTransceivers.value, urlTle.value, urlTransceivers.value)
|
|
||||||
onDismiss()
|
|
||||||
}
|
}
|
||||||
val onCancel = { onDismiss() }
|
val txUrls = remember {
|
||||||
SharedDialog(
|
transceiversUrls.mapIndexed { i, url -> (satelliteUrls.size + i).toLong() to url }.toMutableStateList()
|
||||||
title = stringResource(id = R.string.prefs_data_sources_title),
|
}
|
||||||
onCancel = onCancel,
|
val satEnabled = remember {
|
||||||
onAccept = onAccept,
|
mutableStateMapOf<Long, Boolean>().apply {
|
||||||
) {
|
satUrls.forEachIndexed { i, (id, _) -> this[id] = satelliteEnabled.getOrElse(i) { true } }
|
||||||
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 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)
|
@Preview(showBackground = true)
|
||||||
@@ -252,6 +597,7 @@ fun PreviewNetworkOutputDialog() {
|
|||||||
frequencyAddress = "127.0.0.1",
|
frequencyAddress = "127.0.0.1",
|
||||||
frequencyPort = "4532",
|
frequencyPort = "4532",
|
||||||
frequencyFormat = $$"F $FREQ",
|
frequencyFormat = $$"F $FREQ",
|
||||||
|
frequencyOffsetHz = 0L,
|
||||||
bluetoothRotatorState = false,
|
bluetoothRotatorState = false,
|
||||||
bluetoothRotatorFormat = $$"P $AZ $EL",
|
bluetoothRotatorFormat = $$"P $AZ $EL",
|
||||||
bluetoothRotatorName = "Default",
|
bluetoothRotatorName = "Default",
|
||||||
@@ -261,7 +607,7 @@ fun PreviewNetworkOutputDialog() {
|
|||||||
bluetoothFrequencyFormat = $$"F $FREQ"
|
bluetoothFrequencyFormat = $$"F $FREQ"
|
||||||
),
|
),
|
||||||
onDismiss = {},
|
onDismiss = {},
|
||||||
onSave = { _, _, _, _, _, _, _, _ -> }
|
onSave = { _, _, _, _, _, _, _, _, _ -> }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -272,7 +618,7 @@ fun NetworkOutputDialog(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSave: (
|
onSave: (
|
||||||
Boolean, String, String, String,
|
Boolean, String, String, String,
|
||||||
Boolean, String, String, String
|
Boolean, String, String, String, Long
|
||||||
) -> Unit
|
) -> Unit
|
||||||
) {
|
) {
|
||||||
val padding = LocalSpacing.current.large
|
val padding = LocalSpacing.current.large
|
||||||
@@ -286,12 +632,15 @@ fun NetworkOutputDialog(
|
|||||||
mutableStateOf("${initialSettings.frequencyAddress}:${initialSettings.frequencyPort}")
|
mutableStateOf("${initialSettings.frequencyAddress}:${initialSettings.frequencyPort}")
|
||||||
}
|
}
|
||||||
val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.frequencyFormat) }
|
val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.frequencyFormat) }
|
||||||
|
val frequencyOffsetHz = rememberSaveable { mutableStateOf(initialSettings.frequencyOffsetHz.toString()) }
|
||||||
val onAccept = {
|
val onAccept = {
|
||||||
val (rotIp, rotPort) = splitAddress(rotatorAddress.value)
|
val (rotIp, rotPort) = splitAddress(rotatorAddress.value)
|
||||||
val (freqIp, freqPort) = splitAddress(frequencyAddress.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(
|
onSave(
|
||||||
rotatorState.value, rotIp, rotPort, rotatorFormat.value,
|
rotatorState.value, rotIp, rotPort, rotatorFormat.value,
|
||||||
frequencyState.value, freqIp, freqPort, frequencyFormat.value
|
frequencyState.value, freqIp, freqPort, frequencyFormat.value, offsetHz
|
||||||
)
|
)
|
||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
@@ -324,6 +673,24 @@ fun NetworkOutputDialog(
|
|||||||
onFormatChange = { frequencyFormat.value = it },
|
onFormatChange = { frequencyFormat.value = it },
|
||||||
formatLabel = stringResource(R.string.prefs_net_frequency_format_hint)
|
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))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -352,6 +719,7 @@ fun PreviewBluetoothOutputDialog() {
|
|||||||
frequencyAddress = "127.0.0.1",
|
frequencyAddress = "127.0.0.1",
|
||||||
frequencyPort = "4532",
|
frequencyPort = "4532",
|
||||||
frequencyFormat = $$"F $FREQ",
|
frequencyFormat = $$"F $FREQ",
|
||||||
|
frequencyOffsetHz = 0L,
|
||||||
bluetoothRotatorState = false,
|
bluetoothRotatorState = false,
|
||||||
bluetoothRotatorFormat = $$"P $AZ $EL",
|
bluetoothRotatorFormat = $$"P $AZ $EL",
|
||||||
bluetoothRotatorName = "Default",
|
bluetoothRotatorName = "Default",
|
||||||
|
|||||||
+37
-23
@@ -61,6 +61,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
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.model.OtherSettings
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
@@ -81,13 +82,25 @@ import java.util.Locale
|
|||||||
fun SettingsDestination() {
|
fun SettingsDestination() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
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()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
SettingsScreen(uiState, viewModel::onAction)
|
SettingsScreen(uiState, viewModel::onAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) {
|
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 dialogs = rememberDialogVisibility()
|
||||||
val pendingCustomSourcesGrant = remember { mutableStateOf<(() -> Unit)?>(null) }
|
val pendingCustomSourcesGrant = remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
val pendingCustomSourcesDeny = remember { mutableStateOf<(() -> Unit)?>(null) }
|
val pendingCustomSourcesDeny = remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
@@ -125,30 +138,23 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
}
|
}
|
||||||
if (dialogs.dataSources) {
|
if (dialogs.dataSources) {
|
||||||
DataSourcesDialog(
|
DataSourcesDialog(
|
||||||
useCustomTle = uiState.dataSourcesSettings.useCustomTLE,
|
satelliteUrls = uiState.dataSourcesSettings.satelliteUrls,
|
||||||
useCustomTransceivers = uiState.dataSourcesSettings.useCustomTransceivers,
|
transceiversUrls = uiState.dataSourcesSettings.transceiversUrls,
|
||||||
tleUrl = uiState.dataSourcesSettings.tleUrl,
|
satelliteEnabled = uiState.dataSourcesSettings.satelliteEnabled,
|
||||||
transceiversUrl = uiState.dataSourcesSettings.transceiversUrl,
|
transceiversEnabled = uiState.dataSourcesSettings.transceiversEnabled,
|
||||||
requestCustomSourcesPermission = { onGranted, onDenied ->
|
statusCodes = uiState.dataSourcesStatus,
|
||||||
pendingCustomSourcesGrant.value = onGranted
|
|
||||||
pendingCustomSourcesDeny.value = onDenied
|
|
||||||
permissions.launchCustomSourcesPermission()
|
|
||||||
},
|
|
||||||
onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false },
|
onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false },
|
||||||
onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false },
|
onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false },
|
||||||
onDismiss = { dialogs.dataSources = false },
|
onDismiss = { dialogs.dataSources = false },
|
||||||
onSave = { useCustomTle, useCustomTransceivers, tleUrl, transceiversUrl ->
|
onSave = { satUrls, txUrls, satEnabled, txEnabled ->
|
||||||
val current = uiState.dataSourcesSettings
|
val newSettings = DataSourcesSettings(
|
||||||
val newSettings = current.copy(
|
satelliteUrls = satUrls,
|
||||||
useCustomTLE = if (!useCustomTle || tleUrl.isNotBlank()) useCustomTle else current.useCustomTLE,
|
transceiversUrls = txUrls,
|
||||||
tleUrl = if (!useCustomTle || tleUrl.isNotBlank()) tleUrl else current.tleUrl,
|
satelliteEnabled = satEnabled,
|
||||||
useCustomTransceivers = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) useCustomTransceivers else current.useCustomTransceivers,
|
transceiversEnabled = txEnabled
|
||||||
transceiversUrl = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) transceiversUrl else current.transceiversUrl
|
|
||||||
)
|
)
|
||||||
if (newSettings != current) onAction(SettingsAction.UpdateDataSources(newSettings))
|
if (newSettings != uiState.dataSourcesSettings) onAction(SettingsAction.UpdateDataSources(newSettings))
|
||||||
if (newSettings.useCustomTLE || newSettings.useCustomTransceivers) {
|
onAction(SettingsAction.UpdateFromWeb)
|
||||||
onAction(SettingsAction.UpdateFromWeb)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -156,14 +162,15 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
NetworkOutputDialog(
|
NetworkOutputDialog(
|
||||||
initialSettings = uiState.rcSettings,
|
initialSettings = uiState.rcSettings,
|
||||||
onDismiss = { dialogs.network = false },
|
onDismiss = { dialogs.network = false },
|
||||||
onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt ->
|
onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt, freqOffsetHz ->
|
||||||
onAction(
|
onAction(
|
||||||
SettingsAction.UpdateRC(
|
SettingsAction.UpdateRC(
|
||||||
uiState.rcSettings.copy(
|
uiState.rcSettings.copy(
|
||||||
rotatorState = rotState, rotatorAddress = rotAddr,
|
rotatorState = rotState, rotatorAddress = rotAddr,
|
||||||
rotatorPort = rotPort, rotatorFormat = rotFmt,
|
rotatorPort = rotPort, rotatorFormat = rotFmt,
|
||||||
frequencyState = freqState, frequencyAddress = freqAddr,
|
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 { OtherCard(uiState.otherSettings, onAction) }
|
||||||
item { CardCredits() }
|
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
|
package com.rtbishop.look4sat.feature.settings
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
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.OtherSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
data class PositionSettings(
|
data class PositionSettings(
|
||||||
val isUpdating: Boolean, val stationPos: GeoPos, val messageResId: Int
|
val isUpdating: Boolean, val stationPos: GeoPos, val messageResId: Int
|
||||||
@@ -34,6 +36,15 @@ data class DataSettings(
|
|||||||
val timestamp: Long
|
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(
|
data class SettingsState(
|
||||||
val appVersionName: String,
|
val appVersionName: String,
|
||||||
val positionSettings: PositionSettings,
|
val positionSettings: PositionSettings,
|
||||||
@@ -41,7 +52,9 @@ data class SettingsState(
|
|||||||
val otherSettings: OtherSettings,
|
val otherSettings: OtherSettings,
|
||||||
val rcSettings: RCSettings,
|
val rcSettings: RCSettings,
|
||||||
val radioControlSettings: RadioControlSettings,
|
val radioControlSettings: RadioControlSettings,
|
||||||
val dataSourcesSettings: DataSourcesSettings
|
val dataSourcesSettings: DataSourcesSettings,
|
||||||
|
val dataSourcesStatus: Map<String, Int> = emptyMap(),
|
||||||
|
val updateChecker: UpdateCheckerState = UpdateCheckerState()
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface SettingsAction {
|
sealed interface SettingsAction {
|
||||||
@@ -72,6 +85,11 @@ sealed interface SettingsAction {
|
|||||||
// Data sources
|
// Data sources
|
||||||
data class UpdateDataSources(val settings: DataSourcesSettings) : SettingsAction
|
data class UpdateDataSources(val settings: DataSourcesSettings) : SettingsAction
|
||||||
|
|
||||||
|
// Update checker
|
||||||
|
data object CheckForUpdate : SettingsAction
|
||||||
|
data object DownloadUpdate : SettingsAction
|
||||||
|
data object ConsumeDownloadedApk : SettingsAction
|
||||||
|
|
||||||
// System
|
// System
|
||||||
data class ShowToast(val message: String) : SettingsAction
|
data class ShowToast(val message: String) : SettingsAction
|
||||||
}
|
}
|
||||||
+64
-2
@@ -17,6 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.feature.settings
|
package com.rtbishop.look4sat.feature.settings
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.lifecycle.viewmodel.initializer
|
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.IDatabaseRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
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.usecase.IShowToast
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
class SettingsViewModel(
|
class SettingsViewModel(
|
||||||
private val databaseRepo: IDatabaseRepo,
|
private val databaseRepo: IDatabaseRepo,
|
||||||
private val settingsRepo: ISettingsRepo,
|
private val settingsRepo: ISettingsRepo,
|
||||||
|
private val updateRepo: IUpdateRepository,
|
||||||
|
private val apkFile: File,
|
||||||
private val showToast: IShowToast
|
private val showToast: IShowToast
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -47,7 +53,8 @@ class SettingsViewModel(
|
|||||||
otherSettings = settingsRepo.otherSettings.value,
|
otherSettings = settingsRepo.otherSettings.value,
|
||||||
rcSettings = settingsRepo.rcSettings.value,
|
rcSettings = settingsRepo.rcSettings.value,
|
||||||
radioControlSettings = settingsRepo.radioControlSettings.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) }
|
_uiState.update { it.copy(dataSourcesSettings = settings) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
settingsRepo.dataSourcesStatus.collect { status ->
|
||||||
|
_uiState.update { it.copy(dataSourcesStatus = status) }
|
||||||
|
}
|
||||||
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
settingsRepo.radioControlSettings.collect { settings ->
|
settingsRepo.radioControlSettings.collect { settings ->
|
||||||
_uiState.update { it.copy(radioControlSettings = settings) }
|
_uiState.update { it.copy(radioControlSettings = settings) }
|
||||||
@@ -125,11 +137,59 @@ class SettingsViewModel(
|
|||||||
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
|
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
|
||||||
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
|
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
|
||||||
is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(action.settings)
|
is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(action.settings)
|
||||||
|
// Update checker
|
||||||
|
SettingsAction.CheckForUpdate -> checkForUpdate()
|
||||||
|
SettingsAction.DownloadUpdate -> downloadUpdate()
|
||||||
|
SettingsAction.ConsumeDownloadedApk -> consumeDownloadedApk()
|
||||||
// System
|
// System
|
||||||
is SettingsAction.ShowToast -> showToast(action.message)
|
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
|
// region Position helpers — consolidated from 3 near-identical functions
|
||||||
|
|
||||||
private fun setGpsPosition() {
|
private fun setGpsPosition() {
|
||||||
@@ -201,11 +261,13 @@ class SettingsViewModel(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
fun factory(container: IMainContainer) = viewModelFactory {
|
fun factory(container: IMainContainer, context: Context) = viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
SettingsViewModel(
|
SettingsViewModel(
|
||||||
databaseRepo = container.databaseRepo,
|
databaseRepo = container.databaseRepo,
|
||||||
settingsRepo = container.settingsRepo,
|
settingsRepo = container.settingsRepo,
|
||||||
|
updateRepo = container.updateRepo,
|
||||||
|
apkFile = File(context.cacheDir, "look4sat-update.apk"),
|
||||||
showToast = container.provideShowToast()
|
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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+407
-149
@@ -1,40 +1,38 @@
|
|||||||
package com.rtbishop.look4sat.feature.status
|
package com.rtbishop.look4sat.feature.status
|
||||||
|
|
||||||
import androidx.compose.animation.core.RepeatMode
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.core.animateFloat
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.animation.core.infiniteRepeatable
|
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -42,23 +40,52 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.rotate
|
|
||||||
import androidx.compose.ui.graphics.Color
|
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.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatDay
|
import com.rtbishop.look4sat.core.domain.model.SatDay
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatReport
|
import com.rtbishop.look4sat.core.domain.model.SatReport
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
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.R
|
||||||
import com.rtbishop.look4sat.feature.status.R as StatusR
|
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Calendar
|
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.
|
* Map AMSAT status text to Material3 colorScheme colors.
|
||||||
@@ -68,37 +95,67 @@ import java.util.Calendar
|
|||||||
private fun statusColorOf(statusText: String): Color {
|
private fun statusColorOf(statusText: String): Color {
|
||||||
return when {
|
return when {
|
||||||
statusText.contains("Heard", ignoreCase = true) && !statusText.contains("Not", ignoreCase = true) ->
|
statusText.contains("Heard", ignoreCase = true) && !statusText.contains("Not", ignoreCase = true) ->
|
||||||
MaterialTheme.colorScheme.tertiary // Active
|
MaterialTheme.colorScheme.tertiary
|
||||||
statusText.contains("Telemetry", ignoreCase = true) || statusText.contains("Beacon", ignoreCase = true) ->
|
statusText.contains("Telemetry", ignoreCase = true) || statusText.contains("Beacon", ignoreCase = true) ->
|
||||||
MaterialTheme.colorScheme.tertiaryContainer // Telemetry
|
MaterialTheme.colorScheme.tertiaryContainer
|
||||||
statusText.contains("Not Heard", ignoreCase = true) ->
|
statusText.contains("Not Heard", ignoreCase = true) ->
|
||||||
Color(0xFFDC267F) // NotHeard pink (no semantic slot)
|
Color(0xFFDC267F)
|
||||||
else ->
|
else ->
|
||||||
MaterialTheme.colorScheme.error // Conflict
|
MaterialTheme.colorScheme.error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val NoReportGray = Color(0xFFC0C0C0) // Neutral state
|
@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
|
@Composable
|
||||||
fun SatStatusScreen(container: IMainContainer) {
|
private fun SatStatusScreen(
|
||||||
val viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container))
|
uiState: SatStatusUiState,
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
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) }
|
var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) }
|
||||||
|
Column(modifier = Modifier.fillMaxSize().layoutPadding()) {
|
||||||
Column(
|
StatusHeader(fetchedAtUtcMs = uiState.fetchedAtUtcMs, isRefreshing = uiState.isRefreshing, onRefresh = refresh)
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.statusBarsPadding()
|
|
||||||
.navigationBarsPadding()
|
|
||||||
.padding(horizontal = 8.dp)
|
|
||||||
) {
|
|
||||||
// Top: legend + refresh
|
|
||||||
StatusHeader(
|
|
||||||
fetchedAtUtcMs = uiState.fetchedAtUtcMs,
|
|
||||||
isRefreshing = uiState.isRefreshing,
|
|
||||||
onRefresh = { viewModel.refresh() }
|
|
||||||
)
|
|
||||||
LegendRow()
|
LegendRow()
|
||||||
|
|
||||||
when {
|
when {
|
||||||
@@ -113,29 +170,16 @@ fun SatStatusScreen(container: IMainContainer) {
|
|||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(text = stringResource(id = R.string.amsat_load_failed), color = MaterialTheme.colorScheme.error)
|
||||||
text = stringResource(id = R.string.amsat_load_failed),
|
TextButton(onClick = refresh) { Text(text = stringResource(id = R.string.amsat_retry)) }
|
||||||
color = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
TextButton(onClick = { viewModel.refresh() }) {
|
|
||||||
Text(text = stringResource(id = R.string.amsat_retry))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
// Header
|
|
||||||
HeaderRow(statuses = uiState.statuses)
|
HeaderRow(statuses = uiState.statuses)
|
||||||
HorizontalDivider(thickness = 1.dp)
|
HorizontalDivider(thickness = 1.dp)
|
||||||
// Row
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentPadding = PaddingValues(bottom = 16.dp)
|
|
||||||
) {
|
|
||||||
items(uiState.statuses, key = { it.name }) { status ->
|
items(uiState.statuses, key = { it.name }) { status ->
|
||||||
StatusRow(
|
StatusRow(status = status, onClickDay = { day -> selectedDay = status to day })
|
||||||
status = status,
|
|
||||||
onClickDay = { day -> selectedDay = status to day }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,52 +191,53 @@ fun SatStatusScreen(container: IMainContainer) {
|
|||||||
statusName = status.name,
|
statusName = status.name,
|
||||||
day = day,
|
day = day,
|
||||||
reports = uiState.reports,
|
reports = uiState.reports,
|
||||||
onDismiss = { selectedDay = null }
|
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) */
|
/** Top: update time + refresh button (spinner while loading) */
|
||||||
@Composable
|
@Composable
|
||||||
private fun StatusHeader(
|
private fun StatusHeader(fetchedAtUtcMs: Long, isRefreshing: Boolean, onRefresh: () -> Unit) {
|
||||||
fetchedAtUtcMs: Long,
|
|
||||||
isRefreshing: Boolean,
|
|
||||||
onRefresh: () -> Unit
|
|
||||||
) {
|
|
||||||
val infiniteTransition = rememberInfiniteTransition(label = "refresh")
|
|
||||||
val angle by infiniteTransition.animateFloat(
|
|
||||||
initialValue = 0f,
|
|
||||||
targetValue = 360f,
|
|
||||||
animationSpec = infiniteRepeatable(tween(800), RepeatMode.Restart),
|
|
||||||
label = "angle"
|
|
||||||
)
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
|
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = if (fetchedAtUtcMs > 0) stringResource(id = R.string.amsat_updated) + " " + formatFetchedAt(fetchedAtUtcMs) else stringResource(id = R.string.amsat_title),
|
text = if (fetchedAtUtcMs > 0)
|
||||||
fontSize = 13.sp,
|
stringResource(id = R.string.amsat_updated) + " " + formatFetchedAt(fetchedAtUtcMs)
|
||||||
|
else
|
||||||
|
stringResource(id = R.string.amsat_title),
|
||||||
|
fontSize = 14.sp,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(34.dp)
|
.size(32.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
.clickable(onClick = onRefresh),
|
.clickable(onClick = onRefresh),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
if (isRefreshing) {
|
if (isRefreshing) {
|
||||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
|
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||||
} else {
|
} else {
|
||||||
Image(
|
Image(
|
||||||
painter = painterResource(id = StatusR.drawable.ic_refresh),
|
painter = painterResource(id = R.drawable.ic_refresh),
|
||||||
contentDescription = stringResource(id = R.string.amsat_refresh),
|
contentDescription = stringResource(id = R.string.amsat_refresh),
|
||||||
modifier = Modifier
|
modifier = Modifier.size(16.dp),
|
||||||
.size(18.dp)
|
|
||||||
.rotate(angle),
|
|
||||||
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary)
|
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -200,171 +245,384 @@ private fun StatusHeader(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Legend (official four colors) */
|
/** Legend: FlowRow of colored chips — wraps to two lines on narrow screens, stays one line when wide. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun LegendRow() {
|
private fun LegendRow() {
|
||||||
val legend = listOf(
|
val legend = listOf(
|
||||||
stringResource(id = R.string.amsat_active) to MaterialTheme.colorScheme.tertiary,
|
stringResource(id = R.string.amsat_active) to Color(0xFF648FFF),
|
||||||
stringResource(id = R.string.amsat_tlm) to MaterialTheme.colorScheme.tertiaryContainer,
|
stringResource(id = R.string.amsat_tlm) to Color(0xFFFFB000),
|
||||||
stringResource(id = R.string.amsat_not_heard) to Color(0xFFDC267F),
|
stringResource(id = R.string.amsat_not_heard) to Color(0xFFDC267F),
|
||||||
stringResource(id = R.string.amsat_conflict) to MaterialTheme.colorScheme.error
|
stringResource(id = R.string.amsat_conflict) to Color(0xFFFE6100)
|
||||||
)
|
)
|
||||||
Row(
|
FlowRow(
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
) {
|
) {
|
||||||
legend.forEach { (label, color) ->
|
legend.forEach { (label, color) ->
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(10.dp)
|
.size(8.dp)
|
||||||
.clip(RoundedCornerShape(2.dp))
|
.clip(RoundedCornerShape(4.dp))
|
||||||
.background(color)
|
.background(color)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(3.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
Text(text = label, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text(
|
||||||
|
text = label,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Header: satellite name + 6 day dates */
|
/** Header: satellite name column + fixed-width date labels aligned to tiles. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun HeaderRow(statuses: List<SatStatus>) {
|
private fun HeaderRow(statuses: List<SatStatus>) {
|
||||||
val dates = statuses.firstOrNull()?.days?.map { it.dateLabel } ?: emptyList()
|
val dates = statuses.firstOrNull()?.days?.map { it.dateLabel } ?: emptyList()
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = R.string.amsat_name),
|
text = stringResource(id = R.string.amsat_name),
|
||||||
fontSize = 11.sp,
|
fontSize = 12.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.weight(2f).padding(start = 4.dp)
|
modifier = Modifier.weight(1f).padding(start = 4.dp)
|
||||||
)
|
)
|
||||||
dates.forEach { date ->
|
dates.forEach { date ->
|
||||||
Text(
|
Text(
|
||||||
text = date,
|
text = date,
|
||||||
fontSize = 10.sp,
|
fontSize = 12.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Medium,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.weight(0.8f)
|
modifier = Modifier.width(TILE_WIDTH)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Satellite row: name + 6 day color blocks (displays the newest non-gray status) */
|
/** Satellite row: name takes remaining width; day tiles are fixed-width (tablet-safe). */
|
||||||
@Composable
|
@Composable
|
||||||
private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
|
private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
|
||||||
val noReportGray = 0xFFC0C0C0L
|
val noReportGray = 0xFFC0C0C0L
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 3.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = status.name,
|
text = status.name,
|
||||||
fontSize = 11.sp,
|
fontSize = 14.sp,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(2f).padding(start = 4.dp)
|
modifier = Modifier.weight(1f).padding(end = 4.dp)
|
||||||
)
|
)
|
||||||
status.days.forEach { day ->
|
status.days.forEach { day ->
|
||||||
val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first()
|
val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first()
|
||||||
DayCell(
|
DayCell(
|
||||||
slot = slot,
|
slot = slot,
|
||||||
modifier = Modifier.weight(0.8f).padding(horizontal = 1.dp),
|
modifier = Modifier.width(TILE_WIDTH).padding(horizontal = 2.dp),
|
||||||
onClick = { onClickDay(day) }
|
onClick = { onClickDay(day) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Day block: newest reported status among the day's 12 slots; gray when none */
|
/** Day block: newest reported status among the day's 12 slots; gray when none. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
|
private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
|
||||||
val color = Color(slot.statusColor)
|
val color = Color(slot.statusColor)
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.height(24.dp)
|
.height(28.dp)
|
||||||
.clip(RoundedCornerShape(4.dp))
|
.clip(RoundedCornerShape(4.dp))
|
||||||
.background(color)
|
.background(color)
|
||||||
.clickable(onClick = onClick),
|
.clickable(onClick = onClick),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
if (slot.count > 0) {
|
if (slot.count > 0) {
|
||||||
Text(
|
Text(text = slot.count.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
||||||
text = slot.count.toString(),
|
|
||||||
fontSize = 11.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = Color.White
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Report detail dialog (3 levels: callsign/date/time/grid) */
|
/** Report detail dialog (callsign / date / time / grid) */
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReportDialog(
|
private fun ReportDialog(
|
||||||
statusName: String,
|
statusName: String,
|
||||||
day: SatDay,
|
day: SatDay,
|
||||||
reports: Map<String, SatReport>,
|
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
|
onDismiss: () -> Unit
|
||||||
) {
|
) {
|
||||||
val dayReports = day.slots.flatMap { it.reportIds }
|
val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] }
|
||||||
.mapNotNull { reports[it] }
|
InfoDialog(
|
||||||
AlertDialog(
|
title = "$statusName · ${day.dateLabel}",
|
||||||
onDismissRequest = onDismiss,
|
onDismiss = onDismiss,
|
||||||
title = { Text(text = "$statusName · ${day.dateLabel}") },
|
onAccept = onDismiss,
|
||||||
text = {
|
extraAction = {
|
||||||
if (dayReports.isEmpty()) {
|
CardButton(
|
||||||
Text(stringResource(id = R.string.amsat_no_reports))
|
onClick = onToggleUpload,
|
||||||
} else {
|
text = stringResource(if (upload.isExpanded) R.string.amsat_upload_back else R.string.amsat_upload)
|
||||||
LazyColumn(modifier = Modifier.height(320.dp)) {
|
)
|
||||||
items(dayReports) { report ->
|
}
|
||||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
AnimatedVisibility(visible = upload.isExpanded) {
|
||||||
Box(
|
AmSatUploadPanel(
|
||||||
modifier = Modifier
|
statusName = statusName,
|
||||||
.size(8.dp)
|
upload = upload,
|
||||||
.clip(CircleShape)
|
onReportChange = onReportChange,
|
||||||
.background(statusColorOf(report.statusText))
|
onCallsignChange = onCallsignChange,
|
||||||
)
|
onGridChange = onGridChange,
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
onRequestSubmitReport = onRequestSubmitReport
|
||||||
Text(
|
)
|
||||||
text = report.statusText,
|
}
|
||||||
fontSize = 13.sp,
|
if (upload.isConfirmingSubmit) {
|
||||||
fontWeight = FontWeight.Bold
|
AmSatUploadConfirmDialog(
|
||||||
)
|
statusName = statusName,
|
||||||
}
|
upload = upload,
|
||||||
Text(
|
onConfirm = onConfirmSubmitReport,
|
||||||
text = "${report.call} ${report.dateUtc} ${report.timeUtc}" +
|
onDismiss = onDismissSubmitConfirmation
|
||||||
if (report.grid.isNotBlank() && report.grid != "-") " ${report.grid}" else "",
|
)
|
||||||
fontSize = 12.sp,
|
}
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
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)
|
||||||
}
|
}
|
||||||
HorizontalDivider(thickness = 0.5.dp)
|
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 = {
|
confirmButton = {
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(id = R.string.amsat_close)) }
|
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 {
|
private fun formatFetchedAt(utcMs: Long): String {
|
||||||
val cal = Calendar.getInstance()
|
val cal = Calendar.getInstance()
|
||||||
cal.timeInMillis = utcMs
|
cal.timeInMillis = utcMs
|
||||||
return "%02d-%02d %02d:%02d".format(
|
val day = cal.get(Calendar.DAY_OF_MONTH)
|
||||||
cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH),
|
val monthIndex = cal.get(Calendar.MONTH)
|
||||||
cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE)
|
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))
|
||||||
}
|
}
|
||||||
+224
-24
@@ -4,13 +4,38 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.lifecycle.viewmodel.initializer
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
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.SatReport
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
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.IMainContainer
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
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(
|
data class SatStatusUiState(
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
@@ -18,35 +43,34 @@ data class SatStatusUiState(
|
|||||||
val statuses: List<SatStatus> = emptyList(),
|
val statuses: List<SatStatus> = emptyList(),
|
||||||
val reports: Map<String, SatReport> = emptyMap(),
|
val reports: Map<String, SatReport> = emptyMap(),
|
||||||
val fetchedAtUtcMs: Long = 0L,
|
val fetchedAtUtcMs: Long = 0L,
|
||||||
val error: String? = null
|
val error: String? = null,
|
||||||
|
val upload: AmSatUploadUiState = AmSatUploadUiState()
|
||||||
)
|
)
|
||||||
|
|
||||||
class SatStatusViewModel(
|
class SatStatusViewModel(
|
||||||
private val container: IMainContainer
|
private val amSatRepo: IAmSatRepository,
|
||||||
|
private val settingsRepo: ISettingsRepo
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true))
|
private val cachedStatusPage = amSatRepo.getCachedStatus()
|
||||||
|
private val _uiState = MutableStateFlow(cachedStatusPage?.toUiState() ?: SatStatusUiState(isLoading = true))
|
||||||
val uiState: StateFlow<SatStatusUiState> = _uiState
|
val uiState: StateFlow<SatStatusUiState> = _uiState
|
||||||
|
|
||||||
init {
|
init {
|
||||||
fetch()
|
if (cachedStatusPage == null) fetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fetch() {
|
fun fetch() {
|
||||||
|
amSatRepo.getCachedStatus()?.let { page ->
|
||||||
|
applyStatusPage(page)
|
||||||
|
return
|
||||||
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
try {
|
try {
|
||||||
val page = container.amSatRepo.fetchStatus()
|
val page = amSatRepo.fetchStatus()
|
||||||
if (page != null && page.statuses.isNotEmpty()) {
|
if (page != null && page.statuses.isNotEmpty()) {
|
||||||
_uiState.update {
|
applyStatusPage(page)
|
||||||
it.copy(
|
|
||||||
isLoading = false,
|
|
||||||
isRefreshing = false,
|
|
||||||
statuses = page.statuses,
|
|
||||||
reports = page.reports,
|
|
||||||
fetchedAtUtcMs = page.fetchedAtUtcMs
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(isLoading = false, isRefreshing = false, error = "fetch_failed")
|
it.copy(isLoading = false, isRefreshing = false, error = "fetch_failed")
|
||||||
@@ -65,16 +89,9 @@ class SatStatusViewModel(
|
|||||||
_uiState.update { it.copy(isRefreshing = true, error = null) }
|
_uiState.update { it.copy(isRefreshing = true, error = null) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val page = container.amSatRepo.fetchStatus()
|
val page = amSatRepo.fetchStatus(forceRefresh = true)
|
||||||
if (page != null && page.statuses.isNotEmpty()) {
|
if (page != null && page.statuses.isNotEmpty()) {
|
||||||
_uiState.update {
|
applyStatusPage(page)
|
||||||
it.copy(
|
|
||||||
isRefreshing = false,
|
|
||||||
statuses = page.statuses,
|
|
||||||
reports = page.reports,
|
|
||||||
fetchedAtUtcMs = page.fetchedAtUtcMs
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
_uiState.update { it.copy(isRefreshing = false, error = "fetch_failed") }
|
_uiState.update { it.copy(isRefreshing = false, error = "fetch_failed") }
|
||||||
}
|
}
|
||||||
@@ -84,11 +101,194 @@ class SatStatusViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
companion object {
|
||||||
fun factory(container: IMainContainer) = viewModelFactory {
|
fun factory(container: IMainContainer) = viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
SatStatusViewModel(container)
|
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]
|
[versions]
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionCode = "443"
|
appVersionCode = "451"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionName = "4.4.3"
|
appVersionName = "4.4.6-ba7opf.7"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
compileSdk = "37"
|
compileSdk = "37"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
@@ -23,7 +23,7 @@ compose-activity = "1.13.0"
|
|||||||
compose-lifecycle = "2.11.0"
|
compose-lifecycle = "2.11.0"
|
||||||
compose-navigation3 = "1.1.5"
|
compose-navigation3 = "1.1.5"
|
||||||
|
|
||||||
google-ksp = "2.3.8"
|
google-ksp = "2.3.10"
|
||||||
|
|
||||||
kotlin = "2.4.10"
|
kotlin = "2.4.10"
|
||||||
kotlin-coroutines = "1.11.0"
|
kotlin-coroutines = "1.11.0"
|
||||||
|
|||||||
Reference in new issue
Block a user