Compare commits

...
23 Commits
Author SHA1 Message Date
Arty Bishop 22607e7710 v4.3.2 - Implemented Swipe-to-Focus behavior, minor fixes 2026-05-30 13:16:14 +01:00
Arty Bishop ad0ff6859e Updated release workflow to avoid third-party dependencies 2026-05-25 18:30:49 +01:00
Arty Bishop a39b1716e7 Added consistent pass selection to Radar and Map screens 2026-05-25 18:30:49 +01:00
Arty Bishop 8a2339cfbf Implemented Swipe-to-Focus behavior for satellite passes 2026-05-25 18:30:48 +01:00
Arty Bishop 821fa0dfe7 v4.3.1 - Zipped custom sources handling, translation fixes 2026-05-04 16:55:15 +01:00
Arty Bishop ce8c2a4834 Added zipped data sources handling to DatabaseRepo 2026-05-04 15:36:42 +01:00
Arty Bishop 8cc4ef5610 Tweaked passes list to show DeepSpace ones at the top 2026-05-04 15:36:42 +01:00
Mubi-Baihua dde22faf46 Added minor tweaks to Chinese translation (#216) 2026-05-02 19:32:18 +01:00
Arty Bishop 7ad81e32e1 v4.3.0 - Sticky header, sun/moon positions, red night mode 2026-04-30 19:32:17 +01:00
Arty Bishop 5af963f3c9 Added several tweaks to sunrise/sunset time calculations 2026-04-28 21:00:43 +01:00
Arty Bishop bfb9fb6ca8 Tweaked ViewModel retention to mirror the Nav2 behavior 2026-04-26 14:44:29 +01:00
Emre Can AkdaşandArty Bishop df24c66ef3 Fixes for Turkish translation, by Emre Can Akdaş (TA3ECR)
Co-authored-by: Arty Bishop <44072814+rt-bishop@users.noreply.github.com>
2026-04-26 14:42:16 +01:00
Arty Bishop b166e896c2 Added current moon/sun positions to the RadarScreen 2026-04-26 14:32:16 +01:00
Arty Bishop 18f8ab517d Added current moon/sun positions to the MapScreen 2026-04-26 00:11:08 +01:00
Arty Bishop d3b8e951dd Added red night mode filter overlay for the whole app 2026-04-25 19:22:32 +01:00
Arty Bishop 6e5a3cb0bb Added sticky header with a date and sunrise/sunset time 2026-04-25 19:20:22 +01:00
Arty Bishop 31d329a19b Added CelestialComputer, extracted common functionality 2026-04-25 18:18:00 +01:00
Arty Bishop 3df358b88c v4.2.2 - Kotlin Serialization, Navigation3, translation fixes 2026-04-24 17:24:00 +01:00
Arty Bishop 7d5bca300d Migrated to Compose Navigation3, updated dependencies 2026-04-24 17:20:23 +01:00
Arty Bishop fb2cd85b11 Migrated to KotlinX Serialization library for JSON parsing 2026-04-24 17:18:06 +01:00
Arty Bishop 99158e74fa v4.2.1 - Turkish translation, DeepSpace filter, various tweaks 2026-04-19 13:14:31 +01:00
Arty Bishop 8265b73d75 Added DeepSpace passes filter, fixed refresh issue (#208) 2026-04-18 15:56:28 +01:00
Emre Can Akdaş d2b3184084 Added Turkish translation, by Emre Can Akdaş (TA3ECR) (#211) 2026-04-18 10:38:42 +01:00
59 changed files with 2342 additions and 692 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
+39 -35
View File
@@ -25,50 +25,54 @@ jobs:
java-version: '17'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
uses: gradle/actions/setup-gradle@v6
- name: Assemble Artifacts
run: |
./gradlew assembleRelease
./gradlew bundleRelease
run: ./gradlew assembleRelease bundleRelease
- name: Sign APK
uses: r0adkll/sign-android-release@v1
id: sign_apk
with:
releaseDirectory: app/build/outputs/apk/release
signingKeyBase64: ${{ secrets.KEY_STORE }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
alias: ${{ secrets.KEY_ALIAS }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: "36.0.0"
run: |
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
APK=$(find app/build/outputs/apk/release -name "*.apk" | head -1)
BUILD_TOOLS=$(ls -d ${ANDROID_HOME}/build-tools/*/ | sort -V | tail -1)
${BUILD_TOOLS}apksigner sign \
--ks keystore.jks \
--ks-pass pass:${{ secrets.KEY_STORE_PASSWORD }} \
--ks-key-alias ${{ secrets.KEY_ALIAS }} \
--key-pass pass:${{ secrets.KEY_PASSWORD }} \
--out app/build/outputs/apk/release/look4sat.apk \
"$APK"
rm keystore.jks
- name: Sign Bundle
uses: r0adkll/sign-android-release@v1
id: sign_bundle
with:
releaseDirectory: app/build/outputs/bundle/release
signingKeyBase64: ${{ secrets.KEY_STORE }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
alias: ${{ secrets.KEY_ALIAS }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: "36.0.0"
- name: Rename Artifacts
run: |
mv ${{steps.sign_apk.outputs.signedReleaseFile}} app/build/outputs/apk/release/look4sat.apk
mv ${{steps.sign_bundle.outputs.signedReleaseFile}} app/build/outputs/apk/release/look4sat.aab
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \
-keystore keystore.jks \
-storepass ${{ secrets.KEY_STORE_PASSWORD }} \
-keypass ${{ secrets.KEY_PASSWORD }} \
"$AAB" ${{ secrets.KEY_ALIAS }}
rm keystore.jks
- name: Deploy Bundle
uses: r0adkll/upload-google-play@v1
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
serviceAccountJsonPlainText: ${{secrets.SERVICE_ACCOUNT_JSON}}
packageName: com.rtbishop.look4sat
releaseFiles: app/build/outputs/apk/release/look4sat.aab
track: production
whatsNewDirectory: fastlane/metadata/android/en-US/whatsnew
ruby-version: '3.3'
- name: Deploy Bundle to Google Play
run: |
gem install fastlane --no-document
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json
fastlane supply \
--aab "$AAB" \
--json_key service_account.json \
--package_name com.rtbishop.look4sat \
--track production \
--skip_upload_images true \
--skip_upload_screenshots true \
rm service_account.json
- name: Create Release
run: |
+3 -1
View File
@@ -14,9 +14,11 @@
<application
android:name=".MainApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round">
android:roundIcon="@mipmap/ic_launcher_round"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
@@ -19,12 +19,21 @@ package com.rtbishop.look4sat
import android.content.Context
import android.content.res.Configuration
import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
import android.graphics.Paint
import android.os.Bundle
import android.view.View
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.MainTheme
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
@@ -38,8 +47,37 @@ class MainActivity : ComponentActivity() {
installSplashScreen()
enableEdgeToEdge()
super.onCreate(savedInstanceState)
observeNightFilterState()
setContent {
MainTheme(isDarkTheme = true) { MainScreen() }
}
}
private fun observeNightFilterState() {
val mainContainer = (applicationContext as IContainerProvider).getMainContainer()
lifecycleScope.launch {
mainContainer.settingsRepo.otherSettings
.map { it.stateOfNightMode }
.distinctUntilChanged()
.collect { nightMode -> applyNightFilter(nightMode) }
}
}
private fun applyNightFilter(enabled: Boolean) {
if (enabled) {
val nightMatrix = ColorMatrix(
floatArrayOf(
1f, 0f, 0f, 0f, 0f, // R → R
0f, 0f, 0f, 0f, 0f, // G → 0
0f, 0f, 0f, 0f, 0f, // B → 0
0f, 0f, 0f, 1f, 0f // A → A
)
)
window.decorView.setLayerType(View.LAYER_TYPE_HARDWARE, Paint().apply {
colorFilter = ColorMatrixColorFilter(nightMatrix)
})
} else {
window.decorView.setLayerType(View.LAYER_TYPE_NONE, null)
}
}
}
@@ -25,19 +25,17 @@ import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -58,128 +56,153 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.Screen
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.feature.map.mapDestination
import com.rtbishop.look4sat.feature.passes.passesDestination
import com.rtbishop.look4sat.feature.radar.radarDestination
import com.rtbishop.look4sat.feature.radiocontrol.radioControlDestination
import com.rtbishop.look4sat.feature.satellites.satellitesDestination
import com.rtbishop.look4sat.feature.settings.settingsDestination
import com.rtbishop.look4sat.feature.map.MapDestination
import com.rtbishop.look4sat.feature.passes.PassesDestination
import com.rtbishop.look4sat.feature.radar.RadarDestination
import com.rtbishop.look4sat.feature.radiocontrol.RadioControlDestination
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
import com.rtbishop.look4sat.feature.settings.SettingsDestination
@Composable
fun MainScreen(navController: NavHostController = rememberNavController()) {
val items = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings)
val currentDestination = navController.currentBackStackEntryAsState().value?.destination?.route
val startDestination = Screen.Passes.route
fun MainScreen() {
val backStack = rememberNavBackStack(Screen.Passes)
val currentKey = backStack.lastOrNull()
val navigateBack: () -> Unit = { if (backStack.size > 1) backStack.removeAt(backStack.size - 1) }
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
// val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
// val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings)
// Observe radio tracking state for the status bar
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
NavigationSuiteScaffold(
navigationSuiteItems = {
items.forEach {
navItems.forEach { screen ->
val isSelected = when (currentKey) {
is Screen.Satellites -> screen is Screen.Satellites
is Screen.Passes -> screen is Screen.Passes
is Screen.Radar -> screen is Screen.Radar
is Screen.Map -> screen is Screen.Map
is Screen.Settings -> screen is Screen.Settings
else -> false
}
item(
icon = { Icon(painterResource(it.iconResId), stringResource(it.titleResId)) },
label = { Text(stringResource(it.titleResId)) },
selected = currentDestination?.contains(it.route) ?: false,
icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) },
label = { Text(stringResource(screen.titleResId)) },
selected = isSelected,
onClick = {
if (currentDestination?.contains(it.route) ?: false) return@item
navController.navigate(it.route) {
popUpTo(startDestination) { saveState = false }
launchSingleTop = true
restoreState = false
}
})
if (isSelected) return@item
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
if (screen !is Screen.Passes) backStack.add(screen)
}
)
}
}, navigationSuiteColors = NavigationSuiteDefaults.colors(
},
navigationSuiteColors = NavigationSuiteDefaults.colors(
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
), layoutType = when {
),
layoutType = when {
!hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail
!hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact
else -> NavigationSuiteType.ShortNavigationBarMedium
}
) {
Column {
NavHost(
navController = navController,
startDestination = startDestination,
enterTransition = { fadeIn(animationSpec = tween(350)) },
exitTransition = { fadeOut(animationSpec = tween(350)) },
modifier = Modifier.weight(1f)
) {
satellitesDestination { navController.navigateUp() }
passesDestination { catNum: Int, aosTime: Long ->
val radarRoute = "${Screen.Radar.route}?catNum=${catNum}&aosTime=${aosTime}"
navController.navigate(radarRoute)
}
radarDestination(
navigateUp = { navController.navigateUp() },
navigateToRadioControl = { catNum, aosTime ->
val route = "${Screen.RadioControl.route}?catNum=$catNum&aosTime=$aosTime"
navController.navigate(route)
}
)
radioControlDestination { navController.navigateUp() }
mapDestination()
settingsDestination()
}
// Radio tracking status banner (above bottom navigation)
if (trackingState.isActive) {
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
val alpha by infiniteTransition.animateFloat(
initialValue = 1f, targetValue = 0.4f,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
), label = "pulseAlpha"
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.primaryContainer)
.clickable {
val pass = trackingState.currentPass
if (pass != null) {
val route = "${Screen.RadioControl.route}?catNum=${pass.catNum}&aosTime=${pass.aosTime}"
navController.navigate(route)
NavDisplay(
backStack = backStack,
modifier = Modifier.weight(1f),
onBack = navigateBack,
transitionSpec = { fadeTransition },
popTransitionSpec = { fadeTransition },
predictivePopTransitionSpec = { fadeTransition },
entryDecorators = listOf(
// Required for saving Compose state per entry
rememberSaveableStateHolderNavEntryDecorator(),
// Required for ViewModel scoping per entry
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entry<Screen.Satellites> {
SatellitesDestination(navigateUp = navigateBack)
}
entry<Screen.Passes> {
PassesDestination { catNum, aosTime ->
container.satelliteRepo.selectPass(catNum, aosTime)
backStack.add(Screen.Radar)
}
}
.padding(horizontal = 12.dp, vertical = 6.dp)
) {
Box(
entry<Screen.Radar> {
RadarDestination(navigateToRadioControl = { backStack.add(Screen.RadioControl) })
}
entry<Screen.RadioControl> {
RadioControlDestination(navigateUp = navigateBack)
}
entry<Screen.Map> {
MapDestination()
}
entry<Screen.Settings> {
SettingsDestination()
}
}
)
// Radio tracking status banner
if (trackingState.isActive) {
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
val alpha by infiniteTransition.animateFloat(
initialValue = 1f, targetValue = 0.4f,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
), label = "pulseAlpha"
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(Color(0xFF4CAF50).copy(alpha = alpha))
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Tracking: ${trackingState.currentPass?.name ?: ""}",
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.weight(1f)
)
val txOk = if (trackingState.txConnected) "TX" else ""
val rxOk = if (trackingState.rxConnected) "RX" else ""
Text(
text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.primaryContainer)
.clickable {
val pass = trackingState.currentPass
if (pass != null) {
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
backStack.add(Screen.RadioControl)
}
}
.padding(horizontal = 12.dp, vertical = 6.dp)
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(Color(0xFF4CAF50).copy(alpha = alpha))
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Tracking: ${trackingState.currentPass?.name ?: ""}",
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.weight(1f)
)
val txOk = if (trackingState.txConnected) "TX" else ""
val rxOk = if (trackingState.rxConnected) "RX" else ""
Text(
text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
} // end Column
}
}
@@ -38,6 +38,8 @@ internal class ApplicationPlugin : Plugin<Project> {
implementation(project(":feature:satellites"))
implementation(project(":feature:settings"))
implementation(libs.androidx.core.splashscreen)
implementation(libs.compose.material3.adaptive)
implementation(libs.compose.navigation3)
androidTestImplementation(libs.bundles.androidTest)
}
}
@@ -33,7 +33,7 @@ internal class CoreDataPlugin : Plugin<Project> {
implementation(libs.androidx.room)
implementation(libs.androidx.room.runtime)
ksp(libs.androidx.room.compiler)
implementation(libs.other.coroutines)
implementation(libs.kotlin.coroutines)
implementation(libs.other.okhttp)
}
}
@@ -25,10 +25,11 @@ import org.gradle.kotlin.dsl.dependencies
internal class CoreDomainPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
applyPlugin(libs.plugins.kotlin.jvm)
applyPlugin(libs.plugins.kotlin.serialization)
setupKotlin()
dependencies {
implementation(libs.other.coroutines)
implementation(libs.other.json)
implementation(libs.kotlin.coroutines)
implementation(libs.kotlin.serialization)
}
}
}
@@ -24,12 +24,15 @@ import org.gradle.kotlin.dsl.dependencies
@Suppress("Unused")
internal class CorePresentationPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
applyPlugin(libs.plugins.kotlin.serialization)
setupAndroidLib()
setupCompose()
setupKotlin()
dependencies {
implementation(project(":core:domain"))
implementation(libs.androidx.core.splashscreen)
implementation(libs.kotlin.serialization)
implementation(libs.compose.material3.adaptive)
}
}
}
@@ -74,7 +74,7 @@ internal fun Project.setupAndroidApp() {
}
androidResources {
generateLocaleConfig = true
localeFilters.addAll(listOf("en", "es", "ru", "si", "uk", "zh"))
localeFilters.addAll(listOf("en", "es", "ru", "si", "tr", "uk", "zh"))
}
packaging { resources { excludes += listOf("META-INF/*") } }
}
+1
View File
@@ -4,6 +4,7 @@ plugins {
alias(libs.plugins.compose.compiler) apply false
alias(libs.plugins.google.ksp) apply false
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.serialization) apply false
}
tasks.register("clean", Delete::class.java) {
@@ -25,11 +25,11 @@ data class SatRadio(
@PrimaryKey val uuid: String,
val info: String,
val isAlive: Boolean,
var downlinkLow: Long?,
var downlinkHigh: Long?,
val downlinkLow: Long?,
val downlinkHigh: Long?,
val downlinkMode: String?,
var uplinkLow: Long?,
var uplinkHigh: Long?,
val uplinkLow: Long?,
val uplinkHigh: Long?,
val uplinkMode: String?,
val isInverted: Boolean,
val catnum: Int?
@@ -40,28 +40,22 @@ class DatabaseRepo(
private val settingsRepo: ISettingsRepo
) : IDatabaseRepo {
private companion object {
val tleTypes = setOf("Amsat", "R4UAB", "Other")
val zippedTleTypes = setOf("McCants", "Classified")
}
private val customSourceType = "Other"
override suspend fun updateTLEFromFile(uri: String) = withContext(dispatcher) {
remoteSource.getFileStream(uri)?.let { stream ->
val entries = dataParser.parseTLEStream(stream)
val entries = dataParser.parseTLEStream(unwrapIfZipped(uri, stream))
localSource.insertEntries(entries)
settingsRepo.setSatelliteTypeIds("Other", entries.map { it.catnum })
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
}
setUpdateSuccessful(System.currentTimeMillis())
}
override suspend fun updateTransceiversFromFile(uri: String) = withContext(dispatcher) {
remoteSource.getFileStream(uri)
?.let { dataParser.parseJSONStream(it) }
?.takeIf { it.isNotEmpty() }
?.let {
localSource.deleteRadios()
localSource.insertRadios(it)
}
remoteSource.getFileStream(uri)?.let { stream ->
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
localSource.insertRadios(transceivers)
}
setUpdateSuccessful(System.currentTimeMillis())
}
@@ -69,28 +63,26 @@ class DatabaseRepo(
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
val tleUrls = buildMap {
putAll(Sources.satelliteDataUrls)
if (dataSourcesSettings.useCustomTLE) put("Other", dataSourcesSettings.tleUrl)
}.filterValues { it.isNotEmpty() }
val radioUrls = buildList {
add(Sources.RADIO_DATA_URL)
if (dataSourcesSettings.useCustomTransceivers) add(dataSourcesSettings.transceiversUrl)
}
if (dataSourcesSettings.useCustomTLE) put(customSourceType, dataSourcesSettings.tleUrl)
}.filterValues { it.isNotBlank() }
val radioUrls = buildMap {
putAll(Sources.transceiversDataUrls)
if (dataSourcesSettings.useCustomTransceivers) put(customSourceType, dataSourcesSettings.transceiversUrl)
}.filterValues { it.isNotBlank() }
// launch all network requests concurrently
val tleJobs = tleUrls.map { (type, url) -> async { type to remoteSource.getNetworkStream(url) } }
val radioJobs = radioUrls.map { url -> async { remoteSource.getNetworkStream(url) } }
// parse satellite data
val importedEntries = tleJobs.awaitAll().flatMap { (type, stream) ->
stream?.let { parseSatelliteStream(type, it) }.orEmpty().also { satellites ->
settingsRepo.setSatelliteTypeIds(type, satellites.map { it.catnum })
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
// parse fetched data concurrently and associate with types
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
}
}
// parse radio data
val importedRadios = radioJobs.awaitAll().filterNotNull().flatMap { dataParser.parseJSONStream(it) }
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
}
// insert parsed data into the database
localSource.insertEntries(importedEntries)
localSource.insertRadios(importedRadios)
setUpdateSuccessful(System.currentTimeMillis())
@@ -102,10 +94,9 @@ class DatabaseRepo(
setUpdateSuccessful(0L)
}
private suspend fun parseSatelliteStream(type: String, stream: InputStream): List<OrbitalData> = when (type) {
in tleTypes -> dataParser.parseTLEStream(stream)
in zippedTleTypes -> dataParser.parseTLEStream(ZipInputStream(stream).apply { nextEntry })
else -> dataParser.parseCSVStream(stream)
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> = when {
url.contains("FORMAT=csv", ignoreCase = true) -> dataParser.parseCSVStream(stream)
else -> dataParser.parseTLEStream(stream)
}
private suspend fun setUpdateSuccessful(timestamp: Long) {
@@ -113,4 +104,7 @@ class DatabaseRepo(
DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp)
)
}
private fun unwrapIfZipped(url: String, stream: InputStream): InputStream =
if (url.endsWith(".zip", ignoreCase = true)) ZipInputStream(stream).apply { nextEntry } else stream
}
@@ -31,6 +31,7 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
@@ -45,15 +46,25 @@ class SatelliteRepo(
private val _passes = MutableStateFlow<List<OrbitalPass>>(emptyList())
override val passes: StateFlow<List<OrbitalPass>> = _passes
private val _isCalculating = MutableStateFlow(false)
override val isCalculating: StateFlow<Boolean> = _isCalculating
private val _satellites = MutableStateFlow<List<OrbitalObject>>(emptyList())
override val satellites: StateFlow<List<OrbitalObject>> = _satellites
private val _selectedPass = MutableStateFlow(0 to 0L)
override val selectedPass: StateFlow<Pair<Int, Long>> = _selectedPass
override fun selectPass(catNum: Int, aosTime: Long) {
_selectedPass.value = catNum to aosTime
}
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
override suspend fun initRepository() = withContext(dispatcher) {
settingsRepo.selectedIds.collect { selectedIds ->
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
val (hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value
val (_, hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value
calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
}
}
@@ -95,41 +106,40 @@ class SatelliteRepo(
}
override suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>) {
_isCalculating.value = true
// Normalize to the start of the current minute so that coarse 60-second stepping
// in getLeoPass always begins from the same phase, producing stable AOS/LOS times
val normalizedTime = time / 60_000L * 60_000L
val currentSatellites = _satellites.value
if (currentSatellites.isNotEmpty()) {
withContext(dispatcher) {
val idsWithModes = localStorage.getIdsWithModes(modes)
val stationPos = settingsRepo.stationPosition.value
val filteredSatellites = if (idsWithModes.isEmpty()) {
currentSatellites
} else {
currentSatellites.filter { it.data.catnum in idsWithModes }
}
// Compute passes for each satellite in parallel
val passLists = coroutineScope {
filteredSatellites.map { satellite ->
async { satellite.getPasses(stationPos, normalizedTime, hoursAhead) }
}.awaitAll()
}
// Flatten and filter in a single pass
val timeFuture = normalizedTime + (hoursAhead * 60L * 60L * 1000L)
val newPasses = ArrayList<OrbitalPass>()
for (list in passLists) {
for (pass in list) {
if (pass.losTime > time && pass.aosTime < timeFuture && pass.maxElevation > minElevation) {
newPasses.add(pass)
}
withContext(dispatcher) {
val idsWithModes = localStorage.getIdsWithModes(modes)
val stationPos = settingsRepo.stationPosition.value
val filteredSatellites = if (idsWithModes.isEmpty()) {
currentSatellites
} else {
currentSatellites.filter { it.data.catnum in idsWithModes }
}
// Compute passes for each satellite in parallel
val passLists = coroutineScope {
filteredSatellites.map { satellite ->
async { satellite.getPasses(stationPos, normalizedTime, hoursAhead) }
}.awaitAll()
}
// Flatten and filter in a single pass
val timeFuture = normalizedTime + (hoursAhead * 60L * 60L * 1000L)
val newPasses = ArrayList<OrbitalPass>()
for (list in passLists) {
for (pass in list) {
if (pass.losTime > time && pass.aosTime < timeFuture && pass.maxElevation > minElevation) {
newPasses.add(pass)
}
}
newPasses.sortBy { it.aosTime }
_passes.update { newPasses }
}
} else {
_passes.update { emptyList() }
newPasses.sortBy { it.aosTime }
delay(1000) // Simulate loading time for better UX
_passes.update { newPasses }
}
_isCalculating.value = false
}
private fun OrbitalObject.getPasses(pos: GeoPos, time: Long, hours: Int): List<OrbitalPass> {
@@ -51,6 +51,7 @@ class SettingsRepo(
private val keyBluetoothFrequencyState = "bluetoothFrequencyState"
private val keyBluetoothFrequencyAddress = "bluetoothFrequencyAddress"
private val keyBluetoothFrequencyFormat = "bluetoothFrequencyFormat"
private val keyFilterShowDeepSpace = "filterShowDeepSpace"
private val keyFilterHoursAhead = "filterHoursAhead"
private val keyFilterMinElevation = "filterMinElevation"
private val keyNumberOfRadios = "numberOfRadios"
@@ -71,6 +72,7 @@ class SettingsRepo(
private val keyStateOfSweep = "stateOfSweep"
private val keyStateOfUtc = "stateOfUtc"
private val keyStateOfLightTheme = "stateOfLightTheme"
private val keyStateOfNightMode = "stateOfNightMode"
private val keyStationAltitude = "stationAltitude"
private val keyStationLatitude = "stationLatitude"
private val keyStationLongitude = "stationLongitude"
@@ -121,6 +123,7 @@ class SettingsRepo(
override val passesSettings: StateFlow<PassesSettings> = _passesSettings
override fun setPassesSettings(settings: PassesSettings) = preferences.edit {
putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace)
putInt(keyFilterHoursAhead, settings.hoursAhead)
putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
@@ -128,11 +131,12 @@ class SettingsRepo(
}
private fun getPassesSettings(): PassesSettings {
val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true)
val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24)
val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits()))
val selectedModesString = preferences.getString(keySelectedModes, null)
val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList()
return PassesSettings(hoursAhead, minElevation, selectedModes)
return PassesSettings(showDeepSpace, hoursAhead, minElevation, selectedModes)
}
//endregion
@@ -326,6 +330,7 @@ class SettingsRepo(
putBoolean(keyStateOfSweep, new.stateOfSweep)
putBoolean(keyStateOfUtc, new.stateOfUtc)
putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
}
@@ -339,6 +344,7 @@ class SettingsRepo(
stateOfSweep = preferences.getBoolean(keyStateOfSweep, true),
stateOfUtc = preferences.getBoolean(keyStateOfUtc, false),
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true)
)
@@ -17,16 +17,20 @@
*/
package com.rtbishop.look4sat.core.domain.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SatRadio(
val uuid: String,
val info: String,
val isAlive: Boolean,
var downlinkLow: Long?,
var downlinkHigh: Long?,
val downlinkMode: String?,
var uplinkLow: Long?,
var uplinkHigh: Long?,
val uplinkMode: String?,
val isInverted: Boolean,
val catnum: Int?
@SerialName("uuid") val uuid: String,
@SerialName("description") val info: String,
@SerialName("alive") val isAlive: Boolean,
@SerialName("downlink_low") val downlinkLow: Long?,
@SerialName("downlink_high") val downlinkHigh: Long?,
@SerialName("mode") val downlinkMode: String?,
@SerialName("uplink_low") val uplinkLow: Long?,
@SerialName("uplink_high") val uplinkHigh: Long?,
@SerialName("uplink_mode") val uplinkMode: String?,
@SerialName("invert") val isInverted: Boolean,
@SerialName("norad_cat_id") val catnum: Int?
)
@@ -24,6 +24,7 @@ data class DatabaseState(
)
data class PassesSettings(
val showDeepSpace: Boolean = true,
val hoursAhead: Int,
val minElevation: Double,
val selectedModes: List<String>
@@ -53,6 +54,7 @@ data class OtherSettings(
val stateOfSweep: Boolean,
val stateOfUtc: Boolean,
val stateOfLightTheme: Boolean,
val stateOfNightMode: Boolean = false,
val shouldSeeWarning: Boolean,
val shouldSeeWhatsNew: Boolean
)
@@ -0,0 +1,667 @@
/*
* 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.predict
import com.rtbishop.look4sat.core.domain.utility.toDegrees
import com.rtbishop.look4sat.core.domain.utility.toRadians
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.acos
import kotlin.math.asin
import kotlin.math.atan
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.floor
import kotlin.math.log10
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
import kotlin.math.tan
/**
* Standalone celestial computations extracted from PREDICT v2.2.5.
* Provides Sun position, Moon position, satellite visibility classification,
* orbital metadata, RA/Dec conversion, and rise/set finding for Sun and Moon.
*
* All angles are in degrees unless noted. Time is Unix epoch milliseconds.
*
* Shared math utilities (thetaGJD, modulus, mod2PI, deltaET, millisToDaynum,
* solarPositionECI, eciToGeodetic) live in OrbitalMath.kt in the same package.
*/
object CelestialComputer {
// ── Result types ──
/** Sun position as seen from a ground observer. */
data class SunPosition(
val azimuth: Double, // degrees, 0=N, 90=E
val elevation: Double, // degrees, >0 above horizon
val distance: Double, // normalized: 1.0 + ((range - AU) / AU)
val rangeRate: Double, // km/s
val latitude: Double, // sub-solar point latitude, degrees
val longitude: Double, // sub-solar point longitude, degrees
val rightAscension: Double, // degrees
val declination: Double // degrees
)
/** Moon position as seen from a ground observer. */
data class MoonPosition(
val azimuth: Double, // degrees, 0=N, 90=E
val elevation: Double, // degrees
val rightAscension: Double, // degrees
val declination: Double, // degrees
val gha: Double, // Greenwich Hour Angle, degrees
val angularDiameter: Double, // apparent diameter relative to Earth's diameter
val radialVelocity: Double // m/s, Doppler radial velocity for EME
)
/**
* 3-state satellite visibility classification.
* - [VISIBLE]: satellite is sunlit, observer is in darkness (sun below -12°) — optically visible
* - [DAYLIGHT]: satellite is sunlit, observer is in daylight
* - [ECLIPSED]: satellite is in Earth's shadow
*/
enum class SatVisibility { VISIBLE, DAYLIGHT, ECLIPSED }
/** Orbital metadata not typically included in pass data. */
data class OrbitalMetadata(
val footprintDiameter: Double, // km, ground coverage circle diameter
val orbitNumber: Long, // current orbit/revolution number
val betaAngle: Double, // degrees, angle between orbital plane and Sun
val orbitalPhase: Double // 0-256 phase within current orbit
)
// ── Sun position ──
/**
* Compute the Sun's full position as seen from [observer] at [timeMillis].
* Includes az/el, RA/Dec, sub-solar lat/lon, range, and range rate.
* Based on FindSun() from PREDICT v2.2.5.
*/
fun getSunPosition(observer: GeoPos, timeMillis: Long): SunPosition {
val daynum = millisToDaynum(timeMillis)
val julUtc = daynum + 2444238.5
val sunVec = solarPositionECI(julUtc)
val zeroVel = doubleArrayOf(0.0, 0.0, 0.0)
val obsGeo = observerGeodetic(observer)
// Az, El, Range, RangeRate
val obsSet = computeObsAngles(julUtc, sunVec, zeroVel, obsGeo)
// Lat/Lon of sub-solar point
val latLon = eciToGeodetic(julUtc, sunVec)
// RA/Dec
val raDec = calculateRADec(julUtc, sunVec, zeroVel, obsGeo)
return SunPosition(
azimuth = obsSet[0].toDegrees(),
elevation = obsSet[1].toDegrees(),
distance = 1.0 + ((obsSet[2] - ASTRONOMICAL_UNIT) / ASTRONOMICAL_UNIT),
rangeRate = 1000.0 * obsSet[3],
latitude = latLon[0].toDegrees(),
longitude = latLon[1].toDegrees().let { if (it > 180.0) it - 360.0 else it },
rightAscension = raDec[0].toDegrees(),
declination = raDec[1].toDegrees()
)
}
// ── Moon position ──
/**
* Compute the Moon's position as seen from [observer] at [timeMillis].
* Full Meeus lunar ephemeris from PREDICT v2.2.5 with expanded terms
* and radial velocity approximation for EME Doppler.
*/
fun getMoonPosition(observer: GeoPos, timeMillis: Long): MoonPosition {
val daynum = millisToDaynum(timeMillis)
val jd = daynum + 2444238.5
var t = (jd - 2415020.0) / 36525.0
val t2 = t * t
val t3 = t2 * t
var l1 = 270.434164 + 481267.8831 * t - 0.001133 * t2 + 0.0000019 * t3
var mSun = 358.475833 + 35999.0498 * t - 0.00015 * t2 - 0.0000033 * t3
var m1 = 296.104608 + 477198.8491 * t + 0.009192 * t2 + 0.0000144 * t3
var d = 350.737486 + 445267.1142 * t - 0.001436 * t2 + 0.0000019 * t3
var ff = 11.250889 + 483202.0251 * t - 0.003211 * t2 - 0.0000003 * t3
val om = (259.183275 - 1934.142 * t + 0.002078 * t2 + 0.0000022 * t3) * DEG2RAD
val correction512 = sin((51.2 + 20.2 * t) * DEG2RAD)
val ss = 0.003964 * sin((346.56 + 132.87 * t - 0.0091731 * t2) * DEG2RAD)
l1 += 0.000233 * correction512 + ss + 0.001964 * sin(om)
mSun -= 0.001778 * correction512
m1 += 0.000817 * correction512 + ss + 0.002541 * sin(om)
d += 0.002011 * correction512 + ss + 0.001964 * sin(om)
ff += ss - 0.024691 * sin(om) - 0.004328 * sin(om + (275.05 - 2.3 * t) * DEG2RAD)
val ex = 1.0 - 0.002495 * t - 0.00000752 * t2
l1 = primeAngle(l1); mSun = primeAngle(mSun); m1 = primeAngle(m1)
d = primeAngle(d); ff = primeAngle(ff)
val mR = mSun * DEG2RAD
val m1R = m1 * DEG2RAD
val dR = d * DEG2RAD
val ffR = ff * DEG2RAD
// Ecliptic longitude — expanded v225 terms
var l = l1 + 6.28875 * sin(m1R) + 1.274018 * sin(2 * dR - m1R) + 0.658309 * sin(2 * dR)
l += 0.213616 * sin(2 * m1R) - ex * 0.185596 * sin(mR) - 0.114336 * sin(2 * ffR)
l += 0.058793 * sin(2 * dR - 2 * m1R) + ex * 0.057212 * sin(2 * dR - mR - m1R) + 0.05332 * sin(2 * dR + m1R)
l += ex * 0.045874 * sin(2 * dR - mR) + ex * 0.041024 * sin(m1R - mR) - 0.034718 * sin(dR)
l -= ex * 0.030465 * sin(mR + m1R) + 0.015326 * sin(2 * dR - 2 * ffR) - 0.012528 * sin(2 * ffR + m1R)
l -= 0.01098 * sin(2 * ffR - m1R) + 0.010674 * sin(4 * dR - m1R) + 0.010034 * sin(3 * m1R)
l += 0.008548 * sin(4 * dR - 2 * m1R) - ex * 0.00791 * sin(mR - m1R + 2 * dR)
l -= ex * 0.006783 * sin(2 * dR + mR)
l += 0.005162 * sin(m1R - dR) + ex * 0.005 * sin(mR + dR) + ex * 0.004049 * sin(m1R - mR + 2 * dR)
l += 0.003996 * sin(2 * m1R + 2 * dR) + 0.003862 * sin(4 * dR) + 0.003665 * sin(2 * dR - 3 * m1R)
l += ex * 0.002695 * sin(2 * m1R - mR) + 0.002602 * sin(m1R - 2 * ffR - 2 * dR)
l += ex * 0.002396 * sin(2 * dR - mR - 2 * m1R)
l -= 0.002349 * sin(m1R + dR) + ex * ex * 0.002249 * sin(2 * dR - 2 * mR)
l -= ex * 0.002125 * sin(2 * m1R + mR)
l -= ex * ex * 0.002079 * sin(2 * mR) + ex * ex * 0.002059 * sin(2 * dR - m1R - 2 * mR)
l -= 0.001773 * sin(m1R + 2 * dR - 2 * ffR)
l += ex * 0.00122 * sin(4 * dR - mR - m1R) - 0.00111 * sin(2 * m1R + 2 * ffR) + 0.000892 * sin(m1R - 3 * dR)
l -= ex * 0.000811 * sin(mR + m1R + 2 * dR) + ex * 0.000761 * sin(4 * dR - mR - 2 * m1R)
l += ex * ex * 0.000717 * sin(m1R - 2 * mR)
l += ex * ex * 0.000704 * sin(m1R - 2 * mR - 2 * dR) + ex * 0.000693 * sin(mR - 2 * m1R + 2 * dR)
l += ex * 0.000598 * sin(2 * dR - mR - 2 * ffR) + 0.00055 * sin(m1R + 4 * dR)
l += 0.000538 * sin(4 * m1R) + ex * 0.000521 * sin(4 * dR - mR) + 0.000486 * sin(2 * m1R - dR)
l -= 0.001595 * sin(2 * ffR + 2 * dR)
// Ecliptic latitude — expanded v225 terms
var b =
5.128189 * sin(ffR) + 0.280606 * sin(m1R + ffR) + 0.277693 * sin(m1R - ffR) + 0.173238 * sin(2 * dR - ffR)
b += 0.055413 * sin(2 * dR + ffR - m1R) + 0.046272 * sin(2 * dR - ffR - m1R) + 0.032573 * sin(2 * dR + ffR)
b += 0.017198 * sin(2 * m1R + ffR) + 9.266999e-03 * sin(2 * dR + m1R - ffR) + 0.008823 * sin(2 * m1R - ffR)
b += ex * 0.008247 * sin(2 * dR - mR - ffR) + 0.004323 * sin(2 * dR - ffR - 2 * m1R)
b += 0.0042 * sin(2 * dR + ffR + m1R)
b += ex * 0.003372 * sin(ffR - mR - 2 * dR) + ex * 0.002472 * sin(2 * dR + ffR - mR - m1R)
b += ex * 0.002222 * sin(2 * dR + ffR - mR)
b += 0.002072 * sin(2 * dR - ffR - mR - m1R) + ex * 0.001877 * sin(ffR - mR + m1R)
b += 0.001828 * sin(4 * dR - ffR - m1R)
b -= ex * 0.001803 * sin(ffR + mR) - 0.00175 * sin(3 * ffR)
b += ex * 0.00157 * sin(m1R - mR - ffR) - 0.001487 * sin(ffR + dR)
b -= ex * 0.001481 * sin(ffR + mR + m1R) + ex * 0.001417 * sin(ffR - mR - m1R)
b += ex * 0.00135 * sin(ffR - mR) + 0.00133 * sin(ffR - dR)
b += 0.001106 * sin(ffR + 3 * m1R) + 0.00102 * sin(4 * dR - ffR) + 0.000833 * sin(ffR + 4 * dR - m1R)
b += 0.000781 * sin(m1R - 3 * ffR) + 0.00067 * sin(ffR + 4 * dR - 2 * m1R)
b += 0.000606 * sin(2 * dR - 3 * ffR)
b += 0.000597 * sin(2 * dR + 2 * m1R - ffR) + ex * 0.000492 * sin(2 * dR + m1R - mR - ffR)
b += 0.00045 * sin(2 * m1R - ffR - 2 * dR)
b += 0.000439 * sin(3 * m1R - ffR) + 0.000423 * sin(ffR + 2 * dR + 2 * m1R)
b += 0.000422 * sin(2 * dR - ffR - 3 * m1R)
b -= ex * 0.000367 * sin(mR + ffR + 2 * dR - m1R) - ex * 0.000353 * sin(mR + ffR + 2 * dR)
b += 0.000331 * sin(ffR + 4 * dR)
b += ex * 0.000317 * sin(2 * dR + ffR - mR + m1R) + ex * ex * 0.000306 * sin(2 * dR - 2 * mR - ffR)
b -= 0.000283 * sin(m1R + 3 * ffR)
val w1 = 0.0004664 * cos(om)
val w2 = 0.0000754 * cos(om + (275.05 - 2.3 * t) * DEG2RAD)
val bt = b * (1.0 - w1 - w2)
// Parallax — expanded v225 terms
var p =
0.950724 + 0.051818 * cos(m1R) + 0.009531 * cos(2 * dR - m1R) + 0.007843 * cos(2 * dR) + 0.002824 * cos(2 * m1R)
p += 0.000857 * cos(2 * dR + m1R) + ex * 0.000533 * cos(2 * dR - mR) + ex * 0.000401 * cos(2 * dR - mR - m1R)
p += 0.000173 * cos(3 * m1R) + 0.000167 * cos(4 * dR - m1R) - ex * 0.000111 * cos(mR)
p += 0.000103 * cos(4 * dR - 2 * m1R) - 0.000084 * cos(2 * m1R - 2 * dR) - ex * 0.000083 * cos(2 * dR + mR)
p += 0.000079 * cos(2 * dR + 2 * m1R)
p += 0.000072 * cos(4 * dR) + ex * 0.000064 * cos(2 * dR - mR + m1R) - ex * 0.000063 * cos(2 * dR + mR - m1R)
p += ex * 0.000041 * cos(mR + dR) + ex * 0.000035 * cos(2 * m1R - mR) - 0.000033 * cos(3 * m1R - 2 * dR)
p -= 0.00003 * cos(m1R + dR) - 0.000029 * cos(2 * ffR - 2 * dR) - ex * 0.000029 * cos(2 * m1R + mR)
p += ex * ex * 0.000026 * cos(2 * dR - 2 * mR) - 0.000023 * cos(2 * ffR - 2 * dR + m1R)
p += ex * 0.000019 * cos(4 * dR - mR - m1R)
val bRad = bt * DEG2RAD
val lm = l * DEG2RAD
val moonDx = 3.0 / (PI * p)
// Ecliptic → equatorial
val z = (jd - 2415020.5) / 365.2422
val ob = (23.452294 - (0.46845 * z + 5.9e-07 * z * z) / 3600.0).toRadians()
val dec = asin(sin(bRad) * cos(ob) + cos(bRad) * sin(ob) * sin(lm))
var ra = acos(cos(bRad) * cos(lm) / cos(dec)); if (lm > PI) ra = TWO_PI - ra
val n = observer.latitude * DEG2RAD
t = (jd - 2451545.0) / 36525.0
var teg = 280.46061837 + 360.98564736629 * (jd - 2451545.0) + (0.000387933 * t - t * t / 38710000.0) * t
while (teg > 360.0) teg -= 360.0
// LST = GMST + east longitude (positive east convention)
val th = mod2PI((teg + observer.longitude) * DEG2RAD)
val h = th - ra
val azVal = atan2(sin(h), cos(h) * sin(n) - tan(dec) * cos(n)) + PI
val el = asin(sin(n) * sin(dec) + cos(n) * cos(dec) * cos(h))
// Moon radial velocity approximation (from "Amateur Radio Software", GM4ANB, RSGB 1985)
val mm = fixAngle(1.319238 + daynum * 0.228027135)
val radT2 = 0.10976
val radT1 = mm + radT2 * sin(mm)
var dv = 0.01255 * moonDx * moonDx * sin(radT1) * (1.0 + radT2 * cos(mm))
dv *= 4449.0
val earthR = 6378.0
val moonDist = 384401.0
val radT3 = earthR * moonDist * (cos(dec) * cos(n) * sin(h)) /
sqrt(moonDist * moonDist - moonDist * earthR * sin(el))
val moonDv = dv + radT3 * 0.0753125
val moonRa = ra / DEG2RAD
var moonGha = teg - moonRa
if (moonGha < 0.0) moonGha += 360.0
return MoonPosition(
azimuth = azVal / DEG2RAD,
elevation = el / DEG2RAD,
rightAscension = moonRa,
declination = dec / DEG2RAD,
gha = moonGha,
angularDiameter = moonDx,
radialVelocity = moonDv
)
}
// ── Satellite visibility ──
/**
* Classify satellite visibility given its eclipse state and the Sun's elevation
* at the observer's location.
*
* @param isEclipsed whether the satellite is in Earth's shadow
* @param sunElevationDeg Sun elevation at observer in degrees
* @param satElevationDeg satellite elevation at observer in degrees (must be >= 0)
*/
fun classifyVisibility(
isEclipsed: Boolean,
sunElevationDeg: Double,
satElevationDeg: Double
): SatVisibility {
if (isEclipsed) return SatVisibility.ECLIPSED
return if (sunElevationDeg <= -12.0 && satElevationDeg >= 0.0) SatVisibility.VISIBLE
else SatVisibility.DAYLIGHT
}
// ── Orbital metadata ──
/**
* Compute orbital metadata for a satellite at its current position.
*
* @param altitudeKm satellite altitude in km
* @param meanMotion revolutions per day from TLE
* @param bstar drag term from TLE
* @param meanAnomaly mean anomaly at epoch (radians)
* @param revNumAtEpoch revolution number at TLE epoch
* @param ageDays days since TLE epoch (julUTC - julEpoch)
* @param phase orbital phase in radians (from SGP4/SDP4 output)
* @param satPosECI satellite ECI position [x, y, z]
* @param satVelECI satellite ECI velocity [vx, vy, vz]
* @param sunPosECI sun ECI position [x, y, z]
*/
fun computeOrbitalMetadata(
altitudeKm: Double,
meanMotion: Double,
bstar: Double,
meanAnomaly: Double,
revNumAtEpoch: Int,
ageDays: Double,
phase: Double,
satPosECI: DoubleArray,
satVelECI: DoubleArray,
sunPosECI: DoubleArray
): OrbitalMetadata {
// Footprint diameter (km)
val footprint = 12756.33 * acos(EARTH_RADIUS / (EARTH_RADIUS + altitudeKm))
// Orbit number
val xmnpda = 1.44E3
val orbitNum = floor(
(meanMotion * xmnpda / TWO_PI + ageDays * bstar) * ageDays + meanAnomaly / TWO_PI
).toLong() + revNumAtEpoch
// Beta angle: angle between orbital plane and Sun direction
// Orbital plane normal = cross(pos, vel)
val nx = satPosECI[1] * satVelECI[2] - satPosECI[2] * satVelECI[1]
val ny = satPosECI[2] * satVelECI[0] - satPosECI[0] * satVelECI[2]
val nz = satPosECI[0] * satVelECI[1] - satPosECI[1] * satVelECI[0]
val nMag = sqrt(nx * nx + ny * ny + nz * nz)
val sMag = sqrt(sunPosECI[0] * sunPosECI[0] + sunPosECI[1] * sunPosECI[1] + sunPosECI[2] * sunPosECI[2])
val dotNS = nx * sunPosECI[0] + ny * sunPosECI[1] + nz * sunPosECI[2]
val betaAngle = if (nMag > 0 && sMag > 0) {
(PI / 2.0 - acos(dotNS / (nMag * sMag))).toDegrees()
} else 0.0
// Phase (0-256 scale, matching PREDICT convention)
val orbitalPhase = 256.0 * (phase / TWO_PI)
return OrbitalMetadata(footprint, orbitNum, betaAngle, orbitalPhase)
}
// ── Satellite status checks ──
/** Check if a satellite is geostationary (mean motion ≈ 1.0027 rev/day). */
fun isGeostationary(meanMotion: Double): Boolean = abs(meanMotion - 1.0027) < 0.0002
/**
* Check if a satellite has likely decayed based on drag and time since epoch.
*
* @param meanMotion revolutions per day
* @param drag first derivative of mean motion / 2 (from TLE line 1)
* @param epochDaynum TLE epoch as daynum (days since 31Dec79)
* @param currentDaynum current time as daynum
*/
fun hasDecayed(meanMotion: Double, drag: Double, epochDaynum: Double, currentDaynum: Double): Boolean {
return epochDaynum + ((16.666666 - meanMotion) / (10.0 * abs(drag))) < currentDaynum
}
// ── Rise/Set finding ──
/** Rise and set times for a celestial body. */
data class RiseSetTimes(
val riseTimeMillis: Long, // 0 if not found
val setTimeMillis: Long // 0 if not found
)
/**
* Find the next sunrise and sunset times from [startMillis] for [observer].
* Uses elevation threshold of -0.8333° to match the standard civil definition:
* upper limb on geometric horizon with standard atmospheric refraction (~0.57°)
* and solar semidiameter (~0.27°) corrections applied, matching USNO/timeanddate.com.
*/
fun findSunRiseSet(observer: GeoPos, startMillis: Long): RiseSetTimes {
// Standard civil threshold: center elevation when upper limb meets geometric horizon
// -0.8333° = standard refraction (~0.5667°) + solar semidiameter (~0.2667°)
val threshold = 0.8333
var daynum = millisToDaynum(startMillis)
var sunPos = getSunPosition(observer, daynumToMillis(daynum))
// Phase 1: if sun is above threshold, fast-forward to well past sunset into night
if (sunPos.elevation > -threshold) {
var guard = 0
while (sunPos.elevation > -threshold && guard++ < 500) {
daynum += 0.008 // fixed ~11.5 min steps past the setting sun
sunPos = getSunPosition(observer, daynumToMillis(daynum))
}
// Now advance until sun is clearly below minimum (deep night)
guard = 0
while (sunPos.elevation > -12.0 && guard++ < 500) {
daynum += 0.02
sunPos = getSunPosition(observer, daynumToMillis(daynum))
}
}
// Phase 2: advance until sun starts rising toward threshold (elevation increasing)
var guard = 0
while (sunPos.elevation < -threshold && guard++ < 500) {
daynum += 0.008
sunPos = getSunPosition(observer, daynumToMillis(daynum))
}
// Phase 3: converge symmetrically on elevation = -threshold (sunrise)
var sunrise = 0.0
guard = 0
while (sunrise == 0.0 && guard++ < 200) {
val delta = sunPos.elevation + threshold
if (abs(delta) < 0.01) {
sunrise = daynum
} else {
daynum -= 0.004 * delta
sunPos = getSunPosition(observer, daynumToMillis(daynum))
}
}
if (sunrise == 0.0) sunrise = daynum
// Phase 4: fast-forward through the day until sun drops back below threshold
daynum = sunrise
sunPos = getSunPosition(observer, daynumToMillis(daynum))
guard = 0
while (sunPos.elevation > -threshold && guard++ < 500) {
daynum += 0.008
sunPos = getSunPosition(observer, daynumToMillis(daynum))
}
// Phase 5: converge symmetrically on elevation = -threshold (sunset)
var sunset = 0.0
guard = 0
while (sunset == 0.0 && guard++ < 200) {
val delta = sunPos.elevation + threshold
if (abs(delta) < 0.01) {
sunset = daynum
} else {
daynum += 0.004 * delta
sunPos = getSunPosition(observer, daynumToMillis(daynum))
}
}
if (sunset == 0.0) sunset = daynum
return RiseSetTimes(daynumToMillis(sunrise), daynumToMillis(sunset))
}
/**
* Find the next moonrise and moonset times from [startMillis] for [observer].
* Uses the adaptive iteration from PREDICT v2.2.5's PredictMoon().
*/
fun findMoonRiseSet(observer: GeoPos, startMillis: Long): RiseSetTimes {
var daynum = millisToDaynum(startMillis)
var moonPos = getMoonPosition(observer, daynumToMillis(daynum))
// If moon is already up, move forward until it sets
var guard = 0
if (moonPos.elevation > 0) {
while (moonPos.elevation > 0 && guard++ < 1000) {
daynum += 0.004 * sin(DEG2RAD * (moonPos.elevation + 0.5))
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
}
daynum += 0.4
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
}
// Find moonrise
var moonrise = 0.0
guard = 0
while (moonrise == 0.0 && guard++ < 1000) {
if (abs(moonPos.elevation) < 0.03) {
moonrise = daynum
} else {
daynum -= 0.004 * moonPos.elevation
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
}
}
if (moonrise == 0.0) moonrise = daynum
// Find moonset from moonrise
daynum = moonrise
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
guard = 0
while (moonPos.elevation > -1 && guard++ < 1000) {
daynum += 0.04 * cos(DEG2RAD * (moonPos.elevation + 0.5))
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
}
var moonset = 0.0
guard = 0
while (moonset == 0.0 && guard++ < 1000) {
if (abs(moonPos.elevation) < 0.03) {
moonset = daynum
} else {
daynum += 0.004 * moonPos.elevation
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
}
}
if (moonset == 0.0) moonset = daynum
return RiseSetTimes(daynumToMillis(moonrise), daynumToMillis(moonset))
}
// ── Visual magnitude estimation ──
/**
* Estimate the apparent visual magnitude of a satellite.
*
* Uses the standard formula from McCants/Heavens-Above:
* apparentMag = stdMag + 5 * log10(range / 1000) - 15 * log10(cos(phaseAngle / 2))
*
* @param rangeKm slant range from observer to satellite in km
* @param phaseAngleDeg Sun-satellite-observer angle in degrees
* @param stdMag intrinsic/standard magnitude (default 4.0)
* @return estimated apparent visual magnitude
*/
fun estimateVisualMagnitude(rangeKm: Double, phaseAngleDeg: Double, stdMag: Double = 4.0): Double {
if (rangeKm <= 0) return stdMag
val halfPhaseRad = phaseAngleDeg.toRadians() / 2.0
val cosHalfPhase = cos(halfPhaseRad)
val phaseTerm = if (cosHalfPhase > 1e-6) -15.0 * log10(cosHalfPhase) else 99.0
return stdMag + 5.0 * log10(rangeKm / 1000.0) + phaseTerm
}
/**
* Compute the phase angle (Sun-satellite-observer) in degrees.
*
* @param satPosECI satellite ECI position [x, y, z] in km
* @param sunPosECI sun ECI position [x, y, z] in km
* @param obsPosECI observer ECI position [x, y, z] in km
* @return phase angle in degrees (0 = fully illuminated face toward observer)
*/
fun computePhaseAngle(satPosECI: DoubleArray, sunPosECI: DoubleArray, obsPosECI: DoubleArray): Double {
val toSunX = sunPosECI[0] - satPosECI[0]
val toSunY = sunPosECI[1] - satPosECI[1]
val toSunZ = sunPosECI[2] - satPosECI[2]
val toObsX = obsPosECI[0] - satPosECI[0]
val toObsY = obsPosECI[1] - satPosECI[1]
val toObsZ = obsPosECI[2] - satPosECI[2]
val dot = toSunX * toObsX + toSunY * toObsY + toSunZ * toObsZ
val magSun = sqrt(toSunX * toSunX + toSunY * toSunY + toSunZ * toSunZ)
val magObs = sqrt(toObsX * toObsX + toObsY * toObsY + toObsZ * toObsZ)
if (magSun == 0.0 || magObs == 0.0) return 90.0
val cosAngle = (dot / (magSun * magObs)).coerceIn(-1.0, 1.0)
return acos(cosAngle).toDegrees()
}
// ── Doppler ──
/**
* Compute Doppler shift for a given base frequency and range rate.
*
* @param frequencyHz base frequency in Hz
* @param rangeRateKmS range rate in km/s (negative = approaching)
* @return shifted frequency in Hz
*/
fun dopplerShift(frequencyHz: Double, rangeRateKmS: Double): Double {
return frequencyHz * (299792.458 - rangeRateKmS) / 299792.458
}
// ── Internal helpers ──
private fun observerGeodetic(pos: GeoPos): DoubleArray {
// [lat_rad, lon_rad, alt_km] — longitude positive east, matching OrbitalObject convention.
// LST = thetaGJD(julUtc) + obsGeo[1] = GMST + lon_rad (correct).
return doubleArrayOf(pos.latitude * DEG2RAD, pos.longitude * DEG2RAD, pos.altitude / 1000.0)
}
/**
* Convert az/el observation to Right Ascension / Declination.
* Returns [ra_rad, dec_rad].
* Based on Calculate_RADec() from PREDICT v2.2.5 (Escobal method).
*/
private fun calculateRADec(
julUtc: Double,
targetPos: DoubleArray,
targetVel: DoubleArray,
obsGeo: DoubleArray
): DoubleArray {
val obsSet = computeObsAngles(julUtc, targetPos, targetVel, obsGeo)
val az = obsSet[0]
val el = obsSet[1]
val phi = obsGeo[0]
val theta = mod2PI(thetaGJD(julUtc) + obsGeo[1])
val sinTheta = sin(theta)
val cosTheta = cos(theta)
val sinPhi = sin(phi)
val cosPhi = cos(phi)
val lxh = -cos(az) * cos(el)
val lyh = sin(az) * cos(el)
val lzh = sin(el)
val sx = sinPhi * cosTheta
val ex2 = -sinTheta
val zx = cosTheta * cosPhi
val sy = sinPhi * sinTheta
val zy = sinTheta * cosPhi
val sz = -cosPhi
val lx = sx * lxh + ex2 * lyh + zx * lzh
val ly = sy * lxh + cosTheta * lyh + zy * lzh
val lz = sz * lxh + 0.0 * lyh + sinPhi * lzh
val dec = asin(lz)
val cosDelta = sqrt(1.0 - lz * lz)
val sinAlpha = ly / cosDelta
val cosAlpha = lx / cosDelta
val ra = mod2PI(atan2(sinAlpha, cosAlpha))
return doubleArrayOf(ra, dec)
}
/**
* Compute observer look-angles (az, el, range, rangeRate) to a target at ECI position.
* Returns [azimuth_rad, elevation_rad, range_km, rangeRate_km/s].
* Azimuth is north-referenced (0=N, π/2=E), matching OrbitalObject's convention.
*/
private fun computeObsAngles(
julUtc: Double,
targetPos: DoubleArray,
targetVel: DoubleArray,
obsGeo: DoubleArray // [lat_rad, lon_rad, alt_km]
): DoubleArray {
val theta = mod2PI(thetaGJD(julUtc) + obsGeo[1])
val c = 1.0 / sqrt(1 + FLAT_FACT * (FLAT_FACT - 2) * sin(obsGeo[0]).pow(2))
val sq = (1 - FLAT_FACT).pow(2) * c
val achcp = (EARTH_RADIUS * c + obsGeo[2]) * cos(obsGeo[0])
val ox = achcp * cos(theta)
val oy = achcp * sin(theta)
val oz = (EARTH_RADIUS * sq + obsGeo[2]) * sin(obsGeo[0])
val ovx = -MFACTOR * oy
val ovy = MFACTOR * ox
val rx = targetPos[0] - ox
val ry = targetPos[1] - oy
val rz = targetPos[2] - oz
val rMag = sqrt(rx * rx + ry * ry + rz * rz)
val rvx = targetVel[0] - ovx
val rvy = targetVel[1] - ovy
val rvz = targetVel[2]
val sinLat = sin(obsGeo[0])
val cosLat = cos(obsGeo[0])
val sinTheta = sin(theta)
val cosTheta = cos(theta)
val topS = sinLat * cosTheta * rx + sinLat * sinTheta * ry - cosLat * rz
val topE = -sinTheta * rx + cosTheta * ry
val topZ = cosLat * cosTheta * rx + cosLat * sinTheta * ry + sinLat * rz
// Match north-based convention (0=N, 90=E) used by OrbitalObject.calculateObs
// Must use atan(-topE / topS) not atan2(-topE, topS) — they differ in quadrant handling
var azim = atan(-topE / topS)
if (topS > 0.0) azim += PI
if (azim < 0.0) azim += TWO_PI
val el = asin(topZ / rMag)
val rangeRate = (rx * rvx + ry * rvy + rz * rvz) / rMag
return doubleArrayOf(azim, el, rMag, rangeRate)
}
private const val MFACTOR = 7.292115E-5
private fun primeAngle(x: Double) = x - 360.0 * floor(x / 360.0)
private fun fixAngle(x: Double): Double {
var a = x; while (a > TWO_PI) a -= TWO_PI; return a
}
}
@@ -21,6 +21,7 @@ const val ASTRONOMICAL_UNIT = 1.49597870691E8
const val DEG2RAD = 0.017453292519943295
const val RAD2DEG = 57.29577951308232
const val EARTH_RADIUS = 6378.137
const val EARTH_ROT_PER_SID_DAY = 1.00273790934
const val EPSILON = 1.0E-12
const val FLAT_FACT = 3.35281066474748E-3
const val J3_HARMONIC = -2.53881E-6
@@ -0,0 +1,141 @@
/*
* 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.predict
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.floor
import kotlin.math.sin
import kotlin.math.sqrt
// ── Shared orbital math utilities ──
// Used by both CelestialComputer (sun/moon/celestial) and OrbitalObject (SGP4/SDP4).
// Package-internal — not part of the public API.
/**
* Greenwich Mean Sidereal Time from Julian Date, in radians [0, 2π).
* Identical algorithm used in PREDICT v2.2.5 for both solar and satellite calculations.
*/
internal fun thetaGJD(jd: Double): Double {
val ut = fraction(jd + 0.5)
val aJD = jd - ut
val tu = (aJD - 2451545.0) / 36525.0
var gmst = 24110.54841 + tu * (8640184.812866 + tu * (0.093104 - tu * 6.2E-6))
gmst = modulus(gmst + SEC_PER_DAY * EARTH_ROT_PER_SID_DAY * ut, SEC_PER_DAY)
return TWO_PI * gmst / SEC_PER_DAY
}
/** Fractional part of [arg]. */
internal fun fraction(arg: Double): Double = arg - floor(arg)
/** Modulo: returns [arg1] mod [arg2], result always in [0, arg2). */
internal fun modulus(arg1: Double, arg2: Double): Double {
var r = arg1
val i = floor(r / arg2).toInt()
r -= i * arg2
if (r < 0.0) r += arg2
return r
}
/** Reduce [value] to [0, 2π). */
internal fun mod2PI(value: Double): Double {
var r = value
val i = (r / TWO_PI).toInt()
r -= i * TWO_PI
if (r < 0.0) r += TWO_PI
return r
}
/**
* Delta-ET: difference between Universal Time and Ephemeris Time (seconds).
* Based on least-squares fit from 1950 to 1991 (PREDICT v2.2.5).
*/
internal fun deltaET(year: Double): Double =
26.465 + 0.747622 * (year - 1950) + 1.886913 * sin(TWO_PI * (year - 1975) / 33)
/**
* Convert Unix epoch milliseconds to daynum (days since 31 Dec 1979 00:00:00 UTC).
*/
internal fun millisToDaynum(timeMillis: Long): Double =
(timeMillis - 315446400000L) / 86400000.0
/** Convert daynum back to Unix epoch milliseconds. */
internal fun daynumToMillis(daynum: Double): Long =
((daynum + 3651.0) * 86400000.0).toLong()
/**
* Compute the Sun's ECI position vector at [julUtc] (Julian UTC).
* Returns [x, y, z, magnitude] in km.
* Based on Calculate_Solar_Position() / FindSun() from PREDICT v2.2.5.
*/
internal fun solarPositionECI(julUtc: Double): DoubleArray {
val mjd = julUtc - 2415020.0
val year = 1900 + mjd / 365.25
val t = (mjd + deltaET(year) / SEC_PER_DAY) / 36525.0
val mDeg = mod360(358.47583 + mod360(35999.04975 * t) - (0.000150 + 0.0000033 * t) * t * t)
val m = mDeg * DEG2RAD
val lDeg = mod360(279.69668 + mod360(36000.76892 * t) + 0.0003025 * t * t)
val l = lDeg * DEG2RAD
val e = 0.01675104 - (0.0000418 + 0.000000126 * t) * t
val cDeg = (1.919460 - (0.004789 + 0.000014 * t) * t) * sin(m) +
(0.020094 - 0.000100 * t) * sin(2 * m) + 0.000293 * sin(3 * m)
val c = cDeg * DEG2RAD
val oDeg = mod360(259.18 - 1934.142 * t)
val o = oDeg * DEG2RAD
val lsa = mod2PI(l + c - (0.00569 - 0.00479 * sin(o)) * DEG2RAD)
val nu = mod2PI(m + c)
var r = 1.0000002 * (1.0 - e * e) / (1.0 + e * cos(nu))
val epsDeg = 23.452294 - (0.0130125 + (0.00000164 - 0.000000503 * t) * t) * t + 0.00256 * cos(o)
val eps = epsDeg * DEG2RAD
r *= ASTRONOMICAL_UNIT
return doubleArrayOf(r * cos(lsa), r * sin(lsa) * cos(eps), r * sin(lsa) * sin(eps), r)
}
/**
* Convert ECI position [eciPos] = [x, y, z] (km) to geodetic [lat_rad, lon_rad, alt_km].
* Based on Calculate_LatLonAlt() from PREDICT v2.2.5.
*/
internal fun eciToGeodetic(julUtc: Double, eciPos: DoubleArray): DoubleArray {
val thetaPos = atan2(eciPos[1], eciPos[0])
val lon = mod2PI(thetaPos - thetaGJD(julUtc))
val r = sqrt(eciPos[0] * eciPos[0] + eciPos[1] * eciPos[1])
val e2 = FLAT_FACT * (2.0 - FLAT_FACT)
var lat = atan2(eciPos[2], r)
var phi: Double
var c: Double
var i = 0
do {
phi = lat
c = 1.0 / sqrt(1.0 - e2 * sin(phi) * sin(phi))
lat = atan2(eciPos[2] + EARTH_RADIUS * c * e2 * sin(phi), r)
} while (i++ < 10 && abs(lat - phi) >= 1E-10)
val alt = r / cos(lat) - EARTH_RADIUS * c
if (lat > PI_2) lat -= TWO_PI
return doubleArrayOf(lat, lon, alt)
}
// Private helpers
private fun mod360(x: Double): Double {
var r = x
val i = (r / 360.0).toInt()
r -= i * 360.0
if (r < 0.0) r += 360.0
return r
}
@@ -314,14 +314,8 @@ abstract class OrbitalObject(val data: OrbitalData) {
return 1.0 / value
}
// Calculates the modulus of 2 * PI
internal fun mod2PI(value: Double): Double {
var retVal = value
val i = (retVal / TWO_PI).toInt()
retVal -= i * TWO_PI
if (retVal < 0.0) retVal += TWO_PI
return retVal
}
// Delegates to package-level mod2PI in OrbitalMath.kt
internal fun mod2PI(value: Double): Double = com.rtbishop.look4sat.core.domain.predict.mod2PI(value)
// Solves Keplers' Equation
internal fun converge(temp: DoubleArray, axn: Double, ayn: Double, capu: Double) {
@@ -423,19 +417,8 @@ abstract class OrbitalObject(val data: OrbitalData) {
return acos(dot(v1, v2) / (v1.w * v2.w))
}
/**
* The function Delta_ET has been added to allow calculations on the
* position of the sun. It provides the difference between UT (approximately
* the same as UTC) and ET (now referred to as TDT) This function is based
* on the least squares fit of data from 1950 to 1991 and will need to be
* updated periodically.
*
* Values determined using data from 1950-1991 in the 1990 Astronomical
* Almanac. See DELTA_ET.WQ1 for details.
*/
private fun deltaEt(year: Double): Double {
return 26.465 + 0.747622 * (year - 1950) + (1.886913 * sin(TWO_PI * (year - 1975) / 33))
}
// Delegates to package-level deltaET in OrbitalMath.kt
private fun deltaEt(year: Double): Double = deltaET(year)
private fun radians(degrees: Double): Double {
return degrees * DEG2RAD
@@ -446,23 +429,13 @@ abstract class OrbitalObject(val data: OrbitalData) {
return v1.x * v2.x + v1.y * v2.y + v1.z * v2.z
}
// Returns fractional part of double argument
private fun fraction(arg: Double): Double {
return arg - floor(arg)
}
// Calculates scalar magnitude of a vector4 argument
private fun magnitude(v: Vector4) {
v.w = sqrt(sqr(v.x) + sqr(v.y) + sqr(v.z))
}
private fun modulus(arg1: Double, arg2: Double = SEC_PER_DAY): Double {
var returnValue = arg1
val i = floor(returnValue / arg2).toInt()
returnValue -= i * arg2
if (returnValue < 0.0) returnValue += arg2
return returnValue
}
private fun modulus(arg1: Double, arg2: Double = SEC_PER_DAY): Double =
com.rtbishop.look4sat.core.domain.predict.modulus(arg1, arg2)
// Multiplies the vector v1 by the scalar k
private fun scaleVector(k: Double, v: Vector4) {
@@ -470,13 +443,6 @@ abstract class OrbitalObject(val data: OrbitalData) {
magnitude(v)
}
private fun thetaGJD(theJD: Double): Double {
val earthRotPerSidDay = 1.00273790934
val ut = fraction(theJD + 0.5)
val aJD = theJD - ut
val tu = (aJD - 2451545.0) / 36525.0
var gmst = 24110.54841 + tu * (8640184.812866 + tu * (0.093104 - tu * 6.2E-6))
gmst = modulus(gmst + SEC_PER_DAY * earthRotPerSidDay * ut)
return TWO_PI * gmst / SEC_PER_DAY
}
// Delegates to package-level thetaGJD in OrbitalMath.kt
private fun thetaGJD(theJD: Double): Double = com.rtbishop.look4sat.core.domain.predict.thetaGJD(theJD)
}
@@ -63,10 +63,8 @@ data class OrbitalPos(
val sinBeta = sin(beta)
for (azimuth in 0..720) {
val rads = azimuth * DEG2RAD
val sinRads = sin(rads)
val cosRads = cos(rads)
val lat = asin(sinLat * cosBeta + cosLat * sinBeta * cosRads)
val lon = longitude + atan2(sinRads * sinBeta * cosLat, cosBeta - sinLat * sin(lat))
val lat = asin(sinLat * cosBeta + cosLat * sinBeta * cos(rads))
val lon = longitude + atan2(sin(rads) * sinBeta * cosLat, cosBeta - sinLat * sin(lat))
rangeCirclePoints.add(GeoPos(lat * RAD2DEG, lon * RAD2DEG))
}
return rangeCirclePoints
@@ -31,6 +31,15 @@ interface ISatelliteRepo {
/** Raw calculated passes (without live progress). Updated on selection/filter change. */
val passes: StateFlow<List<OrbitalPass>>
/** Whether the repo is currently calculating passes. */
val isCalculating: StateFlow<Boolean>
/** Currently selected pass (catNum + aosTime), persisted across screen navigations. */
val selectedPass: StateFlow<Pair<Int, Long>>
/** Set the currently selected pass. */
fun selectPass(catNum: Int, aosTime: Long)
/** Load satellite objects from DB based on the current selection. */
suspend fun initRepository()
@@ -18,7 +18,6 @@
package com.rtbishop.look4sat.core.domain.source
object Sources {
const val RADIO_DATA_URL = "https://db.satnogs.org/api/transmitters/?format=json&status=active"
val satelliteDataUrls = mapOf(
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
@@ -49,4 +48,7 @@ object Sources {
"R4UAB" to "https://r4uab.ru/satonline.txt",
"Other" to "" // key for sats filter
)
val transceiversDataUrls = mapOf(
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active"
)
}
@@ -21,13 +21,19 @@ import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.decodeFromJsonElement
import java.io.InputStream
import kotlin.math.pow
class DataParser(private val dispatcher: CoroutineDispatcher) {
private val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
}
suspend fun parseCSVStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
stream.bufferedReader().useLines { lines ->
lines.drop(1).mapNotNull { parseCSV(it.split(",")) }.toList()
@@ -43,13 +49,15 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
suspend fun parseJSONStream(stream: InputStream): List<SatRadio> = withContext(dispatcher) {
runCatching {
val jsonArray = JSONArray(stream.bufferedReader().readText())
(0 until jsonArray.length()).mapNotNull { parseJSON(jsonArray.getJSONObject(it)) }
val root = json.parseToJsonElement(stream.bufferedReader().readText())
(root as? JsonArray)?.mapNotNull { element ->
runCatching { json.decodeFromJsonElement<SatRadio>(element) }
.onFailure { println("JSON parsing exception: $it") }
.getOrNull()
} ?: emptyList()
}.getOrDefault(emptyList())
}
fun isLeapYear(year: Int): Boolean = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
private fun parseCSV(values: List<String>): OrbitalData? = runCatching {
val name = values[0]
val timestamp = values[2]
@@ -95,28 +103,9 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
)
}.onFailure { println("TLE parsing exception: $it") }.getOrNull()
private fun parseJSON(json: JSONObject): SatRadio? = runCatching {
SatRadio(
uuid = json.getString("uuid"),
info = json.getString("description"),
isAlive = json.getBoolean("alive"),
downlinkLow = json.optLongOrNull("downlink_low"),
downlinkHigh = json.optLongOrNull("downlink_high"),
downlinkMode = json.optStringOrNull("mode"),
uplinkLow = json.optLongOrNull("uplink_low"),
uplinkHigh = json.optLongOrNull("uplink_high"),
uplinkMode = json.optStringOrNull("uplink_mode"),
isInverted = json.getBoolean("invert"),
catnum = json.optIntOrNull("norad_cat_id")
)
}.onFailure { println("JSON parsing exception: $it") }.getOrNull()
private fun JSONObject.optStringOrNull(key: String): String? = if (isNull(key)) null else getString(key)
private fun JSONObject.optLongOrNull(key: String): Long? = if (isNull(key)) null else getLong(key)
private fun JSONObject.optIntOrNull(key: String): Int? = if (isNull(key)) null else getInt(key)
private fun getDayOfYear(year: Int, month: Int, dayOfMonth: Int): Int {
val daysInMonth = intArrayOf(31, if (isLeapYear(year)) 29 else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
val isLeapYear = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
val daysInMonth = intArrayOf(31, if (isLeapYear) 29 else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
return daysInMonth.take(month - 1).sum() + dayOfMonth
}
}
@@ -19,9 +19,14 @@ package com.rtbishop.look4sat.core.domain.utility
import com.rtbishop.look4sat.core.domain.predict.DEG2RAD
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
import kotlin.math.acos
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sin
private const val AVG_EARTH_RADIUS_KM = 6371.009
private const val MIN_LATITUDE = -85.05112877980658
private const val MAX_LATITUDE = 85.05112877980658
private const val MIN_LONGITUDE = -180.0
@@ -48,6 +53,27 @@ fun Double.toRadians(): Double = this * DEG2RAD
// return MIN_LONGITUDE + (MAX_LONGITUDE - MIN_LONGITUDE) * this
//}
// Great-circle distance between two positions in kilometers using the spherical law of cosines.
fun greatCircleDistanceKm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val lat1R = lat1.toRadians()
val lat2R = lat2.toRadians()
val lon1R = lon1.toRadians()
val lon2R = lon2.toRadians()
return acos(
sin(lat1R) * sin(lat2R) + cos(lat1R) * cos(lat2R) * cos(lon2R - lon1R)
) * AVG_EARTH_RADIUS_KM
}
// Initial bearing (azimuth) from position 1 to position 2, in degrees (0-360).
fun bearingDeg(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val lat1R = lat1.toRadians()
val lat2R = lat2.toRadians()
val dLon = (lon2 - lon1).toRadians()
val y = sin(dLon) * cos(lat2R)
val x = cos(lat1R) * sin(lat2R) - sin(lat1R) * cos(lat2R) * cos(dLon)
return (atan2(y, x).toDegrees() + 360) % 360
}
fun clipLat(latitude: Double): Double {
return clip(latitude, MIN_LATITUDE, MAX_LATITUDE)
}
@@ -95,11 +95,11 @@ class DataParserTest {
assert(dataParser.parseCSVStream(validCSVStream) == dataParser.parseTLEStream(validTLEStream))
}
@Test
fun `Function isLeapYear returns correct data`() = runTest(testDispatcher) {
val years = listOf(1900, 1984, 1994, 2016, 2022, 2024, 2042, 2048)
val answers = listOf(false, true, false, true, false, true, false, true)
val results = years.map { dataParser.isLeapYear(it) }
assert(results == answers)
}
// @Test
// fun `Function isLeapYear returns correct data`() = runTest(testDispatcher) {
// val years = listOf(1900, 1984, 1994, 2016, 2022, 2024, 2042, 2048)
// val answers = listOf(false, true, false, true, false, true, false, true)
// val results = years.map { dataParser.isLeapYear(it) }
// assert(results == answers)
// }
}
@@ -2,9 +2,9 @@ package com.rtbishop.look4sat.core.domain
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class TransponderMapperTest {
@@ -178,12 +178,6 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(R.drawable.ic_altitude),
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(text = "${pass.altitude} km", fontSize = 15.sp)
}
Text(
@@ -1,10 +1,43 @@
/*
* 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.presentation
sealed class Screen(val route: String, val iconResId: Int, val titleResId: Int) {
data object Satellites : Screen("satellites", R.drawable.ic_satellites, R.string.nav_sat)
data object Passes : Screen("passes", R.drawable.ic_passes, R.string.nav_pass)
data object Radar : Screen("radar", R.drawable.ic_radar, R.string.nav_radar)
data object Map : Screen("map", R.drawable.ic_map, R.string.nav_map)
data object Settings : Screen("settings", R.drawable.ic_settings, R.string.nav_prefs)
data object RadioControl : Screen("radiocontrol", R.drawable.ic_radios, R.string.nav_radiocontrol)
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
@Serializable
sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
@Serializable
data object Satellites : Screen(R.drawable.ic_satellites, R.string.nav_sat)
@Serializable
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
@Serializable
data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar)
@Serializable
data object RadioControl : Screen(0, 0)
@Serializable
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
@Serializable
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
}
@@ -0,0 +1,105 @@
package com.rtbishop.look4sat.core.presentation
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.draw.innerShadow
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
@Composable
fun SwipeableItem(onSwipeRight: () -> Unit, onSwipeLeft: () -> Unit, content: @Composable () -> Unit) {
val coroutineScope = rememberCoroutineScope()
val dismissThresholdPx = with(LocalDensity.current) { 120.dp.toPx() }
val dismissState = rememberSwipeToDismissBoxState { dismissThresholdPx }
val willTrigger by remember { derivedStateOf { dismissState.targetValue != SwipeToDismissBoxValue.Settled } }
val hapticFeedback = LocalHapticFeedback.current
LaunchedEffect(willTrigger) {
val feedbackType = if (willTrigger) HapticFeedbackType.LongPress else HapticFeedbackType.SegmentTick
if (willTrigger) hapticFeedback.performHapticFeedback(feedbackType)
}
SwipeToDismissBox(
state = dismissState,
enableDismissFromStartToEnd = true,
enableDismissFromEndToStart = true,
backgroundContent = {
val isSwipeRight = dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd
SwipeBackground(isSwipeRight, willTrigger, MaterialTheme.colorScheme.primary)
},
content = { content() },
onDismiss = {
val targetValue = dismissState.targetValue
when (targetValue) {
SwipeToDismissBoxValue.StartToEnd -> onSwipeRight()
SwipeToDismissBoxValue.EndToStart -> onSwipeLeft()
SwipeToDismissBoxValue.Settled -> {}
}
if (targetValue != SwipeToDismissBoxValue.Settled) {
coroutineScope.launch { dismissState.snapTo(SwipeToDismissBoxValue.Settled) }
}
}
)
}
@Composable
fun SwipeBackground(isSwipeRight: Boolean, willTrigger: Boolean, bgColor: Color = Color(0x00000000)) {
val shape = RectangleShape
Box(
contentAlignment = if (isSwipeRight) Alignment.CenterStart else Alignment.CenterEnd,
modifier = Modifier
.fillMaxSize()
.border(width = 1.dp, shape = shape, color = bgColor)
.dropShadow(shape = shape) {
color = bgColor
radius = 40f
alpha = if (willTrigger) .2f else 0f
}
.innerShadow(shape = shape) {
color = bgColor
radius = 40f
alpha = if (willTrigger) 1f else .2f
}
) {
val iconScale by animateFloatAsState(targetValue = if (willTrigger) 1f else .8f)
val slide by animateDpAsState(targetValue = if (willTrigger) 32.dp else (12).dp)
Icon(
painter = painterResource(R.drawable.ic_filter),
contentDescription = null,
modifier = Modifier
.size(32.dp)
.fillMaxHeight()
.rotate(if (isSwipeRight) 0f else 180f)
.graphicsLayer {
scaleX = iconScale
scaleY = iconScale
translationX = slide.toPx()
}
)
}
}
@@ -5,5 +5,5 @@
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M4,20h16v2L4,22zM4,2h16v2L4,4zM13,9h3l-4,-4 -4,4h3v6L8,15l4,4 4,-4h-3z" />
android:pathData="M12.34,2.02C6.59,1.82 2,6.42 2,12c0,5.52 4.48,10 10,10c3.71,0 6.93,-2.02 8.66,-5.02C13.15,16.73 8.57,8.55 12.34,2.02z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M11,4V2c0,-0.55 0.45,-1 1,-1s1,0.45 1,1v2c0,0.55 -0.45,1 -1,1S11,4.55 11,4zM18.36,7.05l1.41,-1.42c0.39,-0.39 0.39,-1.02 0,-1.41c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.41,1.42c-0.39,0.39 -0.39,1.02 0,1.41C17.34,7.44 17.97,7.44 18.36,7.05zM22,11h-2c-0.55,0 -1,0.45 -1,1s0.45,1 1,1h2c0.55,0 1,-0.45 1,-1S22.55,11 22,11zM12,19c-0.55,0 -1,0.45 -1,1v2c0,0.55 0.45,1 1,1s1,-0.45 1,-1v-2C13,19.45 12.55,19 12,19zM5.64,7.05L4.22,5.64c-0.39,-0.39 -0.39,-1.03 0,-1.41s1.03,-0.39 1.41,0l1.41,1.41c0.39,0.39 0.39,1.03 0,1.41S6.02,7.44 5.64,7.05zM16.95,16.95c-0.39,0.39 -0.39,1.03 0,1.41l1.41,1.41c0.39,0.39 1.03,0.39 1.41,0c0.39,-0.39 0.39,-1.03 0,-1.41l-1.41,-1.41C17.98,16.56 17.34,16.56 16.95,16.95zM2,13h2c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1H2c-0.55,0 -1,0.45 -1,1S1.45,13 2,13zM5.64,19.78l1.41,-1.41c0.39,-0.39 0.39,-1.03 0,-1.41s-1.03,-0.39 -1.41,0l-1.41,1.41c-0.39,0.39 -0.39,1.03 0,1.41C4.61,20.17 5.25,20.17 5.64,19.78zM12,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6s6,-2.69 6,-6S15.31,6 12,6z" />
</vector>
@@ -0,0 +1,195 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Commmon -->
<string name="app_name" translatable="false">Look4Sat</string>
<string name="btn_accept">Kabul et</string>
<string name="btn_cancel">İptal</string>
<string name="empty_list_message">Gösterilecek veri bulunamadı</string>
<!-- Navigation -->
<string name="nav_sat">Uydular</string>
<string name="nav_pass">Geçişler</string>
<string name="nav_radar">Radar</string>
<string name="nav_map">Harita</string>
<string name="nav_prefs">Ayarlar</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Uydu türü: %s</string>
<string name="sat_type_title">Uydu türü seçin</string>
<string name="sat_search_hint">Id - Ad</string>
<string name="sat_search_clear">Temizle</string>
<string name="sat_clear_all">Tümünü temizle</string>
<string name="sat_select_all">Tümünü seç</string>
<string name="sat_empty_list_message">"Arama sorgunuzun doğru olduğundan ve veritabanının güncellendiğinden emin olun"</string>
<string name="sat_warning_title">Uyarı\!</string>
<string name="sat_warning_message">
Bu uygulamada 9000\'den fazla uydu listelenmiştir.
Hepsini aynı anda takip etmek anlamsızdır.
\n\nListeyi yalnızca ilgilendiğiniz uydularla
daraltmak için arama ve tür seçiciyi kullanın.</string>
<!-- Passes screen -->
<string name="pass_filter_title">Geçişleri filtrele</string>
<string name="pass_filter_elev">Minimum yükseklik açısı</string>
<string name="pass_filter_hours">Gösterilecek saat aralığı</string>
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
<string name="pass_modes_title">Modulasyon türü seçin</string>
<string name="pass_satId" translatable="false">%05d</string>
<string name="pass_elevation">Yükseklik açısı: %.1f°</string>
<string name="pass_deep_space" translatable="false">DeepSpace</string>
<string name="pass_altitude">İrtifa: %d km</string>
<string name="pass_aosLos" translatable="false">%03d° - %03d°</string>
<string name="pass_empty_list_message">
Filtrelerinizin doğru olduğundan ve uydu seçtiğinizden emin olun</string>
<string name="pass_error_message">Gösterilecek yaklaşan uydu geçişi bulunmuyor</string>
<string name="pass_welcome_title">Look4Sat\'a hoş geldiniz\!</string>
<string name="pass_welcome_message">
Ayarlar\'dan GPS, Enlem/Boylam veya QTH kullanarak konumunuzu belirlediğinizden emin olun.
\n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false">
* Switched to short-form commands in NetworkReporter, by theojalba
\n* Added CAT radio control for full-duplex SAT operation, by jcmerg
\n* Added normalized time for passes AOS/LOS, fixed refresh state bug
\n* Added multiple performance tweaks, improved passes calculation
\n* Fixed UTC pass time problem mentioned in issue #201
</string>
<!-- Radar screen -->
<string name="radar_back">Geri</string>
<string name="radar_notify">Bildir</string>
<string name="radar_az_text">Azimut</string>
<string name="radar_az_value" translatable="false">%.1f°</string>
<string name="radar_el_text">Yükseklik açısı</string>
<string name="radar_el_value" translatable="false">%.1f°</string>
<string name="radar_alt_text">İrtifa</string>
<string name="radar_alt_value" translatable="false">%.0f km</string>
<string name="radar_dist_text">Mesafe</string>
<string name="radar_dist_value" translatable="false">%.0f km</string>
<string name="radar_eclipsed">Gölgede</string>
<string name="radar_downlink">Downlink</string>
<string name="radar_uplink">Uplink</string>
<string name="radar_link_low" translatable="false">%.4f</string>
<string name="radar_link_lowHigh" translatable="false">%.4f - %.4f</string>
<string name="radar_no_link" translatable="false">- - . - -</string>
<string name="radar_no_data">Bu uyduda transceiver bulunmuyor</string>
<string name="radar_add">Ekle</string>
<string name="radar_data">Uydu transceiver\'ları:</string>
<string name="radar_inverted">Ters çevrilmiş: %s</string>
<string name="radar_mode">Modülasyon: %s</string>
<string name="radar_string_no">Hayır</string>
<string name="radar_string_yes">Evet</string>
<string name="radar_visible">Görünür</string>
<!-- Map screen -->
<string name="map_prev">Önceki</string>
<string name="map_next">Sonraki</string>
<string name="map_copyright" translatable="false">© OpenStreetMap contributors</string>
<string name="map_azimuth">Azimut: %.1f°</string>
<string name="map_elevation">Yükseklik açısı: %.1f°</string>
<string name="map_altitude">İrtifa: %.0f km</string>
<string name="map_distance">Mesafe: %.0f km</string>
<string name="map_latitude">Enlem: %.1f°</string>
<string name="map_longitude">Boylam: %.1f°</string>
<string name="map_qth" translatable="false">QTH: %s</string>
<string name="map_phase">Faz: %.1f°</string>
<string name="map_eclipsed">Gölgede</string>
<string name="map_period">Periyot: %.0f dk</string>
<string name="map_velocity">Hız: %.2f km/s</string>
<string name="map_visibility">Görünürlük: %s</string>
<string name="map_visible">Görünür</string>
<!-- Settings screen -->
<string name="prefs_app_title" translatable="false">Look4Sat v%s</string>
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
<string name="prefs_donate_title">Bağış yap</string>
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
<string name="prefs_fdroid_title" translatable="false">F-Droid</string>
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string>
<string name="prefs_github_title" translatable="false">GitHub</string>
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
<string name="prefs_license_title" translatable="false">GPLv3</string>
<string name="prefs_license_url" translatable="false">https://www.gnu.org/licenses/gpl-3.0.html</string>
<string name="prefs_privacy_title">Gizlilik Politikası</string>
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
<string name="prefs_updated_title">Güncellendi: %s</string>
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
<string name="prefs_loc_title">İstasyon konumu</string>
<string name="prefs_loc_gps_title">GPS</string>
<string name="prefs_loc_gps_error">Konum izinlerinizi kontrol edin</string>
<string name="prefs_loc_input_title">Manuel giriş</string>
<string name="prefs_loc_input_error">Geçersiz konum girildi</string>
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Geçersiz QTH konumlandırıcısı</string>
<string name="prefs_loc_success">Konum başarıyla güncellendi</string>
<string name="prefs_station_title">İstasyon konumu ayarları</string>
<string name="prefs_station_lat_text">Yer istasyonunuzun enlemini girin</string>
<string name="prefs_station_lon_text">Yer istasyonunuzun boylamını girin</string>
<string name="prefs_locator_title">QTH konumlandırıcı ayarları</string>
<string name="prefs_locator_text">İstasyon konumunu konumlandırıcı ile ayarlayın</string>
<string name="prefs_data_title">Uydu verisi</string>
<string name="prefs_data_entries">Uydular: %s</string>
<string name="prefs_data_radios">Transceiver\'lar: %s</string>
<string name="prefs_data_update">Güncelle</string>
<string name="prefs_data_import">İçe aktar</string>
<string name="prefs_data_clear">Temizle</string>
<string name="prefs_data_clear_success">Veriler başarıyla temizlendi</string>
<string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string>
<string name="prefs_data_sources_title">Özel veri kaynakları</string>
<string name="prefs_data_sources_tle_switch">Özel TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Özel transceiver URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_output_title">Veri aktarımı</string>
<string name="prefs_net_output">Ağ</string>
<string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string>
<!-- Radio Control -->
<string name="nav_radiocontrol">Radyo Kontrolü</string>
<string name="rc_settings_title">CAT Radyo Kontrolü</string>
<string name="rc_radio_model">Radyo Modeli</string>
<string name="rc_tx_device_hint">TX Radyo BT Adresi</string>
<string name="rc_rx_device_hint">RX Radyo BT Adresi</string>
<string name="rc_tx_name_hint">TX Radyo Adı</string>
<string name="rc_rx_name_hint">RX Radyo Adı</string>
<string name="rc_enable_switch">CAT Kontrolünü Etkinleştir</string>
<string name="prefs_net_title">Ağ veri çıkışı</string>
<string name="prefs_net_rotator_switch">Döndürme çıkışını etkinleştir</string>
<string name="prefs_net_rotator_address_hint">IP:Port</string>
<string name="prefs_net_rotator_format_hint">Biçim</string>
<string name="prefs_net_frequency_switch">Frekans çıkışını etkinleştir</string>
<string name="prefs_net_frequency_address_hint">IP:Port</string>
<string name="prefs_net_frequency_format_hint">Biçim</string>
<string name="prefs_bt_title">Bluetooth veri çıkışı</string>
<string name="prefs_bt_rotator_switch">Döndürme çıkışını etkinleştir</string>
<string name="prefs_bt_rotator_device_hint">Cihaz kimliği</string>
<string name="prefs_bt_rotator_output_hint">Veri biçimi</string>
<string name="prefs_bt_frequency_switch">Frekans çıkışını etkinleştir</string>
<string name="prefs_bt_frequency_device_hint">Cihaz kimliği</string>
<string name="prefs_bt_frequency_output_hint">Veri biçimi</string>
<string name="prefs_bt_perm_error">Bluetooth izninizi kontrol edin</string>
<string name="prefs_other_title">Diğer ayarlar</string>
<string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string>
<string name="prefs_other_switch_update">Otomatik veri güncellemeyi etkinleştir</string>
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string>
<string name="prefs_other_switch_sensors">Radar görünümünü döndürmek için sensörleri kullan</string>
<string name="prefs_outro_title">Teşekkürler</string>
<string name="prefs_outro_thanks" translatable="false">
• Look4Sat users and contributors!
\n• David A. B. Johnson (predict4java)
\n• Dave Moten (predict4java)
\n• Alexandru Csete (Gpredict)
\n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS)</string>
<string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string>
</resources>
@@ -130,8 +130,16 @@
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
<string name="prefs_outro_title">我要感谢:</string>
<string name="prefs_outro_thanks" translatable="false">
• Look4Sat 所有用户及贡献者!
\n• David A. B. Johnson (predict4java)
\n• Dave Moten (predict4java)
\n• Alexandru Csete (Gpredict)
\n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS)</string>
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
</resources>
@@ -32,6 +32,7 @@
<string name="pass_filter_title">Filter passes</string>
<string name="pass_filter_elev">Minimal elevation</string>
<string name="pass_filter_hours">Hours ahead</string>
<string name="pass_filter_deep_space">DeepSpace (period >225min)</string>
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
<string name="pass_modes_title">Select modulation type</string>
<string name="pass_satId" translatable="false">%05d</string>
@@ -48,11 +49,9 @@
\n\nPlease update the database at least weekly to get accurate predictions.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false">
* Switched to short-form commands in NetworkReporter, by theojalba
\n* Added CAT radio control for full-duplex SAT operation, by jcmerg
\n* Added normalized time for passes AOS/LOS, fixed refresh state bug
\n* Added multiple performance tweaks, improved passes calculation
\n* Fixed UTC pass time problem mentioned in issue #201
* Implemented Swipe-to-Focus behavior for passes - swipe right on a pass to focus it, and left - to unfocus
\n\n* Implemented preserving the pass selection between the Radar and Map screens. Not every satellite has
a pass overhead every day, so this should make it only slightly easier to keep track of certain satellites.
</string>
<!-- Radar screen -->
@@ -181,6 +180,7 @@
<string name="prefs_other_switch_update">Enable automatic data update</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_night_mode">Enable red night mode filter</string>
<string name="prefs_outro_title">I would like to say thanks to</string>
<string name="prefs_outro_thanks" translatable="false">
@@ -1,6 +1,2 @@
* Switched to short-form commands in NetworkReporter (#206) by theojalba
* Added CAT radio control for full-duplex SAT operation (#207) by jcmerg
* Added normalized time for passes AOS/LOS, fixed refresh state bug
* Added multiple performance tweaks, improved passes calculation
* Simplified data output code, aligned with HamLib defaults
* Fixed UTC pass time problem mentioned in issue #201
* Implemented Swipe-to-Focus behavior for passes - swipe right on a pass to focus it, and left - to unfocus
* Implemented preserving the pass selection between the Radar and Map screens. Not every satellite has a pass overhead every day, so this should make it only slightly easier to keep track of certain satellites.
@@ -1,6 +0,0 @@
* Switched to short-form commands in NetworkReporter (#206) by theojalba
* Added CAT radio control for full-duplex SAT operation (#207) by jcmerg
* Added normalized time for passes AOS/LOS, fixed refresh state bug
* Added multiple performance tweaks, improved passes calculation
* Simplified data output code, aligned with HamLib defaults
* Fixed UTC pass time problem mentioned in issue #201
@@ -0,0 +1,152 @@
/*
* 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 android.graphics.RectF
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Overlay
import kotlin.math.cos
import kotlin.math.sin
/**
* Custom osmdroid overlay that shades the night side of the globe.
*
* Works entirely in screen-pixel space: for each vertical strip on screen it
* asks osmdroid for the geographic coordinate, then tests whether that point is
* in the night half-sphere relative to the sub-solar point. Because the
* computation happens during draw() the result is always correct regardless
* of zoom level or map scroll position — no polygon winding issues possible.
*
* A point (latRad, lonRad) is in night when the angle to the sub-solar point
* exceeds 90°, i.e. the dot product of the two unit vectors is negative:
* dot = sin(lat)*sin(sunLat) + cos(lat)*cos(sunLat)*cos(lon - sunLon) < 0
*
* Performance: we sample one column per [stepPx] pixels (default 4) and draw
* filled vertical rectangles. On a 1080-wide screen this means ~270 trig
* evaluations per row, which is imperceptible.
*/
class MapNightOverlay : Overlay() {
/** Sub-solar latitude in degrees. */
var sunLatDeg: Double = 0.0
/** Sub-solar longitude in degrees. */
var sunLonDeg: Double = 0.0
private val nightPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
color = Color.argb(75, 0, 0, 0)
}
private val rect = RectF()
override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) {
if (shadow) return
val proj = mapView.projection
val sunLatRad = Math.toRadians(sunLatDeg)
val sunLonRad = Math.toRadians(sunLonDeg)
val sinSunLat = sin(sunLatRad)
val cosSunLat = cos(sunLatRad)
val w = mapView.width
val h = mapView.height
val stepPx = 4 // sample every N pixels — balance quality vs CPU
// We scan column by column. For each column we determine the longitude,
// then find the latitude range that is in night and shade it.
// Since longitude is constant along a vertical strip and the day/night
// boundary at a given longitude is at most two latitudes, we can do a
// scan-line fill efficiently.
var x = 0
while (x < w) {
// Get the geographic coordinate at the top and bottom of this column.
val geoTop = proj.fromPixels(x, 0) ?: run { x += stepPx; continue }
val geoBot = proj.fromPixels(x, h - 1) ?: run { x += stepPx; continue }
val lonRad = Math.toRadians(geoTop.longitude)
val cosLonDiff = cos(lonRad - sunLonRad)
// Top pixel geographic lat
val latTopRad = Math.toRadians(geoTop.latitude)
// Bottom pixel geographic lat (osmdroid: y=0 is top of screen, higher y = lower lat)
val latBotRad = Math.toRadians(geoBot.latitude)
// dot(sunVec, pointVec) < 0 → night
// dot = sin(lat)*sinSunLat + cos(lat)*cosSunLat*cosLonDiff
val dotTop = sin(latTopRad) * sinSunLat + cos(latTopRad) * cosSunLat * cosLonDiff
val dotBot = sin(latBotRad) * sinSunLat + cos(latBotRad) * cosSunLat * cosLonDiff
when {
dotTop < 0 && dotBot < 0 -> {
// Entire column is night — shade from top to bottom
rect.set(x.toFloat(), 0f, (x + stepPx).toFloat(), h.toFloat())
canvas.drawRect(rect, nightPaint)
}
dotTop >= 0 && dotBot >= 0 -> {
// Entire column is day — nothing to draw
}
else -> {
// Terminator crosses this column — find the crossing pixel by binary search
val crossY = findCrossingY(proj, x, 0, h - 1, sinSunLat, cosSunLat, cosLonDiff)
if (dotTop < 0) {
// Night at top, day at bottom
rect.set(x.toFloat(), 0f, (x + stepPx).toFloat(), crossY.toFloat())
canvas.drawRect(rect, nightPaint)
} else {
// Day at top, night at bottom
rect.set(x.toFloat(), crossY.toFloat(), (x + stepPx).toFloat(), h.toFloat())
canvas.drawRect(rect, nightPaint)
}
}
}
x += stepPx
}
}
/**
* Binary-search for the pixel row where the day/night boundary crosses column [x].
* [yTop] is in day, [yBot] is in night (or vice versa).
*/
private fun findCrossingY(
proj: org.osmdroid.views.Projection,
x: Int,
yTop: Int,
yBot: Int,
sinSunLat: Double,
cosSunLat: Double,
cosLonDiff: Double
): Int {
var lo = yTop
var hi = yBot
while (hi - lo > 1) {
val mid = (lo + hi) / 2
val geo = proj.fromPixels(x, mid) ?: return mid
val latRad = Math.toRadians(geo.latitude)
val dot = sin(latRad) * sinSunLat + cos(latRad) * cosSunLat * cosLonDiff
if (dot < 0) hi = mid else lo = mid
}
return (lo + hi) / 2
}
}
@@ -60,15 +60,13 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.NextPassRow
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.Screen
import com.rtbishop.look4sat.core.presentation.TimerRow
import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
@@ -86,7 +84,10 @@ private const val OVERLAY_STATION = 0
private const val OVERLAY_TRACK = 1
private const val OVERLAY_FOOTPRINT = 2
private const val OVERLAY_POSITIONS = 3
private const val OVERLAY_COUNT = 4
private const val OVERLAY_TERMINATOR = 4
private const val OVERLAY_SUN = 5
private const val OVERLAY_MOON = 6
private const val OVERLAY_COUNT = 7
private val minLat = MapView.getTileSystem().minLatitude
private val maxLat = MapView.getTileSystem().maxLatitude
@@ -109,14 +110,23 @@ private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
setShadowLayer(3f, 3f, 3f, Color.BLACK)
}
private val iconCache = LruCache<String, Drawable>(128)
private val sunIconPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
colorFilter =
android.graphics.PorterDuffColorFilter("#FFE082".toColorInt(), android.graphics.PorterDuff.Mode.SRC_IN)
}
private val moonIconPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
colorFilter =
android.graphics.PorterDuffColorFilter("#E0E0E0".toColorInt(), android.graphics.PorterDuff.Mode.SRC_IN)
}
fun NavGraphBuilder.mapDestination() {
composable(Screen.Map.route) {
val viewModel = viewModel(MapViewModel::class.java, factory = MapViewModel.Factory)
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val mapView = rememberMapViewWithLifecycle()
MapScreen(uiState, viewModel::onAction, mapView)
}
@Composable
fun MapDestination() {
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val mapView = rememberMapViewWithLifecycle()
MapScreen(uiState, viewModel::onAction, mapView)
}
@Composable
@@ -153,6 +163,9 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
uiState.track?.let { setSatelliteTrack(it, view) }
uiState.footprint?.let { setFootprint(it, view) }
uiState.positions?.let { setPositions(it, view) { item -> onAction(MapAction.SelectItem(item)) } }
setTerminator(uiState.sunLatDeg, uiState.sunLonDeg, view)
setSubSolarPoint(uiState.sunLatDeg, uiState.sunLonDeg, view)
setMoonPosition(uiState.moonLatDeg, uiState.moonLonDeg, view)
view.invalidate()
}
uiState.mapData?.let { mapData ->
@@ -366,14 +379,12 @@ private var footprintPoints: ArrayList<GeoPoint>? = null
private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) {
try {
val rangeCircle = orbitalPos.getRangeCircle()
// Lazily initialize the reusable point list and polyline
var pts = footprintPoints
if (pts == null || pts.size != rangeCircle.size) {
pts = ArrayList(rangeCircle.size)
for (gp in rangeCircle) pts.add(GeoPoint(gp.latitude, gp.longitude))
footprintPoints = pts
} else {
// Update coordinates in-place — zero allocations
for (i in rangeCircle.indices) {
pts[i].latitude = rangeCircle[i].latitude
pts[i].longitude = rangeCircle[i].longitude
@@ -389,6 +400,83 @@ private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) {
println(e)
}
}
/**
* Update the NightOverlay with the current sub-solar position.
* The overlay is created once and kept in OVERLAY_TERMINATOR; only its
* sunLatDeg/sunLonDeg fields are updated each tick so osmdroid redraws it.
*/
private fun setTerminator(sunLatDeg: Double, sunLonDeg: Double, mapView: MapView) {
try {
val overlay = mapView.overlays[OVERLAY_TERMINATOR]
if (overlay is MapNightOverlay) {
overlay.sunLatDeg = sunLatDeg
overlay.sunLonDeg = sunLonDeg
} else {
mapView.overlays[OVERLAY_TERMINATOR] = MapNightOverlay().apply {
this.sunLatDeg = sunLatDeg
this.sunLonDeg = sunLonDeg
}
}
} catch (e: Exception) {
println(e)
}
}
/** Place an ic_sun icon marker at the sub-solar point. */
private fun setSubSolarPoint(sunLatDeg: Double, sunLonDeg: Double, mapView: MapView) {
try {
val overlay = mapView.overlays[OVERLAY_SUN]
val sunPos = GeoPoint(sunLatDeg, sunLonDeg)
if (overlay is Marker) {
overlay.position = sunPos
} else {
val iconSize = 48
val bmp = createBitmap(iconSize, iconSize)
ContextCompat.getDrawable(mapView.context, R.drawable.ic_sun)?.apply {
setBounds(0, 0, iconSize, iconSize)
colorFilter = sunIconPaint.colorFilter
draw(Canvas(bmp))
}
mapView.overlays[OVERLAY_SUN] = Marker(mapView).apply {
setInfoWindow(null)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
icon = bmp.toDrawable(mapView.context.resources)
position = sunPos
}
}
} catch (e: Exception) {
println(e)
}
}
/** Place an ic_moon icon marker at the sub-lunar point. */
private fun setMoonPosition(moonLatDeg: Double, moonLonDeg: Double, mapView: MapView) {
try {
val overlay = mapView.overlays[OVERLAY_MOON]
val moonPos = GeoPoint(moonLatDeg, moonLonDeg)
if (overlay is Marker) {
overlay.position = moonPos
} else {
val iconSize = 48
val bmp = createBitmap(iconSize, iconSize)
val c = Canvas(bmp)
ContextCompat.getDrawable(mapView.context, R.drawable.ic_moon)?.apply {
setBounds(0, 0, iconSize, iconSize)
colorFilter = moonIconPaint.colorFilter
draw(c)
}
mapView.overlays[OVERLAY_MOON] = Marker(mapView).apply {
setInfoWindow(null)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
icon = bmp.toDrawable(mapView.context.resources)
position = moonPos
}
}
} catch (e: Exception) {
println(e)
}
}
// endregion
// region MapView lifecycle
@@ -30,7 +30,11 @@ data class MapState(
val orbitalPass: OrbitalPass,
val track: List<List<GeoPos>>? = null,
val footprint: OrbitalPos? = null,
val positions: Map<OrbitalObject, GeoPos>? = null
val positions: Map<OrbitalObject, GeoPos>? = null,
val sunLatDeg: Double = 0.0,
val sunLonDeg: Double = 0.0,
val moonLatDeg: Double = 0.0,
val moonLonDeg: Double = 0.0
)
sealed interface MapAction {
@@ -18,15 +18,15 @@
package com.rtbishop.look4sat.feature.map
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
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.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.utility.clipLat
@@ -49,8 +49,10 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.Date
class MapViewModel(private val satelliteRepo: ISatelliteRepo, private val settingsRepo: ISettingsRepo) :
ViewModel() {
class MapViewModel(
private val satelliteRepo: ISatelliteRepo,
private val settingsRepo: ISettingsRepo
) : ViewModel() {
private val stationPos = settingsRepo.stationPosition.value
private val defaultPass = getDefaultPass()
@@ -74,7 +76,8 @@ class MapViewModel(private val satelliteRepo: ISatelliteRepo, private val settin
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
}
}
selectDefaultSatellite(-1)
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
selectDefaultSatellite(if (selectedCatNum != 0) selectedCatNum else -1)
}
fun onAction(action: MapAction) {
@@ -124,10 +127,16 @@ class MapViewModel(private val satelliteRepo: ISatelliteRepo, private val settin
val dateNow = Date()
getStationPosition()
getSatTrack(orbitalObject, stationPos, dateNow)
// Scale update interval based on satellite count to avoid excessive CPU usage
val effectiveRate = when {
allSatellites.size > 5000 -> maxOf(updateFreq, 3000L)
allSatellites.size > 1000 -> maxOf(updateFreq, 2000L)
else -> updateFreq
}
while (isActive) {
dateNow.time = System.currentTimeMillis()
updateMapState(orbitalObject, allSatellites, stationPos, dateNow)
delay(updateFreq)
delay(effectiveRate)
}
}
}
@@ -177,10 +186,12 @@ class MapViewModel(private val satelliteRepo: ISatelliteRepo, private val settin
}
}
// 2. Derive footprint and info data from the already-computed selected position
// 2. Derive footprint, info data, sun and moon position from already-computed state
val satPos = selectedSatPos ?: satelliteRepo.getPosition(selected, pos, date.time)
val footprint = satPos
val mapData = buildMapData(selected, satPos, date)
val sunPos = CelestialComputer.getSunPosition(stationPos, date.time)
val moonPos = CelestialComputer.getMoonPosition(stationPos, date.time)
// 3. Single atomic state update — one recomposition per cycle
_uiState.update {
@@ -188,7 +199,11 @@ class MapViewModel(private val satelliteRepo: ISatelliteRepo, private val settin
positions = positionsMap,
footprint = footprint,
mapData = mapData.first,
orbitalPass = mapData.second
orbitalPass = mapData.second,
sunLatDeg = sunPos.latitude,
sunLonDeg = sunPos.longitude,
moonLatDeg = moonPos.declination, // sub-lunar latitude = declination
moonLonDeg = if (moonPos.gha <= 180.0) -moonPos.gha else 360.0 - moonPos.gha
)
}
}
@@ -286,11 +301,12 @@ class MapViewModel(private val satelliteRepo: ISatelliteRepo, private val settin
/** Number of parallel chunks for satellite position computation */
private const val PARALLEL_CHUNKS = 4
val Factory: ViewModelProvider.Factory = viewModelFactory {
val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY
fun factory(container: IMainContainer) = viewModelFactory {
initializer {
val container = (this[applicationKey] as IContainerProvider).getMainContainer()
MapViewModel(container.satelliteRepo, container.settingsRepo)
MapViewModel(
satelliteRepo = container.satelliteRepo,
settingsRepo = container.settingsRepo
)
}
}
}
@@ -32,12 +32,15 @@ import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableDoubleStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
@@ -65,17 +68,29 @@ private val hourSteps = listOf(1, 2, 4, 8, 12, 24, 48, 72, 96, 120, 144, 168, 19
@Preview
@Composable
private fun PassesDialogPreview() {
MainTheme { PassesDialog(24, 16.0, {}) { _, _ -> } }
MainTheme { PassesDialog(24, 16.0, true, {}) { _, _, _ -> } }
}
@Composable
internal fun PassesDialog(hours: Int, elevation: Double, cancel: () -> Unit, accept: (Int, Double) -> Unit) {
internal fun PassesDialog(
hours: Int,
elevation: Double,
showDeepSpace: Boolean,
cancel: () -> Unit,
accept: (Int, Double, Boolean) -> Unit
) {
val hoursIndex = remember { mutableIntStateOf(hourSteps.indexOfFirst { it >= hours }.coerceAtLeast(0)) }
val elevationValueNew = remember { mutableDoubleStateOf(elevation) }
var deepSpaceEnabled by remember { mutableStateOf(showDeepSpace) }
val onAccept = {
accept(hourSteps[hoursIndex.intValue], elevationValueNew.doubleValue).also { cancel() }
accept(hourSteps[hoursIndex.intValue], elevationValueNew.doubleValue, deepSpaceEnabled).also { cancel() }
}
SharedDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) {
ToggleRow(
title = stringResource(R.string.pass_filter_deep_space),
checked = deepSpaceEnabled,
onCheckedChange = { deepSpaceEnabled = it }
)
SliderRow(
title = stringResource(R.string.pass_filter_elev),
value = elevationValueNew.doubleValue,
@@ -188,3 +203,20 @@ internal fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List
}
}
}
@Composable
private fun ToggleRow(title: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
) {
Text(
text = title,
fontSize = 16.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.onSurface
)
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
@@ -17,6 +17,11 @@
*/
package com.rtbishop.look4sat.feature.passes
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -29,10 +34,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
@@ -47,6 +50,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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
@@ -56,20 +60,19 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.rtbishop.look4sat.core.domain.predict.DeepSpaceObject
import com.rtbishop.look4sat.core.domain.predict.NearEarthObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.EmptyListCard
import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.InfoDialog
import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.NextPassRow
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.Screen
import com.rtbishop.look4sat.core.presentation.ScreenColumn
import com.rtbishop.look4sat.core.presentation.SwipeableItem
import com.rtbishop.look4sat.core.presentation.TimerRow
import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
@@ -79,15 +82,13 @@ import java.util.Date
import java.util.Locale
import java.util.TimeZone
fun NavGraphBuilder.passesDestination(navigateToRadar: (Int, Long) -> Unit) {
composable(Screen.Passes.route) {
val viewModel = viewModel(
modelClass = PassesViewModel::class.java,
factory = PassesViewModel.Factory
)
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
PassesScreen(uiState, viewModel::onAction, navigateToRadar)
}
@Composable
fun PassesDestination(navigateToRadar: (Int, Long) -> Unit) {
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: PassesViewModel = viewModel(factory = PassesViewModel.factory(container))
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
PassesScreen(uiState, viewModel::onAction, navigateToRadar)
}
@Composable
@@ -100,9 +101,10 @@ private fun PassesScreen(
PassesDialog(
hours = uiState.hours,
elevation = uiState.elevation,
showDeepSpace = uiState.showDeepSpace,
cancel = { onAction(PassesAction.TogglePassesDialog) }
) { hours, elevation ->
onAction(PassesAction.FilterPasses(hours, elevation))
) { hours, elevation, showDeepSpace ->
onAction(PassesAction.FilterPasses(hours, elevation, showDeepSpace))
}
}
if (uiState.isRadiosDialogShown) {
@@ -121,7 +123,6 @@ private fun PassesScreen(
onAction(PassesAction.DismissWhatsNew)
}
}
val gridState = rememberLazyGridState()
ScreenColumn(
topBar = { isVerticalLayout ->
TopBar(
@@ -145,9 +146,11 @@ private fun PassesScreen(
isRefreshing = uiState.isRefreshing,
isUtc = uiState.isUtc,
passes = uiState.itemsList,
groupedPasses = uiState.groupedPasses,
sunTimes = uiState.sunTimes,
focusedCatNum = uiState.focusedCatNum,
navigateToRadar = navigateToRadar,
refreshPasses = { onAction(PassesAction.RefreshPasses) },
gridState = gridState
onAction = onAction
)
}
}
@@ -158,9 +161,11 @@ private fun PassesList(
isRefreshing: Boolean,
isUtc: Boolean,
passes: List<OrbitalPass>,
groupedPasses: Map<String, List<OrbitalPass>>,
sunTimes: Map<String, Pair<String, String>>,
focusedCatNum: Int?,
navigateToRadar: (Int, Long) -> Unit,
refreshPasses: () -> Unit,
gridState: LazyGridState
onAction: (PassesAction) -> Unit
) {
val isVerticalLayout = isVerticalLayout()
val refreshState = rememberPullToRefreshState()
@@ -168,7 +173,7 @@ private fun PassesList(
PullToRefreshBox(
isRefreshing = isRefreshing,
state = refreshState,
onRefresh = refreshPasses,
onRefresh = { onAction(PassesAction.RefreshPasses) },
indicator = {
PullToRefreshDefaults.Indicator(
state = refreshState,
@@ -182,19 +187,37 @@ private fun PassesList(
if (passes.isEmpty()) {
EmptyListCard(message = stringResource(R.string.pass_empty_list_message))
} else {
LazyVerticalGrid(
state = gridState,
columns = GridCells.Adaptive(320.dp),
modifier = Modifier.fillMaxSize()
) {
items(items = passes, key = { item -> item.catNum + item.aosTime }) { pass ->
PassItem(
pass = pass,
navigateToRadar = navigateToRadar,
modifier = Modifier.animateItem(),
isVerticalLayout = isVerticalLayout,
isUtc = isUtc
)
AnimatedContent(
targetState = focusedCatNum,
transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) },
label = "PassesFocusTransition"
) { targetFocus ->
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp), modifier = Modifier.fillMaxSize()) {
for ((dateLabel, dayPasses) in groupedPasses) {
val headerVisible = targetFocus == null || dayPasses.any { it.catNum == targetFocus }
if (headerVisible) {
stickyHeader(key = "header_$dateLabel") {
val (rise, set) = sunTimes[dateLabel] ?: ("--:--" to "--:--")
StickyDateHeader(label = dateLabel, sunriseTime = rise, sunsetTime = set)
}
}
val visiblePasses = if (targetFocus == null) dayPasses
else dayPasses.filter { it.catNum == targetFocus }
items(items = visiblePasses, key = { item -> item.catNum + item.aosTime }) { pass ->
SwipeableItem(
onSwipeRight = { onAction(PassesAction.FocusCatNum(pass.catNum)) },
onSwipeLeft = { onAction(PassesAction.ClearFocus) }
) {
PassItem(
pass = pass,
navigateToRadar = navigateToRadar,
modifier = Modifier.animateItem(),
isVerticalLayout = isVerticalLayout,
isUtc = isUtc
)
}
}
}
}
}
}
@@ -202,6 +225,45 @@ private fun PassesList(
}
}
@Composable
private fun StickyDateHeader(label: String, sunriseTime: String, sunsetTime: String) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.padding(horizontal = 12.dp, vertical = 4.dp)
) {
Text(
text = label,
fontSize = 14.sp,
fontWeight = FontWeight.Normal,
color = MaterialTheme.colorScheme.primary
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Icon(
painter = painterResource(R.drawable.ic_sun),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
)
Text(text = sunriseTime, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurface)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Icon(
painter = painterResource(R.drawable.ic_moon),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(16.dp)
)
Text(text = sunsetTime, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurface)
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun DeepSpacePassPreview() {
@@ -233,15 +295,15 @@ private fun PassItem(
val timeZone = remember(isUtc) {
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
}
val sdfDate = remember(isUtc) {
SimpleDateFormat("EEE dd MMM", Locale.ENGLISH).also { it.timeZone = timeZone }
}
val sdfTime = remember(isUtc) {
SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone }
}
val aosDateStr = remember(pass.aosTime, isUtc) { sdfDate.format(Date(pass.aosTime)) }
val aosTimeStr = remember(pass.aosTime, isUtc) { sdfTime.format(Date(pass.aosTime)) }
val losTimeStr = remember(pass.losTime, isUtc) { sdfTime.format(Date(pass.losTime)) }
val durationStr = remember(pass.aosTime, pass.losTime) {
val seconds = (pass.losTime - pass.aosTime) / 1000
"${seconds / 60}m ${seconds % 60}s"
}
Column(
modifier = modifier.clickable { navigateToRadar(pass.catNum, pass.aosTime) }
@@ -298,7 +360,7 @@ private fun PassItem(
color = MaterialTheme.colorScheme.onSurface
)
} else {
Text(text = aosDateStr, fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface)
Text(text = durationStr, fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface)
}
}
Row(
@@ -306,13 +368,6 @@ private fun PassItem(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(id = R.drawable.ic_altitude),
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.width(4.dp))
Text(text = "${pass.altitude} km", fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface)
}
Row(
@@ -29,16 +29,22 @@ data class PassesState(
val isNextTimeAos: Boolean = true,
val hours: Int = 24,
val elevation: Double = 16.0,
val showDeepSpace: Boolean = true,
val modes: List<String> = emptyList(),
val itemsList: List<OrbitalPass> = emptyList(),
val shouldSeeWhatsNew: Boolean = false
val groupedPasses: Map<String, List<OrbitalPass>> = emptyMap(),
val shouldSeeWhatsNew: Boolean = false,
val sunTimes: Map<String, Pair<String, String>> = emptyMap(),
val focusedCatNum: Int? = null
)
sealed interface PassesAction {
data object DismissWhatsNew : PassesAction
data class FilterPasses(val hoursAhead: Int, val minElevation: Double) : PassesAction
data class FilterPasses(val hoursAhead: Int, val minElevation: Double, val showDeepSpace: Boolean) : PassesAction
data class FilterRadios(val modes: List<String>) : PassesAction
data object RefreshPasses : PassesAction
data object TogglePassesDialog : PassesAction
data object ToggleRadiosDialog : PassesAction
data class FocusCatNum(val catNum: Int) : PassesAction
data object ClearFocus : PassesAction
}
@@ -18,13 +18,13 @@
package com.rtbishop.look4sat.feature.passes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.utility.round
@@ -34,10 +34,13 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
class PassesViewModel(
private val satelliteRepo: ISatelliteRepo,
@@ -51,6 +54,7 @@ class PassesViewModel(
nextPass = defaultPass,
hours = settingsRepo.passesSettings.value.hoursAhead,
elevation = settingsRepo.passesSettings.value.minElevation,
showDeepSpace = settingsRepo.passesSettings.value.showDeepSpace,
modes = settingsRepo.passesSettings.value.selectedModes,
shouldSeeWhatsNew = settingsRepo.otherSettings.value.shouldSeeWhatsNew
)
@@ -58,48 +62,42 @@ class PassesViewModel(
val uiState: StateFlow<PassesState> = _uiState
init {
// Show refreshing indicator whenever the selected satellites list changes
// Refresh indicator: mirrors the repo's isCalculating state
viewModelScope.launch {
settingsRepo.selectedIds.collectLatest { selectedIds ->
_uiState.update { it.copy(isRefreshing = true) }
if (selectedIds.isEmpty()) {
// No satellites selected — show indicator briefly, then stop
delay(1000)
_uiState.update { it.copy(isRefreshing = false) }
}
// For non-empty selections, the passes collector below will clear isRefreshing
}
}
// Stop refreshing whenever new passes arrive (from initial load, selection change, or filter)
viewModelScope.launch {
satelliteRepo.passes.drop(1).collect { _ ->
_uiState.update { it.copy(isRefreshing = false) }
}
}
// Local tick loop — computes pass progress and countdown timer every second
viewModelScope.launch {
while (isActive) {
val timeNow = System.currentTimeMillis()
val processed = computePassProgress(satelliteRepo.passes.value, timeNow)
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
_uiState.update {
it.copy(
itemsList = processed,
nextPass = nextPass,
nextTime = nextTime,
isNextTimeAos = isAos
)
}
delay(1000)
satelliteRepo.isCalculating.collect { calculating ->
_uiState.update { it.copy(isRefreshing = calculating) }
}
}
// React to settings changes: update UTC flag and whatsNew
viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings ->
_uiState.update {
it.copy(
isUtc = settings.stateOfUtc,
shouldSeeWhatsNew = settings.shouldSeeWhatsNew
)
_uiState.update { it.copy(isUtc = settings.stateOfUtc, shouldSeeWhatsNew = settings.shouldSeeWhatsNew) }
}
}
// Main tick loop: reacts to new passes, then ticks every second
viewModelScope.launch {
satelliteRepo.passes.collectLatest { allPasses ->
while (isActive) {
val timeNow = System.currentTimeMillis()
val isUtc = _uiState.value.isUtc
val showDeepSpace = _uiState.value.showDeepSpace
val filtered = allPasses
.let { if (showDeepSpace) it else it.filter { pass -> !pass.isDeepSpace } }
val processed = computePassProgress(filtered, timeNow)
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
val sunTimes = computeSunTimes(processed, isUtc)
val grouped = groupPasses(processed, isUtc)
_uiState.update {
it.copy(
itemsList = processed,
groupedPasses = grouped,
sunTimes = sunTimes,
nextPass = nextPass,
nextTime = nextTime,
isNextTimeAos = isAos
)
}
delay(1000)
}
}
}
@@ -108,14 +106,58 @@ class PassesViewModel(
fun onAction(action: PassesAction) {
when (action) {
PassesAction.DismissWhatsNew -> settingsRepo.setWhatsNewDismissed()
is PassesAction.FilterPasses -> applyFilter(action.hoursAhead, action.minElevation, _uiState.value.modes)
is PassesAction.FilterRadios -> applyFilter(_uiState.value.hours, _uiState.value.elevation, action.modes)
is PassesAction.FilterPasses ->
applyFilter(action.hoursAhead, action.minElevation, action.showDeepSpace, _uiState.value.modes)
is PassesAction.FilterRadios ->
applyFilter(_uiState.value.hours, _uiState.value.elevation, _uiState.value.showDeepSpace, action.modes)
PassesAction.RefreshPasses -> refreshPasses()
PassesAction.TogglePassesDialog -> _uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) }
PassesAction.ToggleRadiosDialog -> _uiState.update { it.copy(isRadiosDialogShown = !it.isRadiosDialogShown) }
PassesAction.TogglePassesDialog ->
_uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) }
PassesAction.ToggleRadiosDialog ->
_uiState.update { it.copy(isRadiosDialogShown = !it.isRadiosDialogShown) }
is PassesAction.FocusCatNum -> _uiState.update { it.copy(focusedCatNum = action.catNum) }
PassesAction.ClearFocus -> _uiState.update { it.copy(focusedCatNum = null) }
}
}
// Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
val stationPos = settingsRepo.stationPosition.value
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz }
val sdfTime = SimpleDateFormat("HH:mm", Locale.ENGLISH).also { it.timeZone = tz }
val result = LinkedHashMap<String, Pair<String, String>>()
// DeepSpace group always shows today's sun times
if (passes.any { it.isDeepSpace }) {
val riseSet = CelestialComputer.findSunRiseSet(stationPos, System.currentTimeMillis())
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
result["DeepSpace (period >225min)"] = rise to set
}
for (pass in passes) {
if (pass.isDeepSpace) continue
val label = sdfDate.format(Date(pass.aosTime))
if (label in result) continue
val riseSet = CelestialComputer.findSunRiseSet(stationPos, pass.aosTime)
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
result[label] = rise to set
}
return result
}
private fun groupPasses(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, List<OrbitalPass>> {
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz }
val ordered = LinkedHashMap<String, List<OrbitalPass>>()
val deepSpace = passes.filter { it.isDeepSpace }
if (deepSpace.isNotEmpty()) ordered["DeepSpace (period >225min)"] = deepSpace
passes.filter { !it.isDeepSpace }
.groupByTo(LinkedHashMap()) { sdfDate.format(Date(it.aosTime)) }
.forEach { (k, v) -> ordered[k] = v }
return ordered
}
/** Computes live progress for each pass, filtering out expired ones. */
private fun computePassProgress(passList: List<OrbitalPass>, time: Long): List<OrbitalPass> {
val result = ArrayList<OrbitalPass>(passList.size)
@@ -153,28 +195,31 @@ class PassesViewModel(
return Triple(defaultPass, "00:00:00", true)
}
private fun applyFilter(hoursAhead: Int, minElevation: Double, modes: List<String>) = viewModelScope.launch {
_uiState.update { it.copy(isRefreshing = true) }
settingsRepo.setPassesSettings(PassesSettings(hoursAhead, minElevation, modes))
_uiState.update { it.copy(hours = hoursAhead, elevation = minElevation, modes = modes) }
private fun applyFilter(
hoursAhead: Int,
minElevation: Double,
showDeepSpace: Boolean,
modes: List<String>
) = viewModelScope.launch {
settingsRepo.setPassesSettings(PassesSettings(showDeepSpace, hoursAhead, minElevation, modes))
_uiState.update {
it.copy(hours = hoursAhead, elevation = minElevation, showDeepSpace = showDeepSpace, modes = modes)
}
satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
_uiState.update { it.copy(isRefreshing = false) }
}
private fun refreshPasses() = viewModelScope.launch {
_uiState.update { it.copy(isRefreshing = true) }
val (hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value
val (_, hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value
satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
_uiState.update { it.copy(isRefreshing = false) }
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY
fun factory(container: IMainContainer) = viewModelFactory {
initializer {
val container = (this[applicationKey] as IContainerProvider).getMainContainer()
PassesViewModel(container.satelliteRepo, container.settingsRepo)
PassesViewModel(
satelliteRepo = container.satelliteRepo,
settingsRepo = container.settingsRepo
)
}
}
}
@@ -47,6 +47,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.keepScreenOn
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
@@ -58,18 +59,15 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.utility.toDegrees
import com.rtbishop.look4sat.core.presentation.EmptyListCard
import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.NextPassRow
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.Screen
import com.rtbishop.look4sat.core.presentation.TimerRow
import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.getDefaultPass
@@ -77,41 +75,24 @@ import com.rtbishop.look4sat.core.presentation.infiniteMarquee
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import com.rtbishop.look4sat.core.presentation.layoutPadding
fun NavGraphBuilder.radarDestination(
navigateUp: () -> Unit,
navigateToRadioControl: (Int, Long) -> Unit = { _, _ -> }
) {
val radarRoute = "${Screen.Radar.route}?catNum={catNum}&aosTime={aosTime}"
val radarArgs = listOf(
navArgument("catNum") { defaultValue = 0 },
navArgument("aosTime") { defaultValue = 0L }
)
composable(radarRoute, radarArgs) {
val viewModel = viewModel(RadarViewModel::class.java, factory = RadarViewModel.Factory)
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
RadarScreen(uiState, viewModel::onAction, navigateUp, navigateToRadioControl)
}
@Composable
fun RadarDestination(navigateToRadioControl: () -> Unit = {}) {
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
RadarScreen(uiState, viewModel::onAction, navigateToRadioControl)
}
@Composable
private fun RadarScreen(
uiState: RadarState,
onAction: (RadarAction) -> Unit,
navigateUp: () -> Unit,
navigateToRadioControl: (Int, Long) -> Unit
navigateToRadioControl: () -> Unit
) {
val upcomingPass = uiState.currentPass ?: getDefaultPass()
if (upcomingPass.losTime < System.currentTimeMillis()) navigateUp()
val addToCalendar: () -> Unit = {
uiState.currentPass?.let { pass ->
onAction(RadarAction.AddToCalendar(pass.name, pass.aosTime, pass.losTime))
}
}
val openRadioControl: () -> Unit = {
uiState.currentPass?.let { pass ->
navigateToRadioControl(pass.catNum, pass.aosTime)
}
uiState.currentPass?.let { onAction(RadarAction.AddToCalendar(it.name, it.aosTime, it.losTime)) }
}
Column(
modifier = Modifier
@@ -123,16 +104,16 @@ private fun RadarScreen(
if (isVertical) {
TopBar {
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isCurrentTimeAos)
IconCard(action = openRadioControl, resId = R.drawable.ic_radios)
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
IconCard(action = navigateToRadioControl, resId = R.drawable.ic_radios)
}
TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
} else {
TopBar {
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isCurrentTimeAos)
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
IconCard(action = openRadioControl, resId = R.drawable.ic_radios)
IconCard(action = navigateToRadioControl, resId = R.drawable.ic_radios)
}
}
if (isVertical) {
@@ -149,8 +130,8 @@ private fun RadarScreen(
@Composable
private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
val isEclipsed = uiState.orbitalPos?.eclipsed == true
val borderModifier = if (isEclipsed) {
val satellitePos = uiState.orbitalPos
val borderModifier = if (satellitePos?.aboveHorizon == true && satellitePos.eclipsed) {
val infiniteTransition = rememberInfiniteTransition(label = "eclipsedBorder")
val borderAlpha by infiniteTransition.animateFloat(
initialValue = 1.0f,
@@ -183,7 +164,9 @@ private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
azimElev = uiState.orientationValues,
shouldShowSweep = uiState.shouldShowSweep,
shouldUseCompass = uiState.shouldUseCompass,
modifier = Modifier.align(Alignment.Center)
modifier = Modifier.align(Alignment.Center),
sunPosition = uiState.sunPosition,
moonPosition = uiState.moonPosition,
)
PositionOverlay(position)
}
@@ -18,19 +18,23 @@
package com.rtbishop.look4sat.feature.radar
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
data class RadarState(
val currentPass: OrbitalPass? = null,
val currentTime: String = "00:00:00",
val isCurrentTimeAos: Boolean = true,
val isTimeAos: Boolean = true,
val isLos: Boolean = false,
val isUtc: Boolean = false,
val orientationValues: Pair<Float, Float> = 0f to 0f,
val orbitalPos: OrbitalPos? = null,
val satTrack: List<OrbitalPos> = emptyList(),
val shouldShowSweep: Boolean = false,
val shouldUseCompass: Boolean = false,
val sunPosition: CelestialComputer.SunPosition? = null,
val moonPosition: CelestialComputer.MoonPosition? = null,
val transmitters: List<SatRadio> = emptyList(),
val selectedTransmitterUuid: String? = null,
val selectedFrequency: Long? = null
@@ -17,8 +17,6 @@
*/
package com.rtbishop.look4sat.feature.radar
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
@@ -34,6 +32,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
@@ -46,14 +45,19 @@ import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.drawscope.withTransform
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.predict.PI_2
import com.rtbishop.look4sat.core.domain.utility.toRadians
import com.rtbishop.look4sat.core.presentation.R
import kotlin.math.cos
import kotlin.math.sin
@@ -68,11 +72,14 @@ fun RadarViewCompose(
azimElev: Pair<Float, Float>,
shouldShowSweep: Boolean,
shouldUseCompass: Boolean,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
sunPosition: CelestialComputer.SunPosition? = null,
moonPosition: CelestialComputer.MoonPosition? = null,
) {
val radarColor = MaterialTheme.colorScheme.secondary
val trackColor = MaterialTheme.colorScheme.primary
val aimColor = MaterialTheme.colorScheme.error
val primaryColor = MaterialTheme.colorScheme.primary
val radarColor = MaterialTheme.colorScheme.secondary
val sunColor = MaterialTheme.colorScheme.primary
val animTransition = rememberInfiniteTransition(label = "animScale")
val animScale by animTransition.animateFloat(
initialValue = 16f,
@@ -80,21 +87,13 @@ fun RadarViewCompose(
animationSpec = infiniteRepeatable(tween(1000)),
label = "animScale"
)
val eclipsedGlowAlpha by animTransition.animateFloat(
initialValue = 1.0f,
targetValue = 0.0f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1000, delayMillis = 25, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "eclipsedGlowAlpha"
)
val measurer = rememberTextMeasurer()
val sunPainter = painterResource(R.drawable.ic_sun)
val moonPainter = painterResource(R.drawable.ic_moon)
var sweepDegrees by remember { mutableFloatStateOf(0f) }
var cachedRadius by remember { mutableFloatStateOf(0f) }
var trackPath by remember { mutableStateOf(Path()) }
var trackEffect by remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) }
val isEclipsed = item.eclipsed
Canvas(modifier = modifier.aspectRatio(1f)) {
val radius = size.minDimension / 2f * 0.95f
@@ -104,17 +103,26 @@ fun RadarViewCompose(
cachedRadius = radius
}
rotate(if (shouldUseCompass) -azimElev.first else 0f) {
if (shouldShowSweep) drawSweep(center, sweepDegrees, radius, trackColor)
if (shouldShowSweep) drawSweep(center, sweepDegrees, radius, primaryColor)
drawRadar(radius, radarColor)
drawElevationLabels(radius, trackColor, measurer)
drawElevationLabels(radius, primaryColor, measurer)
translate(center.x, center.y) {
drawTrack(trackPath, trackEffect, aimColor, trackColor)
drawTrack(trackPath, trackEffect, aimColor, primaryColor)
if (item.elevation > 0) {
if (isEclipsed) {
drawEclipsedPosition(item, radius, eclipsedGlowAlpha, trackColor)
} else {
drawPosition(item, radius, animScale, trackColor)
}
drawPosition(item, radius, animScale, primaryColor)
}
sunPosition?.let { sun ->
if (sun.elevation > 0) drawBodyIcon(sun.azimuth, sun.elevation, radius, sunColor, sunPainter, 52f)
}
moonPosition?.let { moon ->
if (moon.elevation > 0) drawBodyIcon(
moon.azimuth,
moon.elevation,
radius,
radarColor,
moonPainter,
52f
)
}
if (shouldUseCompass) drawAim(azimElev.first, azimElev.second, radius, aimColor)
}
@@ -150,12 +158,7 @@ private fun DrawScope.drawTrack(path: Path, effect: PathEffect, color: Color, ef
private fun DrawScope.drawPosition(item: OrbitalPos, radius: Float, posRadius: Float, color: Color) {
val pos = sph2Cart(item.azimuth, item.elevation, radius.toDouble())
drawCircle(color, 16f, pos)
drawCircle(color.copy(alpha = 1 - (posRadius / 64f)), posRadius, pos)
}
private fun DrawScope.drawEclipsedPosition(item: OrbitalPos, radius: Float, glowAlpha: Float, color: Color) {
val pos = sph2Cart(item.azimuth, item.elevation, radius.toDouble())
drawCircle(color.copy(alpha = glowAlpha), 16f, pos)
if (!item.eclipsed) drawCircle(color.copy(alpha = 1 - (posRadius / 64f)), posRadius, pos)
}
private fun DrawScope.drawAim(azim: Float, elev: Float, radius: Float, color: Color) {
@@ -201,6 +204,27 @@ private fun createTrackEffect(trackPath: Path): PathEffect {
return PathEffect.stampedPathEffect(shape, trackLength / 2f, trackLength / 4f, StampedPathEffectStyle.Rotate)
}
private fun DrawScope.drawBodyIcon(
azimDeg: Double,
elevDeg: Double,
radius: Float,
color: Color,
painter: Painter,
iconSize: Float
) {
val azimRad = azimDeg.toRadians()
val elevRad = elevDeg.toRadians()
val pos = sph2Cart(azimRad, elevRad, radius.toDouble())
val half = iconSize / 2f
withTransform({
translate(pos.x - half, pos.y - half)
}) {
with(painter) {
draw(Size(iconSize, iconSize), colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(color))
}
}
}
private fun sph2Cart(azim: Double, elev: Double, r: Double): Offset {
val radius = r * (PI_2 - elev) / PI_2
return Offset(
@@ -17,17 +17,15 @@
*/
package com.rtbishop.look4sat.feature.radar
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.IReporter
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
@@ -45,7 +43,6 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
class RadarViewModel(
private val savedStateHandle: SavedStateHandle,
private val bluetoothReporter: IReporter,
private val networkReporter: IReporter,
private val satelliteRepo: ISatelliteRepo,
@@ -85,28 +82,36 @@ class RadarViewModel(
}
// Resolve which pass we're tracking and start the tick loop
viewModelScope.launch {
val catNum = savedStateHandle.get<Int>("catNum") ?: 0
val aosTime = savedStateHandle.get<Long>("aosTime") ?: 0L
val passes = satelliteRepo.passes.value
val currentPass = passes.find { it.catNum == catNum && it.aosTime == aosTime }
?: passes.firstOrNull()
currentPass?.let { satPass ->
_uiState.update { it.copy(currentPass = satPass) }
val transmitters = satelliteRepo.getRadiosWithId(satPass.catNum)
val (catNum, aosTime) = satelliteRepo.selectedPass.value
val satPass = passes.find { it.catNum == catNum && it.aosTime == aosTime } ?: passes.firstOrNull()
satPass?.let { pass ->
_uiState.update { it.copy(currentPass = pass) }
val transmitters = satelliteRepo.getRadiosWithId(pass.catNum)
// Compute track once (it doesn't change for a given pass)
if (!satPass.isDeepSpace) {
val track = satelliteRepo.getTrack(
satPass.orbitalObject, stationPos, satPass.aosTime, satPass.losTime
)
if (!pass.isDeepSpace) {
val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime)
_uiState.update { it.copy(satTrack = track) }
}
// Tick loop — position, timer, and radio updates every second
while (isActive) {
val timeNow = System.currentTimeMillis()
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow)
val (time, isAos) = computeTimer(satPass.isDeepSpace, satPass.aosTime, satPass.losTime, timeNow)
_uiState.update { it.copy(currentTime = time, isCurrentTimeAos = isAos, orbitalPos = pos) }
processRadios(transmitters, satPass.orbitalObject, timeNow)
val pos = satelliteRepo.getPosition(pass.orbitalObject, stationPos, timeNow)
val sunPos = CelestialComputer.getSunPosition(stationPos, timeNow)
val moonPos = CelestialComputer.getMoonPosition(stationPos, timeNow)
val (time, isAos) = computeTimer(pass.isDeepSpace, pass.aosTime, pass.losTime, timeNow)
val isLos = !pass.isDeepSpace && timeNow > pass.losTime
_uiState.update {
it.copy(
currentTime = time,
isTimeAos = isAos,
isLos = isLos,
orbitalPos = pos,
sunPosition = sunPos,
moonPosition = moonPos
)
}
processRadios(transmitters, pass.orbitalObject, timeNow)
sendPassData(pos)
delay(1000)
}
@@ -204,18 +209,15 @@ class RadarViewModel(
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY
fun factory(container: IMainContainer) = viewModelFactory {
initializer {
val container = (this[applicationKey] as IContainerProvider).getMainContainer()
RadarViewModel(
createSavedStateHandle(),
container.provideBluetoothReporter(),
container.provideNetworkReporter(),
container.satelliteRepo,
container.settingsRepo,
container.provideSensorsRepo(),
container.provideAddToCalendar()
bluetoothReporter = container.provideBluetoothReporter(),
networkReporter = container.provideNetworkReporter(),
satelliteRepo = container.satelliteRepo,
settingsRepo = container.settingsRepo,
sensorsRepo = container.provideSensorsRepo(),
addToCalendar = container.provideAddToCalendar()
)
}
}
@@ -45,21 +45,19 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.keepScreenOn
import androidx.compose.ui.platform.LocalContext
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.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.NextPassRow
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.Screen
import com.rtbishop.look4sat.core.presentation.TimerRow
import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.getDefaultPass
@@ -67,17 +65,13 @@ import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import com.rtbishop.look4sat.core.presentation.layoutPadding
import java.util.Locale
fun NavGraphBuilder.radioControlDestination(navigateUp: () -> Unit) {
val route = "${Screen.RadioControl.route}?catNum={catNum}&aosTime={aosTime}"
val args = listOf(
navArgument("catNum") { defaultValue = 0 },
navArgument("aosTime") { defaultValue = 0L }
)
composable(route, args) {
val viewModel = viewModel(RadioControlViewModel::class.java, factory = RadioControlViewModel.Factory)
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
RadioControlScreen(uiState, viewModel::onAction, navigateUp)
}
@Composable
fun RadioControlDestination(navigateUp: () -> Unit) {
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: RadioControlViewModel = viewModel(factory = RadioControlViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
RadioControlScreen(uiState, viewModel::onAction, navigateUp)
}
@Composable
@@ -17,16 +17,13 @@
*/
package com.rtbishop.look4sat.feature.radiocontrol
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
@@ -41,15 +38,14 @@ import kotlinx.coroutines.launch
import java.util.Locale
class RadioControlViewModel(
savedStateHandle: SavedStateHandle,
private val trackingService: IRadioTrackingService,
private val satelliteRepo: ISatelliteRepo,
private val settingsRepo: ISettingsRepo
settingsRepo: ISettingsRepo
) : ViewModel() {
private val stationPos = settingsRepo.stationPosition.value
private var currentPass: OrbitalPass? = null
private var transponders: List<SatRadio> = emptyList()
private val stationPos = settingsRepo.stationPosition.value
private val _uiState = MutableStateFlow(
RadioControlState(
@@ -72,33 +68,26 @@ class RadioControlViewModel(
val uiState: StateFlow<RadioControlState> = _uiState
init {
val catNum = savedStateHandle.get<Int>("catNum") ?: 0
val aosTime = savedStateHandle.get<Long>("aosTime") ?: 0L
// Resolve pass and load transponders
viewModelScope.launch {
val passes = satelliteRepo.passes.value
val pass = passes.find { it.catNum == catNum && it.aosTime == aosTime }
?: passes.firstOrNull()
currentPass = pass
pass?.let { satPass ->
val allRadios = satelliteRepo.getRadiosWithId(satPass.catNum)
val (catNum, aosTime) = satelliteRepo.selectedPass.value
val satPass = passes.find { it.catNum == catNum && it.aosTime == aosTime } ?: passes.firstOrNull()
currentPass = satPass
satPass?.let { pass ->
val allRadios = satelliteRepo.getRadiosWithId(pass.catNum)
transponders = allRadios.filter { it.downlinkLow != null }
_uiState.update {
it.copy(currentPass = satPass, transponders = transponders)
}
_uiState.update { it.copy(currentPass = pass, transponders = transponders) }
// If service is already tracking this pass, sync the selected transponder
val svcState = trackingService.state.value
if (svcState.isActive && svcState.currentPass?.catNum == satPass.catNum) {
_uiState.update {
it.copy(selectedTransponderUuid = svcState.selectedTransponder?.uuid)
}
if (svcState.isActive && svcState.currentPass?.catNum == pass.catNum) {
_uiState.update { it.copy(selectedTransponderUuid = svcState.selectedTransponder?.uuid) }
}
// Tick loop — timer and satellite position updates every second
while (isActive) {
val timeNow = System.currentTimeMillis()
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow)
val (timeStr, isAos) = computeTimer(satPass.isDeepSpace, satPass.aosTime, satPass.losTime, timeNow)
val pos = satelliteRepo.getPosition(pass.orbitalObject, stationPos, timeNow)
val (timeStr, isAos) = computeTimer(pass.isDeepSpace, pass.aosTime, pass.losTime, timeNow)
_uiState.update { state ->
state.copy(
currentTime = timeStr,
@@ -177,10 +166,12 @@ class RadioControlViewModel(
companion object {
val CTCSS_TONES = listOf(67.0, 69.3, 71.9, 74.4, 77.0, 79.7, 82.5, 85.4, 88.5, 91.5,
val CTCSS_TONES = listOf(
67.0, 69.3, 71.9, 74.4, 77.0, 79.7, 82.5, 85.4, 88.5, 91.5,
94.8, 97.4, 100.0, 103.5, 107.2, 110.9, 114.8, 118.8, 123.0, 127.3, 131.8, 136.5,
141.3, 146.2, 151.4, 156.7, 162.2, 167.9, 173.8, 179.9, 186.2, 192.8, 203.5, 210.7,
218.1, 225.7, 233.6, 241.8, 250.3)
218.1, 225.7, 233.6, 241.8, 250.3
)
fun formatFrequency(frequencyHz: Long): String {
if (frequencyHz <= 0) return "---"
@@ -190,15 +181,12 @@ class RadioControlViewModel(
return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz)
}
val Factory: ViewModelProvider.Factory = viewModelFactory {
val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY
fun factory(container: IMainContainer) = viewModelFactory {
initializer {
val container = (this[applicationKey] as IContainerProvider).getMainContainer()
RadioControlViewModel(
createSavedStateHandle(),
container.radioTrackingService,
container.satelliteRepo,
container.settingsRepo
trackingService = container.radioTrackingService,
satelliteRepo = container.satelliteRepo,
settingsRepo = container.settingsRepo
)
}
}
@@ -44,6 +44,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
@@ -56,9 +57,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.rtbishop.look4sat.core.domain.model.SatItem
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.CardLoadingIndicator
import com.rtbishop.look4sat.core.presentation.EmptyListCard
import com.rtbishop.look4sat.core.presentation.IconCard
@@ -66,20 +66,18 @@ import com.rtbishop.look4sat.core.presentation.InfoDialog
import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.Screen
import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import com.rtbishop.look4sat.core.presentation.layoutPadding
fun NavGraphBuilder.satellitesDestination(navigateUp: () -> Unit) {
composable(Screen.Satellites.route) {
val viewModel = viewModel(
modelClass = SatellitesViewModel::class.java, factory = SatellitesViewModel.Factory
)
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
SatellitesScreen(uiState, viewModel::onAction, navigateUp)
}
@Composable
fun SatellitesDestination(navigateUp: () -> Unit) {
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: SatellitesViewModel = viewModel(factory = SatellitesViewModel.factory(container))
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
SatellitesScreen(uiState, viewModel::onAction, navigateUp)
}
@Composable
@@ -18,11 +18,10 @@
package com.rtbishop.look4sat.feature.satellites
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import kotlinx.coroutines.flow.MutableStateFlow
@@ -97,11 +96,12 @@ class SatellitesViewModel(
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY
fun factory(container: IMainContainer) = viewModelFactory {
initializer {
val container = (this[applicationKey] as IContainerProvider).getMainContainer()
SatellitesViewModel(container.selectionRepo, container.settingsRepo)
SatellitesViewModel(
selectionRepo = container.selectionRepo,
settingsRepo = container.settingsRepo
)
}
}
}
@@ -51,6 +51,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -60,16 +61,14 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.Screen
import com.rtbishop.look4sat.core.presentation.ScreenColumn
import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
@@ -78,15 +77,13 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
fun NavGraphBuilder.settingsDestination() {
composable(Screen.Settings.route) {
val viewModel = viewModel(
modelClass = SettingsViewModel::class.java,
factory = SettingsViewModel.Factory
)
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SettingsScreen(uiState, viewModel::onAction)
}
@Composable
fun SettingsDestination() {
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SettingsScreen(uiState, viewModel::onAction)
}
@Composable
@@ -188,32 +185,63 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
val gitHubUrl = stringResource(R.string.prefs_github_url)
val licenseUrl = stringResource(R.string.prefs_license_url)
val privacyUrl = stringResource(R.string.prefs_privacy_url)
val safeOpenUri: (String) -> Unit = { url ->
try { uriHandler.openUri(url) } catch (_: Exception) {}
}
ScreenColumn(
topBar = { isVerticalLayout ->
if (isVerticalLayout) {
TopBar {
TopCard(onClick = { uriHandler.openUri(appUrl) }, version = uiState.appVersionName, modifier = Modifier.weight(1f))
PrimaryIconCard(onClick = { uriHandler.openUri(donateUrl) }, resId = R.drawable.ic_pound)
TopCard(
onClick = { safeOpenUri(appUrl) },
version = uiState.appVersionName,
modifier = Modifier.weight(1f)
)
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
}
TopBar {
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
BotCard(onClick = { uriHandler.openUri(fdroidUrl) }, R.drawable.ic_fdroid, fdroidTitle, modifier = Modifier.weight(1f))
BotCard(onClick = { uriHandler.openUri(gitHubUrl) }, R.drawable.ic_github, gitHubTitle, modifier = Modifier.weight(1f))
BotCard(
onClick = { safeOpenUri(fdroidUrl) },
resId = R.drawable.ic_fdroid,
text = fdroidTitle,
modifier = Modifier.weight(1f)
)
BotCard(
onClick = { safeOpenUri(gitHubUrl) },
resId = R.drawable.ic_github,
text = gitHubTitle,
modifier = Modifier.weight(1f)
)
}
IconCard(action = { uriHandler.openUri(licenseUrl) }, resId = R.drawable.ic_license)
IconCard(action = { uriHandler.openUri(privacyUrl) }, resId = R.drawable.ic_policy)
IconCard(action = { safeOpenUri(licenseUrl) }, resId = R.drawable.ic_license)
IconCard(action = { safeOpenUri(privacyUrl) }, resId = R.drawable.ic_policy)
}
} else {
TopBar {
PrimaryIconCard(onClick = { uriHandler.openUri(donateUrl) }, resId = R.drawable.ic_pound)
TopCard(onClick = { uriHandler.openUri(appUrl) }, version = uiState.appVersionName, modifier = Modifier.weight(1f))
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
TopCard(
onClick = { safeOpenUri(appUrl) },
version = uiState.appVersionName,
modifier = Modifier.weight(1f)
)
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
BotCard(onClick = { uriHandler.openUri(fdroidUrl) }, R.drawable.ic_fdroid, fdroidTitle, modifier = Modifier.weight(1f))
BotCard(onClick = { uriHandler.openUri(gitHubUrl) }, R.drawable.ic_github, gitHubTitle, modifier = Modifier.weight(1f))
BotCard(
onClick = { safeOpenUri(fdroidUrl) },
resId = R.drawable.ic_fdroid,
text = fdroidTitle,
modifier = Modifier.weight(1f)
)
BotCard(
onClick = { safeOpenUri(gitHubUrl) },
resId = R.drawable.ic_github,
text = gitHubTitle,
modifier = Modifier.weight(1f)
)
}
IconCard(action = { uriHandler.openUri(licenseUrl) }, resId = R.drawable.ic_license)
IconCard(action = { uriHandler.openUri(privacyUrl) }, resId = R.drawable.ic_policy)
IconCard(action = { safeOpenUri(licenseUrl) }, resId = R.drawable.ic_license)
IconCard(action = { safeOpenUri(privacyUrl) }, resId = R.drawable.ic_policy)
}
}
}
@@ -426,6 +454,7 @@ private fun OtherCardPreview() = MainTheme {
stateOfSweep = true,
stateOfUtc = false,
stateOfLightTheme = false,
stateOfNightMode = false,
shouldSeeWarning = false,
shouldSeeWhatsNew = false
)
@@ -437,7 +466,7 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.height(220.dp)
.height(268.dp)
) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text(
@@ -456,6 +485,9 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
SwitchRow(R.string.prefs_other_switch_sensors, settings.stateOfSensors) {
onAction(SettingsAction.ToggleSensor(it))
}
SwitchRow(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
onAction(SettingsAction.ToggleNightMode(it))
}
}
}
}
@@ -506,7 +538,7 @@ private fun CardCredits(modifier: Modifier = Modifier) {
ElevatedCard(
modifier = modifier
.fillMaxWidth()
.height(220.dp)
.height(268.dp)
) {
Column(
verticalArrangement = Arrangement.SpaceBetween,
@@ -60,6 +60,7 @@ sealed interface SettingsAction {
data class ToggleSweep(val value: Boolean) : SettingsAction
data class ToggleSensor(val value: Boolean) : SettingsAction
data class ToggleLightTheme(val value: Boolean) : SettingsAction
data class ToggleNightMode(val value: Boolean) : SettingsAction
// Remote control
data class UpdateRC(val settings: RCSettings) : SettingsAction
@@ -18,12 +18,11 @@
package com.rtbishop.look4sat.feature.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import com.rtbishop.look4sat.core.presentation.R
@@ -121,6 +120,7 @@ class SettingsViewModel(
is SettingsAction.ToggleSweep -> settingsRepo.updateOtherSettings { it.copy(stateOfSweep = action.value) }
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
// Remote control & data sources
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
@@ -187,14 +187,12 @@ class SettingsViewModel(
// endregion
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY
fun factory(container: IMainContainer) = viewModelFactory {
initializer {
val container = (this[applicationKey] as IContainerProvider).getMainContainer()
SettingsViewModel(
container.databaseRepo,
container.settingsRepo,
container.provideShowToast()
databaseRepo = container.databaseRepo,
settingsRepo = container.settingsRepo,
showToast = container.provideShowToast()
)
}
}
+25 -26
View File
@@ -1,9 +1,9 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "420"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.2.0"
appVersionCode = "432"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.3.2"
#noinspection GradleDependency,UnusedVersionCatalogEntry
compileSdk = "36"
#noinspection UnusedVersionCatalogEntry
minSdk = "24"
@@ -12,21 +12,23 @@ jdkVersion = "17"
#noinspection UnusedVersionCatalogEntry
packageName = "com.rtbishop.look4sat"
android-gradle-plugin = "9.1.0"
google-ksp = "2.3.6"
kotlin = "2.3.20"
android-gradle-plugin = "9.2.1"
androidx-core-ktx = "1.18.0"
androidx-core-splashscreen = "1.2.0"
androidx-room = "2.8.4"
compose-bom = "2026.03.01"
compose-bom = "2026.05.01"
compose-activity = "1.13.0"
compose-lifecycle = "2.10.0"
compose-navigation = "2.9.7"
compose-navigation3 = "1.1.2"
google-ksp = "2.3.7"
kotlin = "2.3.21"
kotlin-coroutines = "1.11.0"
kotlin-serialization = "1.11.0"
other-coroutines = "1.10.2"
other-json = "20251224"
other-okhttp = "5.3.2"
other-osmdroid = "6.1.20"
@@ -50,32 +52,32 @@ androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "
#noinspection UnusedVersionCatalogEntry
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
compose-animation = { group = "androidx.compose.animation", name = "animation" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
compose-material3-navigation = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite" }
#noinspection UnusedVersionCatalogEntry
compose-material3-adaptive = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite" }
compose-runtime = { group = "androidx.compose.runtime", name = "runtime" }
compose-tooling = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
compose-activity = { module = "androidx.activity:activity-compose", version.ref = "compose-activity" }
compose-lifecycle = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "compose-lifecycle" }
compose-navigation = { module = "androidx.navigation:navigation-compose", version.ref = "compose-navigation" }
compose-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "compose-lifecycle" }
#noinspection UnusedVersionCatalogEntry
compose-navigation3 = { module = "androidx.navigation3:navigation3-ui", version.ref = "compose-navigation3" }
compose-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "compose-lifecycle" }
compose-debug-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
compose-debug-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }
#noinspection UnusedVersionCatalogEntry
kotlin-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlin-coroutines" }
kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
#noinspection UnusedVersionCatalogEntry
kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlin-serialization" }
#noinspection UnusedVersionCatalogEntry
other-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "other-coroutines" }
#noinspection UnusedVersionCatalogEntry
other-json = { module = "org.json:json", version.ref = "other-json" }
#noinspection UnusedVersionCatalogEntry
other-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "other-okhttp" }
#noinspection UnusedVersionCatalogEntry
other-osmdroid = { module = "org.osmdroid:osmdroid-android", version.ref = "other-osmdroid" }
test-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "other-coroutines" }
test-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlin-coroutines" }
test-junit4 = { module = "junit:junit", version.ref = "test-junit4" }
androidTest-junit = { module = "androidx.test.ext:junit", version.ref = "androidTest-junit" }
@@ -87,6 +89,7 @@ android-library = { id = "com.android.library", version.ref = "android-gradle-pl
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
google-ksp = { id = "com.google.devtools.ksp", version.ref = "google-ksp" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
# plugins defined by this project
convention-applicationPlugin = { id = "com.rtbishop.look4sat.convention.applicationPlugin" }
convention-coreDataPlugin = { id = "com.rtbishop.look4sat.convention.coreDataPlugin" }
@@ -96,11 +99,7 @@ convention-featurePlugin = { id = "com.rtbishop.look4sat.convention.featurePlugi
[bundles]
#noinspection UnusedVersionCatalogEntry
composeAll = [
"compose-animation", "compose-runtime", "compose-tooling",
"compose-activity", "compose-lifecycle", "compose-material3",
"compose-material3-navigation", "compose-navigation", "compose-viewmodel"
]
composeAll = ["compose-runtime", "compose-tooling", "compose-activity", "compose-material3", "compose-viewmodel"]
#noinspection UnusedVersionCatalogEntry
composeDebug = ["compose-debug-manifest", "compose-debug-tooling"]
#noinspection UnusedVersionCatalogEntry
+2 -2
View File
@@ -1,7 +1,7 @@
#Sun Mar 22 10:09:53 GMT 2026
#Mon May 25 11:20:48 BST 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME