mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-04 03:37:21 +00:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75d162301b | ||
|
|
07c09ec3e9 | ||
|
|
513d50e56a | ||
|
|
366edd8bbb | ||
|
|
13df0dfcec | ||
|
|
eb1a5d51a2 | ||
|
|
4e3547649f | ||
|
|
7abbeb6eb3 | ||
|
|
5c73d5ed7a | ||
|
|
a663ef7f1b | ||
|
|
1f1e1fb144 | ||
|
|
5166daa297 | ||
|
|
604c2ec59d | ||
|
|
1904a6b3e6 | ||
|
|
7f390117b8 | ||
|
|
346803fe6e | ||
|
|
47b87a1604 | ||
|
|
935fe6d99f | ||
|
|
69af1c38bb | ||
|
|
5c35f2418d | ||
|
|
2aa9858660 | ||
|
|
bc6e065804 | ||
|
|
7924a6f69e | ||
|
|
9a2a715d1f | ||
|
|
ee32c7a1eb | ||
|
|
1d54ac4718 | ||
|
|
433ce85561 | ||
|
|
cebe3b7ec4 | ||
|
|
d8f60cf270 |
No files matched your search
@@ -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 布局调整,系统语言自动切换
|
||||||
|
|
||||||
## 上游仓库
|
## 上游仓库
|
||||||
|
|
||||||
|
|||||||
+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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -51,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
|
||||||
@@ -91,11 +94,16 @@ import com.rtbishop.look4sat.feature.status.SatStatusDestination
|
|||||||
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 { rootBackStack.add(deeplinkResolver.resolve(it)) }
|
deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
|
||||||
}
|
}
|
||||||
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
||||||
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
|
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
|
||||||
|
val navigateToMap: () -> Unit = {
|
||||||
|
while (rootBackStack.size > 1) rootBackStack.removeAt(rootBackStack.size - 1)
|
||||||
|
openMapRequest += 1
|
||||||
|
}
|
||||||
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
|
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
|
||||||
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
|
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
|
||||||
slideOutHorizontally(tween(300)) { -it / 3 }
|
slideOutHorizontally(tween(300)) { -it / 3 }
|
||||||
@@ -114,13 +122,19 @@ fun NavRoot(deeplink: String? = null) {
|
|||||||
rememberViewModelStoreNavEntryDecorator()
|
rememberViewModelStoreNavEntryDecorator()
|
||||||
),
|
),
|
||||||
entryProvider = entryProvider {
|
entryProvider = entryProvider {
|
||||||
entry<Screen.Passes> { MainScreen(navigateToRadar = navigateToRadar) }
|
entry<Screen.Passes> {
|
||||||
|
MainScreen(
|
||||||
|
navigateToRadar = navigateToRadar,
|
||||||
|
openMapRequest = openMapRequest,
|
||||||
|
onOpenMapRequestHandled = { openMapRequest = 0 }
|
||||||
|
)
|
||||||
|
}
|
||||||
entry<RadarDestination> {
|
entry<RadarDestination> {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.background
|
color = MaterialTheme.colorScheme.background
|
||||||
) {
|
) {
|
||||||
RadarDestination(navigateUp = navigateBack)
|
RadarDestination(navigateUp = navigateBack, navigateToMap = navigateToMap)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,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.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()
|
||||||
@@ -157,9 +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.Mutual -> screen is Screen.Mutual
|
is Screen.Map -> screen is Screen.Passes
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -168,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)
|
||||||
}
|
}
|
||||||
@@ -203,11 +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())
|
||||||
navigateToRadar()
|
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||||
}
|
navigateToRadar()
|
||||||
|
},
|
||||||
|
navigateToMap = navigateToMap
|
||||||
|
)
|
||||||
}
|
}
|
||||||
entry<Screen.Map> {
|
entry<Screen.Map> {
|
||||||
MapDestination()
|
MapDestination()
|
||||||
@@ -215,7 +246,6 @@ 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)
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -35,6 +35,8 @@ 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.repository.WavelogRepository
|
||||||
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
|
||||||
@@ -51,6 +53,7 @@ import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
|||||||
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
|
||||||
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||||
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
|
||||||
@@ -78,7 +81,9 @@ 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 wavelogRepo: IWavelogRepository by lazy { WavelogRepository() }
|
||||||
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)
|
||||||
|
|||||||
+125
-11
@@ -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,28 +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
|
||||||
// 72h = 3 days; API hard cap is limit=500 regardless of what we send.
|
// 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.
|
// 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.
|
// 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@withContext null
|
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 */
|
||||||
@@ -93,11 +199,10 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
|||||||
/** Build one SatStatus (3 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 3).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 36).map { slotIdx ->
|
val slots = (0 until 36).map { slotIdx ->
|
||||||
@@ -122,6 +227,15 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
+28
-12
@@ -67,28 +67,44 @@ class DatabaseRepo(
|
|||||||
|
|
||||||
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
||||||
val settings = settingsRepo.dataSourcesSettings.value
|
val settings = settingsRepo.dataSourcesSettings.value
|
||||||
val tleUrls = settings.satelliteUrls.filter { it.isNotBlank() }.map(::normalizeUrl).distinct()
|
// Keep the raw URL as the status key (matches what the settings UI
|
||||||
val radioUrls = settings.transceiversUrls.filter { it.isNotBlank() }.map(::normalizeUrl).distinct()
|
// displays) while requesting with the normalized URL.
|
||||||
|
val tleUrls = settings.satelliteUrls
|
||||||
|
.filterIndexed { i, url -> url.isNotBlank() && settings.isSatelliteEnabled(i) }
|
||||||
|
.map { it to normalizeUrl(it) }
|
||||||
|
.distinctBy { it.second }
|
||||||
|
val radioUrls = settings.transceiversUrls
|
||||||
|
.filterIndexed { i, url -> url.isNotBlank() && settings.isTransceiverEnabled(i) }
|
||||||
|
.map { it to normalizeUrl(it) }
|
||||||
|
.distinctBy { it.second }
|
||||||
val builtinTypesByUrl = Sources.satelliteDataUrls
|
val builtinTypesByUrl = Sources.satelliteDataUrls
|
||||||
.filterValues { it.isNotBlank() }
|
.filterValues { it.isNotBlank() }
|
||||||
.mapValues { normalizeUrl(it.value) }
|
.mapValues { normalizeUrl(it.value) }
|
||||||
.entries
|
.entries
|
||||||
.associate { (type, url) -> url to type }
|
.associate { (type, url) -> url to type }
|
||||||
val importedTypeIds = mutableMapOf<String, MutableList<Int>>()
|
val importedTypeIds = mutableMapOf<String, MutableList<Int>>()
|
||||||
// launch all network requests concurrently
|
// launch all network requests concurrently, keeping the raw url as key for status reporting
|
||||||
val tleJobs = tleUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
val tleJobs = tleUrls.map { (raw, norm) -> async { raw to remoteSource.getNetworkStream(norm) } }
|
||||||
val radioJobs = radioUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
val radioJobs = radioUrls.map { (raw, norm) -> async { raw to remoteSource.getNetworkStream(norm) } }
|
||||||
|
val tleResults = tleJobs.awaitAll()
|
||||||
|
val radioResults = radioJobs.awaitAll()
|
||||||
|
// report the HTTP status code of every source (200, 404, ...)
|
||||||
|
settingsRepo.updateDataSourcesStatus(
|
||||||
|
(tleResults + radioResults).associate { (url, result) -> url to result.code }
|
||||||
|
)
|
||||||
// parse fetched data concurrently and associate known built-in URLs with existing type filters.
|
// parse fetched data concurrently and associate known built-in URLs with existing type filters.
|
||||||
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
|
val importedEntries = tleResults.flatMap { (rawUrl, result) ->
|
||||||
val entries = stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty()
|
val normUrl = normalizeUrl(rawUrl)
|
||||||
val type = builtinTypesByUrl[url] ?: customSourceType
|
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 })
|
importedTypeIds.getOrPut(type) { mutableListOf() }.addAll(entries.map { it.catnum })
|
||||||
entries
|
entries
|
||||||
}
|
}.distinctBy { it.catnum }
|
||||||
importedTypeIds.forEach { (type, ids) -> settingsRepo.setSatelliteTypeIds(type, ids.distinct()) }
|
importedTypeIds.forEach { (type, ids) -> settingsRepo.setSatelliteTypeIds(type, ids.distinct()) }
|
||||||
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
|
val importedRadios = radioResults.flatMap { (rawUrl, result) ->
|
||||||
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
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)
|
||||||
|
|||||||
+171
-6
@@ -29,6 +29,7 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
|||||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||||
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.model.WavelogSettings
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
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.positionToQth
|
||||||
@@ -37,6 +38,8 @@ 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.model.Constants
|
||||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||||
|
|
||||||
@@ -79,6 +82,7 @@ class SettingsRepo(
|
|||||||
private val keyStateOfUtc = "stateOfUtc"
|
private val keyStateOfUtc = "stateOfUtc"
|
||||||
private val keyStateOfLightTheme = "stateOfLightTheme"
|
private val keyStateOfLightTheme = "stateOfLightTheme"
|
||||||
private val keyStateOfNightMode = "stateOfNightMode"
|
private val keyStateOfNightMode = "stateOfNightMode"
|
||||||
|
private val keyStateOfMapGrid = "stateOfMapGrid"
|
||||||
private val keyStationAltitude = "stationAltitude"
|
private val keyStationAltitude = "stationAltitude"
|
||||||
private val keyStationLatitude = "stationLatitude"
|
private val keyStationLatitude = "stationLatitude"
|
||||||
private val keyStationLongitude = "stationLongitude"
|
private val keyStationLongitude = "stationLongitude"
|
||||||
@@ -96,8 +100,13 @@ class SettingsRepo(
|
|||||||
private val keyTransceiversUrl = "transceiversUrl"
|
private val keyTransceiversUrl = "transceiversUrl"
|
||||||
private val keySatelliteUrls = "satelliteUrls"
|
private val keySatelliteUrls = "satelliteUrls"
|
||||||
private val keyTransceiversUrls = "transceiversUrls"
|
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 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())
|
||||||
@@ -130,7 +139,48 @@ class SettingsRepo(
|
|||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
//region # Passes filter settings
|
//region # Wavelog worked-grids settings
|
||||||
|
private val keyWavelogUrl = "wavelogUrl"
|
||||||
|
private val keyWavelogToken = "wavelogToken"
|
||||||
|
private val keyWorkedGrids = "workedGrids"
|
||||||
|
|
||||||
|
private val _wavelogSettings = MutableStateFlow(getWavelogSettings())
|
||||||
|
override val wavelogSettings: StateFlow<WavelogSettings> = _wavelogSettings
|
||||||
|
|
||||||
|
override fun updateWavelogSettings(settings: WavelogSettings) {
|
||||||
|
preferences.edit {
|
||||||
|
putString(keyWavelogUrl, settings.url.trim())
|
||||||
|
putString(keyWavelogToken, settings.token.trim())
|
||||||
|
}
|
||||||
|
_wavelogSettings.value = settings.copy(url = settings.url.trim(), token = settings.token.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getWavelogSettings(): WavelogSettings = WavelogSettings(
|
||||||
|
url = preferences.getString(keyWavelogUrl, null).orEmpty(),
|
||||||
|
token = preferences.getString(keyWavelogToken, null).orEmpty()
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun getWorkedGrids(): Set<String> {
|
||||||
|
val json = preferences.getString(keyWorkedGrids, null).orEmpty()
|
||||||
|
if (json.isBlank()) return emptySet()
|
||||||
|
return try {
|
||||||
|
val array = org.json.JSONArray(json)
|
||||||
|
(0 until array.length()).mapNotNull { i ->
|
||||||
|
array.optString(i).takeIf { it.isNotBlank() }
|
||||||
|
}.toSet()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emptySet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setWorkedGrids(grids: Set<String>) {
|
||||||
|
val array = org.json.JSONArray()
|
||||||
|
grids.sorted().forEach { array.put(it) }
|
||||||
|
preferences.edit { putString(keyWorkedGrids, array.toString()) }
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region # Transceivers settings
|
||||||
private val _passesSettings = MutableStateFlow(getPassesSettings())
|
private val _passesSettings = MutableStateFlow(getPassesSettings())
|
||||||
override val passesSettings: StateFlow<PassesSettings> = _passesSettings
|
override val passesSettings: StateFlow<PassesSettings> = _passesSettings
|
||||||
|
|
||||||
@@ -364,6 +414,7 @@ class SettingsRepo(
|
|||||||
putBoolean(keyStateOfUtc, new.stateOfUtc)
|
putBoolean(keyStateOfUtc, new.stateOfUtc)
|
||||||
putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
|
putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
|
||||||
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
|
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
|
||||||
|
putBoolean(keyStateOfMapGrid, new.stateOfMapGrid)
|
||||||
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
||||||
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
||||||
putString(keySstvMode, new.sstvMode)
|
putString(keySstvMode, new.sstvMode)
|
||||||
@@ -381,6 +432,7 @@ class SettingsRepo(
|
|||||||
stateOfUtc = preferences.getBoolean(keyStateOfUtc, false),
|
stateOfUtc = preferences.getBoolean(keyStateOfUtc, false),
|
||||||
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
|
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
|
||||||
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
|
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
|
||||||
|
stateOfMapGrid = preferences.getBoolean(keyStateOfMapGrid, false),
|
||||||
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
||||||
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
||||||
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
||||||
@@ -394,11 +446,21 @@ 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 {
|
||||||
putString(keySatelliteUrls, settings.satelliteUrls.joinToString(separatorUrl))
|
putString(keySatelliteUrls, normalized.satelliteUrls.joinToString(separatorUrl))
|
||||||
putString(keyTransceiversUrls, settings.transceiversUrls.joinToString(separatorUrl))
|
putString(keyTransceiversUrls, normalized.transceiversUrls.joinToString(separatorUrl))
|
||||||
|
putString(keySatelliteEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
|
||||||
|
putString(keyTransceiversEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
|
||||||
|
putBoolean(keySatnogsTleSourceMigration, true)
|
||||||
}
|
}
|
||||||
_dataSourcesSettings.value = settings
|
_dataSourcesSettings.value = normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
|
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
|
||||||
@@ -407,15 +469,33 @@ class SettingsRepo(
|
|||||||
defaultUrls = Sources.satelliteDataUrls.values.filter { it.isNotBlank() },
|
defaultUrls = Sources.satelliteDataUrls.values.filter { it.isNotBlank() },
|
||||||
legacyEnabledKey = keyUseCustomTle,
|
legacyEnabledKey = keyUseCustomTle,
|
||||||
legacyUrlKey = keyTleUrl
|
legacyUrlKey = keyTleUrl
|
||||||
),
|
).migrateSatnogsTleSource(),
|
||||||
transceiversUrls = getDataSourceUrls(
|
transceiversUrls = getDataSourceUrls(
|
||||||
key = keyTransceiversUrls,
|
key = keyTransceiversUrls,
|
||||||
defaultUrls = Sources.transceiversDataUrls.values.filter { it.isNotBlank() },
|
defaultUrls = Sources.transceiversDataUrls.values.filter { it.isNotBlank() },
|
||||||
legacyEnabledKey = keyUseCustomTransceivers,
|
legacyEnabledKey = keyUseCustomTransceivers,
|
||||||
legacyUrlKey = keyTransceiversUrl
|
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(
|
private fun getDataSourceUrls(
|
||||||
key: String,
|
key: String,
|
||||||
defaultUrls: List<String>,
|
defaultUrls: List<String>,
|
||||||
@@ -434,6 +514,40 @@ class SettingsRepo(
|
|||||||
defaultUrls
|
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
|
||||||
@@ -474,4 +588,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
|
||||||
}
|
}
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
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 java.io.File
|
||||||
|
|
||||||
|
class UpdateRepository(
|
||||||
|
private val remoteSource: IRemoteSource
|
||||||
|
) : IUpdateRepository {
|
||||||
|
|
||||||
|
override suspend fun getLatestRelease(): LatestRelease? = withContext(Dispatchers.IO) {
|
||||||
|
// Use the GitHub releases web page instead of the REST API endpoint:
|
||||||
|
// the API endpoint is rate-limited to 60 requests/hour per IP, which is
|
||||||
|
// quickly exhausted on shared egress IPs (e.g. VPN proxies), causing 403
|
||||||
|
// failures. The web endpoint redirects to the latest tag with no such limit.
|
||||||
|
val result = remoteSource.getNetworkStream(LATEST_RELEASE_URL)
|
||||||
|
val stream = result.stream ?: return@withContext null
|
||||||
|
try {
|
||||||
|
val html = stream.bufferedReader().use { it.readText() }
|
||||||
|
parseRelease(html)
|
||||||
|
} 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(html: String): LatestRelease? {
|
||||||
|
// The redirect target is the latest release's tag page. Extract the tag
|
||||||
|
// from the og:url meta tag ("…/releases/tag/v4.4.6-ba7opf.8") — stable and
|
||||||
|
// unambiguous. The <title> also holds the release name, but that is free
|
||||||
|
// text and cannot be used to build the asset URL.
|
||||||
|
val ogUrl = Regex("<meta\\s+property=\"og:url\"\\s+content=\"([^\"]*)\"").find(html)
|
||||||
|
?.groupValues?.get(1) ?: return null
|
||||||
|
val tag = Regex("/releases/tag/(v[0-9][0-9A-Za-z.\\-]*)$").find(ogUrl)
|
||||||
|
?.groupValues?.get(1) ?: return null
|
||||||
|
val title = Regex("<title>(.*?)</title>", RegexOption.DOT_MATCHES_ALL)
|
||||||
|
.find(html)?.groupValues?.get(1)?.trim()
|
||||||
|
?.substringBefore("·")?.removePrefix("Release")?.trim() ?: tag
|
||||||
|
// The release APK asset follows the fixed naming scheme used by the build:
|
||||||
|
// Look4Sat-<tag without leading v>-release.apk
|
||||||
|
val apkUrl = "$DOWNLOAD_BASE_URL/$tag/Look4Sat-${tag.removePrefix("v")}-release.apk"
|
||||||
|
return LatestRelease(
|
||||||
|
versionTag = tag,
|
||||||
|
title = title,
|
||||||
|
body = "",
|
||||||
|
apkUrl = apkUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
// Web pages are used instead of api.github.com to avoid the 60 req/hour
|
||||||
|
// anonymous rate limit (see getLatestRelease above).
|
||||||
|
const val LATEST_RELEASE_URL = "https://github.com/atsunatsu/Look4Sat/releases/latest"
|
||||||
|
const val DOWNLOAD_BASE_URL = "https://github.com/atsunatsu/Look4Sat/releases/download"
|
||||||
|
}
|
||||||
|
}
|
||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
* 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.data.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the list of worked gridsquares from a self-hosted Wavelog instance.
|
||||||
|
*
|
||||||
|
* Uses the Wavelog v1 API endpoint (verified against Wavelog 3.0.2):
|
||||||
|
* POST {base}/index.php/api/logbook_get_worked_grids
|
||||||
|
* Body: {"key": "<api key>", "logbook_id": "<logbook id>"}
|
||||||
|
* Response 201: JSON array of 4-char gridsquares, e.g. ["JN47","JO30"]
|
||||||
|
* (see Wavelog Api.php logbook_get_worked_grids / Api_model::get_grids_worked_in_logbook)
|
||||||
|
*
|
||||||
|
* The token field carries "<api_key>:<logbook_id>" so one credential line
|
||||||
|
* configures both values (the dialog explains this to the user).
|
||||||
|
*/
|
||||||
|
class WavelogRepository : IWavelogRepository {
|
||||||
|
|
||||||
|
override suspend fun fetchWorkedGrids(url: String, token: String): Set<String>? = withContext(Dispatchers.IO) {
|
||||||
|
val parts = token.trim().split(":")
|
||||||
|
val apiKey = parts.getOrNull(0)?.trim().orEmpty()
|
||||||
|
val logbookId = parts.getOrNull(1)?.trim().orEmpty()
|
||||||
|
if (url.isBlank() || apiKey.isBlank() || logbookId.isBlank()) return@withContext null
|
||||||
|
val base = url.trim().trimEnd('/')
|
||||||
|
val requestUrl = "$base/index.php/api/logbook_get_worked_grids"
|
||||||
|
try {
|
||||||
|
val connection = java.net.URL(requestUrl).openConnection() as java.net.HttpURLConnection
|
||||||
|
connection.connectTimeout = 10_000
|
||||||
|
connection.readTimeout = 15_000
|
||||||
|
connection.requestMethod = "POST"
|
||||||
|
connection.doOutput = true
|
||||||
|
connection.setRequestProperty("Content-Type", "application/json")
|
||||||
|
val body = JSONObject().apply {
|
||||||
|
put("key", apiKey)
|
||||||
|
put("logbook_id", logbookId)
|
||||||
|
}.toString().toByteArray()
|
||||||
|
connection.outputStream.use { it.write(body) }
|
||||||
|
val code = connection.responseCode
|
||||||
|
if (code !in 200..299) {
|
||||||
|
connection.disconnect()
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
val response = connection.inputStream.bufferedReader().use { it.readText() }
|
||||||
|
connection.disconnect()
|
||||||
|
parseWorkedGrids(response)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("WavelogRepository fetch failure: $e")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun parseWorkedGrids(body: String): Set<String>? {
|
||||||
|
return try {
|
||||||
|
val array = when {
|
||||||
|
body.trimStart().startsWith("[") -> JSONArray(body)
|
||||||
|
else -> {
|
||||||
|
// Tolerate {"grids": [...]} wrappers too
|
||||||
|
JSONObject(body).optJSONArray("grids") ?: return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val result = mutableSetOf<String>()
|
||||||
|
for (i in 0 until array.length()) {
|
||||||
|
val grid = array.optString(i, "").trim().uppercase()
|
||||||
|
// Keep only well-formed 4-character Maidenhead squares (e.g. OL62)
|
||||||
|
if (grid.length == 4 && grid[0] in 'A'..'R' && grid[1] in 'A'..'R'
|
||||||
|
&& grid[2] in '0'..'9' && grid[3] in '0'..'9'
|
||||||
|
) {
|
||||||
|
result.add(grid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,10 +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 okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
class RemoteSource(
|
class RemoteSource(
|
||||||
@@ -42,20 +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()
|
||||||
val response = httpClient.newCall(networkRequest).execute()
|
val response = httpClient.newCall(networkRequest).execute()
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
|
val code = response.code
|
||||||
response.close()
|
response.close()
|
||||||
return@withContext null
|
return@withContext NetworkResult(code, null)
|
||||||
}
|
}
|
||||||
// Return the body stream directly as the caller is responsible for closing it.
|
// Return the body stream directly as the caller is responsible for closing it.
|
||||||
// Closing the stream returns the connection to OkHttp's pool.
|
// Closing the stream returns the connection to OkHttp's pool.
|
||||||
response.body.byteStream().buffered()
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,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
|
||||||
|
}
|
||||||
+47
-1
@@ -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
|
||||||
@@ -103,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
|
||||||
@@ -113,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 {
|
||||||
@@ -121,11 +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 getAmSatCatalog(): String? = null
|
||||||
|
|
||||||
override suspend fun getAmSatReports(hours: Int, limit: Int): 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 {
|
||||||
@@ -223,7 +255,21 @@ 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 {
|
||||||
|
|||||||
+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,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
|
||||||
|
)
|
||||||
@@ -59,6 +59,7 @@ data class OtherSettings(
|
|||||||
val stateOfUtc: Boolean,
|
val stateOfUtc: Boolean,
|
||||||
val stateOfLightTheme: Boolean,
|
val stateOfLightTheme: Boolean,
|
||||||
val stateOfNightMode: Boolean = false,
|
val stateOfNightMode: Boolean = false,
|
||||||
|
val stateOfMapGrid: Boolean = false,
|
||||||
val shouldSeeWarning: Boolean,
|
val shouldSeeWarning: Boolean,
|
||||||
val shouldSeeWhatsNew: Boolean,
|
val shouldSeeWhatsNew: Boolean,
|
||||||
val sstvMode: String = "Auto",
|
val sstvMode: String = "Auto",
|
||||||
@@ -68,8 +69,20 @@ data class OtherSettings(
|
|||||||
|
|
||||||
data class DataSourcesSettings(
|
data class DataSourcesSettings(
|
||||||
val satelliteUrls: List<String>,
|
val satelliteUrls: List<String>,
|
||||||
val transceiversUrls: List<String>
|
val transceiversUrls: List<String>,
|
||||||
)
|
val satelliteEnabled: List<Boolean> = emptyList(),
|
||||||
|
val transceiversEnabled: List<Boolean> = emptyList()
|
||||||
|
) {
|
||||||
|
fun isSatelliteEnabled(index: Int): Boolean = satelliteEnabled.getOrElse(index) { true }
|
||||||
|
fun isTransceiverEnabled(index: Int): Boolean = transceiversEnabled.getOrElse(index) { true }
|
||||||
|
}
|
||||||
|
|
||||||
|
data class WavelogSettings(
|
||||||
|
val url: String = "",
|
||||||
|
val token: String = ""
|
||||||
|
) {
|
||||||
|
val isConfigured: Boolean get() = url.isNotBlank() && token.isNotBlank()
|
||||||
|
}
|
||||||
|
|
||||||
data class RadioControlSettings(
|
data class RadioControlSettings(
|
||||||
val enabled: Boolean,
|
val enabled: Boolean,
|
||||||
|
|||||||
+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
|
||||||
}
|
}
|
||||||
+2
@@ -31,6 +31,8 @@ interface IMainContainer {
|
|||||||
val satelliteRepo: ISatelliteRepo
|
val satelliteRepo: ISatelliteRepo
|
||||||
val databaseRepo: IDatabaseRepo
|
val databaseRepo: IDatabaseRepo
|
||||||
val amSatRepo: IAmSatRepository
|
val amSatRepo: IAmSatRepository
|
||||||
|
val updateRepo: IUpdateRepository
|
||||||
|
val wavelogRepo: IWavelogRepository
|
||||||
val radioTrackingService: IRadioTrackingService
|
val radioTrackingService: IRadioTrackingService
|
||||||
val mutualPassData: StateFlow<MutualPassData>
|
val mutualPassData: StateFlow<MutualPassData>
|
||||||
fun setMutualPassData(data: MutualPassData)
|
fun setMutualPassData(data: MutualPassData)
|
||||||
|
|||||||
+23
@@ -23,6 +23,7 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
|||||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||||
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.model.WavelogSettings
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
@@ -73,8 +74,30 @@ 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
|
||||||
|
|
||||||
|
//region # Wavelog worked-grids settings
|
||||||
|
val wavelogSettings: StateFlow<WavelogSettings>
|
||||||
|
fun updateWavelogSettings(settings: WavelogSettings)
|
||||||
|
fun getWorkedGrids(): Set<String>
|
||||||
|
fun setWorkedGrids(grids: Set<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
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
/*
|
||||||
|
* 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.repository
|
||||||
|
|
||||||
|
/** Fetches worked gridsquares from a self-hosted Wavelog instance (API v2). */
|
||||||
|
interface IWavelogRepository {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch worked grids for the configured Wavelog URL/token.
|
||||||
|
* Returns the grid set, or null on any failure.
|
||||||
|
*/
|
||||||
|
suspend fun fetchWorkedGrids(url: String, token: String): Set<String>?
|
||||||
|
}
|
||||||
+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,7 +48,6 @@ 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",
|
"ARISS" to "https://live.ariss.org/iss.txt",
|
||||||
"Other" to "" // key for sats filter
|
"Other" to "" // key for sats filter
|
||||||
)
|
)
|
||||||
|
|||||||
+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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
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.9.1").
|
||||||
|
*
|
||||||
|
* The comparison is done on the numeric version segments first; when the base versions are
|
||||||
|
* equal, the trailing build numbers of the suffix decide. All numeric segments of the suffix
|
||||||
|
* are compared as a sequence, so multi-level suffixes work correctly:
|
||||||
|
* "4.4.6-ba7opf.9.1" > "4.4.6-ba7opf.9" > "4.4.6-ba7opf.8".
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
}
|
||||||
|
val maxBuild = maxOf(cand.second.size, curr.second.size)
|
||||||
|
for (i in 0 until maxBuild) {
|
||||||
|
val c = cand.second.getOrElse(i) { 0 }
|
||||||
|
val k = curr.second.getOrElse(i) { 0 }
|
||||||
|
if (c != k) return c > k
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a version string into (numeric base segments, numeric suffix segments).
|
||||||
|
* "v4.4.6-ba7opf.9.1" -> ([4,4,6], [9,1]); "4.4.6-ba7opf.8" -> ([4,4,6], [8]);
|
||||||
|
* "4.4.6" -> ([4,4,6], []); "4.4.6-ba7opf" -> ([4,4,6], []).
|
||||||
|
*/
|
||||||
|
fun parse(version: String): Pair<List<Int>, List<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() }
|
||||||
|
// All numeric segments of the suffix, in order (e.g. "ba7opf.9.1" -> [9, 1]).
|
||||||
|
val build = suffixPart.split('.').mapNotNull { it.toIntOrNull() }
|
||||||
|
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,58 @@
|
|||||||
|
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 `multi level suffixes compare correctly`() {
|
||||||
|
// The .9.1 hotfix case that exposed the old single-build-number logic.
|
||||||
|
assertTrue(VersionComparator.isNewer("4.4.6-ba7opf.9.1", "4.4.6-ba7opf.9"))
|
||||||
|
assertTrue(VersionComparator.isNewer("v4.4.6-ba7opf.9.1", "4.4.6-ba7opf.8"))
|
||||||
|
assertFalse(VersionComparator.isNewer("4.4.6-ba7opf.9", "4.4.6-ba7opf.9.1"))
|
||||||
|
assertTrue(VersionComparator.isNewer("4.4.6-ba7opf.10", "4.4.6-ba7opf.9.1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse extracts base and suffix segments`() {
|
||||||
|
assertEquals(listOf(4, 4, 6) to listOf(7), VersionComparator.parse("v4.4.6-ba7opf.7"))
|
||||||
|
assertEquals(listOf(4, 4, 6) to listOf(9, 1), VersionComparator.parse("v4.4.6-ba7opf.9.1"))
|
||||||
|
assertEquals(listOf(4, 4, 6) to emptyList<Int>(), VersionComparator.parse("4.4.6"))
|
||||||
|
assertEquals(listOf(4, 4, 6) to emptyList<Int>(), VersionComparator.parse("4.4.6-ba7opf"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -285,6 +285,7 @@ fun InfoDialog(
|
|||||||
title: String,
|
title: String,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onAccept: () -> Unit,
|
onAccept: () -> Unit,
|
||||||
|
extraAction: (@Composable () -> Unit)? = null,
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
DialogShell(onDismissRequest = onDismiss) { padding ->
|
DialogShell(onDismissRequest = onDismiss) { padding ->
|
||||||
@@ -303,6 +304,10 @@ fun InfoDialog(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
extraAction?.let {
|
||||||
|
it()
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
}
|
||||||
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||||
}
|
}
|
||||||
content()
|
content()
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -150,6 +150,7 @@
|
|||||||
<string name="prefs_other_switch_sweep">Habilitar animaciones radar</string>
|
<string name="prefs_other_switch_sweep">Habilitar animaciones radar</string>
|
||||||
<string name="prefs_other_switch_sensors">Usar sensores para rotar vista de radar</string>
|
<string name="prefs_other_switch_sensors">Usar sensores para rotar vista de radar</string>
|
||||||
<string name="prefs_other_switch_night_mode">Activar filtro nocturno rojo</string>
|
<string name="prefs_other_switch_night_mode">Activar filtro nocturno rojo</string>
|
||||||
|
<string name="prefs_other_switch_map_grid">Mostrar cuadrícula Maidenhead en el mapa</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Resaltado de elevación</string>
|
<string name="prefs_highlight_title">Resaltado de elevación</string>
|
||||||
<string name="prefs_highlight_low">Baja < %1$d°</string>
|
<string name="prefs_highlight_low">Baja < %1$d°</string>
|
||||||
|
|||||||
@@ -149,6 +149,7 @@
|
|||||||
<string name="prefs_other_switch_sweep">Показывать анимацию радара</string>
|
<string name="prefs_other_switch_sweep">Показывать анимацию радара</string>
|
||||||
<string name="prefs_other_switch_sensors">Использовать сенсоры устройства</string>
|
<string name="prefs_other_switch_sensors">Использовать сенсоры устройства</string>
|
||||||
<string name="prefs_other_switch_night_mode">Включить красный ночной фильтр</string>
|
<string name="prefs_other_switch_night_mode">Включить красный ночной фильтр</string>
|
||||||
|
<string name="prefs_other_switch_map_grid">Показывать сетку Maidenhead на карте</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Подсветка высоты</string>
|
<string name="prefs_highlight_title">Подсветка высоты</string>
|
||||||
<string name="prefs_highlight_low">Низко < %1$d°</string>
|
<string name="prefs_highlight_low">Низко < %1$d°</string>
|
||||||
|
|||||||
@@ -150,6 +150,7 @@
|
|||||||
<string name="prefs_other_switch_sweep">radar sweep සජීවිකරණය සබල කරන්න</string>
|
<string name="prefs_other_switch_sweep">radar sweep සජීවිකරණය සබල කරන්න</string>
|
||||||
<string name="prefs_other_switch_sensors">Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න </string>
|
<string name="prefs_other_switch_sensors">Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න </string>
|
||||||
<string name="prefs_other_switch_night_mode">රතු රාත්රී පෙරහන සබල කරන්න</string>
|
<string name="prefs_other_switch_night_mode">රතු රාත්රී පෙරහන සබල කරන්න</string>
|
||||||
|
<string name="prefs_other_switch_map_grid">සිතියමේ Maidenhead ජාලකය පෙන්වන්න</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">උස් උද්දීපනය</string>
|
<string name="prefs_highlight_title">උස් උද්දීපනය</string>
|
||||||
<string name="prefs_highlight_low">අඩු < %1$d°</string>
|
<string name="prefs_highlight_low">අඩු < %1$d°</string>
|
||||||
|
|||||||
@@ -192,6 +192,7 @@
|
|||||||
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string>
|
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string>
|
||||||
<string name="prefs_other_switch_sensors">Radar görünümünü döndürmek için sensörleri kullan</string>
|
<string name="prefs_other_switch_sensors">Radar görünümünü döndürmek için sensörleri kullan</string>
|
||||||
<string name="prefs_other_switch_night_mode">Kırmızı gece filtresini etkinleştir</string>
|
<string name="prefs_other_switch_night_mode">Kırmızı gece filtresini etkinleştir</string>
|
||||||
|
<string name="prefs_other_switch_map_grid">Haritada Maidenhead ızgarasını göster</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Elevasyon vurgusu</string>
|
<string name="prefs_highlight_title">Elevasyon vurgusu</string>
|
||||||
<string name="prefs_highlight_low">Düşük < %1$d°</string>
|
<string name="prefs_highlight_low">Düşük < %1$d°</string>
|
||||||
|
|||||||
@@ -150,6 +150,7 @@
|
|||||||
<string name="prefs_other_switch_sweep">Показувати анімацію радара</string>
|
<string name="prefs_other_switch_sweep">Показувати анімацію радара</string>
|
||||||
<string name="prefs_other_switch_sensors">Використовувати сенсори пристрою</string>
|
<string name="prefs_other_switch_sensors">Використовувати сенсори пристрою</string>
|
||||||
<string name="prefs_other_switch_night_mode">Увімкнути червоний нічний фільтр</string>
|
<string name="prefs_other_switch_night_mode">Увімкнути червоний нічний фільтр</string>
|
||||||
|
<string name="prefs_other_switch_map_grid">Показувати сітку Maidenhead на мапі</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Підсвічування висоти</string>
|
<string name="prefs_highlight_title">Підсвічування висоти</string>
|
||||||
<string name="prefs_highlight_low">Низько < %1$d°</string>
|
<string name="prefs_highlight_low">Низько < %1$d°</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>
|
||||||
@@ -119,6 +139,15 @@
|
|||||||
<string name="prefs_station_lat_text">输入站点的纬度</string>
|
<string name="prefs_station_lat_text">输入站点的纬度</string>
|
||||||
<string name="prefs_station_lon_text">输入站点的经度</string>
|
<string name="prefs_station_lon_text">输入站点的经度</string>
|
||||||
<string name="prefs_locator_title">QTH 网格</string>
|
<string name="prefs_locator_title">QTH 网格</string>
|
||||||
|
<string name="prefs_wavelog_title">Wavelog 已通联网格</string>
|
||||||
|
<string name="prefs_wavelog_configure">配置</string>
|
||||||
|
<string name="prefs_wavelog_configured">已从 Wavelog 同步 — 通联 %1$d 个网格</string>
|
||||||
|
<string name="prefs_wavelog_not_configured">未配置 — 连接你的 Wavelog 日志以在地图上高亮已通联网格</string>
|
||||||
|
<string name="prefs_wavelog_url">Wavelog 地址</string>
|
||||||
|
<string name="prefs_wavelog_token">API key :日志 ID</string>
|
||||||
|
<string name="prefs_wavelog_hint">已同步网格数:%1$d。格式:API key:日志ID。请先保存,再点同步。</string>
|
||||||
|
<string name="prefs_wavelog_sync">立即同步</string>
|
||||||
|
<string name="prefs_wavelog_syncing">同步中…</string>
|
||||||
<string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
|
<string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">卫星数据更新</string>
|
<string name="prefs_data_title">卫星数据更新</string>
|
||||||
@@ -134,6 +163,7 @@
|
|||||||
<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_url_title" translatable="false">URL</string>
|
||||||
@@ -181,6 +211,7 @@
|
|||||||
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
|
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
|
||||||
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
|
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
|
||||||
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
|
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
|
||||||
|
<string name="prefs_other_switch_map_grid">地图显示 Maidenhead 网格</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">仰角高亮</string>
|
<string name="prefs_highlight_title">仰角高亮</string>
|
||||||
<string name="prefs_highlight_low">低 < %1$d°</string>
|
<string name="prefs_highlight_low">低 < %1$d°</string>
|
||||||
@@ -230,5 +261,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>
|
||||||
@@ -154,6 +174,15 @@
|
|||||||
<string name="prefs_station_lat_text">Set your ground station\'s latitude</string>
|
<string name="prefs_station_lat_text">Set your ground station\'s latitude</string>
|
||||||
<string name="prefs_station_lon_text">Set your ground station\'s longitude</string>
|
<string name="prefs_station_lon_text">Set your ground station\'s longitude</string>
|
||||||
<string name="prefs_locator_title">QTH locator settings</string>
|
<string name="prefs_locator_title">QTH locator settings</string>
|
||||||
|
<string name="prefs_wavelog_title">Wavelog worked grids</string>
|
||||||
|
<string name="prefs_wavelog_configure">Configure</string>
|
||||||
|
<string name="prefs_wavelog_configured">Synced from Wavelog — %1$d grids worked</string>
|
||||||
|
<string name="prefs_wavelog_not_configured">Not configured — connect your Wavelog logbook to highlight worked grids on the map</string>
|
||||||
|
<string name="prefs_wavelog_url">Wavelog URL</string>
|
||||||
|
<string name="prefs_wavelog_token">API key : logbook ID</string>
|
||||||
|
<string name="prefs_wavelog_hint">Worked grids synced: %1$d. Format: API key, colon, logbook ID. Save first, then tap Sync.</string>
|
||||||
|
<string name="prefs_wavelog_sync">Sync now</string>
|
||||||
|
<string name="prefs_wavelog_syncing">Syncing…</string>
|
||||||
<string name="prefs_locator_text">Set station\'s position using locator</string>
|
<string name="prefs_locator_text">Set station\'s position using locator</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">Satellite data</string>
|
<string name="prefs_data_title">Satellite data</string>
|
||||||
@@ -168,6 +197,7 @@
|
|||||||
<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>
|
||||||
@@ -215,6 +245,7 @@
|
|||||||
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string>
|
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string>
|
||||||
<string name="prefs_other_switch_sensors">Use sensors to rotate radar view</string>
|
<string name="prefs_other_switch_sensors">Use sensors to rotate radar view</string>
|
||||||
<string name="prefs_other_switch_night_mode">Enable red night mode filter</string>
|
<string name="prefs_other_switch_night_mode">Enable red night mode filter</string>
|
||||||
|
<string name="prefs_other_switch_map_grid">Show Maidenhead grid on map</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Elevation highlight</string>
|
<string name="prefs_highlight_title">Elevation highlight</string>
|
||||||
<string name="prefs_highlight_low">Low < %1$d°</string>
|
<string name="prefs_highlight_low">Low < %1$d°</string>
|
||||||
@@ -265,5 +296,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>
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
/*
|
||||||
|
* 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.map
|
||||||
|
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import org.osmdroid.views.MapView
|
||||||
|
import org.osmdroid.views.Projection
|
||||||
|
import org.osmdroid.views.overlay.Overlay
|
||||||
|
import kotlin.math.ceil
|
||||||
|
import kotlin.math.floor
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maidenhead locator grid overlay, drawn directly on the map canvas.
|
||||||
|
*
|
||||||
|
* Zoom levels (map maxZoom = 7):
|
||||||
|
* - zoom < GRID_ZOOM_SUB: 10° x 20° fields with 2-character labels (e.g. "PM")
|
||||||
|
* - zoom >= GRID_ZOOM_SUB: 1° x 2° squares with 4-character labels (e.g. "PM95")
|
||||||
|
*
|
||||||
|
* All geometry is computed per-frame from the projection, so the overlay
|
||||||
|
* stays correct while panning/zooming. Grid lines that cross the antimeridian
|
||||||
|
* are drawn in segments clamped to the visible bounding box.
|
||||||
|
*/
|
||||||
|
class MaidenheadGridOverlay : Overlay() {
|
||||||
|
|
||||||
|
private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
strokeWidth = 1.5f
|
||||||
|
style = Paint.Style.STROKE
|
||||||
|
color = Color.argb(160, 255, 224, 130)
|
||||||
|
}
|
||||||
|
private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
textSize = 26f
|
||||||
|
style = android.graphics.Paint.Style.FILL
|
||||||
|
color = Color.argb(220, 255, 224, 130)
|
||||||
|
setShadowLayer(3f, 2f, 2f, Color.BLACK)
|
||||||
|
}
|
||||||
|
private val workedPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
style = android.graphics.Paint.Style.FILL
|
||||||
|
color = Color.argb(90, 76, 217, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Worked gridsquares (4-char, uppercase) to highlight, e.g. {"OL62", "PM95"}. */
|
||||||
|
var workedGrids: Set<String> = emptySet()
|
||||||
|
|
||||||
|
/** The station's own 4-char gridsquare, drawn with a distinct outline. */
|
||||||
|
var ownGrid: String? = null
|
||||||
|
|
||||||
|
override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) {
|
||||||
|
if (shadow || !isEnabled) return
|
||||||
|
val projection = mapView.projection
|
||||||
|
val zoom = mapView.zoomLevelDouble
|
||||||
|
val cellLat = if (zoom >= GRID_ZOOM_SUB) SUB_SQUARE_LAT else FIELD_LAT
|
||||||
|
val cellLon = if (zoom >= GRID_ZOOM_SUB) SUB_SQUARE_LON else FIELD_LON
|
||||||
|
|
||||||
|
// Visible bounding box in geographic coordinates
|
||||||
|
val north = projection.fromPixels(0, 0)
|
||||||
|
val south = projection.fromPixels(canvas.width, canvas.height)
|
||||||
|
val topLat = max(north.latitude, south.latitude).coerceIn(-90.0, 90.0)
|
||||||
|
val bottomLat = min(north.latitude, south.latitude).coerceIn(-90.0, 90.0)
|
||||||
|
val leftLon = min(north.longitude, south.longitude)
|
||||||
|
val rightLon = max(north.longitude, south.longitude)
|
||||||
|
|
||||||
|
val firstRow = floor(bottomLat / cellLat).toInt()
|
||||||
|
val lastRow = ceil(topLat / cellLat).toInt()
|
||||||
|
// Longitude cell indices may exceed the [-180, 180) range when the view
|
||||||
|
// crosses the antimeridian; index arithmetic still works (PMxx at 360° == same grid)
|
||||||
|
val firstCol = floor(leftLon / cellLon).toInt()
|
||||||
|
val lastCol = ceil(rightLon / cellLon).toInt()
|
||||||
|
val centerLon = (leftLon + rightLon) / 2.0
|
||||||
|
|
||||||
|
// Worked-grid highlight fills (only meaningful at sub-square zoom)
|
||||||
|
if (workedGrids.isNotEmpty() && zoom >= GRID_ZOOM_SUB) {
|
||||||
|
for (row in firstRow..lastRow) {
|
||||||
|
val lat = row * cellLat
|
||||||
|
if (lat < -90.0 || lat >= 90.0) continue
|
||||||
|
val topLatCell = lat + cellLat
|
||||||
|
if (topLatCell > 90.0) continue
|
||||||
|
val yTop = projectionToY(projection, topLatCell)
|
||||||
|
val yBottom = projectionToY(projection, lat)
|
||||||
|
if (yTop == null || yBottom == null) continue
|
||||||
|
for (col in firstCol..lastCol) {
|
||||||
|
val lon = col * cellLon
|
||||||
|
val xLeft = projectionToX(projection, lon, centerLon) ?: continue
|
||||||
|
val xRight = projectionToX(projection, lon + cellLon, centerLon) ?: continue
|
||||||
|
if (xRight < 0f || xLeft > canvas.width) continue
|
||||||
|
if (cellLabel(lat, lon, zoom) in workedGrids) {
|
||||||
|
canvas.drawRect(xLeft, yTop, xRight, yBottom, workedPaint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertical lines (meridians)
|
||||||
|
for (col in firstCol..lastCol) {
|
||||||
|
val lon = col * cellLon
|
||||||
|
val x = projectionToX(projection, lon, centerLon)
|
||||||
|
if (x == null) continue
|
||||||
|
canvas.drawLine(x, 0f, x, canvas.height.toFloat(), linePaint)
|
||||||
|
}
|
||||||
|
// Horizontal lines (parallels)
|
||||||
|
for (row in firstRow..lastRow) {
|
||||||
|
val lat = row * cellLat
|
||||||
|
if (lat <= -90.0 || lat >= 90.0) continue
|
||||||
|
val y = projectionToY(projection, lat)
|
||||||
|
if (y == null) continue
|
||||||
|
canvas.drawLine(0f, y, canvas.width.toFloat(), y, linePaint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Labels: place at the top-left corner of each cell, only when the cell
|
||||||
|
// is large enough on screen to hold a label (avoid clutter at low zoom)
|
||||||
|
val showLabels = when {
|
||||||
|
zoom >= GRID_ZOOM_SUB -> true
|
||||||
|
zoom >= FIELD_ZOOM_LABELS -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
if (!showLabels) return
|
||||||
|
// Estimate on-screen cell height to avoid clutter at low zoom:
|
||||||
|
// project two points 1° apart in latitude and measure the pixel distance.
|
||||||
|
val y1 = projectionToY(projection, 0.0)
|
||||||
|
val y2 = projectionToY(projection, 1.0)
|
||||||
|
if (y1 == null || y2 == null) return
|
||||||
|
val pixelsPerDegree = Math.abs(y2 - y1)
|
||||||
|
if (pixelsPerDegree * cellLat < MIN_LABEL_CELL_PX) return
|
||||||
|
|
||||||
|
val labelOffset = 8f
|
||||||
|
for (row in firstRow..lastRow) {
|
||||||
|
val lat = row * cellLat
|
||||||
|
if (lat < -90.0 || lat >= 90.0) continue
|
||||||
|
val topLatCell = lat + cellLat
|
||||||
|
if (topLatCell > 90.0) continue
|
||||||
|
val y = projectionToY(projection, topLatCell) ?: continue
|
||||||
|
if (y < -labelPaint.textSize || y > canvas.height) continue
|
||||||
|
for (col in firstCol..lastCol) {
|
||||||
|
val lon = col * cellLon
|
||||||
|
val x = projectionToX(projection, lon, centerLon) ?: continue
|
||||||
|
if (x < -200f || x > canvas.width) continue
|
||||||
|
val label = cellLabel(lat, lon, zoom)
|
||||||
|
canvas.drawText(label, x + labelOffset, y + labelPaint.textSize + labelOffset, labelPaint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** X pixel for a longitude (meridians are vertical in Web Mercator). */
|
||||||
|
private fun projectionToX(projection: Projection, lon: Double, centerLon: Double): Float? {
|
||||||
|
// osmdroid clips Mercator X to [0, mapSize], which destroys the projection of
|
||||||
|
// longitudes outside the [0, 360) window of the current view (visible at low
|
||||||
|
// zoom where the whole world is narrower than the viewport). Normalizing the
|
||||||
|
// longitude to the equivalent value closest to the view center keeps every
|
||||||
|
// meridian's X near the center, safely inside the clip window.
|
||||||
|
var normalized = lon
|
||||||
|
while (normalized < centerLon - 180.0) normalized += 360.0
|
||||||
|
while (normalized > centerLon + 180.0) normalized -= 360.0
|
||||||
|
val geo = org.osmdroid.util.GeoPoint(0.0, normalized)
|
||||||
|
val p = projection.toPixels(geo, null)
|
||||||
|
return if (p.x in -MAX_OVERSHOOT_PX..MAX_OVERSHOOT_PX + 4096) p.x.toFloat() else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Y pixel for a latitude, or null when outside the viewport. */
|
||||||
|
private fun projectionToY(projection: Projection, lat: Double): Float? {
|
||||||
|
val geo = org.osmdroid.util.GeoPoint(lat, 0.0)
|
||||||
|
val p = projection.toPixels(geo, null)
|
||||||
|
return p.y.toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cellLabel(lat: Double, lon: Double, zoom: Double): String {
|
||||||
|
// Maidenhead field: longitude 20° fields A-R starting at -180,
|
||||||
|
// latitude 10° fields A-R starting at -90.
|
||||||
|
val lonNorm = normalizeLon(lon)
|
||||||
|
val latNorm = lat.coerceIn(-90.0, 89.999)
|
||||||
|
val fieldLon = ((lonNorm + 180.0) / FIELD_LON).toInt()
|
||||||
|
val fieldLat = ((latNorm + 90.0) / FIELD_LAT).toInt()
|
||||||
|
val field = "${'A' + fieldLon}${'A' + fieldLat}"
|
||||||
|
if (zoom < GRID_ZOOM_SUB) return field
|
||||||
|
// Square: 2° x 1° digits
|
||||||
|
val squareLon = (((lonNorm + 180.0) % FIELD_LON) / SUB_SQUARE_LON).toInt()
|
||||||
|
val squareLat = (((latNorm + 90.0) % FIELD_LAT) / SUB_SQUARE_LAT).toInt()
|
||||||
|
return "$field$squareLon$squareLat"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeLon(lon: Double): Double {
|
||||||
|
var l = lon % 360.0
|
||||||
|
if (l >= 180.0) l -= 360.0
|
||||||
|
if (l < -180.0) l += 360.0
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val FIELD_LAT = 10.0
|
||||||
|
const val FIELD_LON = 20.0
|
||||||
|
const val SUB_SQUARE_LAT = 1.0
|
||||||
|
const val SUB_SQUARE_LON = 2.0
|
||||||
|
const val GRID_ZOOM_SUB = 5.0
|
||||||
|
const val FIELD_ZOOM_LABELS = 3.0
|
||||||
|
const val MIN_LABEL_CELL_PX = 48f
|
||||||
|
const val MAX_OVERSHOOT_PX = 64
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,14 +80,15 @@ import org.osmdroid.views.overlay.Marker
|
|||||||
import org.osmdroid.views.overlay.Polyline
|
import org.osmdroid.views.overlay.Polyline
|
||||||
|
|
||||||
// Overlay indices
|
// Overlay indices
|
||||||
private const val OVERLAY_STATION = 0
|
private const val OVERLAY_GRID = 0
|
||||||
private const val OVERLAY_TRACK = 1
|
private const val OVERLAY_STATION = 1
|
||||||
private const val OVERLAY_FOOTPRINT = 2
|
private const val OVERLAY_TRACK = 2
|
||||||
private const val OVERLAY_POSITIONS = 3
|
private const val OVERLAY_FOOTPRINT = 3
|
||||||
private const val OVERLAY_TERMINATOR = 4
|
private const val OVERLAY_POSITIONS = 4
|
||||||
private const val OVERLAY_SUN = 5
|
private const val OVERLAY_TERMINATOR = 5
|
||||||
private const val OVERLAY_MOON = 6
|
private const val OVERLAY_SUN = 6
|
||||||
private const val OVERLAY_COUNT = 7
|
private const val OVERLAY_MOON = 7
|
||||||
|
private const val OVERLAY_COUNT = 8
|
||||||
|
|
||||||
private val minLat = MapView.getTileSystem().minLatitude
|
private val minLat = MapView.getTileSystem().minLatitude
|
||||||
private val maxLat = MapView.getTileSystem().maxLatitude
|
private val maxLat = MapView.getTileSystem().maxLatitude
|
||||||
@@ -159,17 +160,22 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
|||||||
ElevatedCard(modifier = Modifier.weight(1f)) {
|
ElevatedCard(modifier = Modifier.weight(1f)) {
|
||||||
Box(contentAlignment = Alignment.BottomCenter) {
|
Box(contentAlignment = Alignment.BottomCenter) {
|
||||||
AndroidView({ mapView }) { view ->
|
AndroidView({ mapView }) { view ->
|
||||||
uiState.stationPosition?.let { setStationPosition(it, view) }
|
setGridMode(uiState.isGridMode, uiState.workedGrids, view)
|
||||||
uiState.track?.let { setSatelliteTrack(it, view) }
|
if (!uiState.isGridMode) {
|
||||||
uiState.footprint?.let { setFootprint(it, view) }
|
uiState.stationPosition?.let { setStationPosition(it, view) }
|
||||||
uiState.positions?.let { setPositions(it, view) { item -> onAction(MapAction.SelectItem(item)) } }
|
uiState.track?.let { setSatelliteTrack(it, view) }
|
||||||
setTerminator(uiState.sunLatDeg, uiState.sunLonDeg, view)
|
uiState.footprint?.let { setFootprint(it, view) }
|
||||||
setSubSolarPoint(uiState.sunLatDeg, uiState.sunLonDeg, view)
|
uiState.positions?.let { setPositions(it, view) { item -> onAction(MapAction.SelectItem(item)) } }
|
||||||
setMoonPosition(uiState.moonLatDeg, uiState.moonLonDeg, view)
|
setTerminator(uiState.sunLatDeg, uiState.sunLonDeg, view)
|
||||||
|
setSubSolarPoint(uiState.sunLatDeg, uiState.sunLonDeg, view)
|
||||||
|
setMoonPosition(uiState.moonLatDeg, uiState.moonLonDeg, view)
|
||||||
|
}
|
||||||
view.invalidate()
|
view.invalidate()
|
||||||
}
|
}
|
||||||
uiState.mapData?.let { mapData ->
|
uiState.mapData?.let { mapData ->
|
||||||
if (isVertical) MapDataCard(mapData) else MapDataCards(mapData)
|
if (!uiState.isGridMode) {
|
||||||
|
if (isVertical) MapDataCard(mapData) else MapDataCards(mapData)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,6 +267,30 @@ private fun MapDataCards(data: MapData) {
|
|||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
// region Map overlay helpers
|
// region Map overlay helpers
|
||||||
|
|
||||||
|
/** Toggle the grid-mode layer visibility on/off without recreating any overlay. */
|
||||||
|
private fun setGridMode(gridMode: Boolean, workedGrids: Set<String>, mapView: MapView) {
|
||||||
|
try {
|
||||||
|
val gridOverlay = mapView.overlays[OVERLAY_GRID]
|
||||||
|
if (gridOverlay is MaidenheadGridOverlay) {
|
||||||
|
gridOverlay.isEnabled = gridMode
|
||||||
|
gridOverlay.workedGrids = workedGrids
|
||||||
|
} else {
|
||||||
|
mapView.overlays[OVERLAY_GRID] = MaidenheadGridOverlay().apply {
|
||||||
|
isEnabled = gridMode
|
||||||
|
this.workedGrids = workedGrids
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Satellite-related layers are hidden in grid mode; the grid overlay
|
||||||
|
// itself is controlled by its own enabled flag.
|
||||||
|
for (index in OVERLAY_STATION..OVERLAY_MOON) {
|
||||||
|
mapView.overlays.getOrNull(index)?.isEnabled = !gridMode
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
|
private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
|
||||||
try {
|
try {
|
||||||
val overlay = mapView.overlays[OVERLAY_STATION]
|
val overlay = mapView.overlays[OVERLAY_STATION]
|
||||||
@@ -500,6 +530,7 @@ private fun rememberMapViewWithLifecycle(): MapView {
|
|||||||
overlayManager.tilesOverlay.setColorFilter(createColorFilter())
|
overlayManager.tilesOverlay.setColorFilter(createColorFilter())
|
||||||
setScrollableAreaLimitLatitude(maxLat, minLat, 0)
|
setScrollableAreaLimitLatitude(maxLat, minLat, 0)
|
||||||
overlays.addAll(Array(OVERLAY_COUNT) { FolderOverlay() })
|
overlays.addAll(Array(OVERLAY_COUNT) { FolderOverlay() })
|
||||||
|
overlays[OVERLAY_GRID] = MaidenheadGridOverlay()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val lifecycleObserver = rememberMapViewLifecycleObserver(mapView)
|
val lifecycleObserver = rememberMapViewLifecycleObserver(mapView)
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ data class MapState(
|
|||||||
val mapData: MapData? = null,
|
val mapData: MapData? = null,
|
||||||
val isLightUi: Boolean = false,
|
val isLightUi: Boolean = false,
|
||||||
val isUtc: Boolean = false,
|
val isUtc: Boolean = false,
|
||||||
|
val isGridMode: Boolean = false,
|
||||||
|
val workedGrids: Set<String> = emptySet(),
|
||||||
val stationPosition: GeoPos? = null,
|
val stationPosition: GeoPos? = null,
|
||||||
val orbitalPass: OrbitalPass,
|
val orbitalPass: OrbitalPass,
|
||||||
val track: List<List<GeoPos>>? = null,
|
val track: List<List<GeoPos>>? = null,
|
||||||
|
|||||||
@@ -74,7 +74,12 @@ class MapViewModel(
|
|||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
settingsRepo.otherSettings.collectLatest { settings ->
|
settingsRepo.otherSettings.collectLatest { settings ->
|
||||||
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
|
_uiState.update { it.copy(isUtc = settings.stateOfUtc, isGridMode = settings.stateOfMapGrid) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
settingsRepo.wavelogSettings.collectLatest { _ ->
|
||||||
|
_uiState.update { it.copy(workedGrids = settingsRepo.getWorkedGrids()) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
|
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-21
@@ -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
|
||||||
@@ -145,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
|
||||||
@@ -196,6 +196,8 @@ fun CalculatorPage(
|
|||||||
DopplerFrequencyCalculator(
|
DopplerFrequencyCalculator(
|
||||||
transponder = selectedTransceiver,
|
transponder = selectedTransceiver,
|
||||||
orbitalPos = orbitalPos,
|
orbitalPos = orbitalPos,
|
||||||
|
offsetKHz = calculatorOffsetKHz,
|
||||||
|
onOffsetChange = { onAction(RadarAction.ChangeCalculatorOffset(it)) },
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -548,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
|
||||||
@@ -556,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
|
||||||
@@ -752,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,
|
||||||
|
|||||||
+420
-48
@@ -19,54 +19,84 @@ 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.TextButton
|
||||||
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 androidx.compose.animation.core.spring
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import kotlinx.coroutines.delay
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
import kotlinx.coroutines.launch
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.mutableLongStateOf
|
|
||||||
import androidx.compose.runtime.toMutableStateList
|
|
||||||
import androidx.compose.ui.draw.rotate
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.Constants
|
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -126,6 +156,69 @@ fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Uni
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WavelogDialog(
|
||||||
|
initialSettings: com.rtbishop.look4sat.core.domain.model.WavelogSettings,
|
||||||
|
workedGridsCount: Int,
|
||||||
|
isSyncing: Boolean,
|
||||||
|
message: String?,
|
||||||
|
dismiss: () -> Unit,
|
||||||
|
onSave: (com.rtbishop.look4sat.core.domain.model.WavelogSettings) -> Unit,
|
||||||
|
onSync: () -> Unit
|
||||||
|
) {
|
||||||
|
val url = rememberSaveable { mutableStateOf(initialSettings.url) }
|
||||||
|
val token = rememberSaveable { mutableStateOf(initialSettings.token) }
|
||||||
|
SharedDialog(
|
||||||
|
title = stringResource(R.string.prefs_wavelog_title),
|
||||||
|
onCancel = dismiss,
|
||||||
|
onAccept = {
|
||||||
|
onSave(com.rtbishop.look4sat.core.domain.model.WavelogSettings(url.value, token.value))
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = url.value,
|
||||||
|
onValueChange = { url.value = it },
|
||||||
|
label = { Text(text = stringResource(id = R.string.prefs_wavelog_url)) },
|
||||||
|
placeholder = { Text(text = "http://192.168.1.10") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = token.value,
|
||||||
|
onValueChange = { token.value = it },
|
||||||
|
label = { Text(text = stringResource(id = R.string.prefs_wavelog_token)) },
|
||||||
|
placeholder = { Text(text = "abcdef123456:1") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.prefs_wavelog_hint, workedGridsCount),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||||
|
)
|
||||||
|
if (message != null) {
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Sync button row (inside dialog content so token must be saved first)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onSync, enabled = !isSyncing) {
|
||||||
|
Text(text = if (isSyncing) stringResource(R.string.prefs_wavelog_syncing)
|
||||||
|
else stringResource(R.string.prefs_wavelog_sync))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(0.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
private fun TransceiversDialogPreview() {
|
private fun TransceiversDialogPreview() {
|
||||||
@@ -138,10 +231,16 @@ private fun TransceiversDialogPreview() {
|
|||||||
transceiversUrls = listOf(
|
transceiversUrls = listOf(
|
||||||
"db.satnogs.org/api/transmitters/?format=json&status=active"
|
"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 = {},
|
||||||
onSave = { _, _ -> }
|
onSave = { _, _, _, _ -> }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,10 +249,13 @@ private fun TransceiversDialogPreview() {
|
|||||||
fun DataSourcesDialog(
|
fun DataSourcesDialog(
|
||||||
satelliteUrls: List<String>,
|
satelliteUrls: List<String>,
|
||||||
transceiversUrls: List<String>,
|
transceiversUrls: List<String>,
|
||||||
|
satelliteEnabled: List<Boolean>,
|
||||||
|
transceiversEnabled: List<Boolean>,
|
||||||
|
statusCodes: Map<String, Int>,
|
||||||
onImportTle: () -> Unit,
|
onImportTle: () -> Unit,
|
||||||
onImportTransceivers: () -> Unit,
|
onImportTransceivers: () -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSave: (List<String>, List<String>) -> Unit
|
onSave: (List<String>, List<String>, List<Boolean>, List<Boolean>) -> Unit
|
||||||
) {
|
) {
|
||||||
val padding = LocalSpacing.current.large
|
val padding = LocalSpacing.current.large
|
||||||
// Stable Long IDs avoid key collisions when several entries are empty or duplicated.
|
// Stable Long IDs avoid key collisions when several entries are empty or duplicated.
|
||||||
@@ -164,10 +266,39 @@ fun DataSourcesDialog(
|
|||||||
val txUrls = remember {
|
val txUrls = remember {
|
||||||
transceiversUrls.mapIndexed { i, url -> (satelliteUrls.size + i).toLong() to url }.toMutableStateList()
|
transceiversUrls.mapIndexed { i, url -> (satelliteUrls.size + i).toLong() to url }.toMutableStateList()
|
||||||
}
|
}
|
||||||
|
val satEnabled = remember {
|
||||||
|
mutableStateMapOf<Long, Boolean>().apply {
|
||||||
|
satUrls.forEachIndexed { i, (id, _) -> this[id] = satelliteEnabled.getOrElse(i) { true } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val txEnabled = remember {
|
||||||
|
mutableStateMapOf<Long, Boolean>().apply {
|
||||||
|
txUrls.forEachIndexed { i, (id, _) -> this[id] = transceiversEnabled.getOrElse(i) { true } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val onRestoreDefaults = {
|
||||||
|
val satDefaults = Sources.satelliteDataUrls.values.filter { it.isNotBlank() }
|
||||||
|
val txDefaults = Sources.transceiversDataUrls.values.filter { it.isNotBlank() }
|
||||||
|
nextId.longValue = (satDefaults.size + txDefaults.size).toLong()
|
||||||
|
satUrls.clear()
|
||||||
|
satUrls.addAll(satDefaults.mapIndexed { i, url -> i.toLong() to url })
|
||||||
|
txUrls.clear()
|
||||||
|
txUrls.addAll(txDefaults.mapIndexed { i, url -> (satDefaults.size + i).toLong() to url })
|
||||||
|
satEnabled.clear()
|
||||||
|
txEnabled.clear()
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val satDraggedId = remember { mutableStateOf(-1L) }
|
||||||
|
val txDraggedId = remember { mutableStateOf(-1L) }
|
||||||
val onAccept = {
|
val onAccept = {
|
||||||
|
val satFiltered = satUrls.filter { it.second.trim().isNotBlank() }
|
||||||
|
val txFiltered = txUrls.filter { it.second.trim().isNotBlank() }
|
||||||
onSave(
|
onSave(
|
||||||
satUrls.map { it.second.trim() }.filter { it.isNotBlank() },
|
satFiltered.map { it.second.trim() },
|
||||||
txUrls.map { it.second.trim() }.filter { it.isNotBlank() }
|
txFiltered.map { it.second.trim() },
|
||||||
|
satFiltered.map { satEnabled[it.first] ?: true },
|
||||||
|
txFiltered.map { txEnabled[it.first] ?: true }
|
||||||
)
|
)
|
||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
@@ -177,6 +308,7 @@ fun DataSourcesDialog(
|
|||||||
onAccept = onAccept,
|
onAccept = onAccept,
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxHeight(0.84f)
|
.fillMaxHeight(0.84f)
|
||||||
.padding(horizontal = padding),
|
.padding(horizontal = padding),
|
||||||
@@ -200,12 +332,24 @@ fun DataSourcesDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
CardButton(
|
||||||
|
onClick = onRestoreDefaults,
|
||||||
|
text = stringResource(R.string.prefs_data_sources_restore),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
sourceSection(
|
sourceSection(
|
||||||
sectionKey = "sat",
|
sectionKey = "sat",
|
||||||
labelResId = R.string.prefs_data_sources_satellites_label,
|
labelResId = R.string.prefs_data_sources_satellites_label,
|
||||||
urls = satUrls,
|
urls = satUrls,
|
||||||
|
listState = listState,
|
||||||
|
draggedId = satDraggedId,
|
||||||
|
statusCodes = statusCodes,
|
||||||
|
enabledMap = satEnabled,
|
||||||
|
onToggle = { id -> satEnabled[id] = !(satEnabled[id] ?: true) },
|
||||||
onAdd = { satUrls.add(nextId.longValue++ to "") },
|
onAdd = { satUrls.add(nextId.longValue++ to "") },
|
||||||
onMoveUp = { i -> if (i > 0) satUrls.add(i - 1, satUrls.removeAt(i)) },
|
onMove = { from, to -> satUrls.add(to, satUrls.removeAt(from)) },
|
||||||
onRemove = { i -> satUrls.removeAt(i) },
|
onRemove = { i -> satUrls.removeAt(i) },
|
||||||
onUrlChange = { i, v -> satUrls[i] = satUrls[i].first to v }
|
onUrlChange = { i, v -> satUrls[i] = satUrls[i].first to v }
|
||||||
)
|
)
|
||||||
@@ -213,8 +357,13 @@ fun DataSourcesDialog(
|
|||||||
sectionKey = "tx",
|
sectionKey = "tx",
|
||||||
labelResId = R.string.prefs_data_sources_transceivers_label,
|
labelResId = R.string.prefs_data_sources_transceivers_label,
|
||||||
urls = txUrls,
|
urls = txUrls,
|
||||||
|
listState = listState,
|
||||||
|
draggedId = txDraggedId,
|
||||||
|
statusCodes = statusCodes,
|
||||||
|
enabledMap = txEnabled,
|
||||||
|
onToggle = { id -> txEnabled[id] = !(txEnabled[id] ?: true) },
|
||||||
onAdd = { txUrls.add(nextId.longValue++ to "") },
|
onAdd = { txUrls.add(nextId.longValue++ to "") },
|
||||||
onMoveUp = { i -> if (i > 0) txUrls.add(i - 1, txUrls.removeAt(i)) },
|
onMove = { from, to -> txUrls.add(to, txUrls.removeAt(from)) },
|
||||||
onRemove = { i -> txUrls.removeAt(i) },
|
onRemove = { i -> txUrls.removeAt(i) },
|
||||||
onUrlChange = { i, v -> txUrls[i] = txUrls[i].first to v }
|
onUrlChange = { i, v -> txUrls[i] = txUrls[i].first to v }
|
||||||
)
|
)
|
||||||
@@ -226,8 +375,13 @@ private fun LazyListScope.sourceSection(
|
|||||||
sectionKey: String,
|
sectionKey: String,
|
||||||
labelResId: Int,
|
labelResId: Int,
|
||||||
urls: List<Pair<Long, String>>,
|
urls: List<Pair<Long, String>>,
|
||||||
|
listState: LazyListState,
|
||||||
|
draggedId: MutableState<Long>,
|
||||||
|
statusCodes: Map<String, Int>,
|
||||||
|
enabledMap: Map<Long, Boolean>,
|
||||||
|
onToggle: (Long) -> Unit,
|
||||||
onAdd: () -> Unit,
|
onAdd: () -> Unit,
|
||||||
onMoveUp: (Int) -> Unit,
|
onMove: (Int, Int) -> Unit,
|
||||||
onRemove: (Int) -> Unit,
|
onRemove: (Int) -> Unit,
|
||||||
onUrlChange: (Int, String) -> Unit
|
onUrlChange: (Int, String) -> Unit
|
||||||
) {
|
) {
|
||||||
@@ -243,38 +397,256 @@ private fun LazyListScope.sourceSection(
|
|||||||
IconCard(action = onAdd, resId = R.drawable.ic_add)
|
IconCard(action = onAdd, resId = R.drawable.ic_add)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
itemsIndexed(urls, key = { _, entry -> "$sectionKey-${entry.first}" }) { index, (_, url) ->
|
itemsIndexed(urls, key = { _, entry -> "$sectionKey-${entry.first}" }) { index, (id, url) ->
|
||||||
val enabledTint = MaterialTheme.colorScheme.onSurfaceVariant
|
val enabledTint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
OutlinedTextField(
|
val enabled = enabledMap[id] ?: true
|
||||||
value = url,
|
val rowState = remember { DragRowState() }
|
||||||
onValueChange = { onUrlChange(index, it) },
|
val scope = rememberCoroutineScope()
|
||||||
label = { Text(stringResource(R.string.prefs_data_sources_url_title)) },
|
val isDragging = draggedId.value == id
|
||||||
leadingIcon = {
|
val isLifted = isDragging || rowState.isSettling.value
|
||||||
IconButton(onClick = { onMoveUp(index) }, enabled = index > 0) {
|
LaunchedEffect(isDragging) {
|
||||||
Icon(
|
if (!isDragging) return@LaunchedEffect
|
||||||
painter = painterResource(R.drawable.ic_arrow),
|
autoScroll(listState, rowState.startCenterY, rowState.fingerOffset, rowState.scrollComp)
|
||||||
contentDescription = null,
|
}
|
||||||
tint = if (index > 0) enabledTint else enabledTint.copy(alpha = 0.32f),
|
Row(
|
||||||
modifier = Modifier.rotate(270f)
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
trailingIcon = {
|
|
||||||
IconButton(onClick = { onRemove(index) }) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(R.drawable.ic_delete),
|
|
||||||
contentDescription = null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.animateItem(fadeInSpec = spring(), fadeOutSpec = spring())
|
.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)
|
||||||
@Composable
|
@Composable
|
||||||
fun PreviewNetworkOutputDialog() {
|
fun PreviewNetworkOutputDialog() {
|
||||||
|
|||||||
+96
-6
@@ -63,6 +63,8 @@ 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.DataSourcesSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||||
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
|
||||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||||
@@ -82,13 +84,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) }
|
||||||
@@ -128,11 +142,19 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
DataSourcesDialog(
|
DataSourcesDialog(
|
||||||
satelliteUrls = uiState.dataSourcesSettings.satelliteUrls,
|
satelliteUrls = uiState.dataSourcesSettings.satelliteUrls,
|
||||||
transceiversUrls = uiState.dataSourcesSettings.transceiversUrls,
|
transceiversUrls = uiState.dataSourcesSettings.transceiversUrls,
|
||||||
|
satelliteEnabled = uiState.dataSourcesSettings.satelliteEnabled,
|
||||||
|
transceiversEnabled = uiState.dataSourcesSettings.transceiversEnabled,
|
||||||
|
statusCodes = uiState.dataSourcesStatus,
|
||||||
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 = { satUrls, txUrls ->
|
onSave = { satUrls, txUrls, satEnabled, txEnabled ->
|
||||||
val newSettings = DataSourcesSettings(satelliteUrls = satUrls, transceiversUrls = txUrls)
|
val newSettings = DataSourcesSettings(
|
||||||
|
satelliteUrls = satUrls,
|
||||||
|
transceiversUrls = txUrls,
|
||||||
|
satelliteEnabled = satEnabled,
|
||||||
|
transceiversEnabled = txEnabled
|
||||||
|
)
|
||||||
if (newSettings != uiState.dataSourcesSettings) onAction(SettingsAction.UpdateDataSources(newSettings))
|
if (newSettings != uiState.dataSourcesSettings) onAction(SettingsAction.UpdateDataSources(newSettings))
|
||||||
onAction(SettingsAction.UpdateFromWeb)
|
onAction(SettingsAction.UpdateFromWeb)
|
||||||
}
|
}
|
||||||
@@ -181,6 +203,17 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
onSave = { onAction(SettingsAction.UpdateRadioControl(it)) }
|
onSave = { onAction(SettingsAction.UpdateRadioControl(it)) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (dialogs.wavelog) {
|
||||||
|
WavelogDialog(
|
||||||
|
initialSettings = uiState.wavelogSettings,
|
||||||
|
workedGridsCount = uiState.workedGridsCount,
|
||||||
|
isSyncing = uiState.wavelogSyncing,
|
||||||
|
message = uiState.wavelogMessage,
|
||||||
|
dismiss = { dialogs.wavelog = false },
|
||||||
|
onSave = { onAction(SettingsAction.UpdateWavelog(it)) },
|
||||||
|
onSync = { onAction(SettingsAction.SyncWorkedGrids) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// URLs for top bar
|
// URLs for top bar
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
@@ -285,8 +318,22 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
onRadioControlClick = { dialogs.radioControl = true }
|
onRadioControlClick = { dialogs.radioControl = true }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
WavelogCard(
|
||||||
|
settings = uiState.wavelogSettings,
|
||||||
|
workedGridsCount = uiState.workedGridsCount,
|
||||||
|
showWavelogDialog = { dialogs.wavelog = true }
|
||||||
|
)
|
||||||
|
}
|
||||||
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()
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -462,6 +509,7 @@ private fun OtherCardPreview() = MainTheme {
|
|||||||
stateOfUtc = false,
|
stateOfUtc = false,
|
||||||
stateOfLightTheme = false,
|
stateOfLightTheme = false,
|
||||||
stateOfNightMode = false,
|
stateOfNightMode = false,
|
||||||
|
stateOfMapGrid = false,
|
||||||
shouldSeeWarning = false,
|
shouldSeeWarning = false,
|
||||||
shouldSeeWhatsNew = false
|
shouldSeeWhatsNew = false
|
||||||
)
|
)
|
||||||
@@ -473,7 +521,7 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
|
|||||||
ElevatedCard(
|
ElevatedCard(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(268.dp)
|
.height(312.dp)
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||||
Text(
|
Text(
|
||||||
@@ -495,6 +543,9 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
|
|||||||
SwitchRow(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
|
SwitchRow(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
|
||||||
onAction(SettingsAction.ToggleNightMode(it))
|
onAction(SettingsAction.ToggleNightMode(it))
|
||||||
}
|
}
|
||||||
|
SwitchRow(R.string.prefs_other_switch_map_grid, settings.stateOfMapGrid) {
|
||||||
|
onAction(SettingsAction.ToggleMapGrid(it))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -511,6 +562,44 @@ private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boole
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun WavelogCardPreview() = MainTheme {
|
||||||
|
WavelogCard(settings = WavelogSettings("http://192.168.1.10", "wl2_demo"), workedGridsCount = 42) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WavelogCard(
|
||||||
|
settings: WavelogSettings,
|
||||||
|
workedGridsCount: Int,
|
||||||
|
showWavelogDialog: () -> Unit
|
||||||
|
) {
|
||||||
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.prefs_wavelog_title),
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = if (settings.isConfigured) {
|
||||||
|
stringResource(R.string.prefs_wavelog_configured, workedGridsCount)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.prefs_wavelog_not_configured)
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
maxLines = 2
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
CardButton(
|
||||||
|
onClick = showWavelogDialog,
|
||||||
|
text = stringResource(id = R.string.prefs_wavelog_configure),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun formatUpdateTime(updateTime: Long): String {
|
private fun formatUpdateTime(updateTime: Long): String {
|
||||||
val timePattern = stringResource(id = R.string.prefs_updated_time)
|
val timePattern = stringResource(id = R.string.prefs_updated_time)
|
||||||
@@ -630,6 +719,7 @@ private class DialogVisibility {
|
|||||||
var network by mutableStateOf(false)
|
var network by mutableStateOf(false)
|
||||||
var bluetooth by mutableStateOf(false)
|
var bluetooth by mutableStateOf(false)
|
||||||
var radioControl by mutableStateOf(false)
|
var radioControl by mutableStateOf(false)
|
||||||
|
var wavelog by mutableStateOf(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -637,12 +727,12 @@ private fun rememberDialogVisibility(): DialogVisibility {
|
|||||||
return rememberSaveable(saver = run {
|
return rememberSaveable(saver = run {
|
||||||
androidx.compose.runtime.saveable.Saver(
|
androidx.compose.runtime.saveable.Saver(
|
||||||
save = {
|
save = {
|
||||||
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl)
|
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog)
|
||||||
},
|
},
|
||||||
restore = {
|
restore = {
|
||||||
DialogVisibility().apply {
|
DialogVisibility().apply {
|
||||||
position = it[0]; locator = it[1]; dataSources = it[2]
|
position = it[0]; locator = it[1]; dataSources = it[2]
|
||||||
network = it[3]; bluetooth = it[4]; radioControl = it[5]
|
network = it[3]; bluetooth = it[4]; radioControl = it[5]; wavelog = it[6]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
+29
-1
@@ -18,10 +18,13 @@
|
|||||||
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.model.WavelogSettings
|
||||||
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 +37,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 +53,13 @@ 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 wavelogSettings: WavelogSettings = WavelogSettings(),
|
||||||
|
val workedGridsCount: Int = 0,
|
||||||
|
val wavelogSyncing: Boolean = false,
|
||||||
|
val wavelogMessage: String? = null,
|
||||||
|
val updateChecker: UpdateCheckerState = UpdateCheckerState()
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface SettingsAction {
|
sealed interface SettingsAction {
|
||||||
@@ -64,6 +82,7 @@ sealed interface SettingsAction {
|
|||||||
data class ToggleSensor(val value: Boolean) : SettingsAction
|
data class ToggleSensor(val value: Boolean) : SettingsAction
|
||||||
data class ToggleLightTheme(val value: Boolean) : SettingsAction
|
data class ToggleLightTheme(val value: Boolean) : SettingsAction
|
||||||
data class ToggleNightMode(val value: Boolean) : SettingsAction
|
data class ToggleNightMode(val value: Boolean) : SettingsAction
|
||||||
|
data class ToggleMapGrid(val value: Boolean) : SettingsAction
|
||||||
|
|
||||||
// Remote control
|
// Remote control
|
||||||
data class UpdateRC(val settings: RCSettings) : SettingsAction
|
data class UpdateRC(val settings: RCSettings) : SettingsAction
|
||||||
@@ -72,6 +91,15 @@ sealed interface SettingsAction {
|
|||||||
// Data sources
|
// Data sources
|
||||||
data class UpdateDataSources(val settings: DataSourcesSettings) : SettingsAction
|
data class UpdateDataSources(val settings: DataSourcesSettings) : SettingsAction
|
||||||
|
|
||||||
|
// Wavelog worked grids
|
||||||
|
data class UpdateWavelog(val settings: WavelogSettings) : SettingsAction
|
||||||
|
data object SyncWorkedGrids : 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
|
||||||
}
|
}
|
||||||
+102
-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,23 @@ 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.repository.IWavelogRepository
|
||||||
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 wavelogRepo: IWavelogRepository,
|
||||||
|
private val apkFile: File,
|
||||||
private val showToast: IShowToast
|
private val showToast: IShowToast
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -47,7 +55,10 @@ 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,
|
||||||
|
wavelogSettings = settingsRepo.wavelogSettings.value,
|
||||||
|
workedGridsCount = settingsRepo.getWorkedGrids().size
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -90,11 +101,21 @@ 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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
settingsRepo.wavelogSettings.collect { settings ->
|
||||||
|
_uiState.update { it.copy(wavelogSettings = settings) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -121,15 +142,91 @@ class SettingsViewModel(
|
|||||||
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
|
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
|
||||||
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
|
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
|
||||||
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
|
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
|
||||||
|
is SettingsAction.ToggleMapGrid -> settingsRepo.updateOtherSettings { it.copy(stateOfMapGrid = action.value) }
|
||||||
// Remote control & data sources
|
// Remote control & data sources
|
||||||
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)
|
||||||
|
// Wavelog worked grids
|
||||||
|
is SettingsAction.UpdateWavelog -> settingsRepo.updateWavelogSettings(action.settings)
|
||||||
|
SettingsAction.SyncWorkedGrids -> syncWorkedGrids()
|
||||||
|
// 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 Wavelog worked grids
|
||||||
|
|
||||||
|
private fun syncWorkedGrids() {
|
||||||
|
val settings = _uiState.value.wavelogSettings
|
||||||
|
if (!settings.isConfigured) {
|
||||||
|
_uiState.update { it.copy(wavelogMessage = "Wavelog URL/token not configured") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_uiState.update { it.copy(wavelogSyncing = true, wavelogMessage = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val grids = wavelogRepo.fetchWorkedGrids(settings.url, settings.token)
|
||||||
|
_uiState.update {
|
||||||
|
if (grids != null) {
|
||||||
|
settingsRepo.setWorkedGrids(grids)
|
||||||
|
it.copy(wavelogSyncing = false, workedGridsCount = grids.size, wavelogMessage = null)
|
||||||
|
} else {
|
||||||
|
it.copy(wavelogSyncing = false, wavelogMessage = "Sync failed — check URL/token/network")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// 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 +298,14 @@ 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,
|
||||||
|
wavelogRepo = container.wavelogRepo,
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+310
-9
@@ -1,5 +1,6 @@
|
|||||||
package com.rtbishop.look4sat.feature.status
|
package com.rtbishop.look4sat.feature.status
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -20,12 +21,18 @@ 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.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
|
||||||
@@ -44,6 +51,9 @@ 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.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
|
||||||
@@ -51,14 +61,32 @@ 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.IContainerProvider
|
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.InfoDialog
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
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. */
|
/** Fixed width per day tile — tablet-safe; name column absorbs remaining space. */
|
||||||
private val TILE_WIDTH: Dp = 64.dp
|
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.
|
||||||
* Addresses PR #233 review: use colorScheme instead of hardcoded Color() constants.
|
* Addresses PR #233 review: use colorScheme instead of hardcoded Color() constants.
|
||||||
@@ -82,12 +110,49 @@ fun SatStatusDestination() {
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container))
|
val viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container))
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
SatStatusScreen(uiState) { viewModel.refresh() }
|
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
|
||||||
private fun SatStatusScreen(uiState: SatStatusUiState, refresh: () -> Unit) {
|
private fun SatStatusScreen(
|
||||||
|
uiState: SatStatusUiState,
|
||||||
|
refresh: () -> Unit,
|
||||||
|
onToggleUpload: () -> Unit,
|
||||||
|
onUploadReportChange: (String) -> Unit,
|
||||||
|
onUploadCallsignChange: (String) -> Unit,
|
||||||
|
onUploadGridChange: (String) -> Unit,
|
||||||
|
onRequestSubmitUpload: () -> Unit,
|
||||||
|
onConfirmSubmitUpload: (String) -> Unit,
|
||||||
|
onDismissSubmitConfirmation: () -> Unit,
|
||||||
|
onDismissUpload: () -> Unit
|
||||||
|
) {
|
||||||
var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) }
|
var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) }
|
||||||
Column(modifier = Modifier.fillMaxSize().layoutPadding()) {
|
Column(modifier = Modifier.fillMaxSize().layoutPadding()) {
|
||||||
StatusHeader(fetchedAtUtcMs = uiState.fetchedAtUtcMs, isRefreshing = uiState.isRefreshing, onRefresh = refresh)
|
StatusHeader(fetchedAtUtcMs = uiState.fetchedAtUtcMs, isRefreshing = uiState.isRefreshing, onRefresh = refresh)
|
||||||
@@ -122,7 +187,23 @@ private fun SatStatusScreen(uiState: SatStatusUiState, refresh: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
selectedDay?.let { (status, day) ->
|
selectedDay?.let { (status, day) ->
|
||||||
ReportDialog(statusName = status.name, day = day, reports = uiState.reports, onDismiss = { selectedDay = null })
|
ReportDialog(
|
||||||
|
statusName = status.name,
|
||||||
|
day = day,
|
||||||
|
reports = uiState.reports,
|
||||||
|
upload = uiState.upload,
|
||||||
|
onToggleUpload = onToggleUpload,
|
||||||
|
onReportChange = onUploadReportChange,
|
||||||
|
onCallsignChange = onUploadCallsignChange,
|
||||||
|
onGridChange = onUploadGridChange,
|
||||||
|
onRequestSubmitReport = onRequestSubmitUpload,
|
||||||
|
onConfirmSubmitReport = { onConfirmSubmitUpload(status.name) },
|
||||||
|
onDismissSubmitConfirmation = onDismissSubmitConfirmation,
|
||||||
|
onDismiss = {
|
||||||
|
selectedDay = null
|
||||||
|
onDismissUpload()
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,11 +359,55 @@ private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
|
|||||||
|
|
||||||
/** Report detail dialog (callsign / date / time / grid) */
|
/** Report detail dialog (callsign / date / time / grid) */
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReportDialog(statusName: String, day: SatDay, reports: Map<String, SatReport>, onDismiss: () -> Unit) {
|
private fun ReportDialog(
|
||||||
|
statusName: String,
|
||||||
|
day: SatDay,
|
||||||
|
reports: Map<String, SatReport>,
|
||||||
|
upload: AmSatUploadUiState,
|
||||||
|
onToggleUpload: () -> Unit,
|
||||||
|
onReportChange: (String) -> Unit,
|
||||||
|
onCallsignChange: (String) -> Unit,
|
||||||
|
onGridChange: (String) -> Unit,
|
||||||
|
onRequestSubmitReport: () -> Unit,
|
||||||
|
onConfirmSubmitReport: () -> Unit,
|
||||||
|
onDismissSubmitConfirmation: () -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] }
|
val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] }
|
||||||
InfoDialog(title = "$statusName · ${day.dateLabel}", onDismiss = onDismiss, onAccept = onDismiss) {
|
InfoDialog(
|
||||||
|
title = "$statusName · ${day.dateLabel}",
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
onAccept = onDismiss,
|
||||||
|
extraAction = {
|
||||||
|
CardButton(
|
||||||
|
onClick = onToggleUpload,
|
||||||
|
text = stringResource(if (upload.isExpanded) R.string.amsat_upload_back else R.string.amsat_upload)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(visible = upload.isExpanded) {
|
||||||
|
AmSatUploadPanel(
|
||||||
|
statusName = statusName,
|
||||||
|
upload = upload,
|
||||||
|
onReportChange = onReportChange,
|
||||||
|
onCallsignChange = onCallsignChange,
|
||||||
|
onGridChange = onGridChange,
|
||||||
|
onRequestSubmitReport = onRequestSubmitReport
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (upload.isConfirmingSubmit) {
|
||||||
|
AmSatUploadConfirmDialog(
|
||||||
|
statusName = statusName,
|
||||||
|
upload = upload,
|
||||||
|
onConfirm = onConfirmSubmitReport,
|
||||||
|
onDismiss = onDismissSubmitConfirmation
|
||||||
|
)
|
||||||
|
}
|
||||||
if (dayReports.isEmpty()) {
|
if (dayReports.isEmpty()) {
|
||||||
Text(stringResource(id = R.string.amsat_no_reports))
|
Text(
|
||||||
|
text = stringResource(id = R.string.amsat_no_reports),
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(modifier = Modifier.heightIn(max = 480.dp)) {
|
LazyColumn(modifier = Modifier.heightIn(max = 480.dp)) {
|
||||||
@@ -312,16 +437,192 @@ private fun ReportDialog(statusName: String, day: SatDay, reports: Map<String, S
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AmSatUploadPanel(
|
||||||
|
statusName: String,
|
||||||
|
upload: AmSatUploadUiState,
|
||||||
|
onReportChange: (String) -> Unit,
|
||||||
|
onCallsignChange: (String) -> Unit,
|
||||||
|
onGridChange: (String) -> Unit,
|
||||||
|
onRequestSubmitReport: () -> Unit
|
||||||
|
) {
|
||||||
|
val options = remember(statusName) {
|
||||||
|
if (statusName.startsWith("ISS")) UPLOAD_OPTIONS else UPLOAD_OPTIONS.filter { it.apiValue != AMSAT_REPORT_CREW_ACTIVE }
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f))
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.amsat_upload_report),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.amsat_upload_time_now, formatUtcNowForUpload(System.currentTimeMillis())),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.amsat_upload_status),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
options.forEach { option ->
|
||||||
|
val selected = upload.selectedReport == option.apiValue
|
||||||
|
FilterChip(
|
||||||
|
selected = selected,
|
||||||
|
onClick = { onReportChange(option.apiValue) },
|
||||||
|
label = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(if (selected) Color.White else option.color)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(stringResource(option.labelResId), maxLines = 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = FilterChipDefaults.filterChipColors(
|
||||||
|
containerColor = option.color.copy(alpha = 0.10f),
|
||||||
|
labelColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
selectedContainerColor = option.color,
|
||||||
|
selectedLabelColor = Color.White
|
||||||
|
),
|
||||||
|
border = FilterChipDefaults.filterChipBorder(
|
||||||
|
enabled = true,
|
||||||
|
selected = selected,
|
||||||
|
borderColor = option.color.copy(alpha = 0.60f),
|
||||||
|
selectedBorderColor = option.color
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = upload.callsign,
|
||||||
|
onValueChange = onCallsignChange,
|
||||||
|
label = { Text(stringResource(R.string.amsat_upload_callsign)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = upload.gridSquare,
|
||||||
|
onValueChange = onGridChange,
|
||||||
|
label = { Text(stringResource(R.string.amsat_upload_grid)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = onRequestSubmitReport,
|
||||||
|
enabled = !upload.isSubmitting && upload.callsign.isNotBlank(),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
if (upload.isSubmitting) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||||
|
} else {
|
||||||
|
Text(stringResource(R.string.amsat_upload_submit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
upload.message?.let { message ->
|
||||||
|
if (message == UPLOAD_MESSAGE_SUCCESS) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.amsat_upload_success),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
upload.error?.let { error ->
|
||||||
|
Text(
|
||||||
|
text = uploadErrorText(error),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AmSatUploadConfirmDialog(
|
||||||
|
statusName: String,
|
||||||
|
upload: AmSatUploadUiState,
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val option = UPLOAD_OPTIONS.firstOrNull { it.apiValue == upload.selectedReport } ?: UPLOAD_OPTIONS.first()
|
||||||
|
val statusLabel = stringResource(option.labelResId)
|
||||||
|
val grid = upload.gridSquare.ifBlank { stringResource(R.string.amsat_upload_grid_none) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.amsat_upload_confirm_title)) },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.amsat_upload_confirm_message,
|
||||||
|
statusName,
|
||||||
|
statusLabel,
|
||||||
|
upload.callsign,
|
||||||
|
grid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onConfirm, enabled = !upload.isSubmitting) {
|
||||||
|
Text(stringResource(R.string.amsat_upload_confirm))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss, enabled = !upload.isSubmitting) {
|
||||||
|
Text(stringResource(R.string.btn_cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun uploadErrorText(error: String): String {
|
||||||
|
return when (error) {
|
||||||
|
UPLOAD_ERROR_CALLSIGN_REQUIRED -> stringResource(R.string.amsat_upload_callsign_required)
|
||||||
|
UPLOAD_ERROR_GRID_INVALID -> stringResource(R.string.amsat_upload_grid_invalid)
|
||||||
|
else -> stringResource(R.string.amsat_upload_failed, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val MONTH_ABBR = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
private 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
|
||||||
val day = cal.get(Calendar.DAY_OF_MONTH)
|
val day = cal.get(Calendar.DAY_OF_MONTH)
|
||||||
val month = MONTH_ABBR[cal.get(Calendar.MONTH)]
|
val monthIndex = cal.get(Calendar.MONTH)
|
||||||
val year = cal.get(Calendar.YEAR)
|
val year = cal.get(Calendar.YEAR)
|
||||||
val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
|
val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
|
||||||
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
|
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
|
||||||
val ss = cal.get(Calendar.SECOND).toString().padStart(2, '0')
|
val ss = cal.get(Calendar.SECOND).toString().padStart(2, '0')
|
||||||
return "$day$month $year - $hh:$mm:$ss"
|
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))
|
||||||
|
}
|
||||||
+222
-23
@@ -4,14 +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.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,
|
||||||
@@ -19,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 amSatRepo: IAmSatRepository
|
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 = 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")
|
||||||
@@ -66,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 = 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") }
|
||||||
}
|
}
|
||||||
@@ -85,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.amSatRepo)
|
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 = "446"
|
appVersionCode = "454"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionName = "4.4.6-ba7opf.1"
|
appVersionName = "4.4.6-ba7opf.9.2"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
compileSdk = "37"
|
compileSdk = "37"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
|
|||||||
Reference in new issue
Block a user