Compare commits

...
42 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
Arty Bishop 0c5d3699c0 v4.2.0 - CAT control, HamLib commands, performance tweaks 2026-04-11 16:48:24 +01:00
Arty Bishop a94f95874b Added several tweaks to Settings and RadioControl screens 2026-04-10 14:13:53 +01:00
Arty Bishop 4a3a01729b Added several tweaks to Radar and RadioControl screens 2026-04-10 12:21:03 +01:00
Arty Bishop aadc285d7f Added multiple tweaks to Satellites, Passes and Map screens 2026-04-10 11:50:32 +01:00
jcmerg 2df0b9ba3d Add CAT radio control for full-duplex satellite operation (#207) 2026-04-10 11:21:52 +01:00
Arty Bishop 868155c532 Simplified data output code, aligned with HamLib defaults 2026-04-06 12:48:06 +01:00
Arty Bishop 3f981c09d5 Tweaked output dialogs ui, fixed manual import category 2026-04-06 11:57:12 +01:00
Arty Bishop b79fec13f4 Removed the eclipsed badge, tweaked visual indication 2026-04-05 11:45:07 +01:00
Arty Bishop 6588291997 Fixed UTC pass time problem mentioned in issue #201 2026-04-05 11:27:27 +01:00
Arty Bishop be96602c76 Added normalized time for AOS/LOS, fixed passes refresh 2026-04-04 18:43:05 +01:00
Arty Bishop b59f9a6c17 Simplified SettingsRepo.kt and SettingsScreen.kt classes 2026-04-04 15:44:51 +01:00
Arty Bishop 9afe2ab69e Heavily optimized MapScreen.kt and position calculations 2026-04-03 16:45:02 +01:00
theojalba 2edc892dc2 Switch to short-form commands in NetworkReporter (#206) 2026-04-03 16:42:18 +01:00
Arty Bishop 45eb6915c2 Optimized common composables and satellite selection 2026-04-02 08:28:37 +01:00
Arty Bishop 706c912b13 Simplified BluetoothReporter and NetworkReporter 2026-03-30 22:02:49 +01:00
Arty Bishop e523eb5517 Added tweaks to DataParser.kt and DatabaseRepo.kt 2026-03-29 13:37:04 +01:00
Arty Bishop 0bef2ffddc Added tweaks to positions and passes calculation 2026-03-29 13:09:03 +01:00
Arty Bishop 279594c425 Simplified RadarScreen/View, added sensors smoothing 2026-03-28 20:04:03 +00:00
Arty Bishop 8c1df334d8 Added even more tweaks to convention plugins setup 2026-03-28 20:03:11 +00:00
100 changed files with 6742 additions and 3085 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' java-version: '17'
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@v5 uses: gradle/actions/setup-gradle@v6
- name: Assemble Artifacts - name: Assemble Artifacts
run: | run: ./gradlew assembleRelease bundleRelease
./gradlew assembleRelease
./gradlew bundleRelease
- name: Sign APK - name: Sign APK
uses: r0adkll/sign-android-release@v1 run: |
id: sign_apk echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
with: APK=$(find app/build/outputs/apk/release -name "*.apk" | head -1)
releaseDirectory: app/build/outputs/apk/release BUILD_TOOLS=$(ls -d ${ANDROID_HOME}/build-tools/*/ | sort -V | tail -1)
signingKeyBase64: ${{ secrets.KEY_STORE }} ${BUILD_TOOLS}apksigner sign \
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} --ks keystore.jks \
alias: ${{ secrets.KEY_ALIAS }} --ks-pass pass:${{ secrets.KEY_STORE_PASSWORD }} \
keyPassword: ${{ secrets.KEY_PASSWORD }} --ks-key-alias ${{ secrets.KEY_ALIAS }} \
env: --key-pass pass:${{ secrets.KEY_PASSWORD }} \
BUILD_TOOLS_VERSION: "36.0.0" --out app/build/outputs/apk/release/look4sat.apk \
"$APK"
rm keystore.jks
- name: Sign Bundle - 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: | run: |
mv ${{steps.sign_apk.outputs.signedReleaseFile}} app/build/outputs/apk/release/look4sat.apk echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
mv ${{steps.sign_bundle.outputs.signedReleaseFile}} app/build/outputs/apk/release/look4sat.aab 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 - name: Setup Ruby
uses: r0adkll/upload-google-play@v1 uses: ruby/setup-ruby@v1
with: with:
serviceAccountJsonPlainText: ${{secrets.SERVICE_ACCOUNT_JSON}} ruby-version: '3.3'
packageName: com.rtbishop.look4sat
releaseFiles: app/build/outputs/apk/release/look4sat.aab - name: Deploy Bundle to Google Play
track: production run: |
whatsNewDirectory: fastlane/metadata/android/en-US/whatsnew 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 - name: Create Release
run: | run: |
+1 -1
View File
@@ -1,3 +1,3 @@
plugins { plugins {
alias(libs.plugins.convention.androidAppPlugin) alias(libs.plugins.convention.applicationPlugin)
} }
+3 -1
View File
@@ -14,9 +14,11 @@
<application <application
android:name=".MainApplication" android:name=".MainApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"> android:roundIcon="@mipmap/ic_launcher_round"
android:usesCleartextTraffic="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
@@ -19,12 +19,21 @@ package com.rtbishop.look4sat
import android.content.Context import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration
import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
import android.graphics.Paint
import android.os.Bundle import android.os.Bundle
import android.view.View
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen 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 com.rtbishop.look4sat.core.presentation.MainTheme
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -38,8 +47,37 @@ class MainActivity : ComponentActivity() {
installSplashScreen() installSplashScreen()
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
observeNightFilterState()
setContent { setContent {
MainTheme(isDarkTheme = true) { MainScreen() } 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)
}
}
} }
@@ -17,9 +17,26 @@
*/ */
package com.rtbishop.look4sat package com.rtbishop.look4sat
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
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -27,64 +44,165 @@ import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaul
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.navigation.NavHostController import androidx.compose.ui.text.font.FontWeight
import androidx.navigation.compose.NavHost import androidx.compose.ui.unit.dp
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.compose.ui.unit.sp
import androidx.navigation.compose.rememberNavController import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.Screen
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
import com.rtbishop.look4sat.feature.map.mapDestination import com.rtbishop.look4sat.feature.map.MapDestination
import com.rtbishop.look4sat.feature.passes.passesDestination import com.rtbishop.look4sat.feature.passes.PassesDestination
import com.rtbishop.look4sat.feature.radar.radarDestination import com.rtbishop.look4sat.feature.radar.RadarDestination
import com.rtbishop.look4sat.feature.satellites.satellitesDestination import com.rtbishop.look4sat.feature.radiocontrol.RadioControlDestination
import com.rtbishop.look4sat.feature.settings.settingsDestination import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
import com.rtbishop.look4sat.feature.settings.SettingsDestination
@Composable @Composable
fun MainScreen(navController: NavHostController = rememberNavController()) { fun MainScreen() {
val items = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings) val backStack = rememberNavBackStack(Screen.Passes)
val currentDestination = navController.currentBackStackEntryAsState().value?.destination?.route val currentKey = backStack.lastOrNull()
val startDestination = Screen.Passes.route 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)
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
NavigationSuiteScaffold( NavigationSuiteScaffold(
navigationSuiteItems = { 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( item(
icon = { Icon(painterResource(it.iconResId), stringResource(it.titleResId)) }, icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) },
label = { Text(stringResource(it.titleResId)) }, label = { Text(stringResource(screen.titleResId)) },
selected = currentDestination?.contains(it.route) ?: false, selected = isSelected,
onClick = { onClick = {
if (currentDestination?.contains(it.route) ?: false) return@item if (isSelected) return@item
navController.navigate(it.route) { while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
popUpTo(startDestination) { saveState = false } if (screen !is Screen.Passes) backStack.add(screen)
launchSingleTop = true }
restoreState = false )
}
})
} }
}, navigationSuiteColors = NavigationSuiteDefaults.colors( },
navigationSuiteColors = NavigationSuiteDefaults.colors(
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
), layoutType = when { ),
layoutType = when {
!hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail !hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail
!hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact !hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact
else -> NavigationSuiteType.ShortNavigationBarMedium else -> NavigationSuiteType.ShortNavigationBarMedium
} }
) { ) {
NavHost( Column {
navController = navController, NavDisplay(
startDestination = startDestination, backStack = backStack,
enterTransition = { fadeIn(animationSpec = tween(350)) }, modifier = Modifier.weight(1f),
exitTransition = { fadeOut(animationSpec = tween(350)) } onBack = navigateBack,
) { transitionSpec = { fadeTransition },
satellitesDestination { navController.navigateUp() } popTransitionSpec = { fadeTransition },
passesDestination { catNum: Int, aosTime: Long -> predictivePopTransitionSpec = { fadeTransition },
val radarRoute = "${Screen.Radar.route}?catNum=${catNum}&aosTime=${aosTime}" entryDecorators = listOf(
navController.navigate(radarRoute) // 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)
}
}
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
.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
)
}
} }
radarDestination { navController.navigateUp() }
mapDestination()
settingsDestination()
} }
} }
} }
+15 -15
View File
@@ -24,25 +24,25 @@ group = "com.rtbishop.look4sat.build_logic.convention"
gradlePlugin { gradlePlugin {
plugins { plugins {
register("androidAppPlugin") { register("applicationPlugin") {
id = libs.plugins.convention.androidAppPlugin.get().pluginId id = libs.plugins.convention.applicationPlugin.get().pluginId
implementationClass = "com.rtbishop.look4sat.convention.AndroidAppPlugin" implementationClass = "com.rtbishop.look4sat.convention.ApplicationPlugin"
} }
register("androidLibraryPlugin") { register("coreDataPlugin") {
id = libs.plugins.convention.androidLibraryPlugin.get().pluginId id = libs.plugins.convention.coreDataPlugin.get().pluginId
implementationClass = "com.rtbishop.look4sat.convention.AndroidLibraryPlugin" implementationClass = "com.rtbishop.look4sat.convention.CoreDataPlugin"
} }
register("composeFeaturePlugin") { register("coreDomainPlugin") {
id = libs.plugins.convention.composeFeaturePlugin.get().pluginId id = libs.plugins.convention.coreDomainPlugin.get().pluginId
implementationClass = "com.rtbishop.look4sat.convention.ComposeFeaturePlugin" implementationClass = "com.rtbishop.look4sat.convention.CoreDomainPlugin"
} }
register("composeLibraryPlugin") { register("corePresentationPlugin") {
id = libs.plugins.convention.composeLibraryPlugin.get().pluginId id = libs.plugins.convention.corePresentationPlugin.get().pluginId
implementationClass = "com.rtbishop.look4sat.convention.ComposeLibraryPlugin" implementationClass = "com.rtbishop.look4sat.convention.CorePresentationPlugin"
} }
register("kotlinLibraryPlugin") { register("featurePlugin") {
id = libs.plugins.convention.kotlinLibraryPlugin.get().pluginId id = libs.plugins.convention.featurePlugin.get().pluginId
implementationClass = "com.rtbishop.look4sat.convention.KotlinLibraryPlugin" implementationClass = "com.rtbishop.look4sat.convention.FeaturePlugin"
} }
} }
} }
@@ -22,25 +22,25 @@ import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.dependencies
@Suppress("Unused") @Suppress("Unused")
internal class AndroidAppPlugin : Plugin<Project> { internal class ApplicationPlugin : Plugin<Project> {
override fun apply(target: Project) { override fun apply(target: Project) = with(target) {
with(target) { setupAndroidApp()
setupAndroidApplication() setupCompose()
setupComposeFeature() setupKotlin()
setupKotlinToolchain() dependencies {
setupAndroidTestDependencies() implementation(project(":core:data"))
setupTestDependencies() implementation(project(":core:domain"))
dependencies { implementation(project(":core:presentation"))
IMPLEMENTATION(project(":core:data")) implementation(project(":feature:map"))
IMPLEMENTATION(project(":core:domain")) implementation(project(":feature:passes"))
IMPLEMENTATION(project(":core:presentation")) implementation(project(":feature:radar"))
IMPLEMENTATION(project(":feature:map")) implementation(project(":feature:radiocontrol"))
IMPLEMENTATION(project(":feature:passes")) implementation(project(":feature:satellites"))
IMPLEMENTATION(project(":feature:radar")) implementation(project(":feature:settings"))
IMPLEMENTATION(project(":feature:satellites")) implementation(libs.androidx.core.splashscreen)
IMPLEMENTATION(project(":feature:settings")) implementation(libs.compose.material3.adaptive)
IMPLEMENTATION(libs.androidx.core.splashscreen) implementation(libs.compose.navigation3)
} androidTestImplementation(libs.bundles.androidTest)
} }
} }
} }
@@ -22,25 +22,19 @@ import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.dependencies
@Suppress("Unused") @Suppress("Unused")
internal class AndroidLibraryPlugin : Plugin<Project> { internal class CoreDataPlugin : Plugin<Project> {
override fun apply(target: Project) { override fun apply(target: Project) = with(target) {
with(target) { applyPlugin(libs.plugins.google.ksp)
with(pluginManager) { setupAndroidLib()
alias(libs.plugins.google.ksp) setupKotlin()
} dependencies {
setupCommonLibrary() implementation(project(":core:domain"))
setupKotlinToolchain() implementation(libs.androidx.core.ktx)
setupAndroidTestDependencies() implementation(libs.androidx.room)
setupTestDependencies() implementation(libs.androidx.room.runtime)
dependencies { ksp(libs.androidx.room.compiler)
IMPLEMENTATION(project(":core:domain")) implementation(libs.kotlin.coroutines)
IMPLEMENTATION(libs.androidx.core.ktx) implementation(libs.other.okhttp)
IMPLEMENTATION(libs.androidx.room.asProvider())
IMPLEMENTATION(libs.androidx.room.runtime)
KSP(libs.androidx.room.compiler)
IMPLEMENTATION(libs.other.coroutines)
IMPLEMENTATION(libs.other.okhttp)
}
} }
} }
} }
@@ -22,18 +22,14 @@ import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.dependencies
@Suppress("Unused") @Suppress("Unused")
internal class KotlinLibraryPlugin : Plugin<Project> { internal class CoreDomainPlugin : Plugin<Project> {
override fun apply(target: Project) { override fun apply(target: Project) = with(target) {
with(target) { applyPlugin(libs.plugins.kotlin.jvm)
with(pluginManager) { applyPlugin(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.jvm) setupKotlin()
} dependencies {
setupKotlinToolchain() implementation(libs.kotlin.coroutines)
setupTestDependencies() implementation(libs.kotlin.serialization)
dependencies {
IMPLEMENTATION(libs.other.coroutines)
IMPLEMENTATION(libs.other.json)
}
} }
} }
} }
@@ -22,18 +22,17 @@ import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.dependencies
@Suppress("Unused") @Suppress("Unused")
internal class ComposeLibraryPlugin : Plugin<Project> { internal class CorePresentationPlugin : Plugin<Project> {
override fun apply(target: Project) { override fun apply(target: Project) = with(target) {
with(target) { applyPlugin(libs.plugins.kotlin.serialization)
setupCommonLibrary() setupAndroidLib()
setupComposeFeature() setupCompose()
setupKotlinToolchain() setupKotlin()
setupAndroidTestDependencies() dependencies {
setupTestDependencies() implementation(project(":core:domain"))
dependencies { implementation(libs.androidx.core.splashscreen)
IMPLEMENTATION(project(":core:domain")) implementation(libs.kotlin.serialization)
IMPLEMENTATION(libs.androidx.core.splashscreen) implementation(libs.compose.material3.adaptive)
}
} }
} }
} }
@@ -22,18 +22,14 @@ import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.dependencies
@Suppress("Unused") @Suppress("Unused")
internal class ComposeFeaturePlugin : Plugin<Project> { internal class FeaturePlugin : Plugin<Project> {
override fun apply(target: Project) { override fun apply(target: Project) = with(target) {
with(target) { setupAndroidLib()
setupCommonLibrary() setupCompose()
setupComposeFeature() setupKotlin()
setupKotlinToolchain() dependencies {
setupAndroidTestDependencies() implementation(project(":core:domain"))
setupTestDependencies() implementation(project(":core:presentation"))
dependencies {
IMPLEMENTATION(project(":core:domain"))
IMPLEMENTATION(project(":core:presentation"))
}
} }
} }
} }
@@ -21,7 +21,7 @@ import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.dsl.CommonExtension import com.android.build.api.dsl.CommonExtension
import org.gradle.accessors.dm.LibrariesForLibs import org.gradle.accessors.dm.LibrariesForLibs
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.api.plugins.PluginManager import org.gradle.api.artifacts.dsl.DependencyHandler
import org.gradle.api.provider.Provider import org.gradle.api.provider.Provider
import org.gradle.kotlin.dsl.accessors.runtime.extensionOf import org.gradle.kotlin.dsl.accessors.runtime.extensionOf
import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.configure
@@ -29,23 +29,30 @@ import org.gradle.kotlin.dsl.dependencies
import org.gradle.plugin.use.PluginDependency import org.gradle.plugin.use.PluginDependency
import org.jetbrains.kotlin.gradle.dsl.kotlinExtension import org.jetbrains.kotlin.gradle.dsl.kotlinExtension
internal const val ANDROID_TEST_IMPLEMENTATION = "androidTestImplementation"
internal const val DEBUG_IMPLEMENTATION = "debugImplementation"
internal const val IMPLEMENTATION = "implementation"
internal const val KSP = "ksp"
internal const val TEST_IMPLEMENTATION = "testImplementation"
internal val Project.libs internal val Project.libs
get(): LibrariesForLibs = extensionOf(this, "libs") as LibrariesForLibs get(): LibrariesForLibs = extensionOf(this, "libs") as LibrariesForLibs
internal fun PluginManager.alias(notation: Provider<PluginDependency>) { internal fun Project.applyPlugin(notation: Provider<PluginDependency>) {
apply(notation.get().pluginId) pluginManager.apply(notation.get().pluginId)
} }
internal fun Project.setupAndroidApplication() { internal fun DependencyHandler.implementation(dependencyNotation: Any) =
with(pluginManager) { add("implementation", dependencyNotation)
alias(libs.plugins.android.application)
} internal fun DependencyHandler.debugImplementation(dependencyNotation: Any) =
add("debugImplementation", dependencyNotation)
internal fun DependencyHandler.testImplementation(dependencyNotation: Any) =
add("testImplementation", dependencyNotation)
internal fun DependencyHandler.androidTestImplementation(dependencyNotation: Any) =
add("androidTestImplementation", dependencyNotation)
internal fun DependencyHandler.ksp(dependencyNotation: Any) =
add("ksp", dependencyNotation)
internal fun Project.setupAndroidApp() {
applyPlugin(libs.plugins.android.application)
extensions.configure<ApplicationExtension> { extensions.configure<ApplicationExtension> {
namespace = libs.versions.packageName.get() namespace = libs.versions.packageName.get()
compileSdk = libs.versions.compileSdk.get().toInt() compileSdk = libs.versions.compileSdk.get().toInt()
@@ -55,9 +62,6 @@ internal fun Project.setupAndroidApplication() {
versionCode = libs.versions.appVersionCode.get().toInt() versionCode = libs.versions.appVersionCode.get().toInt()
versionName = libs.versions.appVersionName.get() versionName = libs.versions.appVersionName.get()
} }
buildFeatures {
compose = true
}
buildTypes { buildTypes {
debug { debug {
applicationIdSuffix = ".debug" applicationIdSuffix = ".debug"
@@ -70,49 +74,38 @@ internal fun Project.setupAndroidApplication() {
} }
androidResources { androidResources {
generateLocaleConfig = true 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/*") } } packaging { resources { excludes += listOf("META-INF/*") } }
} }
} }
internal fun Project.setupCommonLibrary() { internal fun Project.setupAndroidLib() {
with(pluginManager) { applyPlugin(libs.plugins.android.library)
alias(libs.plugins.android.library)
}
extensions.configure<CommonExtension> { extensions.configure<CommonExtension> {
compileSdk = libs.versions.compileSdk.get().toInt() compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig.minSdk = libs.versions.minSdk.get().toInt() defaultConfig.minSdk = libs.versions.minSdk.get().toInt()
} }
dependencies {
androidTestImplementation(libs.bundles.androidTest)
}
} }
internal fun Project.setupComposeFeature() { internal fun Project.setupCompose() {
with(pluginManager) { applyPlugin(libs.plugins.compose.compiler)
alias(libs.plugins.compose.compiler)
}
extensions.configure<CommonExtension> { extensions.configure<CommonExtension> {
buildFeatures.compose = true buildFeatures.compose = true
} }
dependencies { dependencies {
IMPLEMENTATION(platform(libs.compose.bom)) implementation(platform(libs.compose.bom))
IMPLEMENTATION(libs.bundles.composeAll) implementation(libs.bundles.composeAll)
DEBUG_IMPLEMENTATION(libs.bundles.composeDebug) debugImplementation(libs.bundles.composeDebug)
} }
} }
internal fun Project.setupKotlinToolchain() { internal fun Project.setupKotlin() {
kotlinExtension.jvmToolchain(libs.versions.jdkVersion.get().toInt()) kotlinExtension.jvmToolchain(libs.versions.jdkVersion.get().toInt())
}
internal fun Project.setupAndroidTestDependencies() {
dependencies { dependencies {
ANDROID_TEST_IMPLEMENTATION(libs.bundles.androidTest) testImplementation(libs.bundles.unitTest)
}
}
internal fun Project.setupTestDependencies() {
dependencies {
TEST_IMPLEMENTATION(libs.test.coroutines)
TEST_IMPLEMENTATION(libs.test.junit4)
} }
} }
+1
View File
@@ -4,6 +4,7 @@ plugins {
alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.compose.compiler) apply false
alias(libs.plugins.google.ksp) apply false alias(libs.plugins.google.ksp) apply false
alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.serialization) apply false
} }
tasks.register("clean", Delete::class.java) { tasks.register("clean", Delete::class.java) {
+1 -1
View File
@@ -1,5 +1,5 @@
plugins { plugins {
alias(libs.plugins.convention.androidLibraryPlugin) alias(libs.plugins.convention.coreDataPlugin)
} }
android { android {
@@ -25,11 +25,11 @@ data class SatRadio(
@PrimaryKey val uuid: String, @PrimaryKey val uuid: String,
val info: String, val info: String,
val isAlive: Boolean, val isAlive: Boolean,
var downlinkLow: Long?, val downlinkLow: Long?,
var downlinkHigh: Long?, val downlinkHigh: Long?,
val downlinkMode: String?, val downlinkMode: String?,
var uplinkLow: Long?, val uplinkLow: Long?,
var uplinkHigh: Long?, val uplinkHigh: Long?,
val uplinkMode: String?, val uplinkMode: String?,
val isInverted: Boolean, val isInverted: Boolean,
val catnum: Int? val catnum: Int?
@@ -20,121 +20,112 @@ package com.rtbishop.look4sat.core.data.framework
import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothSocket import android.bluetooth.BluetoothSocket
import android.util.Log import android.util.Log
import com.rtbishop.look4sat.core.domain.repository.BtService import com.rtbishop.look4sat.core.domain.repository.IReporter
import com.rtbishop.look4sat.core.domain.repository.IReporterRepo
import com.rtbishop.look4sat.core.domain.repository.WithoutExtParams
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import java.io.OutputStream import java.io.OutputStream
import java.util.UUID import java.util.UUID
import kotlin.math.abs
data class DeviceConnection(
var socket: BluetoothSocket? = null,
var outputStream: OutputStream? = null,
var connected: Boolean = false,
var connecting: Boolean = false,
var connectionJob: Job? = null
)
class BluetoothReporter( class BluetoothReporter(
private val bluetoothManager: BluetoothManager, private val bluetoothManager: BluetoothManager,
private val reporterScope: CoroutineScope private val reporterScope: CoroutineScope,
) : IReporterRepo<WithoutExtParams> { private val rotatorDeviceId: String,
private val frequencyDeviceId: String
) : IReporter {
private val tag = "BTReporter" private val tag = "BTReporter"
private val sppid: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb") private val sppId: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
private val serviceToDevice = mutableMapOf<BtService, String>()
private val deviceConnections = mutableMapOf<String, DeviceConnection>()
private val writeMutex = Mutex() private val writeMutex = Mutex()
override fun isConnected(service: BtService): Boolean { private var rotatorSocket: BluetoothSocket? = null
val deviceId = serviceToDevice[service] ?: return false private var rotatorStream: OutputStream? = null
return deviceConnections[deviceId]?.connected == true private var rotatorConnected = false
} private var rotatorConnecting = false
override fun isConnecting(service: BtService): Boolean { private var frequencySocket: BluetoothSocket? = null
val deviceId = serviceToDevice[service] ?: return false private var frequencyStream: OutputStream? = null
return deviceConnections[deviceId]?.connecting == true private var frequencyConnected = false
} private var frequencyConnecting = false
override fun connect(service: BtService, deviceId: String) { override fun reportRotation(format: String, azimuth: Double, elevation: Double) {
serviceToDevice[service] = deviceId reporterScope.launch {
val connection = deviceConnections.getOrPut(deviceId) { ensureRotatorConnected()
DeviceConnection() if (!rotatorConnected) return@launch
val el = if (elevation > 0.0) elevation else 0.0
val command = format
.replace($$"$AZ", azimuth.toString())
.replace($$"$EL", el.toString())
.unescapeControlChars()
write(rotatorStream, command) { rotatorConnected = false }
} }
if (connection.connected || connection.connecting) return }
connection.connectionJob = reporterScope.launch {
override fun reportFrequency(format: String, frequency: Long) {
reporterScope.launch {
ensureFrequencyConnected()
if (!frequencyConnected) return@launch
val command = format
.replace($$"$FREQ", frequency.toString())
.unescapeControlChars()
write(frequencyStream, command) { frequencyConnected = false }
}
}
private fun ensureRotatorConnected() {
if (rotatorConnected || rotatorConnecting || rotatorDeviceId.isBlank()) return
reporterScope.launch {
try { try {
connection.connecting = true rotatorConnecting = true
val device = bluetoothManager.adapter.getRemoteDevice(deviceId) val device = bluetoothManager.adapter.getRemoteDevice(rotatorDeviceId)
val socket = device.createInsecureRfcommSocketToServiceRecord(sppid) val socket = device.createInsecureRfcommSocketToServiceRecord(sppId)
socket.connect() socket.connect()
connection.socket = socket rotatorSocket = socket
connection.outputStream = socket.outputStream rotatorStream = socket.outputStream
connection.connected = true rotatorConnected = true
Log.i(tag, "$tag: Connected to $deviceId") Log.i(tag, "Rotator connected to $rotatorDeviceId")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(tag, "$tag: ${e.message}") Log.e(tag, "Rotator connect error: ${e.message}")
connection.connected = false rotatorConnected = false
} finally { } finally {
connection.connecting = false rotatorConnecting = false
} }
} }
} }
private suspend fun write(service: BtService, buffer: String) { private fun ensureFrequencyConnected() {
val deviceId = serviceToDevice[service] ?: return if (frequencyConnected || frequencyConnecting || frequencyDeviceId.isBlank()) return
val connection = deviceConnections[deviceId] ?: return reporterScope.launch {
if (!connection.connected) return try {
frequencyConnecting = true
val device = bluetoothManager.adapter.getRemoteDevice(frequencyDeviceId)
val socket = device.createInsecureRfcommSocketToServiceRecord(sppId)
socket.connect()
frequencySocket = socket
frequencyStream = socket.outputStream
frequencyConnected = true
Log.i(tag, "Frequency connected to $frequencyDeviceId")
} catch (e: Exception) {
Log.e(tag, "Frequency connect error: ${e.message}")
frequencyConnected = false
} finally {
frequencyConnecting = false
}
}
}
private suspend fun write(stream: OutputStream?, data: String, onError: () -> Unit) {
try { try {
writeMutex.withLock { writeMutex.withLock {
connection.outputStream?.write(buffer.toByteArray()) stream?.write(data.toByteArray())
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(tag, "$tag: Write failed ${e.message}") Log.e(tag, "Write error: ${e.message}")
connection.connected = false onError()
} }
} }
override fun reportRotation(format: String, azimuth: Double, elevation: Double, params: WithoutExtParams) { private fun String.unescapeControlChars(): String =
reporterScope.launch { replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
if (!isConnected(BtService.ROTATOR)) return@launch
val newElevation = if (elevation > 0.0) elevation else 0.0
val azimuthString = intToStringWithLeadingZeroes(azimuth.toInt())
val elevationString = intToStringWithLeadingZeroes(newElevation.toInt())
val buffer = format
.replace($$"$AZ", azimuthString)
.replace($$"$EL", elevationString)
.replace("\\r", "\r")
.replace("\\n", "\n")
.replace("\\t", "\t")
write(BtService.ROTATOR, buffer)
}
}
override fun reportFrequency(format: String, frequency: Long, params: WithoutExtParams) {
reporterScope.launch {
if (!isConnected(BtService.FREQUENCY)) return@launch
val buffer = format
.replace($$"$FREQ", frequency.toString())
.replace("\\r", "\r")
.replace("\\n", "\n")
.replace("\\t", "\t")
write(BtService.FREQUENCY, buffer)
}
}
private fun intToStringWithLeadingZeroes(value: Int): String {
return if (value > 0) {
if (value < 10) "00$value" else if (value < 100) "0$value" else "$value"
} else {
val absValue = abs(value)
if (value > -10) "-00$absValue" else if (value > -100) "-0$absValue" else "-$absValue"
}
}
} }
@@ -0,0 +1,136 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.framework
object Ft817CatProtocol {
const val CMD_SET_FREQ: Byte = 0x01
const val CMD_READ_FREQ_MODE: Byte = 0x03
const val CMD_SET_MODE: Byte = 0x07
const val CMD_PTT_ON: Byte = 0x08
const val CMD_PTT_OFF: Byte = 0x88.toByte()
const val CMD_CTCSS_MODE: Byte = 0x0A
const val CMD_CTCSS_TONE: Byte = 0x0B
const val CTCSS_ENC_ON: Byte = 0x2A
const val CTCSS_OFF: Byte = 0x8A.toByte()
val MODE_TO_BYTE: Map<String, Byte> = mapOf(
"LSB" to 0x00,
"USB" to 0x01,
"CW" to 0x02,
"CW-R" to 0x03,
"AM" to 0x04,
"FM" to 0x08,
"DIG" to 0x0A,
"PKT" to 0x0C
)
val BYTE_TO_MODE: Map<Byte, String> = MODE_TO_BYTE.entries.associate { it.value to it.key }
/**
* Encode frequency in Hz to 4-byte BCD with 10 Hz resolution.
* Example: 145500000 Hz → [0x14, 0x55, 0x00, 0x00]
*/
fun encodeFrequencyBcd(frequencyHz: Long): ByteArray {
val freq10Hz = frequencyHz / 10
val bcd = ByteArray(4)
val digits = String.format("%08d", freq10Hz)
for (i in 0 until 4) {
val high = digits[i * 2] - '0'
val low = digits[i * 2 + 1] - '0'
bcd[i] = ((high shl 4) or low).toByte()
}
return bcd
}
/**
* Decode 4-byte BCD frequency to Hz.
*/
fun decodeFrequencyBcd(bcd: ByteArray): Long {
var freq10Hz = 0L
for (i in 0 until 4) {
val b = bcd[i].toInt() and 0xFF
val high = b shr 4
val low = b and 0x0F
freq10Hz = freq10Hz * 100 + high * 10 + low
}
return freq10Hz * 10
}
/**
* Encode CTCSS tone frequency (in Hz, e.g. 67.0) to 2-byte BCD.
* 67.0 Hz → 670 (in 0.1 Hz) → BCD [0x06, 0x70]
*/
fun encodeCtcssToneBcd(toneHz: Double): ByteArray {
val tone01Hz = (toneHz * 10).toLong()
val digits = String.format("%04d", tone01Hz)
val bcd = ByteArray(2)
for (i in 0 until 2) {
val high = digits[i * 2] - '0'
val low = digits[i * 2 + 1] - '0'
bcd[i] = ((high shl 4) or low).toByte()
}
return bcd
}
fun buildSetFreqCommand(frequencyHz: Long): ByteArray {
val bcd = encodeFrequencyBcd(frequencyHz)
return byteArrayOf(bcd[0], bcd[1], bcd[2], bcd[3], CMD_SET_FREQ)
}
fun buildSetModeCommand(mode: String): ByteArray? {
val modeByte = MODE_TO_BYTE[mode.uppercase()] ?: return null
return byteArrayOf(modeByte, 0x00, 0x00, 0x00, CMD_SET_MODE)
}
fun buildReadFreqModeCommand(): ByteArray {
return byteArrayOf(0x00, 0x00, 0x00, 0x00, CMD_READ_FREQ_MODE)
}
fun buildPttOnCommand(): ByteArray {
return byteArrayOf(0x00, 0x00, 0x00, 0x00, CMD_PTT_ON)
}
fun buildPttOffCommand(): ByteArray {
return byteArrayOf(0x00, 0x00, 0x00, 0x00, CMD_PTT_OFF)
}
fun buildCtcssModeCommand(enabled: Boolean): ByteArray {
val sub = if (enabled) CTCSS_ENC_ON else CTCSS_OFF
return byteArrayOf(sub, 0x00, 0x00, 0x00, CMD_CTCSS_MODE)
}
fun buildSetCtcssToneCommand(toneHz: Double): ByteArray {
val bcd = encodeCtcssToneBcd(toneHz)
return byteArrayOf(bcd[0], bcd[1], 0x00, 0x00, CMD_CTCSS_TONE)
}
/**
* Parse the 5-byte response from a READ FREQ+MODE command.
* Returns (frequencyHz, modeString) or null if parsing fails.
*/
fun parseReadResponse(response: ByteArray): Pair<Long, String>? {
if (response.size < 5) return null
val freqBcd = response.copyOfRange(0, 4)
val frequencyHz = decodeFrequencyBcd(freqBcd)
val modeByte = response[4]
val mode = BYTE_TO_MODE[modeByte] ?: return null
return Pair(frequencyHz, mode)
}
}
@@ -0,0 +1,170 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.framework
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothSocket
import android.util.Log
import com.rtbishop.look4sat.core.domain.repository.IRadioController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.InputStream
import java.io.OutputStream
import java.util.UUID
class Ft817Controller(
private val bluetoothManager: BluetoothManager,
private val deviceAddress: String
) : IRadioController {
private val tag = "FT817"
private val sppId: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
private val ioMutex = Mutex()
private val commandDelayMs = 200L
private var socket: BluetoothSocket? = null
private var outputStream: OutputStream? = null
private var inputStream: InputStream? = null
override var isConnected: Boolean = false
private set
override suspend fun connect(): Boolean = withContext(Dispatchers.IO) {
if (isConnected) return@withContext true
if (deviceAddress.isBlank()) return@withContext false
try {
val device = bluetoothManager.adapter.getRemoteDevice(deviceAddress)
val btSocket = device.createInsecureRfcommSocketToServiceRecord(sppId)
btSocket.connect()
socket = btSocket
outputStream = btSocket.outputStream
inputStream = btSocket.inputStream
isConnected = true
Log.i(tag, "Connected to $deviceAddress")
true
} catch (e: Exception) {
Log.e(tag, "Connect error: ${e.message}")
isConnected = false
false
}
}
override suspend fun disconnect() {
withContext(Dispatchers.IO) {
try {
inputStream?.close()
outputStream?.close()
socket?.close()
} catch (e: Exception) {
Log.e(tag, "Disconnect error: ${e.message}")
} finally {
inputStream = null
outputStream = null
socket = null
isConnected = false
Log.i(tag, "Disconnected from $deviceAddress")
}
}
}
override suspend fun setFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
ioMutex.withLock {
sendCommandWithAck(Ft817CatProtocol.buildSetFreqCommand(frequencyHz))
}
}
override suspend fun setMode(mode: String): Boolean = withContext(Dispatchers.IO) {
val cmd = Ft817CatProtocol.buildSetModeCommand(mode) ?: return@withContext false
ioMutex.withLock { sendCommandWithAck(cmd) }
}
override suspend fun setCtcssMode(enabled: Boolean): Boolean = withContext(Dispatchers.IO) {
ioMutex.withLock {
sendCommandWithAck(Ft817CatProtocol.buildCtcssModeCommand(enabled))
}
}
override suspend fun setCtcssTone(toneHz: Double): Boolean = withContext(Dispatchers.IO) {
ioMutex.withLock {
sendCommandWithAck(Ft817CatProtocol.buildSetCtcssToneCommand(toneHz))
}
}
override suspend fun readFrequencyAndMode(): Pair<Long, String>? = withContext(Dispatchers.IO) {
ioMutex.withLock {
val sent = sendCommand(Ft817CatProtocol.buildReadFreqModeCommand())
if (!sent) return@withContext null
delay(commandDelayMs)
val response = readResponse(5) ?: return@withContext null
Ft817CatProtocol.parseReadResponse(response)
}
}
override suspend fun pttOn(): Boolean = withContext(Dispatchers.IO) {
ioMutex.withLock { sendCommandWithAck(Ft817CatProtocol.buildPttOnCommand()) }
}
override suspend fun pttOff(): Boolean = withContext(Dispatchers.IO) {
ioMutex.withLock { sendCommandWithAck(Ft817CatProtocol.buildPttOffCommand()) }
}
private suspend fun sendCommand(bytes: ByteArray): Boolean {
return try {
outputStream?.write(bytes) ?: return false
outputStream?.flush()
delay(commandDelayMs)
true
} catch (e: Exception) {
Log.e(tag, "Send error: ${e.message}")
isConnected = false
false
}
}
/** Send command and read the 1-byte ACK response (0x00 = OK). */
private suspend fun sendCommandWithAck(bytes: ByteArray): Boolean {
if (!sendCommand(bytes)) return false
return try {
val ack = inputStream?.read() ?: return false
ack == 0x00
} catch (e: Exception) {
Log.e(tag, "ACK read error: ${e.message}")
true // command was sent, ACK read failed - continue anyway
}
}
private fun readResponse(length: Int): ByteArray? {
return try {
val buffer = ByteArray(length)
var read = 0
while (read < length) {
val count = inputStream?.read(buffer, read, length - read) ?: return null
if (count < 0) return null
read += count
}
buffer
} catch (e: Exception) {
Log.e(tag, "Read error: ${e.message}")
isConnected = false
null
}
}
}
@@ -17,11 +17,8 @@
*/ */
package com.rtbishop.look4sat.core.data.framework package com.rtbishop.look4sat.core.data.framework
import com.rtbishop.look4sat.core.domain.repository.BtService import com.rtbishop.look4sat.core.domain.repository.IReporter
import com.rtbishop.look4sat.core.domain.repository.ExtendedParams
import com.rtbishop.look4sat.core.domain.repository.IReporterRepo
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
@@ -29,89 +26,94 @@ import java.net.InetSocketAddress
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.channels.SocketChannel import java.nio.channels.SocketChannel
enum class NetworkService { ROTATOR, FREQUENCY } class NetworkReporter(
private val reporterScope: CoroutineScope,
private val rotatorServer: String,
private val rotatorPort: Int,
private val frequencyServer: String,
private val frequencyPort: Int
) : IReporter {
data class SocketConnection(
var socket: SocketChannel? = null,
var connected: Boolean = false,
var connecting: Boolean = false,
var connectionJob: Job? = null
)
class NetworkReporter(private val reporterScope: CoroutineScope) : IReporterRepo<ExtendedParams> {
private val serviceToAddress = mutableMapOf<NetworkService, String>()
private val connections = mutableMapOf<String, SocketConnection>()
private val writeMutex = Mutex() private val writeMutex = Mutex()
fun isConnected(service: NetworkService): Boolean { private var rotatorSocket: SocketChannel? = null
val addr = serviceToAddress[service] ?: return false private var rotatorConnected = false
return connections[addr]?.connected == true private var rotatorConnecting = false
private var frequencySocket: SocketChannel? = null
private var frequencyConnected = false
private var frequencyConnecting = false
override fun reportRotation(format: String, azimuth: Double, elevation: Double) {
reporterScope.launch {
ensureRotatorConnected()
if (!rotatorConnected) return@launch
val el = if (elevation > 0.0) elevation else 0.0
val command = format
.replace($$"$AZ", azimuth.toString())
.replace($$"$EL", el.toString())
.unescapeControlChars()
write(rotatorSocket, command) { rotatorConnected = false }
}
} }
private fun getOrCreateConnection(addr: String): SocketConnection { override fun reportFrequency(format: String, frequency: Long) {
return connections.getOrPut(addr) { SocketConnection() } reporterScope.launch {
ensureFrequencyConnected()
if (!frequencyConnected) return@launch
val command = format
.replace($$"$FREQ", frequency.toString())
.unescapeControlChars()
write(frequencySocket, command) { frequencyConnected = false }
}
} }
fun connect(service: NetworkService, server: String, port: Int) { private fun ensureRotatorConnected() {
val addr = "$server:$port" if (rotatorConnected || rotatorConnecting || rotatorServer.isBlank()) return
serviceToAddress[service] = addr reporterScope.launch {
val connection = getOrCreateConnection(addr)
if (connection.connected || connection.connecting) return
connection.connectionJob = reporterScope.launch {
try { try {
connection.connecting = true rotatorConnecting = true
val socket = SocketChannel.open(InetSocketAddress(server, port)) rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
connection.socket = socket rotatorConnected = true
connection.connected = true println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
println("NetworkReporter: $service connected to $addr")
} catch (e: Exception) { } catch (e: Exception) {
println("NetworkReporter connect error: ${e.message}") println("NetworkReporter rotator connect error: ${e.message}")
connection.connected = false rotatorConnected = false
} finally { } finally {
connection.connecting = false rotatorConnecting = false
} }
} }
} }
private suspend fun write(service: NetworkService, command: String) { private fun ensureFrequencyConnected() {
val addr = serviceToAddress[service] ?: return if (frequencyConnected || frequencyConnecting || frequencyServer.isBlank()) return
val connection = connections[addr] ?: return reporterScope.launch {
if (!connection.connected) return try {
frequencyConnecting = true
frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
frequencyConnected = true
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
} catch (e: Exception) {
println("NetworkReporter frequency connect error: ${e.message}")
frequencyConnected = false
} finally {
frequencyConnecting = false
}
}
}
private suspend fun write(socket: SocketChannel?, command: String, onError: () -> Unit) {
try { try {
writeMutex.withLock { writeMutex.withLock {
val buffer = ByteBuffer.wrap("\\$command\n".toByteArray()) val buffer = ByteBuffer.wrap("$command\n".toByteArray())
connection.socket?.write(buffer) socket?.write(buffer)
} }
} catch (e: Exception) { } catch (e: Exception) {
println("NetworkReporter write error: ${e.message}") println("NetworkReporter write error: ${e.message}")
connection.connected = false onError()
} }
} }
override fun isConnected(service: BtService): Boolean = false private fun String.unescapeControlChars(): String =
replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
override fun isConnecting(service: BtService): Boolean = false
override fun connect(service: BtService, deviceId: String) {}
override fun reportRotation(format: String, azimuth: Double, elevation: Double, params: ExtendedParams) {
reporterScope.launch {
connect(NetworkService.ROTATOR, params.server, params.port)
if (!isConnected(NetworkService.ROTATOR)) return@launch
val el = if (elevation > 0.0) elevation else 0.0
val command = format
.replace("\$AZ", azimuth.toString())
.replace("\$EL", el.toString())
write(NetworkService.ROTATOR, command)
}
}
override fun reportFrequency(format: String, frequency: Long, params: ExtendedParams) {
reporterScope.launch {
connect(NetworkService.FREQUENCY, params.server, params.port)
if (!isConnected(NetworkService.FREQUENCY)) return@launch
val command = format.replace("\$FREQ", frequency.toString())
write(NetworkService.FREQUENCY, command)
}
}
} }
@@ -0,0 +1,357 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.framework
import android.bluetooth.BluetoothManager
import android.util.Log
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.repository.IRadioController
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
class RadioTrackingService(
private val appScope: CoroutineScope,
private val bluetoothManager: BluetoothManager,
private val satelliteRepo: ISatelliteRepo,
private val settingsRepo: ISettingsRepo
) : IRadioTrackingService {
private val tag = "RadioTracking"
private val _state = MutableStateFlow(RadioTrackingState())
override val state: StateFlow<RadioTrackingState> = _state
private var txController: IRadioController? = null
private var rxController: IRadioController? = null
private var trackingJob: Job? = null
override suspend fun connectRadios() {
// Disconnect old controllers if any
txController?.disconnect()
rxController?.disconnect()
// Read current addresses from settings
val rcSettings = settingsRepo.radioControlSettings.value
val txAddr = rcSettings.txRadioAddress
val rxAddr = rcSettings.rxRadioAddress
Log.i(tag, "Connecting TX=$txAddr RX=$rxAddr")
if (txAddr.isBlank() && rxAddr.isBlank()) {
_state.update { it.copy(errorMessage = "No radio addresses configured. Set them in Settings → FT-817.") }
return
}
val tx = Ft817Controller(bluetoothManager, txAddr)
val rx = Ft817Controller(bluetoothManager, rxAddr)
txController = tx
rxController = rx
_state.update { it.copy(errorMessage = null) }
val txOk = if (txAddr.isNotBlank()) tx.connect() else false
val rxOk = if (rxAddr.isNotBlank()) rx.connect() else false
_state.update {
it.copy(
txConnected = txOk,
rxConnected = rxOk,
errorMessage = when {
!txOk && !rxOk -> "Could not connect to TX and RX radios"
!txOk -> "Could not connect to TX radio ($txAddr)"
!rxOk -> "Could not connect to RX radio ($rxAddr)"
else -> null
}
)
}
}
override suspend fun disconnectRadios() {
stopTracking()
txController?.disconnect()
rxController?.disconnect()
txController = null
rxController = null
_state.update {
it.copy(
txConnected = false,
rxConnected = false,
isActive = false
)
}
}
override fun startTracking(pass: OrbitalPass, transponder: SatRadio, txBaseFreqHz: Long?) {
_state.update {
it.copy(
isActive = true,
currentPass = pass,
selectedTransponder = transponder,
txBaseFrequencyHz = txBaseFreqHz
)
}
trackingJob?.cancel()
trackingJob = appScope.launch {
// Set modes on both radios at tracking start
val tx = txController
val rx = rxController
val txMode = transponder.uplinkMode
val rxMode = transponder.downlinkMode
?: transponder.uplinkMode?.let {
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
}
if (tx != null && tx.isConnected && txMode != null) {
tx.setMode(txMode)
Log.i(tag, "TX mode set to $txMode")
}
if (rx != null && rx.isConnected && rxMode != null) {
rx.setMode(rxMode)
Log.i(tag, "RX mode set to $rxMode")
}
// Set CTCSS if FM
if (txMode?.uppercase() == "FM") {
_state.value.ctcssTone?.let { tone ->
tx?.setCtcssTone(tone)
tx?.setCtcssMode(true)
}
}
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
var lastSetTxFreq = 0.0
var lastSetRxFreq = 0.0
var tuningRadio = "" // "", "tx", or "rx" - which radio the user is tuning
var lastReadFreq = 0L
var stableCount = 0
while (isActive) {
val currentState = _state.value
if (!currentState.isActive) break
val satPass = currentState.currentPass ?: break
val xpdr = currentState.selectedTransponder ?: break
var txBaseFreq = currentState.txBaseFrequencyHz
val stationPos = settingsRepo.stationPosition.value
val timeNow = System.currentTimeMillis()
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow)
val tx = txController
val rx = rxController
val hasUplink = txBaseFreq != null
val c = com.rtbishop.look4sat.core.domain.predict.SPEED_OF_LIGHT
val v = pos.distanceRate * 1000.0
if (tuningRadio.isNotEmpty()) {
// --- User is tuning: keep reading, wait for stabilization ---
val radio = if (tuningRadio == "tx") tx else rx
if (radio != null && radio.isConnected) {
val readResult = radio.readFrequencyAndMode()
if (readResult != null) {
val (freq, _) = readResult
if (kotlin.math.abs(freq - lastReadFreq) <= 20) {
stableCount++
} else {
stableCount = 0
lastReadFreq = freq
}
// Stable for 2 reads → user stopped turning
if (stableCount >= 2) {
if (tuningRadio == "tx" && txBaseFreq != null) {
val newBase = (freq.toDouble() * c / (c + v)).toLong()
if (newBase > 0) {
txBaseFreq = newBase
_state.update { it.copy(txBaseFrequencyHz = newBase) }
Log.i(tag, "TX tuning done → base=$newBase")
}
} else if (tuningRadio == "rx") {
val rxNominal = (freq.toDouble() * c / (c - v)).toLong()
val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr)
if (newTxBase != null && newTxBase > 0) {
txBaseFreq = newTxBase
_state.update { it.copy(txBaseFrequencyHz = newTxBase) }
Log.i(tag, "RX tuning done → txBase=$newTxBase")
}
}
tuningRadio = ""
stableCount = 0
lastSetTxFreq = 0.0
lastSetRxFreq = 0.0
}
}
}
} else {
// --- Normal tracking: read, detect changes, command ---
// TX dial feedback
if (hasUplink && tx != null && tx.isConnected && lastSetTxFreq > 0.0) {
val readResult = tx.readFrequencyAndMode()
if (readResult != null) {
val (actualTxFreq, _) = readResult
if (kotlin.math.abs(actualTxFreq - lastSetTxFreq) >= 20.0) {
tuningRadio = "tx"
lastReadFreq = actualTxFreq
stableCount = 0
Log.i(tag, "TX tuning detected (read=$actualTxFreq, lastSet=$lastSetTxFreq)")
}
}
}
// RX dial feedback (only if TX not tuning)
if (tuningRadio.isEmpty() && rx != null && rx.isConnected && lastSetRxFreq > 0.0) {
val readResult = rx.readFrequencyAndMode()
if (readResult != null) {
val (actualRxFreq, _) = readResult
if (kotlin.math.abs(actualRxFreq - lastSetRxFreq) >= 20.0) {
tuningRadio = "rx"
lastReadFreq = actualRxFreq
stableCount = 0
Log.i(tag, "RX tuning detected (read=$actualRxFreq, lastSet=$lastSetRxFreq)")
}
}
}
}
// Compute Doppler-corrected frequencies
val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) }
val rxBaseFreq = if (txBaseFreq != null) {
TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr)
} else {
xpdr.downlinkLow
}
val rxRadioFreq = rxBaseFreq?.let { pos.getDownlinkFreq(it) }
// Command radios (only when not tuning)
if (tuningRadio.isEmpty()) {
if (tx != null && tx.isConnected && txRadioFreq != null) {
tx.setFrequency(txRadioFreq)
lastSetTxFreq = txRadioFreq.toDouble()
}
if (rx != null && rx.isConnected && rxRadioFreq != null) {
rx.setFrequency(rxRadioFreq)
lastSetRxFreq = rxRadioFreq.toDouble()
}
}
_state.update {
it.copy(
txConnected = tx?.isConnected ?: false,
rxConnected = rx?.isConnected ?: false,
txFrequencyHz = txRadioFreq,
rxFrequencyHz = rxRadioFreq,
azimuth = Math.toDegrees(pos.azimuth),
elevation = Math.toDegrees(pos.elevation),
distance = pos.distance
)
}
delay(1000)
}
}
}
override fun stopTracking() {
trackingJob?.cancel()
trackingJob = null
_state.update { it.copy(isActive = false) }
}
override fun setTransponder(transponder: SatRadio) {
appScope.launch {
val tx = txController
val rx = rxController
transponder.uplinkMode?.let { tx?.setMode(it) }
val rxMode = transponder.downlinkMode
?: transponder.uplinkMode?.let {
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
}
rxMode?.let { rx?.setMode(it) }
if (transponder.uplinkMode?.uppercase() == "FM") {
_state.value.ctcssTone?.let { tone ->
tx?.setCtcssTone(tone)
tx?.setCtcssMode(true)
}
}
}
val txCenter = when {
transponder.uplinkLow != null && transponder.uplinkHigh != null ->
(transponder.uplinkLow!! + transponder.uplinkHigh!!) / 2
transponder.uplinkLow != null -> transponder.uplinkLow!!
else -> null
}
// Show nominal frequencies immediately
val rxNominal = if (txCenter != null) {
TransponderMapper.mapUplinkToDownlink(txCenter, transponder)
} else {
// Downlink-only transponder (beacon etc.) - use downlink directly
transponder.downlinkLow
}
_state.update {
it.copy(
selectedTransponder = transponder,
txBaseFrequencyHz = txCenter,
txFrequencyHz = txCenter,
rxFrequencyHz = rxNominal,
txMode = transponder.uplinkMode,
rxMode = transponder.downlinkMode
?: transponder.uplinkMode?.let { m ->
TransponderMapper.mapUplinkModeToDownlinkMode(m, transponder.isInverted)
}
)
}
}
override fun setTxBaseFrequency(frequencyHz: Long) {
_state.update { it.copy(txBaseFrequencyHz = frequencyHz) }
}
override fun adjustTxBaseFrequency(deltaHz: Long) {
val current = _state.value.txBaseFrequencyHz ?: return
_state.update { it.copy(txBaseFrequencyHz = current + deltaHz) }
}
override fun setCtcssTone(toneHz: Double?) {
_state.update { it.copy(ctcssTone = toneHz) }
appScope.launch {
val tx = txController
if (toneHz != null) {
tx?.setCtcssTone(toneHz)
tx?.setCtcssMode(true)
} else {
tx?.setCtcssMode(false)
}
}
}
override fun setMode(txMode: String, rxMode: String) {
appScope.launch {
txController?.setMode(txMode)
rxController?.setMode(rxMode)
}
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
}
}
@@ -9,7 +9,9 @@ import android.view.WindowManager
import androidx.room.Room import androidx.room.Room
import com.rtbishop.look4sat.core.data.database.Look4SatDb import com.rtbishop.look4sat.core.data.database.Look4SatDb
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
import com.rtbishop.look4sat.core.data.framework.NetworkReporter import com.rtbishop.look4sat.core.data.framework.NetworkReporter
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
import com.rtbishop.look4sat.core.data.repository.SelectionRepo import com.rtbishop.look4sat.core.data.repository.SelectionRepo
@@ -19,15 +21,15 @@ import com.rtbishop.look4sat.core.data.source.LocalSource
import com.rtbishop.look4sat.core.data.source.RemoteSource import com.rtbishop.look4sat.core.data.source.RemoteSource
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
import com.rtbishop.look4sat.core.data.usecase.ShowToast import com.rtbishop.look4sat.core.data.usecase.ShowToast
import com.rtbishop.look4sat.core.domain.repository.ExtendedParams
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.repository.IMainContainer import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.IReporterRepo import com.rtbishop.look4sat.core.domain.repository.IRadioController
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
import com.rtbishop.look4sat.core.domain.repository.IReporter
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.repository.WithoutExtParams
import com.rtbishop.look4sat.core.domain.source.ILocalSource import com.rtbishop.look4sat.core.domain.source.ILocalSource
import com.rtbishop.look4sat.core.domain.source.IRemoteSource import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
@@ -48,18 +50,47 @@ class MainContainer(private val context: Context) : IMainContainer {
override val selectionRepo = provideSelectionRepo() override val selectionRepo = provideSelectionRepo()
override val satelliteRepo = provideSatelliteRepo() override val satelliteRepo = provideSatelliteRepo()
override val databaseRepo = provideDatabaseRepo() override val databaseRepo = provideDatabaseRepo()
override val radioTrackingService: IRadioTrackingService by lazy {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
}
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context) override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
override fun provideShowToast(): IShowToast = ShowToast(context) override fun provideShowToast(): IShowToast = ShowToast(context)
override fun provideBluetoothReporter(): IReporterRepo<WithoutExtParams> { override fun provideBluetoothReporter(): IReporter {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
return BluetoothReporter(manager, CoroutineScope(Dispatchers.IO)) val rc = settingsRepo.rcSettings.value
return BluetoothReporter(
manager,
CoroutineScope(Dispatchers.IO),
rc.bluetoothRotatorAddress,
rc.bluetoothFrequencyAddress
)
} }
override fun provideNetworkReporter(): IReporterRepo<ExtendedParams> { override fun provideNetworkReporter(): IReporter {
return NetworkReporter(CoroutineScope(Dispatchers.IO)) val rc = settingsRepo.rcSettings.value
return NetworkReporter(
CoroutineScope(Dispatchers.IO),
rc.rotatorAddress,
rc.rotatorPort.toIntOrNull() ?: 0,
rc.frequencyAddress,
rc.frequencyPort.toIntOrNull() ?: 0
)
}
override fun provideTxRadioController(): IRadioController {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val address = settingsRepo.radioControlSettings.value.txRadioAddress
return Ft817Controller(manager, address)
}
override fun provideRxRadioController(): IRadioController {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val address = settingsRepo.radioControlSettings.value.rxRadioAddress
return Ft817Controller(manager, address)
} }
override fun provideSensorsRepo(): ISensorsRepo { override fun provideSensorsRepo(): ISensorsRepo {
@@ -18,7 +18,6 @@
package com.rtbishop.look4sat.core.data.repository package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.DatabaseState import com.rtbishop.look4sat.core.domain.model.DatabaseState
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalData import com.rtbishop.look4sat.core.domain.predict.OrbitalData
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
@@ -28,7 +27,9 @@ import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.domain.utility.DataParser import com.rtbishop.look4sat.core.domain.utility.DataParser
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.InputStream
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
class DatabaseRepo( class DatabaseRepo(
@@ -39,78 +40,49 @@ class DatabaseRepo(
private val settingsRepo: ISettingsRepo private val settingsRepo: ISettingsRepo
) : IDatabaseRepo { ) : IDatabaseRepo {
private val customSourceType = "Other"
override suspend fun updateTLEFromFile(uri: String) = withContext(dispatcher) { override suspend fun updateTLEFromFile(uri: String) = withContext(dispatcher) {
val importedSatellites = remoteSource.getFileStream(uri)?.let { dataParser.parseTLEStream(it) } remoteSource.getFileStream(uri)?.let { stream ->
importedSatellites?.let { localSource.insertEntries(it) } val entries = dataParser.parseTLEStream(unwrapIfZipped(uri, stream))
localSource.insertEntries(entries)
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
}
setUpdateSuccessful(System.currentTimeMillis()) setUpdateSuccessful(System.currentTimeMillis())
} }
override suspend fun updateTransceiversFromFile(uri: String) = withContext(dispatcher) { override suspend fun updateTransceiversFromFile(uri: String) = withContext(dispatcher) {
val radios = remoteSource.getFileStream(uri)?.let { dataParser.parseJSONStream(it) } remoteSource.getFileStream(uri)?.let { stream ->
radios?.let { val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
if(it.isNotEmpty()) { localSource.insertRadios(transceivers)
localSource.deleteRadios()
localSource.insertRadios(it)
}
} }
setUpdateSuccessful(System.currentTimeMillis()) setUpdateSuccessful(System.currentTimeMillis())
} }
override suspend fun updateFromRemote() = withContext(dispatcher) { override suspend fun updateFromRemote() = withContext(dispatcher) {
val importedEntries = mutableListOf<OrbitalData>() val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
val importedRadios = mutableListOf<SatRadio>() val tleUrls = buildMap {
val tleUrls = Sources.satelliteDataUrls.toMutableMap().apply { putAll(Sources.satelliteDataUrls)
if (settingsRepo.dataSourcesSettings.value.useCustomTLE) { if (dataSourcesSettings.useCustomTLE) put(customSourceType, dataSourcesSettings.tleUrl)
this["Other"] = settingsRepo.dataSourcesSettings.value.tleUrl }.filterValues { it.isNotBlank() }
val radioUrls = buildMap {
putAll(Sources.transceiversDataUrls)
if (dataSourcesSettings.useCustomTransceivers) put(customSourceType, dataSourcesSettings.transceiversUrl)
}.filterValues { it.isNotBlank() }
// launch all network requests concurrently
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
// parse fetched data concurrently and associate with types
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
} }
} }
val jobsMap = tleUrls val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
.filter { (_, url) -> url.isNotEmpty() } stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
.mapValues { (_, url) -> async { remoteSource.getNetworkStream(url) } }
val radioUrls = buildList {
add(Sources.RADIO_DATA_URL)
if (settingsRepo.dataSourcesSettings.value.useCustomTransceivers) {
add(settingsRepo.dataSourcesSettings.value.transceiversUrl)
}
} }
val jobRadios = radioUrls.associateWith { url -> async { remoteSource.getNetworkStream(url) } } // insert parsed data into the database
// parse
jobsMap.mapValues { job -> job.value.await() }.forEach { entry ->
entry.value?.let { stream ->
when (val type = entry.key) {
"Amsat", "R4UAB", "Other" -> {
// parse tle stream
val satellites = dataParser.parseTLEStream(stream)
val catnums = satellites.map { it.catnum }
settingsRepo.setSatelliteTypeIds(type, catnums)
importedEntries.addAll(satellites)
}
"McCants", "Classified" -> {
// unzip and parse tle stream
val unzipped = ZipInputStream(stream).apply { nextEntry }
val satellites = dataParser.parseTLEStream(unzipped)
val catnums = satellites.map { it.catnum }
settingsRepo.setSatelliteTypeIds(type, catnums)
importedEntries.addAll(satellites)
}
else -> {
// parse csv stream
val satellites = dataParser.parseCSVStream(stream)
val catnums = satellites.map { it.catnum }
settingsRepo.setSatelliteTypeIds(type, catnums)
importedEntries.addAll(satellites)
}
}
}
}
jobRadios.values.forEach { job ->
job.await()?.let {
importedRadios.addAll(dataParser.parseJSONStream(it))
}
}
// insert
localSource.insertEntries(importedEntries) localSource.insertEntries(importedEntries)
localSource.insertRadios(importedRadios) localSource.insertRadios(importedRadios)
setUpdateSuccessful(System.currentTimeMillis()) setUpdateSuccessful(System.currentTimeMillis())
@@ -122,9 +94,17 @@ class DatabaseRepo(
setUpdateSuccessful(0L) setUpdateSuccessful(0L)
} }
private suspend fun setUpdateSuccessful(timestamp: Long) = withContext(dispatcher) { private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> = when {
val numberOfRadios = localSource.getRadiosTotal() url.contains("FORMAT=csv", ignoreCase = true) -> dataParser.parseCSVStream(stream)
val numberOfSatellites = localSource.getEntriesTotal() else -> dataParser.parseTLEStream(stream)
settingsRepo.updateDatabaseState(DatabaseState(numberOfRadios, numberOfSatellites, timestamp))
} }
private suspend fun setUpdateSuccessful(timestamp: Long) {
settingsRepo.updateDatabaseState(
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
} }
@@ -28,6 +28,10 @@ import com.rtbishop.look4sat.core.domain.source.ILocalSource
import com.rtbishop.look4sat.core.domain.utility.round import com.rtbishop.look4sat.core.domain.utility.round
import com.rtbishop.look4sat.core.domain.utility.toDegrees import com.rtbishop.look4sat.core.domain.utility.toDegrees
import kotlinx.coroutines.CoroutineDispatcher 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -42,17 +46,26 @@ class SatelliteRepo(
private val _passes = MutableStateFlow<List<OrbitalPass>>(emptyList()) private val _passes = MutableStateFlow<List<OrbitalPass>>(emptyList())
override val passes: StateFlow<List<OrbitalPass>> = _passes 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()) private val _satellites = MutableStateFlow<List<OrbitalObject>>(emptyList())
override val satellites: StateFlow<List<OrbitalObject>> = _satellites 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 getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
override suspend fun initRepository() = withContext(dispatcher) { override suspend fun initRepository() = withContext(dispatcher) {
settingsRepo.selectedIds.collect { selectedIds -> settingsRepo.selectedIds.collect { selectedIds ->
_satellites.update { localStorage.getEntriesWithIds(selectedIds) } _satellites.update { localStorage.getEntriesWithIds(selectedIds) }
val (hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value val (_, hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value
val timeNow = 1000 * ((System.currentTimeMillis() + 500) / 1000) calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
calculatePasses(timeNow, hoursAhead, minElevation, modes)
} }
} }
@@ -62,7 +75,8 @@ class SatelliteRepo(
override suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos> { override suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos> {
return withContext(dispatcher) { return withContext(dispatcher) {
val positions = mutableListOf<OrbitalPos>() val estimatedSize = ((end - start) / 15000).toInt() + 1
val positions = ArrayList<OrbitalPos>(estimatedSize)
var currentTime = start var currentTime = start
while (currentTime < end) { while (currentTime < end) {
positions.add(sat.getPosition(pos, currentTime)) positions.add(sat.getPosition(pos, currentTime))
@@ -80,48 +94,52 @@ class SatelliteRepo(
): List<SatRadio> { ): List<SatRadio> {
return withContext(dispatcher) { return withContext(dispatcher) {
val satPos = sat.getPosition(pos, time) val satPos = sat.getPosition(pos, time)
val copiedList = radios.map { it.copy() } radios.map { transmitter ->
copiedList.forEach { transmitter -> transmitter.copy(
transmitter.downlinkLow?.let { transmitter.downlinkLow = satPos.getDownlinkFreq(it) } downlinkLow = transmitter.downlinkLow?.let { satPos.getDownlinkFreq(it) },
transmitter.downlinkHigh?.let { transmitter.downlinkHigh = satPos.getDownlinkFreq(it) } downlinkHigh = transmitter.downlinkHigh?.let { satPos.getDownlinkFreq(it) },
transmitter.uplinkLow?.let { transmitter.uplinkLow = satPos.getUplinkFreq(it) } uplinkLow = transmitter.uplinkLow?.let { satPos.getUplinkFreq(it) },
transmitter.uplinkHigh?.let { transmitter.uplinkHigh = satPos.getUplinkFreq(it) } uplinkHigh = transmitter.uplinkHigh?.let { satPos.getUplinkFreq(it) }
)
} }
copiedList.map { it.copy() }
}
}
override suspend fun processPasses(passList: List<OrbitalPass>, time: Long): List<OrbitalPass> {
return withContext(dispatcher) {
passList.forEach { pass ->
if (!pass.isDeepSpace) {
val timeStart = pass.aosTime
if (time > timeStart) {
val deltaNow = time.minus(timeStart).toFloat()
val deltaTotal = pass.losTime.minus(timeStart).toFloat()
pass.progress = (deltaNow / deltaTotal).round(2)
}
}
}
passList.filter { pass -> pass.progress < 1.0 }.map { it.copy() }
} }
} }
override suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>) { override suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>) {
if (_satellites.value.isNotEmpty()) { _isCalculating.value = true
withContext(dispatcher) { // Normalize to the start of the current minute so that coarse 60-second stepping
val newPasses = mutableListOf<OrbitalPass>() // in getLeoPass always begins from the same phase, producing stable AOS/LOS times
val idsWithModes = localStorage.getIdsWithModes(modes) val normalizedTime = time / 60_000L * 60_000L
_satellites.value.forEach { satellite -> val currentSatellites = _satellites.value
if (idsWithModes.isEmpty() || satellite.data.catnum in idsWithModes) { withContext(dispatcher) {
newPasses.addAll(satellite.getPasses(settingsRepo.stationPosition.value, time, hoursAhead)) 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)
} }
} }
_passes.update { newPasses.filter(time, hoursAhead, minElevation) }
} }
} else { newPasses.sortBy { it.aosTime }
_passes.update { emptyList() } 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> { private fun OrbitalObject.getPasses(pos: GeoPos, time: Long, hours: Int): List<OrbitalPass> {
@@ -149,12 +167,6 @@ class SatelliteRepo(
return passes return passes
} }
private fun List<OrbitalPass>.filter(time: Long, hoursAhead: Int, minElev: Double): List<OrbitalPass> {
val timeFuture = time + (hoursAhead * 60L * 60L * 1000L)
return this.filter { it.losTime > time }.filter { it.aosTime < timeFuture }
.filter { it.maxElevation > minElev }.sortedBy { it.aosTime }
}
private fun getGeoPass(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPass { private fun getGeoPass(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPass {
val satPos = sat.getPosition(pos, time) val satPos = sat.getPosition(pos, time)
val aos = time - 24 * 60L * 60L * 1000L val aos = time - 24 * 60L * 60L * 1000L
@@ -170,72 +182,64 @@ class SatelliteRepo(
var calendarTimeMillis = time var calendarTimeMillis = time
var elevation: Double var elevation: Double
var maxElevation = 0.0 var maxElevation = 0.0
var alt = 0.0 // var tcaAz = 0.0
// rewind 1/4 of an orbit // rewind 1/4 of an orbit
if (rewind) calendarTimeMillis += -quarterOrbitMin * 60L * 1000L if (rewind) calendarTimeMillis -= quarterOrbitMin * 60L * 1000L
var satPos = sat.getPosition(pos, calendarTimeMillis) // Use lightweight elevation check for coarse searching
if (satPos.elevation > 0.0) { if (sat.getElevation(pos, calendarTimeMillis) > 0.0) {
// move forward in 30 second intervals until the sat goes below the horizon // move forward in 30 second intervals until the sat goes below the horizon
do { do {
calendarTimeMillis += 30 * 1000L calendarTimeMillis += 30 * 1000L
satPos = sat.getPosition(pos, calendarTimeMillis) } while (sat.getElevation(pos, calendarTimeMillis) > 0.0)
} while (satPos.elevation > 0.0)
// move forward 3/4 of an orbit // move forward 3/4 of an orbit
calendarTimeMillis += quarterOrbitMin * 3 * 60L * 1000L calendarTimeMillis += quarterOrbitMin * 3 * 60L * 1000L
} }
// find the next time sat comes above the horizon // find the next time sat comes above the horizon (coarse: 60s steps)
do { do {
calendarTimeMillis += 60L * 1000L calendarTimeMillis += 60L * 1000L
satPos = sat.getPosition(pos, calendarTimeMillis) elevation = sat.getElevation(pos, calendarTimeMillis)
elevation = satPos.elevation if (elevation > maxElevation) maxElevation = elevation
if (elevation > maxElevation) { } while (elevation < 0.0)
maxElevation = elevation
alt = satPos.altitude // tcaAz = satPos.azimuth.toDegrees()
}
} while (satPos.elevation < 0.0)
// refine to 1 second // refine AOS to ~500ms precision
calendarTimeMillis += -60L * 1000L calendarTimeMillis -= 60L * 1000L
do { do {
calendarTimeMillis += 1L * 500L calendarTimeMillis += 500L
satPos = sat.getPosition(pos, calendarTimeMillis) elevation = sat.getElevation(pos, calendarTimeMillis)
elevation = satPos.elevation if (elevation > maxElevation) maxElevation = elevation
if (elevation > maxElevation) { } while (elevation < 0.0)
maxElevation = elevation
alt = satPos.altitude // tcaAz = satPos.azimuth.toDegrees()
}
} while (satPos.elevation < 0.0)
val aos = 1000 * ((satPos.time + 500) / 1000) // Get full position for AOS data (azimuth, altitude)
val aosAz = satPos.azimuth.toDegrees().round(1) val aosPos = sat.getFullPosition(pos, calendarTimeMillis)
val aos = 1000 * ((aosPos.time + 500) / 1000)
val aosAz = aosPos.azimuth.toDegrees().round(1)
// find when sat goes below // find when sat goes below (coarse: 30s steps)
do { do {
calendarTimeMillis += 30L * 1000L calendarTimeMillis += 30L * 1000L
satPos = sat.getPosition(pos, calendarTimeMillis) elevation = sat.getElevation(pos, calendarTimeMillis)
elevation = satPos.elevation if (elevation > maxElevation) maxElevation = elevation
if (elevation > maxElevation) { } while (elevation > 0.0)
maxElevation = elevation
alt = satPos.altitude // tcaAz = satPos.azimuth.toDegrees()
}
} while (satPos.elevation > 0.0)
// refine to 1 second // refine LOS to ~500ms precision
calendarTimeMillis += -30L * 1000L calendarTimeMillis -= 30L * 1000L
do { do {
calendarTimeMillis += 1L * 500L calendarTimeMillis += 500L
satPos = sat.getPosition(pos, calendarTimeMillis) elevation = sat.getElevation(pos, calendarTimeMillis)
elevation = satPos.elevation if (elevation > maxElevation) maxElevation = elevation
if (elevation > maxElevation) { } while (elevation > 0.0)
maxElevation = elevation
alt = satPos.altitude // tcaAz = satPos.azimuth.toDegrees() // Get full position for LOS data (azimuth, altitude)
} val losPos = sat.getFullPosition(pos, calendarTimeMillis)
} while (satPos.elevation > 0.0) val los = 1000 * ((losPos.time + 500) / 1000)
val losAz = losPos.azimuth.toDegrees().round(1)
// Get altitude at approximate TCA (max elevation)
val tcaTime = (aos + los) / 2
val tcaPos = sat.getFullPosition(pos, tcaTime)
val alt = tcaPos.altitude
val los = 1000 * ((satPos.time + 500) / 1000) // val tca = (aos + los) / 2
val losAz = satPos.azimuth.toDegrees().round(1)
val elev = maxElevation.toDegrees().round(1) val elev = maxElevation.toDegrees().round(1)
return OrbitalPass(aos, aosAz, los, losAz, alt.toInt(), elev, sat) return OrbitalPass(aos, aosAz, los, losAz, alt.toInt(), elev, sat)
} }
@@ -40,11 +40,23 @@ class SelectionRepo(
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList()) private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
private val currentTypes = MutableStateFlow(settingsRepo.selectedTypes.value) private val currentTypes = MutableStateFlow(settingsRepo.selectedTypes.value)
private val currentQuery = MutableStateFlow("") private val currentQuery = MutableStateFlow("")
// Resolve type IDs once when types change, then filter items reactively.
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> -> private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
currentItems.map { items -> items.filterByTypes(types) } val catnumSet: Set<Int>? = if (types.isEmpty()) {
null // null = no filtering
} else {
val ids = settingsRepo.getSatelliteTypesIds(types)
if (ids.isEmpty()) null else ids.toHashSet()
}
currentItems.map { items ->
if (catnumSet == null) items else items.filter { it.catnum in catnumSet }
}
} }
private val itemsWithQuery = currentQuery.flatMapLatest { query -> private val itemsWithQuery = currentQuery.flatMapLatest { query ->
itemsWithTypes.map { items -> items.filterByQuery(query) } itemsWithTypes.map { items -> filterByQuery(items, query) }
} }
override fun getCurrentTypes() = currentTypes.value override fun getCurrentTypes() = currentTypes.value
@@ -54,7 +66,7 @@ class SelectionRepo(
} }
override suspend fun getEntriesFlow() = withContext(dispatcher) { override suspend fun getEntriesFlow() = withContext(dispatcher) {
val selectedIds = settingsRepo.selectedIds.value val selectedIds = settingsRepo.selectedIds.value.toHashSet()
currentItems.value = localSource.getEntriesList().map { item -> currentItems.value = localSource.getEntriesList().map { item ->
item.copy(isSelected = item.catnum in selectedIds) item.copy(isSelected = item.catnum in selectedIds)
} }
@@ -66,17 +78,19 @@ class SelectionRepo(
settingsRepo.setSelectedTypes(types) settingsRepo.setSelectedTypes(types)
} }
override suspend fun setQuery(query: String) = withContext(dispatcher) { override suspend fun setQuery(query: String) {
currentQuery.value = query currentQuery.value = query
} }
override suspend fun setSelection(selectAll: Boolean) = withContext(dispatcher) { override suspend fun setSelection(selectAll: Boolean) = withContext(dispatcher) {
setSelection(itemsWithQuery.first().map { item -> item.catnum }, selectAll) val visibleIds = itemsWithQuery.first().mapTo(HashSet()) { it.catnum }
setSelection(visibleIds, selectAll)
} }
override suspend fun setSelection(ids: List<Int>, isTicked: Boolean) = withContext(dispatcher) { override suspend fun setSelection(ids: List<Int>, isTicked: Boolean) = withContext(dispatcher) {
val idSet = ids.toHashSet()
currentItems.value = currentItems.value.map { item -> currentItems.value = currentItems.value.map { item ->
if (item.catnum in ids) item.copy(isSelected = isTicked) else item if (item.catnum in idSet) item.copy(isSelected = isTicked) else item
} }
} }
@@ -85,19 +99,24 @@ class SelectionRepo(
settingsRepo.setSelectedIds(currentSelection) settingsRepo.setSelectedIds(currentSelection)
} }
private suspend fun List<SatItem>.filterByTypes(types: List<String>) = withContext(dispatcher) { /**
if (types.isEmpty()) return@withContext this@filterByTypes * Bulk selection using a pre-built Set for O(1) lookups.
val catnums = settingsRepo.getSatelliteTypesIds(types) */
if (catnums.isEmpty()) return@withContext this@filterByTypes private suspend fun setSelection(idSet: Set<Int>, isTicked: Boolean) = withContext(dispatcher) {
return@withContext this@filterByTypes.filter { item -> item.catnum in catnums } currentItems.value = currentItems.value.map { item ->
} if (item.catnum in idSet) item.copy(isSelected = isTicked) else item
private suspend fun List<SatItem>.filterByQuery(query: String) = withContext(dispatcher) {
if (query.isBlank()) return@withContext this@filterByQuery
return@withContext try {
this@filterByQuery.filter { it.catnum == query.toInt() }
} catch (_: Exception) {
this@filterByQuery.filter { item -> item.name.lowercase().contains(query.lowercase()) }
} }
} }
/**
* Filters items by query. Uses toIntOrNull() instead of exception-based flow,
* and lowercases the query once up front instead of per-item.
*/
private fun filterByQuery(items: List<SatItem>, query: String): List<SatItem> {
if (query.isBlank()) return items
val catnum = query.toIntOrNull()
if (catnum != null) return items.filter { it.catnum == catnum }
val lowerQuery = query.lowercase()
return items.filter { it.name.lowercase().contains(lowerQuery) }
}
} }
@@ -35,78 +35,109 @@ class SensorsRepo(
private val sensorManager: SensorManager, private val sensorManager: SensorManager,
private val sensor: Sensor?, private val sensor: Sensor?,
private val windowManager: WindowManager private val windowManager: WindowManager
) : ) : SensorEventListener, ISensorsRepo {
SensorEventListener, ISensorsRepo {
private val _orientation = MutableStateFlow(Pair(0f, 0f)) private val _orientation = MutableStateFlow(Pair(0f, 0f))
private val rotationMatrix = FloatArray(9) private val rotationMatrix = FloatArray(9)
private val tempMatrix = FloatArray(9)
private val orientationValues = FloatArray(3) private val orientationValues = FloatArray(3)
private var sensorAccuracy = SensorManager.SENSOR_STATUS_UNRELIABLE private var smoothAzimuth = 0f
private var smoothPitch = 0f
private var hasInitialReading = false
companion object {
private const val SMOOTHING_FACTOR = 0.15f
}
override val orientation: StateFlow<Pair<Float, Float>> = _orientation override val orientation: StateFlow<Pair<Float, Float>> = _orientation
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float { override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
val latitude = geoPos.latitude.toFloat() return GeomagneticField(
val longitude = geoPos.longitude.toFloat() geoPos.latitude.toFloat(),
return GeomagneticField(latitude, longitude, geoPos.altitude.toFloat(), time).declination geoPos.longitude.toFloat(),
geoPos.altitude.toFloat(),
time
).declination
} }
override fun enableSensor() { override fun enableSensor() {
hasInitialReading = false
sensor?.let { sensorManager.registerListener(this, it, 8000) } sensor?.let { sensorManager.registerListener(this, it, 8000) }
} }
override fun disableSensor() = sensorManager.unregisterListener(this) override fun disableSensor() = sensorManager.unregisterListener(this)
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) { override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
sensorAccuracy = accuracy
}
override fun onSensorChanged(event: SensorEvent) = when { override fun onSensorChanged(event: SensorEvent) {
sensorAccuracy == SensorManager.SENSOR_STATUS_UNRELIABLE -> {} if (event.sensor == sensor) updateOrientation(event.values)
event.sensor == sensor -> updateOrientation(event.values)
else -> {}
} }
private fun getDisplayRotation(): Int { private fun getDisplayRotation(): Int {
return try { return try {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
windowManager.defaultDisplay.rotation windowManager.defaultDisplay.rotation
} catch (e: Exception) { } catch (_: Exception) {
Surface.ROTATION_0 Surface.ROTATION_0
} }
} }
private fun transformRotationMatrix(rotationMatrix: FloatArray, rotation: Int) { private fun remapForRotation(rotation: Int) {
val tempMatrix = FloatArray(9) val remapped = when (rotation) {
when (rotation) { Surface.ROTATION_90 -> SensorManager.remapCoordinateSystem(
Surface.ROTATION_0 -> { rotationMatrix, SensorManager.AXIS_Y, SensorManager.AXIS_MINUS_X, tempMatrix
SensorManager.remapCoordinateSystem(rotationMatrix, SensorManager.AXIS_X, SensorManager.AXIS_Y, tempMatrix) )
System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
} Surface.ROTATION_180 -> SensorManager.remapCoordinateSystem(
Surface.ROTATION_90 -> { rotationMatrix, SensorManager.AXIS_MINUS_X, SensorManager.AXIS_MINUS_Y, tempMatrix
SensorManager.remapCoordinateSystem(rotationMatrix, SensorManager.AXIS_Y, SensorManager.AXIS_MINUS_X, tempMatrix) )
SensorManager.remapCoordinateSystem(tempMatrix, SensorManager.AXIS_MINUS_X, SensorManager.AXIS_Y, rotationMatrix)
} Surface.ROTATION_270 -> SensorManager.remapCoordinateSystem(
Surface.ROTATION_180 -> { rotationMatrix, SensorManager.AXIS_MINUS_Y, SensorManager.AXIS_X, tempMatrix
SensorManager.remapCoordinateSystem(rotationMatrix, SensorManager.AXIS_MINUS_X, SensorManager.AXIS_MINUS_Y, tempMatrix) )
System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
} else -> false
Surface.ROTATION_270 -> {
SensorManager.remapCoordinateSystem(rotationMatrix, SensorManager.AXIS_MINUS_Y, SensorManager.AXIS_X, tempMatrix)
SensorManager.remapCoordinateSystem(tempMatrix, SensorManager.AXIS_MINUS_X, SensorManager.AXIS_Y, rotationMatrix)
}
} }
if (remapped) System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
} }
private fun updateOrientation(rotationVector: FloatArray) { private fun updateOrientation(rotationVector: FloatArray) {
SensorManager.getRotationMatrixFromVector(rotationMatrix, rotationVector) SensorManager.getRotationMatrixFromVector(rotationMatrix, rotationVector)
transformRotationMatrix(rotationMatrix, getDisplayRotation()) remapForRotation(getDisplayRotation())
SensorManager.getOrientation(rotationMatrix, orientationValues) SensorManager.getOrientation(rotationMatrix, orientationValues)
val azimuth = (orientationValues[0] * RAD2DEG).toFloat() val azimuth = (orientationValues[0] * RAD2DEG).toFloat()
val pitch = (orientationValues[1] * RAD2DEG).toFloat() // roll [2] val pitch = (orientationValues[1] * RAD2DEG).toFloat()
val magneticAzimuth = (azimuth + 360f) % 360f val magneticAzimuth = (azimuth + 360f) % 360f
val roundedAzimuth = round(magneticAzimuth * 10) / 10
val roundedPitch = round(pitch * 10) / 10 if (!hasInitialReading) {
_orientation.value = Pair(roundedAzimuth, roundedPitch) smoothAzimuth = magneticAzimuth
smoothPitch = pitch
hasInitialReading = true
} else {
smoothAzimuth = lowPassAngle(smoothAzimuth, magneticAzimuth)
smoothPitch = lowPass(smoothPitch, pitch)
}
_orientation.value = Pair(
round(smoothAzimuth * 10) / 10,
round(smoothPitch * 10) / 10
)
}
/** Standard exponential low-pass filter. */
private fun lowPass(previous: Float, current: Float): Float {
return previous + SMOOTHING_FACTOR * (current - previous)
}
/**
* Low-pass filter that accounts for the 0°/360° wraparound.
* Always takes the shortest angular path between the two values.
*/
private fun lowPassAngle(previous: Float, current: Float): Float {
var delta = current - previous
// Normalise delta into the range (-180, 180]
while (delta > 180f) delta -= 360f
while (delta <= -180f) delta += 360f
return (previous + SMOOTHING_FACTOR * delta + 360f) % 360f
} }
} }
@@ -28,6 +28,7 @@ import com.rtbishop.look4sat.core.domain.model.DatabaseState
import com.rtbishop.look4sat.core.domain.model.OtherSettings import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.utility.positionToQth import com.rtbishop.look4sat.core.domain.utility.positionToQth
@@ -50,6 +51,7 @@ class SettingsRepo(
private val keyBluetoothFrequencyState = "bluetoothFrequencyState" private val keyBluetoothFrequencyState = "bluetoothFrequencyState"
private val keyBluetoothFrequencyAddress = "bluetoothFrequencyAddress" private val keyBluetoothFrequencyAddress = "bluetoothFrequencyAddress"
private val keyBluetoothFrequencyFormat = "bluetoothFrequencyFormat" private val keyBluetoothFrequencyFormat = "bluetoothFrequencyFormat"
private val keyFilterShowDeepSpace = "filterShowDeepSpace"
private val keyFilterHoursAhead = "filterHoursAhead" private val keyFilterHoursAhead = "filterHoursAhead"
private val keyFilterMinElevation = "filterMinElevation" private val keyFilterMinElevation = "filterMinElevation"
private val keyNumberOfRadios = "numberOfRadios" private val keyNumberOfRadios = "numberOfRadios"
@@ -70,6 +72,7 @@ class SettingsRepo(
private val keyStateOfSweep = "stateOfSweep" private val keyStateOfSweep = "stateOfSweep"
private val keyStateOfUtc = "stateOfUtc" private val keyStateOfUtc = "stateOfUtc"
private val keyStateOfLightTheme = "stateOfLightTheme" private val keyStateOfLightTheme = "stateOfLightTheme"
private val keyStateOfNightMode = "stateOfNightMode"
private val keyStationAltitude = "stationAltitude" private val keyStationAltitude = "stationAltitude"
private val keyStationLatitude = "stationLatitude" private val keyStationLatitude = "stationLatitude"
private val keyStationLongitude = "stationLongitude" private val keyStationLongitude = "stationLongitude"
@@ -120,6 +123,7 @@ class SettingsRepo(
override val passesSettings: StateFlow<PassesSettings> = _passesSettings override val passesSettings: StateFlow<PassesSettings> = _passesSettings
override fun setPassesSettings(settings: PassesSettings) = preferences.edit { override fun setPassesSettings(settings: PassesSettings) = preferences.edit {
putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace)
putInt(keyFilterHoursAhead, settings.hoursAhead) putInt(keyFilterHoursAhead, settings.hoursAhead)
putLong(keyFilterMinElevation, settings.minElevation.toRawBits()) putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma)) putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
@@ -127,11 +131,12 @@ class SettingsRepo(
} }
private fun getPassesSettings(): PassesSettings { private fun getPassesSettings(): PassesSettings {
val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true)
val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24) val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24)
val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits())) val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits()))
val selectedModesString = preferences.getString(keySelectedModes, null) val selectedModesString = preferences.getString(keySelectedModes, null)
val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList() val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList()
return PassesSettings(hoursAhead, minElevation, selectedModes) return PassesSettings(showDeepSpace, hoursAhead, minElevation, selectedModes)
} }
//endregion //endregion
@@ -246,82 +251,51 @@ class SettingsRepo(
//endregion //endregion
//region # RC settings //region # RC settings
init {
migrateRCFormats()
}
// TODO: Remove after a few releases (added in v4.2.0)
private val keyRCFormatsMigrated = "rcFormatsMigrated"
private fun migrateRCFormats() {
if (preferences.getBoolean(keyRCFormatsMigrated, false)) return
val formatKeys = listOf(
keyRotatorFormat, keyFrequencyFormat, keyBluetoothRotatorFormat, keyBluetoothFrequencyFormat
)
preferences.edit {
for (key in formatKeys) {
val value = preferences.getString(key, null) ?: continue
if (value.contains("_") && !value.startsWith("\\")) {
putString(key, "\\$value")
}
}
putBoolean(keyRCFormatsMigrated, true)
}
}
private val _rcSettings = MutableStateFlow(getRCSettings()) private val _rcSettings = MutableStateFlow(getRCSettings())
override val rcSettings: StateFlow<RCSettings> = _rcSettings override val rcSettings: StateFlow<RCSettings> = _rcSettings
override fun setBluetoothRotatorAddress(value: String) { override fun updateRCSettings(settings: RCSettings) {
preferences.edit { putString(keyBluetoothRotatorAddress, value) } preferences.edit {
_rcSettings.update { it.copy(bluetoothRotatorAddress = value) } putBoolean(keyRotatorState, settings.rotatorState)
} putString(keyRotatorAddress, settings.rotatorAddress)
putString(keyRotatorPort, settings.rotatorPort)
override fun setBluetoothRotatorFormat(value: String) { putString(keyRotatorFormat, settings.rotatorFormat)
preferences.edit { putString(keyBluetoothRotatorFormat, value) } putBoolean(keyFrequencyState, settings.frequencyState)
_rcSettings.update { it.copy(bluetoothRotatorFormat = value) } putString(keyFrequencyAddress, settings.frequencyAddress)
} putString(keyFrequencyPort, settings.frequencyPort)
putString(keyFrequencyFormat, settings.frequencyFormat)
override fun setBluetoothRotatorName(value: String) { putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
preferences.edit { putString(keyBluetoothRotatorName, value) } putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
_rcSettings.update { it.copy(bluetoothRotatorName = value) } putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
} putString(keyBluetoothRotatorAddress, settings.bluetoothRotatorAddress)
putBoolean(keyBluetoothFrequencyState, settings.bluetoothFrequencyState)
override fun setBluetoothRotatorState(value: Boolean) { putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
preferences.edit { putBoolean(keyBluetoothRotatorState, value) } putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
_rcSettings.update { it.copy(bluetoothRotatorState = value) } }
} _rcSettings.value = settings
override fun setBluetoothFrequencyState(value: Boolean) {
preferences.edit { putBoolean(keyBluetoothFrequencyState, value) }
_rcSettings.update { it.copy(bluetoothFrequencyState = value) }
}
override fun setBluetoothFrequencyAddress(value: String) {
preferences.edit { putString(keyBluetoothFrequencyAddress, value) }
_rcSettings.update { it.copy(bluetoothFrequencyAddress = value) }
}
override fun setBluetoothFrequencyFormat(value: String) {
preferences.edit { putString(keyBluetoothFrequencyFormat, value) }
_rcSettings.update { it.copy(bluetoothFrequencyFormat = value) }
}
override fun setRotatorAddress(value: String) {
preferences.edit { putString(keyRotatorAddress, value) }
_rcSettings.update { it.copy(rotatorAddress = value) }
}
override fun setRotatorPort(value: String) {
preferences.edit { putString(keyRotatorPort, value) }
_rcSettings.update { it.copy(rotatorPort = value) }
}
override fun setRotatorState(value: Boolean) {
preferences.edit { putBoolean(keyRotatorState, value) }
_rcSettings.update { it.copy(rotatorState = value) }
}
override fun setRotatorFormat(value: String) {
preferences.edit { putString(keyRotatorFormat, value) }
_rcSettings.update { it.copy(rotatorFormat = value) }
}
override fun setFrequencyState(value: Boolean) {
preferences.edit { putBoolean(keyFrequencyState, value) }
_rcSettings.update { it.copy(frequencyState = value) }
}
override fun setFrequencyAddress(value: String) {
preferences.edit { putString(keyFrequencyAddress, value) }
_rcSettings.update { it.copy(frequencyAddress = value) }
}
override fun setFrequencyPort(value: String) {
preferences.edit { putString(keyFrequencyPort, value) }
_rcSettings.update { it.copy(frequencyPort = value) }
}
override fun setFrequencyFormat(value: String) {
preferences.edit { putString(keyFrequencyFormat, value) }
_rcSettings.update { it.copy(frequencyFormat = value) }
} }
private fun getRCSettings(): RCSettings = RCSettings( private fun getRCSettings(): RCSettings = RCSettings(
@@ -332,14 +306,14 @@ class SettingsRepo(
frequencyState = preferences.getBoolean(keyFrequencyState, false), frequencyState = preferences.getBoolean(keyFrequencyState, false),
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1", frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532", frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"set_freq $FREQ", frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ",
bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false), bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"W$AZ $EL", bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default", bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
bluetoothRotatorAddress = preferences.getString(keyBluetoothRotatorAddress, null) ?: "00:0C:BF:13:80:5D", bluetoothRotatorAddress = preferences.getString(keyBluetoothRotatorAddress, null) ?: "00:0C:BF:13:80:5D",
bluetoothFrequencyState = preferences.getBoolean(keyBluetoothFrequencyState, false), bluetoothFrequencyState = preferences.getBoolean(keyBluetoothFrequencyState, false),
bluetoothFrequencyAddress = preferences.getString(keyBluetoothFrequencyAddress, null) ?: "00:0C:BF:13:80:5D", bluetoothFrequencyAddress = preferences.getString(keyBluetoothFrequencyAddress, null) ?: "00:0C:BF:13:80:5D",
bluetoothFrequencyFormat = preferences.getString(keyBluetoothFrequencyFormat, null) ?: $$"FA$FREQ" bluetoothFrequencyFormat = preferences.getString(keyBluetoothFrequencyFormat, null) ?: $$"F $FREQ"
) )
//endregion //endregion
@@ -347,39 +321,21 @@ class SettingsRepo(
private val _otherSettings = MutableStateFlow(getOtherSettings()) private val _otherSettings = MutableStateFlow(getOtherSettings())
override val otherSettings: StateFlow<OtherSettings> = _otherSettings override val otherSettings: StateFlow<OtherSettings> = _otherSettings
override fun setStateOfAutoUpdate(value: Boolean) { override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) {
preferences.edit { putBoolean(keyStateOfAutoUpdate, value) } _otherSettings.update { current ->
_otherSettings.update { it.copy(stateOfAutoUpdate = value) } val new = transform(current)
} preferences.edit {
putBoolean(keyStateOfAutoUpdate, new.stateOfAutoUpdate)
override fun setStateOfSensors(value: Boolean) { putBoolean(keyStateOfSensors, new.stateOfSensors)
preferences.edit { putBoolean(keyStateOfSensors, value) } putBoolean(keyStateOfSweep, new.stateOfSweep)
_otherSettings.update { it.copy(stateOfSensors = value) } putBoolean(keyStateOfUtc, new.stateOfUtc)
} putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
override fun setStateOfSweep(value: Boolean) { putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
preferences.edit { putBoolean(keyStateOfSweep, value) } putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
_otherSettings.update { it.copy(stateOfSweep = value) } }
} new
}
override fun setStateOfUtc(value: Boolean) {
preferences.edit { putBoolean(keyStateOfUtc, value) }
_otherSettings.update { it.copy(stateOfUtc = value) }
}
override fun setStateOfLightTheme(value: Boolean){
preferences.edit { putBoolean(keyStateOfLightTheme, value) }
_otherSettings.update { it.copy(stateOfLightTheme = value) }
}
override fun setWarningDismissed() {
preferences.edit { putBoolean(keyShouldSeeWarning, false) }
_otherSettings.update { it.copy(shouldSeeWarning = false) }
}
override fun setWhatsNewDismissed() {
preferences.edit { putBoolean(keyShouldSeeWhatsNew, false) }
_otherSettings.update { it.copy(shouldSeeWhatsNew = false) }
} }
private fun getOtherSettings(): OtherSettings = OtherSettings( private fun getOtherSettings(): OtherSettings = OtherSettings(
@@ -388,6 +344,7 @@ class SettingsRepo(
stateOfSweep = preferences.getBoolean(keyStateOfSweep, true), stateOfSweep = preferences.getBoolean(keyStateOfSweep, true),
stateOfUtc = preferences.getBoolean(keyStateOfUtc, false), stateOfUtc = preferences.getBoolean(keyStateOfUtc, false),
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false), stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true), shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true) shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true)
) )
@@ -397,24 +354,14 @@ class SettingsRepo(
private val _dataSourcesSettings = MutableStateFlow(getDataSourcesSettings()) private val _dataSourcesSettings = MutableStateFlow(getDataSourcesSettings())
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings
override fun setUseCustomTle(value: Boolean) { override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
preferences.edit { putBoolean(keyUseCustomTle, value) } preferences.edit {
_dataSourcesSettings.update { it.copy(useCustomTLE = value) } putBoolean(keyUseCustomTle, settings.useCustomTLE)
} putBoolean(keyUseCustomTransceivers, settings.useCustomTransceivers)
putString(keyTleUrl, settings.tleUrl)
override fun setUseCustomTransceivers(value: Boolean) { putString(keyTransceiversUrl, settings.transceiversUrl)
preferences.edit { putBoolean(keyUseCustomTransceivers, value) } }
_dataSourcesSettings.update { it.copy(useCustomTransceivers = value) } _dataSourcesSettings.value = settings
}
override fun setTleUrl(value: String) {
preferences.edit { putString(keyTleUrl, value) }
_dataSourcesSettings.update { it.copy(tleUrl = value) }
}
override fun setTransceiversUrl(value: String) {
preferences.edit { putString(keyTransceiversUrl, value) }
_dataSourcesSettings.update { it.copy(transceiversUrl = value) }
} }
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings( private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
@@ -424,4 +371,40 @@ class SettingsRepo(
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: "" transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: ""
) )
//endregion //endregion
//region # Radio control settings
private val keyRadioControlEnabled = "radioControlEnabled"
private val keyRadioModel = "radioModel"
private val keyTxRadioAddress = "txRadioAddress"
private val keyRxRadioAddress = "rxRadioAddress"
private val keyTxRadioName = "txRadioName"
private val keyRxRadioName = "rxRadioName"
private val keyRadioBaudRate = "radioBaudRate"
private val _radioControlSettings = MutableStateFlow(getRadioControlSettings())
override val radioControlSettings: StateFlow<RadioControlSettings> = _radioControlSettings
override fun updateRadioControlSettings(settings: RadioControlSettings) {
preferences.edit {
putBoolean(keyRadioControlEnabled, settings.enabled)
putString(keyRadioModel, settings.radioModel)
putString(keyTxRadioAddress, settings.txRadioAddress)
putString(keyRxRadioAddress, settings.rxRadioAddress)
putString(keyTxRadioName, settings.txRadioName)
putString(keyRxRadioName, settings.rxRadioName)
putInt(keyRadioBaudRate, settings.baudRate)
}
_radioControlSettings.value = settings
}
private fun getRadioControlSettings(): RadioControlSettings = RadioControlSettings(
enabled = preferences.getBoolean(keyRadioControlEnabled, false),
radioModel = preferences.getString(keyRadioModel, null) ?: "Yaesu FT-817/818",
txRadioAddress = preferences.getString(keyTxRadioAddress, null) ?: "",
rxRadioAddress = preferences.getString(keyRxRadioAddress, null) ?: "",
txRadioName = preferences.getString(keyTxRadioName, null) ?: "TX Radio",
rxRadioName = preferences.getString(keyRxRadioName, null) ?: "RX Radio",
baudRate = preferences.getInt(keyRadioBaudRate, 4800)
)
//endregion
} }
@@ -0,0 +1,149 @@
package com.rtbishop.look4sat.core.data
import com.rtbishop.look4sat.core.data.framework.Ft817CatProtocol
import org.junit.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class Ft817CatProtocolTest {
@Test
fun encodeFrequencyBcd_145500000() {
val bcd = Ft817CatProtocol.encodeFrequencyBcd(145500000L)
assertContentEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00), bcd)
}
@Test
fun encodeFrequencyBcd_435100000() {
val bcd = Ft817CatProtocol.encodeFrequencyBcd(435100000L)
assertContentEquals(byteArrayOf(0x43, 0x51, 0x00, 0x00), bcd)
}
@Test
fun encodeFrequencyBcd_7074000() {
val bcd = Ft817CatProtocol.encodeFrequencyBcd(7074000L)
assertContentEquals(byteArrayOf(0x00, 0x70, 0x74, 0x00), bcd)
}
@Test
fun decodeFrequencyBcd_roundTrips() {
val testFreqs = listOf(145500000L, 435100000L, 7074000L, 14200000L, 28500000L)
for (freq in testFreqs) {
val rounded = (freq / 10) * 10
val bcd = Ft817CatProtocol.encodeFrequencyBcd(freq)
assertEquals(rounded, Ft817CatProtocol.decodeFrequencyBcd(bcd))
}
}
@Test
fun buildSetFreqCommand_correctFormat() {
val cmd = Ft817CatProtocol.buildSetFreqCommand(145500000L)
assertEquals(5, cmd.size)
assertEquals(0x01.toByte(), cmd[4])
assertContentEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01), cmd)
}
@Test
fun buildSetModeCommand_usb() {
val cmd = Ft817CatProtocol.buildSetModeCommand("USB")
assertNotNull(cmd)
assertContentEquals(byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x07), cmd)
}
@Test
fun buildSetModeCommand_fm() {
val cmd = Ft817CatProtocol.buildSetModeCommand("FM")
assertNotNull(cmd)
assertContentEquals(byteArrayOf(0x08, 0x00, 0x00, 0x00, 0x07), cmd)
}
@Test
fun buildSetModeCommand_unknownReturnsNull() {
assertNull(Ft817CatProtocol.buildSetModeCommand("INVALID"))
}
@Test
fun encodeCtcssTone_67_0() {
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(67.0)
assertContentEquals(byteArrayOf(0x06, 0x70), bcd)
}
@Test
fun encodeCtcssTone_74_4() {
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(74.4)
assertContentEquals(byteArrayOf(0x07, 0x44), bcd)
}
@Test
fun encodeCtcssTone_141_3() {
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(141.3)
assertContentEquals(byteArrayOf(0x14, 0x13), bcd)
}
@Test
fun buildSetCtcssToneCommand_correctFormat() {
val cmd = Ft817CatProtocol.buildSetCtcssToneCommand(67.0)
assertEquals(5, cmd.size)
assertEquals(0x0B.toByte(), cmd[4])
assertContentEquals(byteArrayOf(0x06, 0x70, 0x00, 0x00, 0x0B), cmd)
}
@Test
fun buildCtcssModeCommand_enable() {
val cmd = Ft817CatProtocol.buildCtcssModeCommand(true)
assertContentEquals(byteArrayOf(0x2A, 0x00, 0x00, 0x00, 0x0A), cmd)
}
@Test
fun buildCtcssModeCommand_disable() {
val cmd = Ft817CatProtocol.buildCtcssModeCommand(false)
assertEquals(0x8A.toByte(), cmd[0])
assertEquals(0x0A.toByte(), cmd[4])
}
@Test
fun parseReadResponse_validResponse() {
val response = byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01)
val result = Ft817CatProtocol.parseReadResponse(response)
assertNotNull(result)
assertEquals(145500000L, result.first)
assertEquals("USB", result.second)
}
@Test
fun parseReadResponse_fmMode() {
val response = byteArrayOf(0x14, 0x60, 0x00, 0x00, 0x08)
val result = Ft817CatProtocol.parseReadResponse(response)
assertNotNull(result)
assertEquals(146000000L, result.first)
assertEquals("FM", result.second)
}
@Test
fun parseReadResponse_tooShort() {
assertNull(Ft817CatProtocol.parseReadResponse(byteArrayOf(0x14, 0x55, 0x00)))
}
@Test
fun parseReadResponse_unknownMode() {
val response = byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x0F)
assertNull(Ft817CatProtocol.parseReadResponse(response))
}
@Test
fun buildPttCommands() {
val on = Ft817CatProtocol.buildPttOnCommand()
assertContentEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x08), on)
val off = Ft817CatProtocol.buildPttOffCommand()
assertEquals(0x88.toByte(), off[4])
}
@Test
fun buildReadCommand() {
val cmd = Ft817CatProtocol.buildReadFreqModeCommand()
assertContentEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x03), cmd)
}
}
+1 -1
View File
@@ -1,3 +1,3 @@
plugins { plugins {
alias(libs.plugins.convention.kotlinLibraryPlugin) alias(libs.plugins.convention.coreDomainPlugin)
} }
@@ -17,16 +17,20 @@
*/ */
package com.rtbishop.look4sat.core.domain.model package com.rtbishop.look4sat.core.domain.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SatRadio( data class SatRadio(
val uuid: String, @SerialName("uuid") val uuid: String,
val info: String, @SerialName("description") val info: String,
val isAlive: Boolean, @SerialName("alive") val isAlive: Boolean,
var downlinkLow: Long?, @SerialName("downlink_low") val downlinkLow: Long?,
var downlinkHigh: Long?, @SerialName("downlink_high") val downlinkHigh: Long?,
val downlinkMode: String?, @SerialName("mode") val downlinkMode: String?,
var uplinkLow: Long?, @SerialName("uplink_low") val uplinkLow: Long?,
var uplinkHigh: Long?, @SerialName("uplink_high") val uplinkHigh: Long?,
val uplinkMode: String?, @SerialName("uplink_mode") val uplinkMode: String?,
val isInverted: Boolean, @SerialName("invert") val isInverted: Boolean,
val catnum: Int? @SerialName("norad_cat_id") val catnum: Int?
) )
@@ -24,6 +24,7 @@ data class DatabaseState(
) )
data class PassesSettings( data class PassesSettings(
val showDeepSpace: Boolean = true,
val hoursAhead: Int, val hoursAhead: Int,
val minElevation: Double, val minElevation: Double,
val selectedModes: List<String> val selectedModes: List<String>
@@ -53,6 +54,7 @@ data class OtherSettings(
val stateOfSweep: Boolean, val stateOfSweep: Boolean,
val stateOfUtc: Boolean, val stateOfUtc: Boolean,
val stateOfLightTheme: Boolean, val stateOfLightTheme: Boolean,
val stateOfNightMode: Boolean = false,
val shouldSeeWarning: Boolean, val shouldSeeWarning: Boolean,
val shouldSeeWhatsNew: Boolean val shouldSeeWhatsNew: Boolean
) )
@@ -63,3 +65,20 @@ data class DataSourcesSettings(
val tleUrl: String, val tleUrl: String,
val transceiversUrl: String val transceiversUrl: String
) )
data class RadioControlSettings(
val enabled: Boolean,
val radioModel: String,
val txRadioAddress: String,
val rxRadioAddress: String,
val txRadioName: String,
val rxRadioName: String,
val baudRate: Int
) {
companion object {
val SUPPORTED_RADIOS = listOf(
"Yaesu FT-817/818",
"Yaesu FT-857/897"
)
}
}
@@ -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 DEG2RAD = 0.017453292519943295
const val RAD2DEG = 57.29577951308232 const val RAD2DEG = 57.29577951308232
const val EARTH_RADIUS = 6378.137 const val EARTH_RADIUS = 6378.137
const val EARTH_ROT_PER_SID_DAY = 1.00273790934
const val EPSILON = 1.0E-12 const val EPSILON = 1.0E-12
const val FLAT_FACT = 3.35281066474748E-3 const val FLAT_FACT = 3.35281066474748E-3
const val J3_HARMONIC = -2.53881E-6 const val J3_HARMONIC = -2.53881E-6
@@ -97,40 +97,38 @@ class DeepSpaceObject(data: OrbitalData) : OrbitalObject(data) {
} }
internal fun calculateSDP4(tSince: Double) { internal fun calculateSDP4(tSince: Double) {
synchronized(this) { val temp = DoubleArray(12)
val temp = DoubleArray(12) val xmdf = data.xmo + dsv.xmdot * tSince
val xmdf = data.xmo + dsv.xmdot * tSince val tsq = tSince * tSince
val tsq = tSince * tSince val templ = t2cof * tsq
val templ = t2cof * tsq dsv.xll = xmdf + dsv.xnodp * templ
dsv.xll = xmdf + dsv.xnodp * templ dsv.omgadf = data.omegao + dsv.omgdot * tSince
dsv.omgadf = data.omegao + dsv.omgdot * tSince val xnoddf = data.xnodeo + dsv.xnodot * tSince
val xnoddf = data.xnodeo + dsv.xnodot * tSince dsv.xnode = xnoddf + xnodcf * tsq
dsv.xnode = xnoddf + xnodcf * tsq val tempa = 1.0 - c1 * tSince
val tempa = 1.0 - c1 * tSince val tempe = data.bstar * c4 * tSince
val tempe = data.bstar * c4 * tSince dsv.xn = dsv.xnodp
dsv.xn = dsv.xnodp dsv.t = tSince
dsv.t = tSince deep.dpsec(data)
deep.dpsec(data) val a = (XKE / dsv.xn).pow(TWO_THIRDS) * tempa * tempa
val a = (XKE / dsv.xn).pow(TWO_THIRDS) * tempa * tempa dsv.em -= tempe
dsv.em -= tempe deep.dpper()
deep.dpper() val xl = dsv.xll + dsv.omgadf + dsv.xnode
val xl = dsv.xll + dsv.omgadf + dsv.xnode val beta = sqrt(1.0 - dsv.em * dsv.em)
val beta = sqrt(1.0 - dsv.em * dsv.em) dsv.xn = XKE / a.pow(1.5)
dsv.xn = XKE / a.pow(1.5) // Long period periodics
// Long period periodics val axn = dsv.em * cos(dsv.omgadf)
val axn = dsv.em * cos(dsv.omgadf) temp[0] = invert(a * beta * beta)
temp[0] = invert(a * beta * beta) val xll = temp[0] * xlcof * axn
val xll = temp[0] * xlcof * axn val aynl = temp[0] * aycof
val aynl = temp[0] * aycof val xlt = xl + xll
val xlt = xl + xll val ayn = dsv.em * sin(dsv.omgadf) + aynl
val ayn = dsv.em * sin(dsv.omgadf) + aynl // Solve Kepler's equation
// Solve Kepler's equation val capu = mod2PI(xlt - dsv.xnode)
val capu = mod2PI(xlt - dsv.xnode) temp[2] = capu
temp[2] = capu converge(temp, axn, ayn, capu)
converge(temp, axn, ayn, capu) calculatePosAndVel(temp, a, axn, ayn)
calculatePosAndVel(temp, a, axn, ayn) calculatePhase(xlt, dsv.xnode, dsv.omgadf)
calculatePhase(xlt, dsv.xnode, dsv.omgadf)
}
} }
private fun calculatePosAndVel(temp: DoubleArray, a: Double, axn: Double, ayn: Double) { private fun calculatePosAndVel(temp: DoubleArray, a: Double, axn: Double, ayn: Double) {
@@ -141,53 +141,51 @@ class NearEarthObject(data: OrbitalData) : OrbitalObject(data) {
} }
internal fun calculateSGP4(tSince: Double) { internal fun calculateSGP4(tSince: Double) {
synchronized(this) { val temp = DoubleArray(9)
val temp = DoubleArray(9) val xmdf = data.xmo + xmdot * tSince
val xmdf = data.xmo + xmdot * tSince val omgadf = data.omegao + omgdot * tSince
val omgadf = data.omegao + omgdot * tSince val xnoddf = data.xnodeo + xnodot * tSince
val xnoddf = data.xnodeo + xnodot * tSince var omega = omgadf
var omega = omgadf var xmp = xmdf
var xmp = xmdf val tsq = tSince * tSince
val tsq = sqr(tSince) val xnode = xnoddf + xnodcf * tsq
val xnode = xnoddf + xnodcf * tsq val bstar = data.bstar
val bstar = data.bstar var tempa = 1.0 - c1 * tSince
var tempa = 1.0 - c1 * tSince var tempe = bstar * c4 * tSince
var tempe = bstar * c4 * tSince var templ = t2cof * tsq
var templ = t2cof * tsq if (!sgp4Simple) {
if (!sgp4Simple) { val delomg = omgcof * tSince
val delomg = omgcof * tSince val delm = xmcof * ((1.0 + eta * cos(xmdf)).pow(3.0) - delmo)
val delm = xmcof * ((1.0 + eta * cos(xmdf)).pow(3.0) - delmo) temp[0] = delomg + delm
temp[0] = delomg + delm xmp = xmdf + temp[0]
xmp = xmdf + temp[0] omega = omgadf - temp[0]
omega = omgadf - temp[0] val tcube = tsq * tSince
val tcube = tsq * tSince val tfour = tSince * tcube
val tfour = tSince * tcube tempa = tempa - d2 * tsq - d3 * tcube - d4 * tfour
tempa = tempa - d2 * tsq - d3 * tcube - d4 * tfour tempe += bstar * c5 * (sin(xmp) - sinmo)
tempe += bstar * c5 * (sin(xmp) - sinmo) templ += t3cof * tcube + tfour * (t4cof + tSince * t5cof)
templ += t3cof * tcube + tfour * (t4cof + tSince * t5cof)
}
val a = aodp * tempa.pow(2.0)
val eo = data.eccn
val e = eo - tempe
val xl = xmp + omega + xnode + xnodp * templ
val beta = sqrt(1.0 - e * e)
val xn = XKE / a.pow(1.5)
// Long period periodics
val axn = e * cos(omega)
temp[0] = invert(a * sqr(beta))
val xll = temp[0] * xlcof * axn
val aynl = temp[0] * aycof
val xlt = xl + xll
val ayn = e * sin(omega) + aynl
// Solve Kepler's equation
val capu = mod2PI(xlt - xnode)
temp[2] = capu
converge(temp, axn, ayn, capu)
calculatePosAndVel(temp, xnode, a, xn, axn, ayn)
calculatePhase(xlt, xnode, omgadf)
} }
val a = aodp * tempa * tempa
val eo = data.eccn
val e = eo - tempe
val xl = xmp + omega + xnode + xnodp * templ
val beta = sqrt(1.0 - e * e)
val xn = XKE / a.pow(1.5)
// Long period periodics
val axn = e * cos(omega)
temp[0] = invert(a * sqr(beta))
val xll = temp[0] * xlcof * axn
val aynl = temp[0] * aycof
val xlt = xl + xll
val ayn = e * sin(omega) + aynl
// Solve Kepler's equation
val capu = mod2PI(xlt - xnode)
temp[2] = capu
converge(temp, axn, ayn, capu)
calculatePosAndVel(temp, xnode, a, xn, axn, ayn)
calculatePhase(xlt, xnode, omgadf)
} }
private fun calculatePosAndVel( private fun calculatePosAndVel(
@@ -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
}
@@ -42,6 +42,27 @@ abstract class OrbitalObject(val data: OrbitalData) {
var qoms24 = 0.0 var qoms24 = 0.0
var s4 = 0.0 var s4 = 0.0
// Pre-allocated reusable vectors to avoid GC pressure in hot loops
private val obsPos = Vector4()
private val obsVel = Vector4()
private val rangeVector = Vector4()
private val rgvelVector = Vector4()
private val squintVector = Vector4()
// Cached observer position data to avoid recalculation when observer hasn't moved
private var cachedGsLat = Double.NaN
private var cachedGsLon = Double.NaN
private var cachedGsAlt = Double.NaN
private var cachedSinLat = 0.0
private var cachedCosLat = 0.0
private var cachedObsC = 0.0
private var cachedObsSq = 0.0
private var cachedObsAchFactor = 0.0
private var cachedObsZFactor = 0.0
// Cache for julian epoch to avoid recomputing every call
private val julEpoch: Double = juliandDateOfEpoch(data.epoch)
fun willBeSeen(pos: GeoPos): Boolean { fun willBeSeen(pos: GeoPos): Boolean {
return if (data.meanmo < 1e-8) false return if (data.meanmo < 1e-8) false
else { else {
@@ -57,15 +78,13 @@ abstract class OrbitalObject(val data: OrbitalData) {
orbitalPos = OrbitalPos() orbitalPos = OrbitalPos()
// Date/time at which the position and velocity were calculated // Date/time at which the position and velocity were calculated
julUTC = calcCurrentDaynum(time) + 2444238.5 julUTC = calcCurrentDaynum(time) + 2444238.5
// Convert satellite's epoch time to Julian and calculate time since epoch in minutes // Calculate time since epoch in minutes
val julEpoch = juliandDateOfEpoch(data.epoch)
val tsince = (julUTC - julEpoch) * MIN_PER_DAY val tsince = (julUTC - julEpoch) * MIN_PER_DAY
calculateSDP4orSGP4(tsince) calculateSDP4orSGP4(tsince)
// Scale position and velocity vectors to km and km/sec // Scale position and velocity vectors to km and km/sec
convertSatState(position, velocity) convertSatState(position, velocity)
// Calculate velocity of satellite // Calculate velocity of satellite
magnitude(velocity) magnitude(velocity)
val squintVector = Vector4()
// Angles in rads, dist in km, vel in km/S. Calculate sat Az, El, Range and Range-rate. // Angles in rads, dist in km, vel in km/S. Calculate sat Az, El, Range and Range-rate.
calculateObs(julUTC, position, velocity, pos, squintVector) calculateObs(julUTC, position, velocity, pos, squintVector)
calculateLatLonAlt(julUTC) calculateLatLonAlt(julUTC)
@@ -75,6 +94,38 @@ abstract class OrbitalObject(val data: OrbitalData) {
return orbitalPos return orbitalPos
} }
/**
* Lightweight elevation-only check for pass finding. Avoids full lat/lon/alt,
* eclipse, and squint calculations that aren't needed when just searching for
* horizon crossings.
*/
fun getElevation(pos: GeoPos, time: Long): Double {
julUTC = calcCurrentDaynum(time) + 2444238.5
val tsince = (julUTC - julEpoch) * MIN_PER_DAY
calculateSDP4orSGP4(tsince)
convertSatState(position, velocity)
magnitude(velocity)
calculateObs(julUTC, position, velocity, pos, squintVector)
return orbitalPos.elevation
}
/**
* Full position calculation that also populates azimuth, altitude, etc.
* Used when we need all fields (AOS/LOS refinement, track computation).
*/
fun getFullPosition(pos: GeoPos, time: Long): OrbitalPos {
orbitalPos = OrbitalPos()
julUTC = calcCurrentDaynum(time) + 2444238.5
val tsince = (julUTC - julEpoch) * MIN_PER_DAY
calculateSDP4orSGP4(tsince)
convertSatState(position, velocity)
magnitude(velocity)
calculateObs(julUTC, position, velocity, pos, squintVector)
calculateLatLonAlt(julUTC)
orbitalPos.time = time
return orbitalPos
}
private fun calcCurrentDaynum(now: Long): Double { private fun calcCurrentDaynum(now: Long): Double {
val then = 315446400000 // time in millis on 31Dec79 00:00:00 UTC (daynum 0) val then = 315446400000 // time in millis on 31Dec79 00:00:00 UTC (daynum 0)
return (now - then) / 1000.0 / 60.0 / 60.0 / 24.0 return (now - then) / 1000.0 / 60.0 / 60.0 / 24.0
@@ -117,38 +168,34 @@ abstract class OrbitalObject(val data: OrbitalData) {
gsPos: GeoPos, gsPos: GeoPos,
squintVector: Vector4 squintVector: Vector4
) { ) {
val obsPos = Vector4()
val obsVel = Vector4()
val range = Vector4()
val rgvel = Vector4()
calculateUserPosVel(julianUTC, gsPos, obsPos, obsVel) calculateUserPosVel(julianUTC, gsPos, obsPos, obsVel)
range.setXYZ( rangeVector.setXYZ(
positionVector.x - obsPos.x, positionVector.x - obsPos.x,
positionVector.y - obsPos.y, positionVector.y - obsPos.y,
positionVector.z - obsPos.z positionVector.z - obsPos.z
) )
// Save these values globally for calculating squint angles later // Save these values globally for calculating squint angles later
squintVector.setXYZ(range.x, range.y, range.z) squintVector.setXYZ(rangeVector.x, rangeVector.y, rangeVector.z)
rgvel.setXYZ( rgvelVector.setXYZ(
velocityVector.x - obsVel.x, velocityVector.x - obsVel.x,
velocityVector.y - obsVel.y, velocityVector.y - obsVel.y,
velocityVector.z - obsVel.z velocityVector.z - obsVel.z
) )
magnitude(range) magnitude(rangeVector)
val sinLat = sin(DEG2RAD * gsPos.latitude) val sinLat = cachedSinLat
val cosLat = cos(DEG2RAD * gsPos.latitude) val cosLat = cachedCosLat
val sinTheta = sin(gsPosTheta) val sinTheta = sin(gsPosTheta)
val cosTheta = cos(gsPosTheta) val cosTheta = cos(gsPosTheta)
val topS = sinLat * cosTheta * range.x + sinLat * sinTheta * range.y - cosLat * range.z val topS = sinLat * cosTheta * rangeVector.x + sinLat * sinTheta * rangeVector.y - cosLat * rangeVector.z
val topE = -sinTheta * range.x + cosTheta * range.y val topE = -sinTheta * rangeVector.x + cosTheta * rangeVector.y
val topZ = cosLat * cosTheta * range.x + cosLat * sinTheta * range.y + sinLat * range.z val topZ = cosLat * cosTheta * rangeVector.x + cosLat * sinTheta * rangeVector.y + sinLat * rangeVector.z
var azim = atan(-topE / topS) var azim = atan(-topE / topS)
if (topS > 0.0) azim += PI if (topS > 0.0) azim += PI
if (azim < 0.0) azim += TWO_PI if (azim < 0.0) azim += TWO_PI
orbitalPos.azimuth = azim orbitalPos.azimuth = azim
orbitalPos.elevation = asin(topZ / range.w) orbitalPos.elevation = asin(topZ / rangeVector.w)
orbitalPos.distance = range.w orbitalPos.distance = rangeVector.w
orbitalPos.distanceRate = dot(range, rgvel) / range.w orbitalPos.distanceRate = dot(rangeVector, rgvelVector) / rangeVector.w
var elevation = orbitalPos.elevation / TWO_PI * 360.0 var elevation = orbitalPos.elevation / TWO_PI * 360.0
if (elevation > 90) elevation = 180 - elevation if (elevation > 90) elevation = 180 - elevation
orbitalPos.aboveHorizon = elevation - 0 > EPSILON orbitalPos.aboveHorizon = elevation - 0 > EPSILON
@@ -163,12 +210,24 @@ abstract class OrbitalObject(val data: OrbitalData) {
) { ) {
val mFactor = 7.292115E-5 val mFactor = 7.292115E-5
gsPosTheta = mod2PI(thetaGJD(time) + DEG2RAD * gsPos.longitude) gsPosTheta = mod2PI(thetaGJD(time) + DEG2RAD * gsPos.longitude)
val c = invert(sqrt(1.0 + FLAT_FACT * (FLAT_FACT - 2) * sqr(sin(DEG2RAD * gsPos.latitude))))
val sq = sqr(1.0 - FLAT_FACT) * c // Cache trig and position factors when observer position changes
val achcp = (EARTH_RADIUS * c + gsPos.altitude / 1000.0) * cos(DEG2RAD * gsPos.latitude) if (gsPos.latitude != cachedGsLat || gsPos.longitude != cachedGsLon || gsPos.altitude != cachedGsAlt) {
cachedGsLat = gsPos.latitude
cachedGsLon = gsPos.longitude
cachedGsAlt = gsPos.altitude
cachedSinLat = sin(DEG2RAD * gsPos.latitude)
cachedCosLat = cos(DEG2RAD * gsPos.latitude)
cachedObsC = invert(sqrt(1.0 + FLAT_FACT * (FLAT_FACT - 2) * sqr(cachedSinLat)))
cachedObsSq = sqr(1.0 - FLAT_FACT) * cachedObsC
cachedObsAchFactor = (EARTH_RADIUS * cachedObsC + gsPos.altitude / 1000.0) * cachedCosLat
cachedObsZFactor = (EARTH_RADIUS * cachedObsSq + gsPos.altitude / 1000.0) * cachedSinLat
}
obsPos.setXYZ( obsPos.setXYZ(
achcp * cos(gsPosTheta), achcp * sin(gsPosTheta), cachedObsAchFactor * cos(gsPosTheta),
(EARTH_RADIUS * sq + gsPos.altitude / 1000.0) * sin(DEG2RAD * gsPos.latitude) cachedObsAchFactor * sin(gsPosTheta),
cachedObsZFactor
) )
obsVel.setXYZ(-mFactor * obsPos.y, mFactor * obsPos.x, 0.0) obsVel.setXYZ(-mFactor * obsPos.y, mFactor * obsPos.x, 0.0)
magnitude(obsPos) magnitude(obsPos)
@@ -255,14 +314,8 @@ abstract class OrbitalObject(val data: OrbitalData) {
return 1.0 / value return 1.0 / value
} }
// Calculates the modulus of 2 * PI // Delegates to package-level mod2PI in OrbitalMath.kt
internal fun mod2PI(value: Double): Double { internal fun mod2PI(value: Double): Double = com.rtbishop.look4sat.core.domain.predict.mod2PI(value)
var retVal = value
val i = (retVal / TWO_PI).toInt()
retVal -= i * TWO_PI
if (retVal < 0.0) retVal += TWO_PI
return retVal
}
// Solves Keplers' Equation // Solves Keplers' Equation
internal fun converge(temp: DoubleArray, axn: Double, ayn: Double, capu: Double) { internal fun converge(temp: DoubleArray, axn: Double, ayn: Double, capu: Double) {
@@ -364,19 +417,8 @@ abstract class OrbitalObject(val data: OrbitalData) {
return acos(dot(v1, v2) / (v1.w * v2.w)) return acos(dot(v1, v2) / (v1.w * v2.w))
} }
/** // Delegates to package-level deltaET in OrbitalMath.kt
* The function Delta_ET has been added to allow calculations on the private fun deltaEt(year: Double): Double = deltaET(year)
* 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 a 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))
}
private fun radians(degrees: Double): Double { private fun radians(degrees: Double): Double {
return degrees * DEG2RAD return degrees * DEG2RAD
@@ -387,23 +429,13 @@ abstract class OrbitalObject(val data: OrbitalData) {
return v1.x * v2.x + v1.y * v2.y + v1.z * v2.z 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 // Calculates scalar magnitude of a vector4 argument
private fun magnitude(v: Vector4) { private fun magnitude(v: Vector4) {
v.w = sqrt(sqr(v.x) + sqr(v.y) + sqr(v.z)) v.w = sqrt(sqr(v.x) + sqr(v.y) + sqr(v.z))
} }
private fun modulus(arg1: Double, arg2: Double = SEC_PER_DAY): Double { private fun modulus(arg1: Double, arg2: Double = SEC_PER_DAY): Double =
var returnValue = arg1 com.rtbishop.look4sat.core.domain.predict.modulus(arg1, arg2)
val i = floor(returnValue / arg2).toInt()
returnValue -= i * arg2
if (returnValue < 0.0) returnValue += arg2
return returnValue
}
// Multiplies the vector v1 by the scalar k // Multiplies the vector v1 by the scalar k
private fun scaleVector(k: Double, v: Vector4) { private fun scaleVector(k: Double, v: Vector4) {
@@ -411,13 +443,6 @@ abstract class OrbitalObject(val data: OrbitalData) {
magnitude(v) magnitude(v)
} }
private fun thetaGJD(theJD: Double): Double { // Delegates to package-level thetaGJD in OrbitalMath.kt
val earthRotPerSidDay = 1.00273790934 private fun thetaGJD(theJD: Double): Double = com.rtbishop.look4sat.core.domain.predict.thetaGJD(theJD)
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
}
} }
@@ -25,7 +25,7 @@ data class OrbitalPass(
val altitude: Int = 1000, val altitude: Int = 1000,
val maxElevation: Double = 75.0, val maxElevation: Double = 75.0,
val orbitalObject: OrbitalObject, val orbitalObject: OrbitalObject,
var progress: Float = 0.0f val progress: Float = 0.0f
) { ) {
val catNum: Int = orbitalObject.data.catnum val catNum: Int = orbitalObject.data.catnum
val name: String = orbitalObject.data.name val name: String = orbitalObject.data.name
@@ -21,7 +21,6 @@ import kotlin.math.acos
import kotlin.math.asin import kotlin.math.asin
import kotlin.math.atan2 import kotlin.math.atan2
import kotlin.math.cos import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.sin import kotlin.math.sin
import kotlin.math.sqrt import kotlin.math.sqrt
@@ -50,23 +49,30 @@ data class OrbitalPos(
} }
fun getOrbitalVelocity(): Double { fun getOrbitalVelocity(): Double {
val earthG = 6.674 * 10.0.pow(-11) val radius = EARTH_RADIUS_M + altitude * 1000.0
val earthM = 5.98 * 10.0.pow(24) return sqrt(GM_EARTH / radius) / 1000.0
val radius = 6.37 * 10.0.pow(6) + altitude * 10.0.pow(3)
return sqrt(earthG * earthM / radius) / 1000
} }
fun getRangeCircle(): List<GeoPos> { fun getRangeCircle(): List<GeoPos> {
val rangeCirclePoints = mutableListOf<GeoPos>() val pointCount = 721
val beta = acos(EARTH_RADIUS / (EARTH_RADIUS + altitude)) // * EARTH_RADIUS = radiusKm val rangeCirclePoints = ArrayList<GeoPos>(pointCount)
val beta = acos(EARTH_RADIUS / (EARTH_RADIUS + altitude))
val sinLat = sin(latitude)
val cosLat = cos(latitude)
val cosBeta = cos(beta)
val sinBeta = sin(beta)
for (azimuth in 0..720) { for (azimuth in 0..720) {
val rads = azimuth * DEG2RAD val rads = azimuth * DEG2RAD
val lat = asin(sin(latitude) * cos(beta) + (cos(latitude) * sin(beta) * cos(rads))) val lat = asin(sinLat * cosBeta + cosLat * sinBeta * cos(rads))
val lon = (longitude + atan2( val lon = longitude + atan2(sin(rads) * sinBeta * cosLat, cosBeta - sinLat * sin(lat))
sin(rads) * sin(beta) * cos(latitude), cos(beta) - sin(latitude) * sin(lat)
))
rangeCirclePoints.add(GeoPos(lat * RAD2DEG, lon * RAD2DEG)) rangeCirclePoints.add(GeoPos(lat * RAD2DEG, lon * RAD2DEG))
} }
return rangeCirclePoints return rangeCirclePoints
} }
companion object {
// Pre-computed constants for orbital velocity calculation
private const val GM_EARTH = 3.986004418E14 // m^3/s^2
private const val EARTH_RADIUS_M = 6.37E6 // meters
}
} }
@@ -12,9 +12,12 @@ interface IMainContainer {
val databaseRepo: IDatabaseRepo val databaseRepo: IDatabaseRepo
fun provideAddToCalendar(): IAddToCalendar fun provideAddToCalendar(): IAddToCalendar
fun provideShowToast(): IShowToast fun provideShowToast(): IShowToast
fun provideBluetoothReporter(): IReporterRepo<WithoutExtParams> fun provideBluetoothReporter(): IReporter
fun provideNetworkReporter(): IReporterRepo<ExtendedParams> fun provideNetworkReporter(): IReporter
fun provideSensorsRepo(): ISensorsRepo fun provideSensorsRepo(): ISensorsRepo
fun provideTxRadioController(): IRadioController
fun provideRxRadioController(): IRadioController
val radioTrackingService: IRadioTrackingService
} }
interface IContainerProvider { interface IContainerProvider {
@@ -0,0 +1,41 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.repository
interface IRadioController {
val isConnected: Boolean
suspend fun connect(): Boolean
suspend fun disconnect()
suspend fun setFrequency(frequencyHz: Long): Boolean
suspend fun setMode(mode: String): Boolean
suspend fun setCtcssMode(enabled: Boolean): Boolean
suspend fun setCtcssTone(toneHz: Double): Boolean
suspend fun readFrequencyAndMode(): Pair<Long, String>?
suspend fun pttOn(): Boolean
suspend fun pttOff(): Boolean
}
@@ -0,0 +1,55 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import kotlinx.coroutines.flow.StateFlow
data class RadioTrackingState(
val isActive: Boolean = false,
val txConnected: Boolean = false,
val rxConnected: Boolean = false,
val txFrequencyHz: Long? = null,
val rxFrequencyHz: Long? = null,
val txMode: String? = null,
val rxMode: String? = null,
val ctcssTone: Double? = null,
val txBaseFrequencyHz: Long? = null,
val selectedTransponder: SatRadio? = null,
val currentPass: OrbitalPass? = null,
val azimuth: Double = 0.0,
val elevation: Double = 0.0,
val distance: Double = 0.0,
val errorMessage: String? = null
)
interface IRadioTrackingService {
val state: StateFlow<RadioTrackingState>
suspend fun connectRadios()
suspend fun disconnectRadios()
fun startTracking(pass: OrbitalPass, transponder: SatRadio, txBaseFreqHz: Long?)
fun stopTracking()
fun setTransponder(transponder: SatRadio)
fun setTxBaseFrequency(frequencyHz: Long)
fun adjustTxBaseFrequency(deltaHz: Long)
fun setCtcssTone(toneHz: Double?)
fun setMode(txMode: String, rxMode: String)
}
@@ -17,18 +17,7 @@
*/ */
package com.rtbishop.look4sat.core.domain.repository package com.rtbishop.look4sat.core.domain.repository
interface IReporterParams interface IReporter {
fun reportRotation(format: String, azimuth: Double, elevation: Double)
interface IReporterRepo<T : IReporterParams> { fun reportFrequency(format: String, frequency: Long)
fun isConnected(service: BtService): Boolean
fun isConnecting(service: BtService): Boolean
fun connect(service: BtService, deviceId: String)
fun reportRotation(format: String, azimuth: Double, elevation: Double, params: T)
fun reportFrequency(format: String, frequency: Long, params: T)
} }
data class ExtendedParams(val server: String, val port: Int) : IReporterParams
data class WithoutExtParams(val dummy: Int) : IReporterParams
enum class BtService { ROTATOR, FREQUENCY }
@@ -25,13 +25,36 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
interface ISatelliteRepo { interface ISatelliteRepo {
val passes: StateFlow<List<OrbitalPass>> /** All selected OrbitalObjects, updated when the selection changes. */
val satellites: StateFlow<List<OrbitalObject>> val satellites: StateFlow<List<OrbitalObject>>
suspend fun getRadiosWithId(id: Int): List<SatRadio>
/** 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() suspend fun initRepository()
suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos
suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos> /** Recalculate passes with the given filter parameters. */
suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List<SatRadio>, time: Long): List<SatRadio>
suspend fun processPasses(passList: List<OrbitalPass>, time: Long): List<OrbitalPass>
suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>) suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>)
/** Get the current position of a single satellite. */
suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos
/** Get the ground track for a satellite over a time range. */
suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos>
/** Get Doppler-shifted radio frequencies for a satellite at the given time. */
suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List<SatRadio>, time: Long): List<SatRadio>
/** Fetch radio transceivers for a satellite by its catalog number. */
suspend fun getRadiosWithId(id: Int): List<SatRadio>
} }
@@ -22,6 +22,7 @@ import com.rtbishop.look4sat.core.domain.model.DatabaseState
import com.rtbishop.look4sat.core.domain.model.OtherSettings import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.predict.GeoPos
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -57,39 +58,23 @@ interface ISettingsRepo {
//region # RC settings //region # RC settings
val rcSettings: StateFlow<RCSettings> val rcSettings: StateFlow<RCSettings>
fun setBluetoothRotatorAddress(value: String) fun updateRCSettings(settings: RCSettings)
fun setBluetoothRotatorFormat(value: String)
fun setBluetoothRotatorName(value: String)
fun setBluetoothRotatorState(value: Boolean)
fun setBluetoothFrequencyAddress(value: String)
fun setBluetoothFrequencyFormat(value: String)
fun setBluetoothFrequencyState(value: Boolean)
fun setRotatorAddress(value: String)
fun setRotatorPort(value: String)
fun setRotatorState(value: Boolean)
fun setRotatorFormat(value: String)
fun setFrequencyAddress(value: String)
fun setFrequencyPort(value: String)
fun setFrequencyState(value: Boolean)
fun setFrequencyFormat(value: String)
//endregion //endregion
//region # Other settings //region # Other settings
val otherSettings: StateFlow<OtherSettings> val otherSettings: StateFlow<OtherSettings>
fun setStateOfAutoUpdate(value: Boolean) fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings)
fun setStateOfSensors(value: Boolean) fun setWarningDismissed() = updateOtherSettings { it.copy(shouldSeeWarning = false) }
fun setStateOfSweep(value: Boolean) fun setWhatsNewDismissed() = updateOtherSettings { it.copy(shouldSeeWhatsNew = false) }
fun setStateOfUtc(value: Boolean)
fun setStateOfLightTheme(value: Boolean)
fun setWarningDismissed()
fun setWhatsNewDismissed()
//endregion //endregion
//region # Transceivers settings //region # Transceivers settings
val dataSourcesSettings: StateFlow<DataSourcesSettings> val dataSourcesSettings: StateFlow<DataSourcesSettings>
fun setUseCustomTle(value: Boolean) fun updateDataSourcesSettings(settings: DataSourcesSettings)
fun setUseCustomTransceivers(value: Boolean) //endregion
fun setTleUrl(value: String)
fun setTransceiversUrl(value: String) //region # Radio control settings
val radioControlSettings: StateFlow<RadioControlSettings>
fun updateRadioControlSettings(settings: RadioControlSettings)
//endregion //endregion
} }
@@ -18,7 +18,6 @@
package com.rtbishop.look4sat.core.domain.source package com.rtbishop.look4sat.core.domain.source
object Sources { object Sources {
const val RADIO_DATA_URL = "https://db.satnogs.org/api/transmitters/?format=json&status=active"
val satelliteDataUrls = mapOf( val satelliteDataUrls = mapOf(
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv", "All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv", "Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
@@ -49,4 +48,7 @@ object Sources {
"R4UAB" to "https://r4uab.ru/satonline.txt", "R4UAB" to "https://r4uab.ru/satonline.txt",
"Other" to "" // key for sats filter "Other" to "" // key for sats filter
) )
val transceiversDataUrls = mapOf(
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active"
)
} }
@@ -21,134 +21,91 @@ import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalData import com.rtbishop.look4sat.core.domain.predict.OrbitalData
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONArray import kotlinx.serialization.json.Json
import org.json.JSONObject import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.decodeFromJsonElement
import java.io.InputStream import java.io.InputStream
import kotlin.math.pow import kotlin.math.pow
class DataParser(private val dispatcher: CoroutineDispatcher) { class DataParser(private val dispatcher: CoroutineDispatcher) {
private val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
}
suspend fun parseCSVStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) { suspend fun parseCSVStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
val parsedItems = mutableListOf<OrbitalData>()
stream.bufferedReader().useLines { lines -> stream.bufferedReader().useLines { lines ->
lines.forEachIndexed { index, line -> lines.drop(1).mapNotNull { parseCSV(it.split(",")) }.toList()
if (index != 0) {
val values = line.split(",")
parseCSV(values)?.let { tle -> parsedItems.add(tle) }
}
}
} }
return@withContext parsedItems
} }
suspend fun parseTLEStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) { suspend fun parseTLEStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
val tleStrings = mutableListOf(String(), String(), String()) stream.bufferedReader().readLines()
val parsedItems = mutableListOf<OrbitalData>() .chunked(3)
var lineIndex = 0 .filter { it.size == 3 && it[1].startsWith("1") && it[2].startsWith("2") }
stream.bufferedReader().forEachLine { line -> .mapNotNull { parseTLE(it) }
tleStrings[lineIndex] = line
if (lineIndex < 2) {
lineIndex++
} else {
val isLineOneValid = tleStrings[1].substring(0, 1) == "1"
val isLineTwoValid = tleStrings[2].substring(0, 1) == "2"
if (!isLineOneValid && !isLineTwoValid) return@forEachLine
parseTLE(tleStrings)?.let { tle -> parsedItems.add(tle) }
lineIndex = 0
}
}
return@withContext parsedItems
} }
suspend fun parseJSONStream(stream: InputStream): List<SatRadio> = withContext(dispatcher) { suspend fun parseJSONStream(stream: InputStream): List<SatRadio> = withContext(dispatcher) {
val parsedItems = mutableListOf<SatRadio>() runCatching {
try { val root = json.parseToJsonElement(stream.bufferedReader().readText())
val jsonArray = JSONArray(stream.bufferedReader().readText()) (root as? JsonArray)?.mapNotNull { element ->
for (index in 0 until jsonArray.length()) { runCatching { json.decodeFromJsonElement<SatRadio>(element) }
val jsonObject = jsonArray.getJSONObject(index) .onFailure { println("JSON parsing exception: $it") }
parseJSON(jsonObject)?.let { parsedItems.add(it) } .getOrNull()
} } ?: emptyList()
return@withContext parsedItems }.getOrDefault(emptyList())
} catch (_: Exception) {
return@withContext parsedItems
}
} }
fun isLeapYear(year: Int): Boolean { private fun parseCSV(values: List<String>): OrbitalData? = runCatching {
return ((year % 4 == 0) && (year % 100 != 0)) || (year % 400 == 0)
}
private fun parseCSV(values: List<String>): OrbitalData? = try {
val name = values[0] val name = values[0]
val year = values[2].substring(0, 4) val timestamp = values[2]
val month = values[2].substring(5, 7) val year = timestamp.substring(0, 4)
val dayOfMonth = values[2].substring(8, 10) val month = timestamp.substring(5, 7).toInt()
val dayInt = getDayOfYear(year.toInt(), month.toInt(), dayOfMonth.toInt()) val dayOfMonth = timestamp.substring(8, 10).toInt()
val day = if (dayInt < 10) "00$dayInt" else if (dayInt < 100) "0$dayInt" else "$dayInt" val dayInt = getDayOfYear(year.toInt(), month, dayOfMonth)
val hour = values[2].substring(11, 13).toInt() * 3600000 // ms in one hour val day = dayInt.toString().padStart(3, '0')
val min = values[2].substring(14, 16).toInt() * 60000 // ms in one minute val hour = timestamp.substring(11, 13).toInt() * 3600000
val sec = values[2].substring(17, 19).toInt() * 1000 // ms in one second val min = timestamp.substring(14, 16).toInt() * 60000
val ms = values[2].substring(20, 26).toInt() / 1000.0 // microseconds to ms val sec = timestamp.substring(17, 19).toInt() * 1000
val frac = ((hour + min + sec + ms) / 86400000.0).toString() val ms = timestamp.substring(20, 26).toInt() / 1000.0
val epoch = "${year.substring(2)}$day${frac.substring(1)}".toDouble() val frac = ((hour + min + sec + ms) / 86400000.0).toString().substring(1)
val meanmo = values[3].toDouble() val epoch = "${year.substring(2)}$day$frac".toDouble()
val eccn = values[4].toDouble() OrbitalData(
val incl = values[5].toDouble() name = name,
val raan = values[6].toDouble() epoch = epoch,
val argper = values[7].toDouble() meanmo = values[3].toDouble(),
val meanan = values[8].toDouble() eccn = values[4].toDouble(),
val catnum = values[11].toInt() incl = values[5].toDouble(),
val bstar = values[14].toDouble() raan = values[6].toDouble(),
OrbitalData(name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar) argper = values[7].toDouble(),
} catch (exception: Exception) { meanan = values[8].toDouble(),
println("CSV parsing exception: $exception") catnum = values[11].toInt(),
null bstar = values[14].toDouble()
} )
}.onFailure { println("CSV parsing exception: $it") }.getOrNull()
private fun parseTLE(tle: List<String>): OrbitalData? = try { private fun parseTLE(tle: List<String>): OrbitalData? = runCatching {
val name: String = tle[0].trim() val line1 = tle[1]
val epoch: Double = tle[1].substring(18, 32).toDouble() val line2 = tle[2]
val meanmo: Double = tle[2].substring(52, 63).toDouble() OrbitalData(
val eccn: Double = tle[2].substring(26, 33).toDouble() / 10000000.0 name = tle[0].trim(),
val incl: Double = tle[2].substring(8, 16).toDouble() epoch = line1.substring(18, 32).toDouble(),
val raan: Double = tle[2].substring(17, 25).toDouble() meanmo = line2.substring(52, 63).toDouble(),
val argper: Double = tle[2].substring(34, 42).toDouble() eccn = line2.substring(26, 33).toDouble() / 1e7,
val meanan: Double = tle[2].substring(43, 51).toDouble() incl = line2.substring(8, 16).toDouble(),
val catnum: Int = tle[1].substring(2, 7).trim().toInt() raan = line2.substring(17, 25).toDouble(),
val bstar: Double = 1.0e-5 * tle[1].substring(53, 59).toDouble() / 10.0.pow(tle[1].substring(60, 61).toDouble()) argper = line2.substring(34, 42).toDouble(),
OrbitalData(name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar) meanan = line2.substring(43, 51).toDouble(),
} catch (exception: Exception) { catnum = line1.substring(2, 7).trim().toInt(),
println("TLE parsing exception: $exception") bstar = 1e-5 * line1.substring(53, 59).toDouble() / 10.0.pow(line1.substring(60, 61).toDouble())
null )
} }.onFailure { println("TLE parsing exception: $it") }.getOrNull()
private fun parseJSON(json: JSONObject): SatRadio? = try {
val uuid = json.getString("uuid")
val info = json.getString("description")
val alive = json.getBoolean("alive")
val dlinkLow = if (json.isNull("downlink_low")) null else json.getLong("downlink_low")
val dlinkHigh = if (json.isNull("downlink_high")) null else json.getLong("downlink_high")
val dlinkMode = if (json.isNull("mode")) null else json.getString("mode")
val ulinkLow = if (json.isNull("uplink_low")) null else json.getLong("uplink_low")
val ulinkHigh = if (json.isNull("uplink_high")) null else json.getLong("uplink_high")
val ulinkMode = if (json.isNull("uplink_mode")) null else json.getString("uplink_mode")
val inverted = json.getBoolean("invert")
val catnum = if (json.isNull("norad_cat_id")) null else json.getInt("norad_cat_id")
SatRadio(uuid, info, alive, dlinkLow, dlinkHigh, dlinkMode, ulinkLow, ulinkHigh, ulinkMode, inverted, catnum)
} catch (exception: Exception) {
println("JSON parsing exception: $exception")
null
}
private fun getDayOfYear(year: Int, month: Int, dayOfMonth: Int): Int { private fun getDayOfYear(year: Int, month: Int, dayOfMonth: Int): Int {
if (month == 1) return dayOfMonth val isLeapYear = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
val daysArray = arrayOf(31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) val daysInMonth = intArrayOf(31, if (isLeapYear) 29 else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
var dayOfYear = dayOfMonth return daysInMonth.take(month - 1).sum() + dayOfMonth
// If leap year increment Feb days
if (isLeapYear(year)) daysArray[1]++
for (i in 0 until month - 1) {
dayOfYear += daysArray[i]
}
return dayOfYear
} }
} }
@@ -21,10 +21,11 @@ import java.util.Locale
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
fun Long.toTimerString(): String { fun Long.toTimerString(): String {
val millis = coerceAtLeast(0L)
val format = "%02d:%02d:%02d" val format = "%02d:%02d:%02d"
val hours = TimeUnit.MILLISECONDS.toHours(this) val hours = TimeUnit.MILLISECONDS.toHours(millis)
val minutes = TimeUnit.MILLISECONDS.toMinutes(this) % 60 val minutes = TimeUnit.MILLISECONDS.toMinutes(millis) % 60
val seconds = TimeUnit.MILLISECONDS.toSeconds(this) % 60 val seconds = TimeUnit.MILLISECONDS.toSeconds(millis) % 60
return String.format(Locale.ENGLISH, format, hours, minutes, seconds) return String.format(Locale.ENGLISH, format, hours, minutes, seconds)
} }
@@ -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.DEG2RAD
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG 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.max
import kotlin.math.min 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 MIN_LATITUDE = -85.05112877980658
private const val MAX_LATITUDE = 85.05112877980658 private const val MAX_LATITUDE = 85.05112877980658
private const val MIN_LONGITUDE = -180.0 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 // 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 { fun clipLat(latitude: Double): Double {
return clip(latitude, MIN_LATITUDE, MAX_LATITUDE) return clip(latitude, MIN_LATITUDE, MAX_LATITUDE)
} }
@@ -0,0 +1,73 @@
/*
* 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.utility
import com.rtbishop.look4sat.core.domain.model.SatRadio
object TransponderMapper {
fun mapUplinkToDownlink(txFreqHz: Long, transponder: SatRadio): Long? {
val uplinkLow = transponder.uplinkLow ?: return null
val downlinkLow = transponder.downlinkLow ?: return null
// Single-frequency transponder (FM repeater) - just return the downlink freq
val uplinkHigh = transponder.uplinkHigh
val downlinkHigh = transponder.downlinkHigh
if (uplinkHigh == null || downlinkHigh == null
|| uplinkLow == uplinkHigh || downlinkLow == downlinkHigh
) {
return downlinkLow
}
// Passband transponder (linear) - map within the band
val offset = txFreqHz - uplinkLow
return if (transponder.isInverted) {
downlinkHigh - offset
} else {
downlinkLow + offset
}
}
fun mapDownlinkToUplink(rxFreqHz: Long, transponder: SatRadio): Long? {
val uplinkLow = transponder.uplinkLow ?: return null
val downlinkLow = transponder.downlinkLow ?: return null
val uplinkHigh = transponder.uplinkHigh
val downlinkHigh = transponder.downlinkHigh
if (uplinkHigh == null || downlinkHigh == null
|| uplinkLow == uplinkHigh || downlinkLow == downlinkHigh
) {
return uplinkLow
}
return if (transponder.isInverted) {
uplinkLow + (downlinkHigh - rxFreqHz)
} else {
uplinkLow + (rxFreqHz - downlinkLow)
}
}
fun mapUplinkModeToDownlinkMode(txMode: String, isInverted: Boolean): String {
if (!isInverted) return txMode
return when (txMode.uppercase()) {
"USB" -> "LSB"
"LSB" -> "USB"
else -> txMode
}
}
}
@@ -95,11 +95,11 @@ class DataParserTest {
assert(dataParser.parseCSVStream(validCSVStream) == dataParser.parseTLEStream(validTLEStream)) assert(dataParser.parseCSVStream(validCSVStream) == dataParser.parseTLEStream(validTLEStream))
} }
@Test // @Test
fun `Function isLeapYear returns correct data`() = runTest(testDispatcher) { // fun `Function isLeapYear returns correct data`() = runTest(testDispatcher) {
val years = listOf(1900, 1984, 1994, 2016, 2022, 2024, 2042, 2048) // val years = listOf(1900, 1984, 1994, 2016, 2022, 2024, 2042, 2048)
val answers = listOf(false, true, false, true, false, true, false, true) // val answers = listOf(false, true, false, true, false, true, false, true)
val results = years.map { dataParser.isLeapYear(it) } // val results = years.map { dataParser.isLeapYear(it) }
assert(results == answers) // assert(results == answers)
} // }
} }
@@ -0,0 +1,91 @@
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
class TransponderMapperTest {
private fun makeRadio(
uplinkLow: Long? = 435000000L,
uplinkHigh: Long? = 435100000L,
downlinkLow: Long? = 145800000L,
downlinkHigh: Long? = 145900000L,
isInverted: Boolean = true
) = SatRadio(
uuid = "test",
info = "Test",
isAlive = true,
downlinkLow = downlinkLow,
downlinkHigh = downlinkHigh,
downlinkMode = "USB",
uplinkLow = uplinkLow,
uplinkHigh = uplinkHigh,
uplinkMode = "USB",
isInverted = isInverted,
catnum = 0
)
@Test
fun invertedTransponder_mapsFrequencyCorrectly() {
val radio = makeRadio(isInverted = true)
// TX at uplinkLow → RX should be downlinkHigh
assertEquals(145900000L, TransponderMapper.mapUplinkToDownlink(435000000L, radio))
// TX at uplinkHigh → RX should be downlinkLow
assertEquals(145800000L, TransponderMapper.mapUplinkToDownlink(435100000L, radio))
// TX at center → RX at center
assertEquals(145850000L, TransponderMapper.mapUplinkToDownlink(435050000L, radio))
}
@Test
fun nonInvertedTransponder_mapsFrequencyCorrectly() {
val radio = makeRadio(isInverted = false)
// TX at uplinkLow → RX should be downlinkLow
assertEquals(145800000L, TransponderMapper.mapUplinkToDownlink(435000000L, radio))
// TX at uplinkHigh → RX should be downlinkHigh
assertEquals(145900000L, TransponderMapper.mapUplinkToDownlink(435100000L, radio))
}
@Test
fun nullFrequencies_returnsNull() {
assertNull(TransponderMapper.mapUplinkToDownlink(435000000L, makeRadio(uplinkLow = null)))
assertNull(TransponderMapper.mapUplinkToDownlink(435000000L, makeRadio(downlinkLow = null)))
}
@Test
fun singleFrequencyTransponder_returnsDownlinkLow() {
// FM repeater style: no passband, just single frequencies
val radio = makeRadio(
uplinkLow = 145990000L, uplinkHigh = null,
downlinkLow = 436300000L, downlinkHigh = null,
isInverted = false
)
assertEquals(436300000L, TransponderMapper.mapUplinkToDownlink(145990000L, radio))
}
@Test
fun equalLowHighTransponder_returnsDownlinkLow() {
val radio = makeRadio(
uplinkLow = 145990000L, uplinkHigh = 145990000L,
downlinkLow = 436300000L, downlinkHigh = 436300000L,
isInverted = false
)
assertEquals(436300000L, TransponderMapper.mapUplinkToDownlink(145990000L, radio))
}
@Test
fun invertedMode_swapsUsbLsb() {
assertEquals("LSB", TransponderMapper.mapUplinkModeToDownlinkMode("USB", true))
assertEquals("USB", TransponderMapper.mapUplinkModeToDownlinkMode("LSB", true))
assertEquals("FM", TransponderMapper.mapUplinkModeToDownlinkMode("FM", true))
}
@Test
fun nonInvertedMode_keepsSame() {
assertEquals("USB", TransponderMapper.mapUplinkModeToDownlinkMode("USB", false))
assertEquals("LSB", TransponderMapper.mapUplinkModeToDownlinkMode("LSB", false))
assertEquals("FM", TransponderMapper.mapUplinkModeToDownlinkMode("FM", false))
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
plugins { plugins {
alias(libs.plugins.convention.composeLibraryPlugin) alias(libs.plugins.convention.corePresentationPlugin)
} }
android { android {
@@ -45,6 +45,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@@ -62,8 +63,7 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.TimeZone
private val sdfTime = SimpleDateFormat("HH:mm:ss", Locale.ENGLISH)
@Composable @Composable
@Preview(showBackground = true) @Preview(showBackground = true)
@@ -76,19 +76,23 @@ private fun TopBarPreview() = MainTheme {
} }
@Composable @Composable
fun TopBar(content: @Composable (RowScope.() -> Unit)) { fun TopBar(content: @Composable RowScope.() -> Unit) {
Row( Row(
modifier = Modifier.height(48.dp), modifier = Modifier.height(48.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically,
) { content() } content = content
)
} }
@Composable @Composable
fun RowScope.TimerRow(timeString: String, isTimeAos: Boolean) { fun RowScope.TimerRow(timeString: String, isTimeAos: Boolean) {
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
val aosColor = if (isTimeAos) colorScheme.primary else colorScheme.onSurface val (aosColor, losColor) = if (isTimeAos) {
val losColor = if (!isTimeAos) colorScheme.primary else colorScheme.onSurface colorScheme.primary to colorScheme.onSurface
} else {
colorScheme.onSurface to colorScheme.primary
}
ElevatedCard(modifier = Modifier.weight(1f)) { ElevatedCard(modifier = Modifier.weight(1f)) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -100,7 +104,7 @@ fun RowScope.TimerRow(timeString: String, isTimeAos: Boolean) {
text = timeString, text = timeString,
fontSize = 32.sp, fontSize = 32.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary color = colorScheme.primary
) )
Text(text = "LOS", fontSize = 16.sp, fontWeight = FontWeight.Medium, color = losColor) Text(text = "LOS", fontSize = 16.sp, fontWeight = FontWeight.Medium, color = losColor)
} }
@@ -114,7 +118,13 @@ private fun NextPassRowPreview() = MainTheme {
} }
@Composable @Composable
fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier) { fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc: Boolean = false) {
val timeZone = remember(isUtc) {
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
}
val sdfTime = remember(isUtc) {
SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone }
}
ElevatedCard( ElevatedCard(
modifier = modifier modifier = modifier
.height(48.dp) .height(48.dp)
@@ -124,13 +134,11 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier) {
verticalArrangement = Arrangement.spacedBy((-2).dp), verticalArrangement = Arrangement.spacedBy((-2).dp),
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
// .background(color = MaterialTheme.colorScheme.background)
.padding(start = 6.dp, top = 1.dp, end = 6.dp, bottom = 0.dp) .padding(start = 6.dp, top = 1.dp, end = 6.dp, bottom = 0.dp)
) { ) {
val passSatId = stringResource(id = R.string.pass_satId, pass.catNum)
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
text = "$passSatId - ", text = "${stringResource(R.string.pass_satId, pass.catNum)} - ",
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
Text( Text(
@@ -144,7 +152,7 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier) {
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Icon( Icon(
painter = painterResource(id = R.drawable.ic_elevation), painter = painterResource(R.drawable.ic_elevation),
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
@@ -160,47 +168,24 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier) {
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Row( Text(
modifier = Modifier.weight(1f), text = sdfTime.format(Date(pass.aosTime)),
horizontalArrangement = Arrangement.Start, fontSize = 15.sp,
verticalAlignment = Alignment.CenterVertically modifier = Modifier.weight(1f)
) { )
Text(
text = sdfTime.format(Date(pass.aosTime)),
fontSize = 15.sp,
modifier = Modifier.weight(1f)
)
}
Row( Row(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( Text(text = "${pass.altitude} km", fontSize = 15.sp)
painter = painterResource(id = 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
)
}
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(
id = R.string.pass_aosLos,
pass.aosAzimuth.toInt(),
pass.losAzimuth.toInt()
),
fontSize = 15.sp
)
} }
Text(
text = stringResource(R.string.pass_aosLos, pass.aosAzimuth.toInt(), pass.losAzimuth.toInt()),
fontSize = 15.sp,
textAlign = TextAlign.End,
modifier = Modifier.weight(1f)
)
} }
} }
} }
@@ -209,34 +194,41 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier) {
@Composable @Composable
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) { fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
ElevatedButton( ElevatedButton(
onClick = { onClick() }, colors = ButtonDefaults.buttonColors( onClick = onClick,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant, containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant contentColor = MaterialTheme.colorScheme.onSurfaceVariant
), shape = MaterialTheme.shapes.small, modifier = modifier, ),
shape = MaterialTheme.shapes.small,
modifier = modifier,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp) contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp)
) { Text(text = text, fontSize = 16.sp, textAlign = TextAlign.Center) } ) { Text(text = text, fontSize = 16.sp, textAlign = TextAlign.Center) }
} }
@Composable @Composable
fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier) { fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier) {
val clickableMod = Modifier.clickable { action() }
ElevatedCard(modifier = Modifier.size(48.dp)) { ElevatedCard(modifier = Modifier.size(48.dp)) {
Box(modifier = clickableMod.fillMaxSize(), contentAlignment = Alignment.Center) { Box(
Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier) modifier = Modifier
} .clickable(onClick = action)
.fillMaxSize(),
contentAlignment = Alignment.Center
) { Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier) }
} }
} }
@Composable @Composable
fun PrimaryIconCard(modifier: Modifier = Modifier, resId: Int, onClick: () -> Unit) { fun PrimaryIconCard(modifier: Modifier = Modifier, resId: Int, onClick: () -> Unit) {
val clickableMod = modifier.clickable { onClick() }
ElevatedCard( ElevatedCard(
modifier = Modifier.size(102.dp, 48.dp), modifier = Modifier.size(102.dp, 48.dp),
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.primary) colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.primary)
) { ) {
Box(modifier = clickableMod.fillMaxSize(), contentAlignment = Alignment.Center) { Box(
Icon(painter = painterResource(id = resId), contentDescription = null) modifier = modifier
} .clickable(onClick = onClick)
.fillMaxSize(),
contentAlignment = Alignment.Center
) { Icon(painter = painterResource(resId), contentDescription = null) }
} }
} }
@@ -262,56 +254,56 @@ fun EmptyListCard(message: String) {
} }
} }
fun getDefaultPass(): OrbitalPass { private val defaultOrbitalData = OrbitalData(
val orbitalData = OrbitalData( name = """ ¯\_(ツ)_/¯ ⚠️""",
""" ¯\_(ツ)_/¯ ⚠️""", epoch = 0.0, meanmo = 0.0, eccn = 0.0, incl = 0.0,
0.0, raan = 0.0, argper = 0.0, meanan = 0.0, catnum = 0, bstar = 0.0
0.0, )
0.0,
0.0, fun getDefaultPass(): OrbitalPass = OrbitalPass(
0.0, aosTime = 0L, aosAzimuth = 0.0, losTime = Long.MAX_VALUE, losAzimuth = 0.0,
0.0, altitude = 0, maxElevation = 0.0, orbitalObject = NearEarthObject(defaultOrbitalData), progress = 0f
0.0, )
0,
0.0
)
val satellite = NearEarthObject(orbitalData)
return OrbitalPass(0L, 0.0, Long.MAX_VALUE, 0.0, 0, 0.0, satellite, 0f)
}
@Composable @Composable
fun SharedDialog( fun SharedDialog(
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
) { ) {
val padding = LocalSpacing.current.large DialogShell(title = title, titleFontSize = 16, onDismissRequest = onCancel) {
Dialog(onDismissRequest = { onCancel() }) { content()
ElevatedCard { Row(modifier = Modifier.padding(start = it, bottom = it, end = it)) {
Column( CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
horizontalAlignment = Alignment.CenterHorizontally, Spacer(modifier = Modifier.weight(1f))
verticalArrangement = Arrangement.spacedBy(padding) CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
) {
Text(
text = title,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = padding, top = padding, end = padding)
)
content()
Row(modifier = Modifier.padding(start = padding, bottom = padding, end = padding)) {
CardButton(onClick = onCancel, text = stringResource(id = R.string.btn_cancel))
Spacer(modifier = Modifier.weight(1f))
CardButton(onClick = onAccept, text = stringResource(id = R.string.btn_accept))
}
}
} }
} }
} }
@Composable @Composable
fun InfoDialog(title: String, text: String, onDismiss: () -> Unit) { fun InfoDialog(title: String, text: String, onDismiss: () -> Unit) {
DialogShell(title = title, titleFontSize = 18, onDismissRequest = {}) {
Text(
text = text,
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = it)
)
Row(modifier = Modifier.padding(start = it, bottom = it, end = it)) {
Spacer(modifier = Modifier.weight(1f))
CardButton(onClick = onDismiss, text = stringResource(R.string.btn_accept))
}
}
}
@Composable
private fun DialogShell(
title: String,
titleFontSize: Int,
onDismissRequest: () -> Unit,
content: @Composable (padding: androidx.compose.ui.unit.Dp) -> Unit
) {
val padding = LocalSpacing.current.large val padding = LocalSpacing.current.large
Dialog(onDismissRequest = {}) { Dialog(onDismissRequest = onDismissRequest) {
ElevatedCard { ElevatedCard {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@@ -319,54 +311,36 @@ fun InfoDialog(title: String, text: String, onDismiss: () -> Unit) {
) { ) {
Text( Text(
text = title, text = title,
fontSize = 18.sp, fontSize = titleFontSize.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = padding, top = padding, end = padding) modifier = Modifier.padding(start = padding, top = padding, end = padding)
) )
Text( content(padding)
text = text,
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = padding)
)
Row(modifier = Modifier.padding(start = padding, bottom = padding, end = padding)) {
Spacer(modifier = Modifier.weight(1f))
CardButton(
onClick = onDismiss,
text = stringResource(id = R.string.btn_accept)
)
}
} }
} }
} }
} }
@Composable @Composable
fun hasEnoughHeight(): Boolean { fun hasEnoughHeight(): Boolean =
return currentWindowAdaptiveInfo().windowSizeClass.isHeightAtLeastBreakpoint(480) currentWindowAdaptiveInfo().windowSizeClass.isHeightAtLeastBreakpoint(480)
}
@Composable @Composable
fun hasEnoughWidth(): Boolean { fun hasEnoughWidth(): Boolean =
return currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(600) currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(600)
}
@Composable @Composable
fun isVerticalLayout(): Boolean { fun isVerticalLayout(): Boolean = !hasEnoughWidth()
return currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(600).not()
}
@Composable @Composable
fun Modifier.infiniteMarquee(): Modifier { fun Modifier.infiniteMarquee(): Modifier =
return basicMarquee(iterations = Int.MAX_VALUE, spacing = MarqueeSpacing(16.dp)) basicMarquee(iterations = Int.MAX_VALUE, spacing = MarqueeSpacing(16.dp))
}
@Composable @Composable
fun Modifier.layoutPadding(): Modifier { fun Modifier.layoutPadding(): Modifier {
val statusBarMod = this.statusBarsPadding()
val spacing = LocalSpacing.current.extraSmall val spacing = LocalSpacing.current.extraSmall
return statusBarMod.padding(start = spacing, top = 0.dp, end = spacing, bottom = spacing) return statusBarsPadding().padding(start = spacing, top = 0.dp, end = spacing, bottom = spacing)
} }
@Composable @Composable
@@ -375,15 +349,15 @@ fun ScreenColumn(
floatingBar: @Composable () -> Unit = {}, floatingBar: @Composable () -> Unit = {},
content: @Composable (Boolean) -> Unit = {} content: @Composable (Boolean) -> Unit = {}
) { ) {
val isVerticalLayout = isVerticalLayout() val isVertical = isVerticalLayout()
Surface(color = MaterialTheme.colorScheme.background) { Surface(color = MaterialTheme.colorScheme.background) {
Box(modifier = Modifier.layoutPadding(), contentAlignment = Alignment.BottomCenter) { Box(modifier = Modifier.layoutPadding(), contentAlignment = Alignment.BottomCenter) {
Column(verticalArrangement = Arrangement.spacedBy(LocalSpacing.current.extraSmall)) { Column(verticalArrangement = Arrangement.spacedBy(LocalSpacing.current.extraSmall)) {
topBar(isVerticalLayout) topBar(isVertical)
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surfaceContainer color = MaterialTheme.colorScheme.surfaceContainer
) { content(isVerticalLayout) } ) { content(isVertical) }
} }
floatingBar() floatingBar()
} }
@@ -394,21 +368,14 @@ fun ScreenColumn(
fun TopBar( fun TopBar(
isVerticalLayout: Boolean, isVerticalLayout: Boolean,
startAction: @Composable () -> Unit, startAction: @Composable () -> Unit,
topInfo: @Composable (RowScope.() -> Unit), topInfo: @Composable RowScope.() -> Unit,
bottomInfo: @Composable (RowScope.() -> Unit), bottomInfo: @Composable RowScope.() -> Unit,
endAction: @Composable () -> Unit endAction: @Composable () -> Unit
) { ) {
val topBarRow = @Composable { content: @Composable RowScope.() -> Unit ->
Row(
modifier = Modifier.height(48.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) { content() }
}
if (isVerticalLayout) { if (isVerticalLayout) {
topBarRow { startAction().also { topInfo() }.also { endAction() } } TopBar { startAction(); topInfo(); endAction() }
topBarRow { bottomInfo() } TopBar { bottomInfo() }
} else { } else {
topBarRow { startAction().also { topInfo() }.also { bottomInfo() }.also { endAction() } } TopBar { startAction(); topInfo(); bottomInfo(); endAction() }
} }
} }
@@ -1,9 +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 package com.rtbishop.look4sat.core.presentation
sealed class Screen(val route: String, val iconResId: Int, val titleResId: Int) { import androidx.navigation3.runtime.NavKey
data object Satellites : Screen("satellites", R.drawable.ic_satellites, R.string.nav_sat) import kotlinx.serialization.Serializable
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) @Serializable
data object Map : Screen("map", R.drawable.ic_map, R.string.nav_map) sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
data object Settings : Screen("settings", R.drawable.ic_settings, R.string.nav_prefs)
@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"> android:viewportHeight="24">
<path <path
android:fillColor="@android:color/white" 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> </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>
@@ -118,12 +118,10 @@
<string name="prefs_net_title">Salida de datos de red</string> <string name="prefs_net_title">Salida de datos de red</string>
<string name="prefs_net_rotator_switch">Enable rotation output</string> <string name="prefs_net_rotator_switch">Enable rotation output</string>
<string name="prefs_net_rotator_ip_hint">Dirección IP</string> <string name="prefs_net_rotator_address_hint">IP:Puerto</string>
<string name="prefs_net_rotator_port_hint">Puerto</string>
<string name="prefs_net_rotator_format_hint">Formato de datos</string> <string name="prefs_net_rotator_format_hint">Formato de datos</string>
<string name="prefs_net_frequency_switch">Enable frequency output</string> <string name="prefs_net_frequency_switch">Enable frequency output</string>
<string name="prefs_net_frequency_ip_hint">Dirección IP</string> <string name="prefs_net_frequency_address_hint">IP:Puerto</string>
<string name="prefs_net_frequency_port_hint">Puerto</string>
<string name="prefs_net_frequency_format_hint">Formato de datos</string> <string name="prefs_net_frequency_format_hint">Formato de datos</string>
<string name="prefs_bt_title">Salida por Bluetooth</string> <string name="prefs_bt_title">Salida por Bluetooth</string>
@@ -117,12 +117,10 @@
<string name="prefs_net_title">Вывод сетевых данных</string> <string name="prefs_net_title">Вывод сетевых данных</string>
<string name="prefs_net_rotator_switch">Включить вывод ротатора</string> <string name="prefs_net_rotator_switch">Включить вывод ротатора</string>
<string name="prefs_net_rotator_ip_hint">IP адрес</string> <string name="prefs_net_rotator_address_hint">IP:Порт</string>
<string name="prefs_net_rotator_port_hint">Порт</string>
<string name="prefs_net_rotator_format_hint">Формат</string> <string name="prefs_net_rotator_format_hint">Формат</string>
<string name="prefs_net_frequency_switch">Включить вывод частоты</string> <string name="prefs_net_frequency_switch">Включить вывод частоты</string>
<string name="prefs_net_frequency_ip_hint">IP адрес</string> <string name="prefs_net_frequency_address_hint">IP:Порт</string>
<string name="prefs_net_frequency_port_hint">Порт</string>
<string name="prefs_net_frequency_format_hint">Формат</string> <string name="prefs_net_frequency_format_hint">Формат</string>
<string name="prefs_bt_title">Вывод данных Bluetooth</string> <string name="prefs_bt_title">Вывод данных Bluetooth</string>
@@ -118,12 +118,10 @@
<string name="prefs_net_title">ජාල ප්‍රතිදානය</string> <string name="prefs_net_title">ජාල ප්‍රතිදානය</string>
<string name="prefs_net_rotator_switch">Enable rotation output</string> <string name="prefs_net_rotator_switch">Enable rotation output</string>
<string name="prefs_net_rotator_ip_hint">IP ලිපිනය</string> <string name="prefs_net_rotator_address_hint">IP:Port</string>
<string name="prefs_net_rotator_port_hint">Port</string>
<string name="prefs_net_rotator_format_hint">දත්ත අකාරය</string> <string name="prefs_net_rotator_format_hint">දත්ත අකාරය</string>
<string name="prefs_net_frequency_switch">Enable frequency output</string> <string name="prefs_net_frequency_switch">Enable frequency output</string>
<string name="prefs_net_frequency_ip_hint">IP ලිපිනය</string> <string name="prefs_net_frequency_address_hint">IP:Port</string>
<string name="prefs_net_frequency_port_hint">Port</string>
<string name="prefs_net_frequency_format_hint">දත්ත අකාරය</string> <string name="prefs_net_frequency_format_hint">දත්ත අකාරය</string>
<string name="prefs_bt_title">Bluetooth දත්ත ප්‍රතිදානය</string> <string name="prefs_bt_title">Bluetooth දත්ත ප්‍රතිදානය</string>
@@ -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>
@@ -118,12 +118,10 @@
<string name="prefs_net_title">Вивід мережевих даних</string> <string name="prefs_net_title">Вивід мережевих даних</string>
<string name="prefs_net_rotator_switch">Enable rotation output</string> <string name="prefs_net_rotator_switch">Enable rotation output</string>
<string name="prefs_net_rotator_ip_hint">IP адреса</string> <string name="prefs_net_rotator_address_hint">IP:Порт</string>
<string name="prefs_net_rotator_port_hint">Порт</string>
<string name="prefs_net_rotator_format_hint">Формат даних</string> <string name="prefs_net_rotator_format_hint">Формат даних</string>
<string name="prefs_net_frequency_switch">Enable frequency output</string> <string name="prefs_net_frequency_switch">Enable frequency output</string>
<string name="prefs_net_frequency_ip_hint">IP адреса</string> <string name="prefs_net_frequency_address_hint">IP:Порт</string>
<string name="prefs_net_frequency_port_hint">Порт</string>
<string name="prefs_net_frequency_format_hint">Формат даних</string> <string name="prefs_net_frequency_format_hint">Формат даних</string>
<string name="prefs_bt_title">Вивід даних Bluetooth</string> <string name="prefs_bt_title">Вивід даних Bluetooth</string>
@@ -110,12 +110,10 @@
<string name="prefs_net_title">网络数据输出</string> <string name="prefs_net_title">网络数据输出</string>
<string name="prefs_net_rotator_switch">启用方位俯仰角输出</string> <string name="prefs_net_rotator_switch">启用方位俯仰角输出</string>
<string name="prefs_net_rotator_ip_hint">IP地址</string> <string name="prefs_net_rotator_address_hint">IP:端口</string>
<string name="prefs_net_rotator_port_hint">端口</string>
<string name="prefs_net_rotator_format_hint">数据格式</string> <string name="prefs_net_rotator_format_hint">数据格式</string>
<string name="prefs_net_frequency_switch">启用多普勒频率输出</string> <string name="prefs_net_frequency_switch">启用多普勒频率输出</string>
<string name="prefs_net_frequency_ip_hint">IP地址</string> <string name="prefs_net_frequency_address_hint">IP:端口</string>
<string name="prefs_net_frequency_port_hint">端口</string>
<string name="prefs_net_frequency_format_hint">数据格式</string> <string name="prefs_net_frequency_format_hint">数据格式</string>
<string name="prefs_bt_title">蓝牙数据输出</string> <string name="prefs_bt_title">蓝牙数据输出</string>
@@ -132,8 +130,16 @@
<string name="prefs_other_switch_update">启用卫星数据自动更新</string> <string name="prefs_other_switch_update">启用卫星数据自动更新</string>
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string> <string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string> <string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
<string name="prefs_outro_title">我要感谢:</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> <string name="prefs_outro_license">该应用程序不提供任何保修.</string>
</resources> </resources>
@@ -32,6 +32,7 @@
<string name="pass_filter_title">Filter passes</string> <string name="pass_filter_title">Filter passes</string>
<string name="pass_filter_elev">Minimal elevation</string> <string name="pass_filter_elev">Minimal elevation</string>
<string name="pass_filter_hours">Hours ahead</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_time_placeholder" translatable="false"> -- : -- : -- </string>
<string name="pass_modes_title">Select modulation type</string> <string name="pass_modes_title">Select modulation type</string>
<string name="pass_satId" translatable="false">%05d</string> <string name="pass_satId" translatable="false">%05d</string>
@@ -48,8 +49,9 @@
\n\nPlease update the database at least weekly to get accurate predictions.</string> \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_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false"> <string name="pass_whatsnew_message" translatable="false">
* Londre7 fixed the track path creation issue for split screen * Implemented Swipe-to-Focus behavior for passes - swipe right on a pass to focus it, and left - to unfocus
as well as enabled the compass data usage for landscape orientation \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> </string>
<!-- Radar screen --> <!-- Radar screen -->
@@ -144,15 +146,24 @@
<string name="prefs_data_output_title">Data output</string> <string name="prefs_data_output_title">Data output</string>
<string name="prefs_net_output">Network</string> <string name="prefs_net_output">Network</string>
<string name="prefs_bt_output">Bluetooth</string> <string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string>
<!-- Radio Control -->
<string name="nav_radiocontrol">Radio Control</string>
<string name="rc_settings_title">CAT Radio Control</string>
<string name="rc_radio_model">Radio Model</string>
<string name="rc_tx_device_hint">TX Radio BT Address</string>
<string name="rc_rx_device_hint">RX Radio BT Address</string>
<string name="rc_tx_name_hint">TX Radio Name</string>
<string name="rc_rx_name_hint">RX Radio Name</string>
<string name="rc_enable_switch">Enable CAT Control</string>
<string name="prefs_net_title">Network data output</string> <string name="prefs_net_title">Network data output</string>
<string name="prefs_net_rotator_switch">Enable rotation output</string> <string name="prefs_net_rotator_switch">Enable rotation output</string>
<string name="prefs_net_rotator_ip_hint">IP address</string> <string name="prefs_net_rotator_address_hint">IP:Port</string>
<string name="prefs_net_rotator_port_hint">Port</string>
<string name="prefs_net_rotator_format_hint">Format</string> <string name="prefs_net_rotator_format_hint">Format</string>
<string name="prefs_net_frequency_switch">Enable frequency output</string> <string name="prefs_net_frequency_switch">Enable frequency output</string>
<string name="prefs_net_frequency_ip_hint">IP address</string> <string name="prefs_net_frequency_address_hint">IP:Port</string>
<string name="prefs_net_frequency_port_hint">Port</string>
<string name="prefs_net_frequency_format_hint">Format</string> <string name="prefs_net_frequency_format_hint">Format</string>
<string name="prefs_bt_title">Bluetooth data output</string> <string name="prefs_bt_title">Bluetooth data output</string>
@@ -169,6 +180,7 @@
<string name="prefs_other_switch_update">Enable automatic data update</string> <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_sweep">Enable radar sweep animation</string>
<string name="prefs_other_switch_sensors">Use sensors to rotate radar view</string> <string name="prefs_other_switch_sensors">Use sensors to rotate radar view</string>
<string name="prefs_other_switch_night_mode">Enable red night mode filter</string>
<string name="prefs_outro_title">I would like to say thanks to</string> <string name="prefs_outro_title">I would like to say thanks to</string>
<string name="prefs_outro_thanks" translatable="false"> <string name="prefs_outro_thanks" translatable="false">
@@ -1,2 +1,2 @@
* Fixed track path creation issue for split screens (#199) by londre7 * Implemented Swipe-to-Focus behavior for passes - swipe right on a pass to focus it, and left - to unfocus
* Enabled using compass data for landscape orientation (#202) by londre7 * 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,2 +0,0 @@
* Fixed track path creation issue for split screens (#199) by londre7
* Enabled using compass data for landscape orientation (#202) by londre7
+1 -1
View File
@@ -1,5 +1,5 @@
plugins { plugins {
alias(libs.plugins.convention.composeFeaturePlugin) alias(libs.plugins.convention.featurePlugin)
} }
android { android {
@@ -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
}
}
@@ -24,6 +24,7 @@ import android.graphics.ColorMatrixColorFilter
import android.graphics.Paint import android.graphics.Paint
import android.graphics.Rect import android.graphics.Rect
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import androidx.collection.LruCache
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -40,7 +41,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -59,15 +60,13 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos 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.IconCard
import com.rtbishop.look4sat.core.presentation.NextPassRow import com.rtbishop.look4sat.core.presentation.NextPassRow
import com.rtbishop.look4sat.core.presentation.R 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.TimerRow
import com.rtbishop.look4sat.core.presentation.TopBar import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.isVerticalLayout import com.rtbishop.look4sat.core.presentation.isVerticalLayout
@@ -80,6 +79,16 @@ import org.osmdroid.views.overlay.FolderOverlay
import org.osmdroid.views.overlay.Marker import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polyline import org.osmdroid.views.overlay.Polyline
// Overlay indices
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_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 minLat = MapView.getTileSystem().minLatitude
private val maxLat = MapView.getTileSystem().maxLatitude private val maxLat = MapView.getTileSystem().maxLatitude
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
@@ -100,98 +109,122 @@ private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = "#FFE082".toColorInt() color = "#FFE082".toColorInt()
setShadowLayer(3f, 3f, 3f, Color.BLACK) setShadowLayer(3f, 3f, 3f, Color.BLACK)
} }
private val labelRect = Rect() private val iconCache = LruCache<String, Drawable>(128)
private val sunIconPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
fun NavGraphBuilder.mapDestination() { colorFilter =
composable(Screen.Map.route) { android.graphics.PorterDuffColorFilter("#FFE082".toColorInt(), android.graphics.PorterDuff.Mode.SRC_IN)
val viewModel = viewModel(MapViewModel::class.java, factory = MapViewModel.Factory) }
val uiState = viewModel.uiState.collectAsStateWithLifecycle() private val moonIconPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
val mapView = rememberMapViewWithLifecycle() colorFilter =
MapScreen(uiState, mapView) android.graphics.PorterDuffColorFilter("#E0E0E0".toColorInt(), android.graphics.PorterDuff.Mode.SRC_IN)
}
} }
@Composable @Composable
private fun MapScreen(uiState: State<MapState>, mapView: MapView) { fun MapDestination() {
val onItemClick = { item: OrbitalObject -> uiState.value.sendAction(MapAction.SelectItem(item)) } val context = LocalContext.current
val selectPrev = { uiState.value.sendAction(MapAction.SelectPrev) } val container = (context.applicationContext as IContainerProvider).getMainContainer()
val selectNext = { uiState.value.sendAction(MapAction.SelectNext) } val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val mapView = rememberMapViewWithLifecycle()
MapScreen(uiState, viewModel::onAction, mapView)
}
@Composable
private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView: MapView) {
val rotateMod = Modifier.rotate(180f) val rotateMod = Modifier.rotate(180f)
val timeString = uiState.value.mapData?.aosTime ?: "00:00:00" val timeString = uiState.mapData?.aosTime ?: "00:00:00"
val isTimeAos = uiState.value.mapData?.isTimeAos ?: true val isTimeAos = uiState.mapData?.isTimeAos ?: true
LaunchedEffect(uiState.value.track) {
val latitude = uiState.value.track?.get(0)?.get(0)?.latitude ?: 0.0 LaunchedEffect(uiState.track) {
val longitude = uiState.value.track?.get(0)?.get(0)?.longitude ?: 0.0 val firstPos = uiState.track?.firstOrNull()?.firstOrNull() ?: return@LaunchedEffect
mapView.controller.animateTo(GeoPoint(latitude, longitude)) mapView.controller.animateTo(GeoPoint(firstPos.latitude, firstPos.longitude))
} }
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (isVerticalLayout()) { val isVertical = isVerticalLayout()
if (isVertical) {
TopBar { TopBar {
IconCard(action = selectPrev, resId = R.drawable.ic_arrow, modifier = rotateMod) IconCard(action = { onAction(MapAction.SelectPrev) }, resId = R.drawable.ic_arrow, modifier = rotateMod)
TimerRow(timeString = timeString, isTimeAos = isTimeAos) TimerRow(timeString = timeString, isTimeAos = isTimeAos)
IconCard(action = selectNext, resId = R.drawable.ic_arrow) IconCard(action = { onAction(MapAction.SelectNext) }, resId = R.drawable.ic_arrow)
} }
TopBar { NextPassRow(pass = uiState.value.orbitalPass) } TopBar { NextPassRow(pass = uiState.orbitalPass, isUtc = uiState.isUtc) }
} else { } else {
TopBar { TopBar {
IconCard(action = selectPrev, resId = R.drawable.ic_arrow, modifier = rotateMod) IconCard(action = { onAction(MapAction.SelectPrev) }, resId = R.drawable.ic_arrow, modifier = rotateMod)
TimerRow(timeString = timeString, isTimeAos = isTimeAos) TimerRow(timeString = timeString, isTimeAos = isTimeAos)
NextPassRow(pass = uiState.value.orbitalPass, modifier = Modifier.weight(1f)) NextPassRow(pass = uiState.orbitalPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
IconCard(action = selectNext, resId = R.drawable.ic_arrow) IconCard(action = { onAction(MapAction.SelectNext) }, resId = R.drawable.ic_arrow)
} }
} }
ElevatedCard(modifier = Modifier.weight(1f)) { ElevatedCard(modifier = Modifier.weight(1f)) {
Box(contentAlignment = Alignment.BottomCenter) { Box(contentAlignment = Alignment.BottomCenter) {
AndroidView({ mapView }) { mapView -> AndroidView({ mapView }) { view ->
uiState.value.stationPosition?.let { setStationPosition(it, mapView) } uiState.stationPosition?.let { setStationPosition(it, view) }
uiState.value.positions?.let { setPositions(it, mapView, onItemClick) } uiState.track?.let { setSatelliteTrack(it, view) }
uiState.value.track?.let { setSatelliteTrack(it, mapView) } uiState.footprint?.let { setFootprint(it, view) }
uiState.value.footprint?.let { setFootprint(it, mapView) } 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.value.mapData?.let { mapData -> uiState.mapData?.let { mapData ->
if (isVerticalLayout()) MapDataCard(mapData) else MapDataCards(mapData) if (isVertical) MapDataCard(mapData) else MapDataCards(mapData)
} }
} }
} }
} }
} }
// region Map data composables
@Composable @Composable
private fun MapDataCard(data: MapData) { private fun MapDataCard(data: MapData) {
val textColor = MaterialTheme.colorScheme.primary val textColor = MaterialTheme.colorScheme.primary
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest) val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.SpaceBetween) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = stringResource(R.string.map_copyright), fontSize = 14.sp) Text(text = stringResource(R.string.map_copyright), fontSize = 14.sp)
Card(colors = cardColors) { Card(colors = cardColors) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) { Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { MapDataRow(
Text(text = stringResource(R.string.map_azimuth, data.azimuth), color = textColor) stringResource(R.string.map_azimuth, data.azimuth) to textColor,
Text(text = stringResource(R.string.map_elevation, data.elevation), color = textColor) stringResource(R.string.map_elevation, data.elevation) to textColor
} )
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { MapDataRow(
Text(text = stringResource(R.string.map_altitude, data.altitude)) stringResource(R.string.map_altitude, data.altitude) to null,
Text(text = stringResource(R.string.map_distance, data.range)) stringResource(R.string.map_distance, data.range) to null
} )
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { MapDataRow(
Text(text = stringResource(R.string.map_latitude, data.osmPos.latitude), color = textColor) stringResource(R.string.map_latitude, data.osmPos.latitude) to textColor,
Text(text = stringResource(R.string.map_longitude, data.osmPos.longitude), color = textColor) stringResource(R.string.map_longitude, data.osmPos.longitude) to textColor
} )
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { MapDataRow(
Text(text = stringResource(R.string.map_qth, data.qthLoc)) stringResource(R.string.map_qth, data.qthLoc) to null,
Text(text = stringResource(R.string.map_phase, data.phase)) stringResource(R.string.map_phase, data.phase) to null
} )
} }
} }
} }
} }
@Composable
private fun MapDataRow(
left: Pair<String, androidx.compose.ui.graphics.Color?>,
right: Pair<String, androidx.compose.ui.graphics.Color?>
) {
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
if (left.second != null) Text(text = left.first, color = left.second!!)
else Text(text = left.first)
if (right.second != null) Text(text = right.first, color = right.second!!)
else Text(text = right.first)
}
}
@Composable @Composable
private fun MapDataCards(data: MapData) { private fun MapDataCards(data: MapData) {
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest) val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
val paddingMod = Modifier val paddingMod = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp) .padding(horizontal = 8.dp, vertical = 4.dp)
.width(160.dp) .width(160.dp)
val osmText = stringResource(R.string.map_copyright)
val textColor = MaterialTheme.colorScheme.primary val textColor = MaterialTheme.colorScheme.primary
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
Card(colors = cardColors, modifier = Modifier.align(Alignment.TopStart)) { Card(colors = cardColors, modifier = Modifier.align(Alignment.TopStart)) {
@@ -212,7 +245,11 @@ private fun MapDataCards(data: MapData) {
Text(text = stringResource(R.string.map_qth, data.qthLoc)) Text(text = stringResource(R.string.map_qth, data.qthLoc))
} }
} }
Text(text = osmText, fontSize = 14.sp, modifier = Modifier.align(Alignment.BottomCenter)) Text(
text = stringResource(R.string.map_copyright),
fontSize = 14.sp,
modifier = Modifier.align(Alignment.BottomCenter)
)
Card(colors = cardColors, modifier = Modifier.align(Alignment.BottomEnd)) { Card(colors = cardColors, modifier = Modifier.align(Alignment.BottomEnd)) {
Column(horizontalAlignment = Alignment.End, modifier = paddingMod) { Column(horizontalAlignment = Alignment.End, modifier = paddingMod) {
Text(text = stringResource(R.string.map_latitude, data.osmPos.latitude), color = textColor) Text(text = stringResource(R.string.map_latitude, data.osmPos.latitude), color = textColor)
@@ -221,113 +258,250 @@ private fun MapDataCards(data: MapData) {
} }
} }
} }
// endregion
// region Map overlay helpers
private fun setStationPosition(stationPos: GeoPos, mapView: MapView) { private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
try { try {
Marker(mapView).apply { val overlay = mapView.overlays[OVERLAY_STATION]
setInfoWindow(null) if (overlay is Marker) {
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) overlay.position = GeoPoint(stationPos.latitude, stationPos.longitude)
icon = ContextCompat.getDrawable(mapView.context, R.drawable.ic_position) } else {
position = GeoPoint(stationPos.latitude, stationPos.longitude) mapView.overlays[OVERLAY_STATION] = Marker(mapView).apply {
mapView.overlays[0] = this
mapView.invalidate()
}
} catch (exception: Exception) {
println(exception)
}
}
private fun setPositions(posMap: Map<OrbitalObject, GeoPos>, mapView: MapView, action: (OrbitalObject) -> Unit) {
val markers = FolderOverlay()
try {
posMap.entries.forEach {
Marker(mapView).apply {
setInfoWindow(null) setInfoWindow(null)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
icon = getCustomTextIcon(it.key.data.name, mapView) icon = ContextCompat.getDrawable(mapView.context, R.drawable.ic_position)
try { position = GeoPoint(stationPos.latitude, stationPos.longitude)
position = GeoPoint(it.value.latitude, it.value.longitude)
} catch (exception: IllegalArgumentException) {
println(exception.stackTraceToString())
}
setOnMarkerClickListener { _, _ ->
action(it.key)
return@setOnMarkerClickListener true
}
markers.add(this)
} }
} }
mapView.overlays[3] = markers } catch (e: Exception) {
mapView.invalidate() println(e)
} catch (exception: Exception) {
println(exception)
} }
} }
private fun getCustomTextIcon(textLabel: String, mapView: MapView): Drawable { /** Pool of reusable Marker objects keyed by satellite name, to avoid re-creation every frame */
textPaint.getTextBounds(textLabel, 0, textLabel.length, labelRect) private val markerPool = HashMap<String, Marker>()
private var lastMapView: MapView? = null
private fun setPositions(
posMap: Map<OrbitalObject, GeoPos>,
mapView: MapView,
action: (OrbitalObject) -> Unit
) {
try {
// Clear caches when the MapView instance changes (e.g. config change)
if (lastMapView !== mapView) {
lastMapView = mapView
markerPool.clear()
iconCache.evictAll()
footprintPolyline = null
footprintPoints = null
}
// Reuse the existing FolderOverlay — creating a new one and replacing it
// causes osmdroid to detach shared Marker objects, making them invisible.
val folder = mapView.overlays[OVERLAY_POSITIONS] as? FolderOverlay ?: FolderOverlay().also {
mapView.overlays[OVERLAY_POSITIONS] = it
}
folder.items.clear()
val activeNames = HashSet<String>(posMap.size)
posMap.forEach { (satellite, geoPos) ->
val name = satellite.data.name
activeNames.add(name)
val marker = markerPool.getOrPut(name) {
Marker(mapView).apply {
setInfoWindow(null)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
icon = getCachedTextIcon(name, mapView)
}
}
// Update position in-place — reuse existing GeoPoint if available
val pos = marker.position
if (pos != null) {
pos.latitude = geoPos.latitude
pos.longitude = geoPos.longitude
} else {
marker.position = GeoPoint(geoPos.latitude, geoPos.longitude)
}
marker.setOnMarkerClickListener { _, _ ->
action(satellite)
true
}
folder.add(marker)
}
// Evict markers for satellites no longer tracked
val iter = markerPool.keys.iterator()
while (iter.hasNext()) {
if (iter.next() !in activeNames) iter.remove()
}
} catch (e: Exception) {
println(e)
}
}
private fun getCachedTextIcon(name: String, mapView: MapView): Drawable {
iconCache[name]?.let { return it }
val labelRect = Rect()
textPaint.getTextBounds(name, 0, name.length, labelRect)
val iconSize = 10f val iconSize = 10f
val width = labelRect.width() + iconSize * 2f val width = labelRect.width() + iconSize * 2f
val height = textPaint.textSize * 3f + iconSize * 2f val height = textPaint.textSize * 3f + iconSize * 2f
val bitmap = createBitmap(width.toInt(), height.toInt()) val bitmap = createBitmap(width.toInt(), height.toInt())
Canvas(bitmap).run { Canvas(bitmap).run {
drawCircle(width / 2f, height / 2f, iconSize, textPaint) drawCircle(width / 2f, height / 2f, iconSize, textPaint)
drawText(textLabel, iconSize / 2f, height - iconSize, textPaint) drawText(name, iconSize / 2f, height - iconSize, textPaint)
} }
return bitmap.toDrawable(mapView.context.resources) val drawable = bitmap.toDrawable(mapView.context.resources)
iconCache.put(name, drawable)
return drawable
} }
private fun setSatelliteTrack(satTrack: List<List<GeoPos>>, mapView: MapView) { private fun setSatelliteTrack(satTrack: List<List<GeoPos>>, mapView: MapView) {
val trackOverlay = FolderOverlay() val trackOverlay = FolderOverlay()
try { try {
satTrack.forEach { track -> satTrack.forEach { track ->
val trackPoints = track.map { GeoPoint(it.latitude, it.longitude) }
Polyline().apply { Polyline().apply {
setPoints(trackPoints) setPoints(track.map { GeoPoint(it.latitude, it.longitude) })
outlinePaint.set(trackPaint) outlinePaint.set(trackPaint)
trackOverlay.add(this) trackOverlay.add(this)
} }
} }
mapView.overlays[1] = trackOverlay mapView.overlays[OVERLAY_TRACK] = trackOverlay
} catch (exception: Exception) { } catch (e: Exception) {
println(exception) println(e)
} }
} }
/** Reusable footprint Polyline — created once, points updated in-place each frame */
private var footprintPolyline: Polyline? = null
private var footprintPoints: ArrayList<GeoPoint>? = null
private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) { private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) {
val footprintPoints = orbitalPos.getRangeCircle().map { GeoPoint(it.latitude, it.longitude) }
try { try {
val footprintOverlay = Polyline().apply { val rangeCircle = orbitalPos.getRangeCircle()
outlinePaint.set(footprintPaint) var pts = footprintPoints
setPoints(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 {
for (i in rangeCircle.indices) {
pts[i].latitude = rangeCircle[i].latitude
pts[i].longitude = rangeCircle[i].longitude
}
} }
mapView.overlays[2] = footprintOverlay val polyline = footprintPolyline ?: Polyline().apply {
} catch (exception: Exception) { outlinePaint.set(footprintPaint)
println(exception) footprintPolyline = this
}
polyline.setPoints(pts)
mapView.overlays[OVERLAY_FOOTPRINT] = polyline
} catch (e: Exception) {
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
@Composable @Composable
private fun rememberMapViewWithLifecycle(): MapView { private fun rememberMapViewWithLifecycle(): MapView {
val tileSource = XYTileSource("tiles", 0, 6, 256, ".webp", emptyArray<String>()) val tileSource = XYTileSource("tiles", 0, 6, 256, ".webp", emptyArray<String>())
val context = LocalContext.current val context = LocalContext.current
val mapView = remember { MapView(context) }.apply { val isVertical = isVerticalLayout()
setMultiTouchControls(true) val mapView = remember {
setUseDataConnection(false) MapView(context).apply {
setTileSource(tileSource) setMultiTouchControls(true)
minZoomLevel = getMinZoom(resources.displayMetrics.heightPixels) setUseDataConnection(false)
maxZoomLevel = 7.0 setTileSource(tileSource)
controller.setCenter(GeoPoint(48.8575, 6.3514)) minZoomLevel = getMinZoom(resources.displayMetrics.heightPixels, isVertical)
controller.setZoom(minZoomLevel + 2) maxZoomLevel = 7.0
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) controller.setCenter(GeoPoint(48.8575, 6.3514))
overlayManager.tilesOverlay.loadingBackgroundColor = Color.TRANSPARENT controller.setZoom(minZoomLevel + 2)
overlayManager.tilesOverlay.loadingLineColor = Color.TRANSPARENT zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
overlayManager.tilesOverlay.setColorFilter(getColorFilter()) overlayManager.tilesOverlay.loadingBackgroundColor = Color.TRANSPARENT
setScrollableAreaLimitLatitude(maxLat, minLat, 0) overlayManager.tilesOverlay.loadingLineColor = Color.TRANSPARENT
// add overlays: 0 - GSP, 1 - SatTrack, 2 - SatFootprint, 3 - SatIcons overlayManager.tilesOverlay.setColorFilter(createColorFilter())
overlays.addAll(Array(4) { FolderOverlay() }) setScrollableAreaLimitLatitude(maxLat, minLat, 0)
overlays.addAll(Array(OVERLAY_COUNT) { FolderOverlay() })
}
} }
// Makes MapView follow the lifecycle of this composable
val lifecycleObserver = rememberMapViewLifecycleObserver(mapView) val lifecycleObserver = rememberMapViewLifecycleObserver(mapView)
val lifecycle = LocalLifecycleOwner.current.lifecycle val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) { DisposableEffect(lifecycle) {
@@ -348,8 +522,7 @@ private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapVie
} }
} }
@Composable private fun createColorFilter(): ColorMatrixColorFilter {
private fun getColorFilter(): ColorMatrixColorFilter {
val grayScale = ColorMatrix().apply { setSaturation(0f) } val grayScale = ColorMatrix().apply { setSaturation(0f) }
val negative = ColorMatrix( val negative = ColorMatrix(
floatArrayOf(-1f, 0f, 0f, 0f, 260f, 0f, -1f, 0f, 0f, 260f, 0f, 0f, -1f, 0f, 260f, 0f, 0f, 0f, 1f, 0f) floatArrayOf(-1f, 0f, 0f, 0f, 260f, 0f, -1f, 0f, 0f, 260f, 0f, 0f, -1f, 0f, 260f, 0f, 0f, 0f, 1f, 0f)
@@ -358,8 +531,8 @@ private fun getColorFilter(): ColorMatrixColorFilter {
return ColorMatrixColorFilter(negative) return ColorMatrixColorFilter(negative)
} }
@Composable private fun getMinZoom(screenHeight: Int, isVertical: Boolean): Double {
private fun getMinZoom(screenHeight: Int): Double { if (!isVertical) return 3.5
if (!isVerticalLayout()) return 3.5
return MapView.getTileSystem().getLatitudeZoom(maxLat, minLat, screenHeight) return MapView.getTileSystem().getLatitudeZoom(maxLat, minLat, screenHeight)
} }
// endregion
@@ -23,21 +23,25 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
data class MapState( data class MapState(
val mapData: MapData?, val mapData: MapData? = null,
val isLightUi: Boolean, val isLightUi: Boolean = false,
val stationPosition: GeoPos?, val isUtc: Boolean = false,
val stationPosition: GeoPos? = null,
val orbitalPass: OrbitalPass, val orbitalPass: OrbitalPass,
val track: List<List<GeoPos>>?, val track: List<List<GeoPos>>? = null,
val footprint: OrbitalPos?, val footprint: OrbitalPos? = null,
val positions: Map<OrbitalObject, GeoPos>?, val positions: Map<OrbitalObject, GeoPos>? = null,
val sendAction: (MapAction) -> Unit val sunLatDeg: Double = 0.0,
val sunLonDeg: Double = 0.0,
val moonLatDeg: Double = 0.0,
val moonLonDeg: Double = 0.0
) )
sealed class MapAction { sealed interface MapAction {
data object SelectPrev: MapAction() data object SelectPrev : MapAction
data object SelectNext: MapAction() data object SelectNext : MapAction
data class SelectItem(val item: OrbitalObject): MapAction() data class SelectItem(val item: OrbitalObject) : MapAction
data class SelectDefaultItem(val catnum: Int): MapAction() data class SelectDefaultItem(val catnum: Int) : MapAction
} }
data class MapData( data class MapData(
@@ -18,13 +18,15 @@
package com.rtbishop.look4sat.feature.map package com.rtbishop.look4sat.feature.map
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.utility.clipLat import com.rtbishop.look4sat.core.domain.utility.clipLat
@@ -34,30 +36,31 @@ import com.rtbishop.look4sat.core.domain.utility.toDegrees
import com.rtbishop.look4sat.core.domain.utility.toTimerString import com.rtbishop.look4sat.core.domain.utility.toTimerString
import com.rtbishop.look4sat.core.presentation.getDefaultPass import com.rtbishop.look4sat.core.presentation.getDefaultPass
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Date import java.util.Date
class MapViewModel(private val satelliteRepo: ISatelliteRepo, settingsRepo: ISettingsRepo) : class MapViewModel(
ViewModel() { private val satelliteRepo: ISatelliteRepo,
private val settingsRepo: ISettingsRepo
) : ViewModel() {
private val stationPos = settingsRepo.stationPosition.value private val stationPos = settingsRepo.stationPosition.value
private val defaultPass = getDefaultPass() private val defaultPass = getDefaultPass()
private val _uiState = MutableStateFlow( private val _uiState = MutableStateFlow(
MapState( MapState(
mapData = null,
isLightUi = settingsRepo.otherSettings.value.stateOfLightTheme, isLightUi = settingsRepo.otherSettings.value.stateOfLightTheme,
stationPosition = null, isUtc = settingsRepo.otherSettings.value.stateOfUtc,
orbitalPass = defaultPass, orbitalPass = defaultPass
track = null,
footprint = null,
positions = null,
sendAction = ::handleAction
) )
) )
private var allPasses = satelliteRepo.passes.value private var allPasses = satelliteRepo.passes.value
@@ -68,10 +71,16 @@ class MapViewModel(private val satelliteRepo: ISatelliteRepo, settingsRepo: ISet
val uiState: StateFlow<MapState> = _uiState val uiState: StateFlow<MapState> = _uiState
init { init {
selectDefaultSatellite(-1) viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings ->
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
}
}
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
selectDefaultSatellite(if (selectedCatNum != 0) selectedCatNum else -1)
} }
private fun handleAction(action: MapAction) { fun onAction(action: MapAction) {
when (action) { when (action) {
MapAction.SelectPrev -> scrollSelection(true) MapAction.SelectPrev -> scrollSelection(true)
MapAction.SelectNext -> scrollSelection(false) MapAction.SelectNext -> scrollSelection(false)
@@ -118,17 +127,147 @@ class MapViewModel(private val satelliteRepo: ISatelliteRepo, settingsRepo: ISet
val dateNow = Date() val dateNow = Date()
getStationPosition() getStationPosition()
getSatTrack(orbitalObject, stationPos, dateNow) 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) { while (isActive) {
dateNow.time = System.currentTimeMillis() dateNow.time = System.currentTimeMillis()
getPositions(allSatellites, stationPos, dateNow) updateMapState(orbitalObject, allSatellites, stationPos, dateNow)
getSatFootprint(orbitalObject, stationPos, dateNow) delay(effectiveRate)
getSatData(orbitalObject, stationPos, dateNow)
delay(updateFreq)
} }
} }
} }
} }
/**
* Combined update: computes all satellite positions in parallel, then derives
* the selected satellite's footprint and info panel data from the same batch,
* and emits a single state update to avoid redundant recompositions.
*/
private suspend fun updateMapState(
selected: OrbitalObject,
orbitalObjects: List<OrbitalObject>,
pos: GeoPos,
date: Date
) {
// 1. Compute all positions in parallel, collecting full OrbitalPos for the selected sat
val positionsMap = HashMap<OrbitalObject, GeoPos>(orbitalObjects.size)
var selectedSatPos: OrbitalPos? = null
coroutineScope {
// Chunk satellites to balance parallelism vs coroutine overhead
val chunkSize = maxOf(1, orbitalObjects.size / PARALLEL_CHUNKS)
val deferreds = orbitalObjects.chunked(chunkSize).map { chunk ->
async {
val localPositions = ArrayList<Pair<OrbitalObject, GeoPos>>(chunk.size)
var localSelectedPos: OrbitalPos? = null
for (satellite in chunk) {
val satPos = satelliteRepo.getPosition(satellite, pos, date.time)
val osmLat = clipLat(satPos.latitude.toDegrees())
val osmLon = clipLon(satPos.longitude.toDegrees())
localPositions.add(satellite to GeoPos(osmLat, osmLon))
if (satellite === selected) {
localSelectedPos = satPos
}
}
Pair(localPositions, localSelectedPos)
}
}
for ((localPositions, localSelectedPos) in deferreds.awaitAll()) {
for (pair in localPositions) {
positionsMap[pair.first] = pair.second
}
if (localSelectedPos != null) {
selectedSatPos = localSelectedPos
}
}
}
// 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 {
it.copy(
positions = positionsMap,
footprint = footprint,
mapData = mapData.first,
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
)
}
}
/**
* Builds the map info panel data from an already-computed OrbitalPos.
* No additional getPosition() call needed.
*/
private fun buildMapData(
sat: OrbitalObject,
satPos: OrbitalPos,
date: Date
): Pair<MapData, OrbitalPass> {
var orbitalPass = defaultPass
var aosTime = 0L.toTimerString()
var isTimeAos = true
allPasses.find { pass -> pass.catNum == sat.data.catnum && pass.progress < 1 }
?.let { satPass ->
orbitalPass = satPass
if (!satPass.isDeepSpace) {
when {
date.time < satPass.aosTime -> {
// Pass hasn't started yet — count down to AOS
isTimeAos = true
aosTime = (satPass.aosTime - date.time).toTimerString()
}
date.time < satPass.losTime -> {
// Pass is in progress — count down to LOS
isTimeAos = false
aosTime = (satPass.losTime - date.time).toTimerString()
}
// else: pass has ended (losTime <= date.time) — keep default "00:00:00"
}
}
}
val azimuth = satPos.azimuth.toDegrees()
val elevation = satPos.elevation.toDegrees()
val osmLat = clipLat(satPos.latitude.toDegrees())
val osmLon = clipLon(satPos.longitude.toDegrees())
val osmPos = GeoPos(osmLat, osmLon)
val qthLoc = positionToQth(osmPos.latitude, osmPos.longitude) ?: "-- --"
val velocity = satPos.getOrbitalVelocity()
val phase = satPos.phase.toDegrees()
val visibility = satPos.eclipsed
val satData = MapData(
sat.data.catnum,
sat.data.name,
aosTime,
isTimeAos,
azimuth,
elevation,
satPos.distance,
satPos.altitude,
velocity,
qthLoc,
osmPos,
sat.data.orbitalPeriod,
phase,
visibility
)
return satData to orbitalPass
}
private suspend fun getSatTrack(orbitalObject: OrbitalObject, pos: GeoPos, date: Date) { private suspend fun getSatTrack(orbitalObject: OrbitalObject, pos: GeoPos, date: Date) {
val satTracks = mutableListOf<List<GeoPos>>() val satTracks = mutableListOf<List<GeoPos>>()
val currentTrack = mutableListOf<GeoPos>() val currentTrack = mutableListOf<GeoPos>()
@@ -158,76 +297,16 @@ class MapViewModel(private val satelliteRepo: ISatelliteRepo, settingsRepo: ISet
_uiState.update { it.copy(track = satTracks) } _uiState.update { it.copy(track = satTracks) }
} }
private suspend fun getPositions(orbitalObjects: List<OrbitalObject>, pos: GeoPos, date: Date) {
val positions = mutableMapOf<OrbitalObject, GeoPos>()
orbitalObjects.forEach { satellite ->
val satPos = satelliteRepo.getPosition(satellite, pos, date.time)
val osmLat = clipLat(satPos.latitude.toDegrees())
val osmLon = clipLon(satPos.longitude.toDegrees())
positions[satellite] = GeoPos(osmLat, osmLon)
}
_uiState.update { it.copy(positions = positions) }
}
private suspend fun getSatFootprint(orbitalObject: OrbitalObject, pos: GeoPos, date: Date) {
val satPos = satelliteRepo.getPosition(orbitalObject, pos, date.time)
_uiState.update { it.copy(footprint = satPos) }
}
private suspend fun getSatData(sat: OrbitalObject, pos: GeoPos, date: Date) {
var orbitalPass = defaultPass
var aosTime = 0L.toTimerString()
var isTimeAos = true
allPasses.find { pass -> pass.catNum == sat.data.catnum && pass.progress < 1 }
?.let { satPass ->
orbitalPass = satPass
if (!satPass.isDeepSpace) {
aosTime = if (date.time < satPass.aosTime) {
val millisBeforeStart = satPass.aosTime.minus(date.time)
isTimeAos = true
millisBeforeStart.toTimerString()
} else {
val millisBeforeEnd = satPass.losTime.minus(date.time)
isTimeAos = false
millisBeforeEnd.toTimerString()
}
}
}
val satPos = satelliteRepo.getPosition(sat, pos, date.time)
val azimuth = satPos.azimuth.toDegrees()
val elevation = satPos.elevation.toDegrees()
val osmLat = clipLat(satPos.latitude.toDegrees())
val osmLon = clipLon(satPos.longitude.toDegrees())
val osmPos = GeoPos(osmLat, osmLon)
val qthLoc = positionToQth(osmPos.latitude, osmPos.longitude) ?: "-- --"
val velocity = satPos.getOrbitalVelocity()
val phase = satPos.phase.toDegrees()
val visibility = satPos.eclipsed
val satData = MapData(
sat.data.catnum,
sat.data.name,
aosTime,
isTimeAos,
azimuth,
elevation,
satPos.distance,
satPos.altitude,
velocity,
qthLoc,
osmPos,
sat.data.orbitalPeriod,
phase,
visibility
)
_uiState.update { it.copy(mapData = satData, orbitalPass = orbitalPass) }
}
companion object { companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory { /** Number of parallel chunks for satellite position computation */
val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY private const val PARALLEL_CHUNKS = 4
fun factory(container: IMainContainer) = viewModelFactory {
initializer { initializer {
val container = (this[applicationKey] as IContainerProvider).getMainContainer() MapViewModel(
MapViewModel(container.satelliteRepo, container.settingsRepo) satelliteRepo = container.satelliteRepo,
settingsRepo = container.settingsRepo
)
} }
} }
} }
+1 -1
View File
@@ -1,5 +1,5 @@
plugins { plugins {
alias(libs.plugins.convention.composeFeaturePlugin) alias(libs.plugins.convention.featurePlugin)
} }
android { android {
@@ -32,13 +32,15 @@ import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.Surface import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableDoubleStateOf import androidx.compose.runtime.mutableDoubleStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@@ -61,34 +63,49 @@ private val allModes = listOf(
"PSK", "PSK31", "PSK63", "QPSK", "QPSK31", "QPSK63", "SSTV", "USB", "WSJT" "PSK", "PSK31", "PSK63", "QPSK", "QPSK31", "QPSK63", "SSTV", "USB", "WSJT"
) )
private val hourSteps = listOf(1, 2, 4, 8, 12, 24, 48, 72, 96, 120, 144, 168, 192, 216, 240)
@Preview @Preview
@Composable @Composable
private fun PassesDialogPreview() { private fun PassesDialogPreview() {
MainTheme { PassesDialog(24, 16.0, {}) { _, _ -> } } MainTheme { PassesDialog(24, 16.0, true, {}) { _, _, _ -> } }
} }
@Composable @Composable
fun PassesDialog(hours: Int, elevation: Double, cancel: () -> Unit, accept: (Int, Double) -> Unit) { internal fun PassesDialog(
val hoursValue = remember { mutableIntStateOf(hours) } 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) } val elevationValueNew = remember { mutableDoubleStateOf(elevation) }
var deepSpaceEnabled by remember { mutableStateOf(showDeepSpace) }
val onAccept = { val onAccept = {
accept(hoursValue.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) { 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( SliderRow(
title = stringResource(R.string.pass_filter_elev), title = stringResource(R.string.pass_filter_elev),
value = elevationValueNew.doubleValue, value = elevationValueNew.doubleValue,
valuePostfix = "°", displayValue = "${elevationValueNew.doubleValue.toInt()}°",
valueResId = R.drawable.ic_elevation, valueResId = R.drawable.ic_elevation,
valueRange = 0f..60f valueRange = 0f..60f
) { elevationValueNew.doubleValue = it.toDouble() } ) { elevationValueNew.doubleValue = it.toDouble() }
SliderRow( SliderRow(
title = stringResource(R.string.pass_filter_hours), title = stringResource(R.string.pass_filter_hours),
value = hoursValue.intValue.toDouble(), value = hoursIndex.intValue.toDouble(),
valuePostfix = "h", displayValue = "${hourSteps[hoursIndex.intValue]}h",
valueResId = R.drawable.ic_clock, valueResId = R.drawable.ic_clock,
valueRange = 1f..240f valueRange = 0f..(hourSteps.size - 1).toFloat(),
) { hoursValue.intValue = it.toInt() } steps = hourSteps.size - 2
) { hoursIndex.intValue = it.toInt().coerceIn(0, hourSteps.size - 1) }
} }
} }
@@ -96,9 +113,10 @@ fun PassesDialog(hours: Int, elevation: Double, cancel: () -> Unit, accept: (Int
private fun SliderRow( private fun SliderRow(
title: String, title: String,
value: Double, value: Double,
valuePostfix: String, displayValue: String,
valueResId: Int, valueResId: Int,
valueRange: ClosedFloatingPointRange<Float>, valueRange: ClosedFloatingPointRange<Float>,
steps: Int = 0,
onChange: (Float) -> Unit onChange: (Float) -> Unit
) { ) {
Column( Column(
@@ -123,13 +141,13 @@ private fun SliderRow(
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
) )
Text( Text(
text = "${value.toInt()}$valuePostfix", text = displayValue,
fontSize = 18.sp, fontSize = 18.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
} }
Slider(value = value.toFloat(), onValueChange = onChange, valueRange = valueRange) Slider(value = value.toFloat(), onValueChange = onChange, valueRange = valueRange, steps = steps)
} }
} }
@@ -140,12 +158,12 @@ private fun RadiosDialogPreview() {
} }
@Composable @Composable
fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit) { internal fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit) {
val selected = remember { mutableStateListOf<String>().apply { addAll(modes) } } val selected = remember { mutableStateOf(modes.toSet()) }
val select = { mode: String -> val toggle = { mode: String ->
if (selected.contains(mode)) selected.remove(mode) else selected.add(mode) selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode
} }
val onAccept = { accept(selected.toList()).also { cancel() } } val onAccept = { accept(selected.value.toList()).also { cancel() } }
SharedDialog(title = stringResource(R.string.pass_modes_title), onCancel = cancel, onAccept = onAccept) { SharedDialog(title = stringResource(R.string.pass_modes_title), onCancel = cancel, onAccept = onAccept) {
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Adaptive(240.dp), columns = GridCells.Adaptive(240.dp),
@@ -156,34 +174,49 @@ fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List<String>)
verticalArrangement = Arrangement.spacedBy(1.dp) verticalArrangement = Arrangement.spacedBy(1.dp)
) { ) {
itemsIndexed(allModes) { index, item -> itemsIndexed(allModes) { index, item ->
Surface { Row(
Row( verticalAlignment = Alignment.CenterVertically,
verticalAlignment = Alignment.CenterVertically, modifier = Modifier
modifier = Modifier .background(MaterialTheme.colorScheme.surface)
.background(MaterialTheme.colorScheme.surface) .clickable { toggle(item) }
.clickable { select(item) } ) {
) { Text(
Text( text = "${index + 1}).",
text = "${index + 1}).", modifier = Modifier.padding(start = 16.dp, end = 8.dp),
modifier = Modifier.padding(start = 16.dp, end = 8.dp), fontWeight = FontWeight.Normal,
fontWeight = FontWeight.Normal, color = MaterialTheme.colorScheme.primary
color = MaterialTheme.colorScheme.primary )
) Text(
Text( text = item,
text = item, modifier = Modifier.weight(1f),
modifier = Modifier.weight(1f), fontWeight = FontWeight.Medium,
fontWeight = FontWeight.Medium, maxLines = 1,
maxLines = 1, overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis )
) Checkbox(
Checkbox( checked = item in selected.value,
checked = selected.contains(item), onCheckedChange = null,
onCheckedChange = null, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) )
)
}
} }
} }
} }
} }
} }
@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 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.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -29,23 +34,23 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells 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.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -55,20 +60,19 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.rtbishop.look4sat.core.domain.predict.DeepSpaceObject import com.rtbishop.look4sat.core.domain.predict.DeepSpaceObject
import com.rtbishop.look4sat.core.domain.predict.NearEarthObject import com.rtbishop.look4sat.core.domain.predict.NearEarthObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalData import com.rtbishop.look4sat.core.domain.predict.OrbitalData
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass 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.EmptyListCard
import com.rtbishop.look4sat.core.presentation.IconCard import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.InfoDialog import com.rtbishop.look4sat.core.presentation.InfoDialog
import com.rtbishop.look4sat.core.presentation.MainTheme import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.NextPassRow import com.rtbishop.look4sat.core.presentation.NextPassRow
import com.rtbishop.look4sat.core.presentation.R 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.ScreenColumn
import com.rtbishop.look4sat.core.presentation.SwipeableItem
import com.rtbishop.look4sat.core.presentation.TimerRow import com.rtbishop.look4sat.core.presentation.TimerRow
import com.rtbishop.look4sat.core.presentation.TopBar import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.infiniteMarquee import com.rtbishop.look4sat.core.presentation.infiniteMarquee
@@ -76,38 +80,39 @@ import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.TimeZone
private val sdfDate = SimpleDateFormat("EEE dd MMM", Locale.ENGLISH) @Composable
private val sdfTime = SimpleDateFormat("HH:mm:ss", Locale.ENGLISH) fun PassesDestination(navigateToRadar: (Int, Long) -> Unit) {
val context = LocalContext.current
fun NavGraphBuilder.passesDestination(navigateToRadar: (Int, Long) -> Unit) { val container = (context.applicationContext as IContainerProvider).getMainContainer()
composable(Screen.Passes.route) { val viewModel: PassesViewModel = viewModel(factory = PassesViewModel.factory(container))
val viewModel = viewModel( val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
modelClass = PassesViewModel::class.java, PassesScreen(uiState, viewModel::onAction, navigateToRadar)
factory = PassesViewModel.Factory
)
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
PassesScreen(uiState, navigateToRadar)
}
} }
@Composable @Composable
private fun PassesScreen(uiState: PassesState, navigateToRadar: (Int, Long) -> Unit) { private fun PassesScreen(
val refreshList = { uiState.takeAction(PassesAction.RefreshPasses) } uiState: PassesState,
val showPassesDialog = { uiState.takeAction(PassesAction.TogglePassesDialog) } onAction: (PassesAction) -> Unit,
val showRadiosDialog = { uiState.takeAction(PassesAction.ToggleRadiosDialog) } navigateToRadar: (Int, Long) -> Unit
) {
if (uiState.isPassesDialogShown) { if (uiState.isPassesDialogShown) {
PassesDialog( PassesDialog(
hours = uiState.hours, hours = uiState.hours,
elevation = uiState.elevation, elevation = uiState.elevation,
cancel = showPassesDialog showDeepSpace = uiState.showDeepSpace,
) { hours, elevation -> cancel = { onAction(PassesAction.TogglePassesDialog) }
uiState.takeAction(PassesAction.FilterPasses(hours, elevation)) ) { hours, elevation, showDeepSpace ->
onAction(PassesAction.FilterPasses(hours, elevation, showDeepSpace))
} }
} }
if (uiState.isRadiosDialogShown) { if (uiState.isRadiosDialogShown) {
RadiosDialog(modes = uiState.modes, cancel = showRadiosDialog) { modes -> RadiosDialog(
uiState.takeAction(PassesAction.FilterRadios(modes)) modes = uiState.modes,
cancel = { onAction(PassesAction.ToggleRadiosDialog) }
) { modes ->
onAction(PassesAction.FilterRadios(modes))
} }
} }
if (uiState.shouldSeeWhatsNew) { if (uiState.shouldSeeWhatsNew) {
@@ -115,35 +120,37 @@ private fun PassesScreen(uiState: PassesState, navigateToRadar: (Int, Long) -> U
title = stringResource(R.string.pass_whatsnew_title), title = stringResource(R.string.pass_whatsnew_title),
text = stringResource(R.string.pass_whatsnew_message) text = stringResource(R.string.pass_whatsnew_message)
) { ) {
uiState.takeAction(PassesAction.DismissWhatsNew) onAction(PassesAction.DismissWhatsNew)
} }
} }
val gridState = rememberLazyGridState()
ScreenColumn( ScreenColumn(
topBar = { isVerticalLayout -> topBar = { isVerticalLayout ->
TopBar( TopBar(
isVerticalLayout = isVerticalLayout, isVerticalLayout = isVerticalLayout,
startAction = { startAction = {
IconCard(action = showPassesDialog, resId = R.drawable.ic_filter) IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter)
}, },
topInfo = { topInfo = {
TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos) TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos)
}, },
bottomInfo = { bottomInfo = {
NextPassRow(pass = uiState.nextPass) NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
}, },
endAction = { endAction = {
IconCard(action = showRadiosDialog, resId = R.drawable.ic_radios) IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
} }
) )
} }
) { _ -> ) { _ ->
PassesList( PassesList(
isRefreshing = uiState.isRefreshing, isRefreshing = uiState.isRefreshing,
isUtc = uiState.isUtc,
passes = uiState.itemsList, passes = uiState.itemsList,
groupedPasses = uiState.groupedPasses,
sunTimes = uiState.sunTimes,
focusedCatNum = uiState.focusedCatNum,
navigateToRadar = navigateToRadar, navigateToRadar = navigateToRadar,
refreshPasses = refreshList, onAction = onAction
gridState = gridState
) )
} }
} }
@@ -152,10 +159,13 @@ private fun PassesScreen(uiState: PassesState, navigateToRadar: (Int, Long) -> U
@Composable @Composable
private fun PassesList( private fun PassesList(
isRefreshing: Boolean, isRefreshing: Boolean,
isUtc: Boolean,
passes: List<OrbitalPass>, passes: List<OrbitalPass>,
groupedPasses: Map<String, List<OrbitalPass>>,
sunTimes: Map<String, Pair<String, String>>,
focusedCatNum: Int?,
navigateToRadar: (Int, Long) -> Unit, navigateToRadar: (Int, Long) -> Unit,
refreshPasses: () -> Unit, onAction: (PassesAction) -> Unit
gridState: LazyGridState
) { ) {
val isVerticalLayout = isVerticalLayout() val isVerticalLayout = isVerticalLayout()
val refreshState = rememberPullToRefreshState() val refreshState = rememberPullToRefreshState()
@@ -163,7 +173,7 @@ private fun PassesList(
PullToRefreshBox( PullToRefreshBox(
isRefreshing = isRefreshing, isRefreshing = isRefreshing,
state = refreshState, state = refreshState,
onRefresh = refreshPasses, onRefresh = { onAction(PassesAction.RefreshPasses) },
indicator = { indicator = {
PullToRefreshDefaults.Indicator( PullToRefreshDefaults.Indicator(
state = refreshState, state = refreshState,
@@ -177,18 +187,37 @@ private fun PassesList(
if (passes.isEmpty()) { if (passes.isEmpty()) {
EmptyListCard(message = stringResource(R.string.pass_empty_list_message)) EmptyListCard(message = stringResource(R.string.pass_empty_list_message))
} else { } else {
LazyVerticalGrid( AnimatedContent(
state = gridState, targetState = focusedCatNum,
columns = GridCells.Adaptive(320.dp), transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) },
modifier = Modifier.fillMaxSize() label = "PassesFocusTransition"
) { ) { targetFocus ->
items(items = passes, key = { item -> item.catNum + item.aosTime }) { pass -> LazyVerticalGrid(columns = GridCells.Adaptive(320.dp), modifier = Modifier.fillMaxSize()) {
NearEarthPass( for ((dateLabel, dayPasses) in groupedPasses) {
pass = pass, val headerVisible = targetFocus == null || dayPasses.any { it.catNum == targetFocus }
navigateToRadar = navigateToRadar, if (headerVisible) {
modifier = Modifier.animateItem(), stickyHeader(key = "header_$dateLabel") {
isVerticalLayout = isVerticalLayout 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
)
}
}
}
} }
} }
} }
@@ -196,13 +225,52 @@ 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) @Preview(showBackground = true)
@Composable @Composable
private fun DeepSpacePassPreview() { private fun DeepSpacePassPreview() {
val data = OrbitalData("Satellite", 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 45000, 0.0) val data = OrbitalData("Satellite", 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 45000, 0.0)
val satellite = DeepSpaceObject(data) val satellite = DeepSpaceObject(data)
val pass = OrbitalPass(1L, 180.0, 10L, 360.0, 36650, 45.0, satellite, 0.5f) val pass = OrbitalPass(1L, 180.0, 10L, 360.0, 36650, 45.0, satellite, 0.5f)
MainTheme { NearEarthPass(pass = pass, { _, _ -> }) } MainTheme { PassItem(pass = pass, { _, _ -> }) }
} }
@Preview(showBackground = true) @Preview(showBackground = true)
@@ -211,126 +279,136 @@ private fun NearEarthPassPreview() {
val data = OrbitalData("Satellite", 0.0, 15.0, 0.0, 0.0, 0.0, 0.0, 0.0, 45000, 0.0) val data = OrbitalData("Satellite", 0.0, 15.0, 0.0, 0.0, 0.0, 0.0, 0.0, 45000, 0.0)
val satellite = NearEarthObject(data) val satellite = NearEarthObject(data)
val pass = OrbitalPass(1L, 180.0, 10L, 360.0, 36650, 45.0, satellite, 0.5f) val pass = OrbitalPass(1L, 180.0, 10L, 360.0, 36650, 45.0, satellite, 0.5f)
MainTheme { NearEarthPass(pass = pass, { _, _ -> }) } MainTheme { PassItem(pass = pass, { _, _ -> }) }
} }
@Composable @Composable
private fun NearEarthPass( private fun PassItem(
pass: OrbitalPass, pass: OrbitalPass,
navigateToRadar: (Int, Long) -> Unit, navigateToRadar: (Int, Long) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isVerticalLayout: Boolean = true isVerticalLayout: Boolean = true,
isUtc: Boolean = false
) { ) {
val passSatId = stringResource(id = R.string.pass_satId, pass.catNum) val passSatId = stringResource(id = R.string.pass_satId, pass.catNum)
val horizontalPadding = if (isVerticalLayout) 6.dp else 10.dp val horizontalPadding = if (isVerticalLayout) 6.dp else 10.dp
Surface(color = MaterialTheme.colorScheme.background, modifier = modifier) { val timeZone = remember(isUtc) {
Surface(modifier = Modifier if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
.padding(bottom = 2.dp) }
.clickable { navigateToRadar(pass.catNum, pass.aosTime) }) { val sdfTime = remember(isUtc) {
Column( SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone }
verticalArrangement = Arrangement.spacedBy(1.dp), }
modifier = Modifier val aosTimeStr = remember(pass.aosTime, isUtc) { sdfTime.format(Date(pass.aosTime)) }
.background(color = MaterialTheme.colorScheme.surface) val losTimeStr = remember(pass.losTime, isUtc) { sdfTime.format(Date(pass.losTime)) }
.padding(horizontal = horizontalPadding, vertical = 4.dp) 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) }
) {
Column(
verticalArrangement = Arrangement.spacedBy(1.dp),
modifier = Modifier
.fillMaxWidth()
.background(color = MaterialTheme.colorScheme.surface)
.padding(horizontal = horizontalPadding, vertical = 4.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "$passSatId - ",
color = MaterialTheme.colorScheme.primary
)
Text(
text = pass.name,
modifier = Modifier
.weight(1f)
.padding(end = 6.dp)
.infiniteMarquee(),
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface
)
Icon(
painter = painterResource(id = R.drawable.ic_elevation),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "${pass.maxElevation}°",
color = MaterialTheme.colorScheme.primary
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "$passSatId - ",
color = MaterialTheme.colorScheme.primary
)
Text(
text = pass.name,
modifier = Modifier
.weight(1f)
.padding(end = 6.dp)
.infiniteMarquee(),
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Icon(
painter = painterResource(id = R.drawable.ic_elevation),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "${pass.maxElevation}°",
color = MaterialTheme.colorScheme.primary
)
}
Row( Row(
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.Start,
modifier = Modifier.fillMaxWidth() verticalAlignment = Alignment.CenterVertically
) { ) {
Row( if (pass.isDeepSpace) {
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
if (pass.isDeepSpace) {
Text(text = stringResource(R.string.pass_deep_space), fontSize = 15.sp)
} else {
Text(
text = sdfDate.format(Date(pass.aosTime)),
fontSize = 15.sp
)
}
}
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(id = R.drawable.ic_altitude),
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text( Text(
text = "${pass.altitude} km", text = stringResource(R.string.pass_deep_space),
fontSize = 15.sp fontSize = 15.sp,
) color = MaterialTheme.colorScheme.onSurface
}
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(
id = R.string.pass_aosLos,
pass.aosAzimuth.toInt(),
pass.losAzimuth.toInt()
),
fontSize = 15.sp
) )
} else {
Text(text = durationStr, fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface)
} }
} }
Row( Row(
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth() verticalAlignment = Alignment.CenterVertically
) {
Text(text = "${pass.altitude} km", fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface)
}
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) { ) {
val defaultTime = " - - : - - "
Text( Text(
text = if (pass.isDeepSpace) defaultTime else sdfTime.format(Date(pass.aosTime)), text = stringResource(
fontSize = 15.sp id = R.string.pass_aosLos,
) pass.aosAzimuth.toInt(),
LinearProgressIndicator( pass.losAzimuth.toInt()
progress = { if (pass.isDeepSpace) 100f else pass.progress }, ),
drawStopIndicator = {}, fontSize = 15.sp,
modifier = modifier.fillMaxWidth(0.75f) color = MaterialTheme.colorScheme.onSurface
)
Text(
text = if (pass.isDeepSpace) defaultTime else sdfTime.format(Date(pass.losTime)),
fontSize = 15.sp
) )
} }
} }
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
val defaultTime = " - - : - - "
Text(
text = if (pass.isDeepSpace) defaultTime else aosTimeStr,
fontSize = 15.sp,
color = MaterialTheme.colorScheme.onSurface
)
LinearProgressIndicator(
progress = { if (pass.isDeepSpace) 100f else pass.progress },
drawStopIndicator = {},
modifier = Modifier.fillMaxWidth(0.75f)
)
Text(
text = if (pass.isDeepSpace) defaultTime else losTimeStr,
fontSize = 15.sp,
color = MaterialTheme.colorScheme.onSurface
)
}
} }
HorizontalDivider(thickness = 2.dp, color = MaterialTheme.colorScheme.background)
} }
} }
@@ -20,26 +20,31 @@ package com.rtbishop.look4sat.feature.passes
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
data class PassesState( data class PassesState(
val isPassesDialogShown: Boolean, val isPassesDialogShown: Boolean = false,
val isRadiosDialogShown: Boolean, val isRadiosDialogShown: Boolean = false,
val isRefreshing: Boolean, val isRefreshing: Boolean = true,
val isUtc: Boolean, val isUtc: Boolean = false,
val nextPass: OrbitalPass, val nextPass: OrbitalPass,
val nextTime: String, val nextTime: String = "00:00:00",
val isNextTimeAos: Boolean, val isNextTimeAos: Boolean = true,
val hours: Int, val hours: Int = 24,
val elevation: Double, val elevation: Double = 16.0,
val modes: List<String>, val showDeepSpace: Boolean = true,
val itemsList: List<OrbitalPass>, val modes: List<String> = emptyList(),
val shouldSeeWhatsNew: Boolean, val itemsList: List<OrbitalPass> = emptyList(),
val takeAction: (PassesAction) -> Unit val groupedPasses: Map<String, List<OrbitalPass>> = emptyMap(),
val shouldSeeWhatsNew: Boolean = false,
val sunTimes: Map<String, Pair<String, String>> = emptyMap(),
val focusedCatNum: Int? = null
) )
sealed class PassesAction { sealed interface PassesAction {
data object DismissWhatsNew : 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 class FilterRadios(val modes: List<String>) : PassesAction
data object RefreshPasses : PassesAction() data object RefreshPasses : PassesAction
data object TogglePassesDialog : PassesAction() data object TogglePassesDialog : PassesAction
data object ToggleRadiosDialog : PassesAction() data object ToggleRadiosDialog : PassesAction
data class FocusCatNum(val catNum: Int) : PassesAction
data object ClearFocus : PassesAction
} }
@@ -18,19 +18,18 @@
package com.rtbishop.look4sat.feature.passes package com.rtbishop.look4sat.feature.passes
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.model.PassesSettings 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.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.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.utility.round
import com.rtbishop.look4sat.core.domain.utility.toTimerString import com.rtbishop.look4sat.core.domain.utility.toTimerString
import com.rtbishop.look4sat.core.presentation.getDefaultPass import com.rtbishop.look4sat.core.presentation.getDefaultPass
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -38,110 +37,189 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
class PassesViewModel( class PassesViewModel(
private val satelliteRepo: ISatelliteRepo, private val satelliteRepo: ISatelliteRepo,
private val settingsRepo: ISettingsRepo private val settingsRepo: ISettingsRepo
) : ViewModel() { ) : ViewModel() {
private val defaultPass = getDefaultPass()
private val _uiState = MutableStateFlow( private val _uiState = MutableStateFlow(
PassesState( PassesState(
isPassesDialogShown = false,
isRadiosDialogShown = false,
isRefreshing = true,
isUtc = settingsRepo.otherSettings.value.stateOfUtc, isUtc = settingsRepo.otherSettings.value.stateOfUtc,
nextTime = "00:00:00", nextPass = defaultPass,
isNextTimeAos = true,
nextPass = getDefaultPass(),
hours = settingsRepo.passesSettings.value.hoursAhead, hours = settingsRepo.passesSettings.value.hoursAhead,
elevation = settingsRepo.passesSettings.value.minElevation, elevation = settingsRepo.passesSettings.value.minElevation,
showDeepSpace = settingsRepo.passesSettings.value.showDeepSpace,
modes = settingsRepo.passesSettings.value.selectedModes, modes = settingsRepo.passesSettings.value.selectedModes,
itemsList = emptyList(), shouldSeeWhatsNew = settingsRepo.otherSettings.value.shouldSeeWhatsNew
shouldSeeWhatsNew = settingsRepo.otherSettings.value.shouldSeeWhatsNew,
takeAction = ::handleAction
) )
) )
private var processing: Job? = null
val uiState: StateFlow<PassesState> = _uiState val uiState: StateFlow<PassesState> = _uiState
init { init {
// Refresh indicator: mirrors the repo's isCalculating state
viewModelScope.launch { viewModelScope.launch {
delay(1000) satelliteRepo.isCalculating.collect { calculating ->
satelliteRepo.passes.collectLatest { passes -> _uiState.update { it.copy(isRefreshing = calculating) }
processing?.cancelAndJoin() }
processing = viewModelScope.launch { }
while (isActive) { // React to settings changes: update UTC flag and whatsNew
val timeNow = System.currentTimeMillis() viewModelScope.launch {
val newPasses = satelliteRepo.processPasses(passes, timeNow) settingsRepo.otherSettings.collectLatest { settings ->
setPassInfo(newPasses, timeNow) _uiState.update { it.copy(isUtc = settings.stateOfUtc, shouldSeeWhatsNew = settings.shouldSeeWhatsNew) }
_uiState.update { it.copy(isRefreshing = false, itemsList = newPasses) } }
delay(1000) }
// 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)
} }
} }
} }
viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings ->
_uiState.update { it.copy(shouldSeeWhatsNew = settings.shouldSeeWhatsNew) }
}
}
} }
private fun handleAction(action: PassesAction) { fun onAction(action: PassesAction) {
when (action) { when (action) {
PassesAction.DismissWhatsNew -> settingsRepo.setWhatsNewDismissed() PassesAction.DismissWhatsNew -> settingsRepo.setWhatsNewDismissed()
is PassesAction.FilterPasses -> applyFilter(action.hoursAhead, action.minElevation, uiState.value.modes) is PassesAction.FilterPasses ->
is PassesAction.FilterRadios -> applyFilter(uiState.value.hours, uiState.value.elevation, action.modes) 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.RefreshPasses -> refreshPasses()
PassesAction.TogglePassesDialog -> toggleFilterDialog() PassesAction.TogglePassesDialog ->
PassesAction.ToggleRadiosDialog -> toggleRadiosDialog() _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) }
} }
} }
private fun applyFilter(hoursAhead: Int, minElevation: Double, modes: List<String>) = viewModelScope.launch { // Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace
_uiState.update { it.copy(isRefreshing = true) } private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
processing?.cancelAndJoin() val stationPos = settingsRepo.stationPosition.value
settingsRepo.setPassesSettings(PassesSettings(hoursAhead, minElevation, modes)) val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
_uiState.update { it.copy(hours = hoursAhead, elevation = minElevation, modes = modes) } 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)
for (pass in passList) {
if (!pass.isDeepSpace && time > pass.aosTime) {
val deltaNow = time.minus(pass.aosTime).toFloat()
val deltaTotal = pass.losTime.minus(pass.aosTime).toFloat()
val newProgress = (deltaNow / deltaTotal).round(2)
if (newProgress >= 1.0f) continue
if (newProgress != pass.progress) {
result.add(pass.copy(progress = newProgress))
} else {
result.add(pass)
}
} else {
result.add(pass)
}
}
return result
}
/** Resolves the next upcoming or active pass and its countdown timer. */
private fun resolveNextPass(
passes: List<OrbitalPass>,
timeNow: Long
): Triple<OrbitalPass, String, Boolean> {
val upcoming = passes.firstOrNull { it.aosTime > timeNow }
if (upcoming != null) {
return Triple(upcoming, (upcoming.aosTime - timeNow).toTimerString(), true)
}
if (passes.isNotEmpty()) {
val lastPass = passes.last()
return Triple(lastPass, (lastPass.losTime - timeNow).toTimerString(), false)
}
return Triple(defaultPass, "00:00:00", true)
}
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) satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
} }
private fun refreshPasses() = viewModelScope.launch { private fun refreshPasses() = viewModelScope.launch {
_uiState.update { it.copy(isRefreshing = true) } val (_, hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value
processing?.cancelAndJoin()
val (hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value
satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes) satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
} }
private fun setPassInfo(passes: List<OrbitalPass>, timeNow: Long) {
if (passes.isEmpty()) return
try {
val nextPass = passes.first { it.aosTime.minus(timeNow) > 0 }
val time = nextPass.aosTime.minus(timeNow).toTimerString()
_uiState.update { it.copy(nextPass = nextPass, nextTime = time, isNextTimeAos = true) }
} catch (_: NoSuchElementException) {
val lastPass = passes.last()
val time = lastPass.losTime.minus(timeNow).toTimerString()
_uiState.update { it.copy(nextPass = lastPass, nextTime = time, isNextTimeAos = false) }
}
}
private fun toggleFilterDialog() {
val currentDialogState = _uiState.value.isPassesDialogShown
_uiState.update { it.copy(isPassesDialogShown = currentDialogState.not()) }
}
private fun toggleRadiosDialog() {
val currentDialogState = _uiState.value.isRadiosDialogShown
_uiState.update { it.copy(isRadiosDialogShown = currentDialogState.not()) }
}
companion object { companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory { fun factory(container: IMainContainer) = viewModelFactory {
val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY
initializer { initializer {
val container = (this[applicationKey] as IContainerProvider).getMainContainer() PassesViewModel(
PassesViewModel(container.satelliteRepo, container.settingsRepo) satelliteRepo = container.satelliteRepo,
settingsRepo = container.settingsRepo
)
} }
} }
} }
+1 -1
View File
@@ -1,5 +1,5 @@
plugins { plugins {
alias(libs.plugins.convention.composeFeaturePlugin) alias(libs.plugins.convention.featurePlugin)
} }
android { android {
@@ -37,16 +37,17 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.keepScreenOn import androidx.compose.ui.keepScreenOn
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
@@ -58,17 +59,15 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import 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.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.domain.utility.toDegrees
import com.rtbishop.look4sat.core.presentation.EmptyListCard import com.rtbishop.look4sat.core.presentation.EmptyListCard
import com.rtbishop.look4sat.core.presentation.IconCard import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.MainTheme import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.NextPassRow import com.rtbishop.look4sat.core.presentation.NextPassRow
import com.rtbishop.look4sat.core.presentation.R 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.TimerRow
import com.rtbishop.look4sat.core.presentation.TopBar import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.getDefaultPass import com.rtbishop.look4sat.core.presentation.getDefaultPass
@@ -76,262 +75,212 @@ import com.rtbishop.look4sat.core.presentation.infiniteMarquee
import com.rtbishop.look4sat.core.presentation.isVerticalLayout import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import com.rtbishop.look4sat.core.presentation.layoutPadding import com.rtbishop.look4sat.core.presentation.layoutPadding
fun NavGraphBuilder.radarDestination(navigateUp: () -> Unit) { @Composable
val radarRoute = "${Screen.Radar.route}?catNum={catNum}&aosTime={aosTime}" fun RadarDestination(navigateToRadioControl: () -> Unit = {}) {
val radarArgs = listOf( val context = LocalContext.current
navArgument("catNum") { defaultValue = 0 }, val container = (context.applicationContext as IContainerProvider).getMainContainer()
navArgument("aosTime") { defaultValue = 0L } val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
) val uiState by viewModel.uiState.collectAsStateWithLifecycle()
composable(radarRoute, radarArgs) { RadarScreen(uiState, viewModel::onAction, navigateToRadioControl)
val viewModel = viewModel(RadarViewModel::class.java, factory = RadarViewModel.Factory)
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
RadarScreen(uiState, navigateUp)
}
} }
@Composable @Composable
private fun RadarScreen(uiState: RadarState, navigateUp: () -> Unit) { private fun RadarScreen(
// BluetoothCIV.init(LocalContext.current) uiState: RadarState,
val addToCalendar: () -> Unit = { onAction: (RadarAction) -> Unit,
uiState.currentPass?.let { pass -> navigateToRadioControl: () -> Unit
uiState.sendAction(RadarAction.AddToCalendar(pass.name, pass.aosTime, pass.losTime)) ) {
}
}
val upcomingPass = uiState.currentPass ?: getDefaultPass() val upcomingPass = uiState.currentPass ?: getDefaultPass()
if (upcomingPass.losTime < System.currentTimeMillis()) navigateUp() val addToCalendar: () -> Unit = {
uiState.currentPass?.let { onAction(RadarAction.AddToCalendar(it.name, it.aosTime, it.losTime)) }
}
Column( Column(
modifier = Modifier modifier = Modifier
.layoutPadding() .layoutPadding()
.keepScreenOn(), verticalArrangement = Arrangement.spacedBy(6.dp) .keepScreenOn(),
verticalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
if (isVerticalLayout()) { val isVertical = isVerticalLayout()
if (isVertical) {
TopBar { TopBar {
IconCard(action = navigateUp, resId = R.drawable.ic_back)
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isCurrentTimeAos)
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar) IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
IconCard(action = navigateToRadioControl, resId = R.drawable.ic_radios)
} }
TopBar { NextPassRow(pass = upcomingPass) } TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
} else { } else {
TopBar { TopBar {
IconCard(action = navigateUp, resId = R.drawable.ic_back)
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isCurrentTimeAos)
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f))
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar) IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
IconCard(action = navigateToRadioControl, resId = R.drawable.ic_radios)
} }
} }
if(isVerticalLayout()) { if (isVertical) {
ElevatedCard(modifier = Modifier.weight(1f)) { RadarCard(uiState, Modifier.weight(1f))
Box(contentAlignment = Alignment.Center) { TransmittersCard(uiState.transmitters, uiState.selectedTransmitterUuid, onAction, Modifier.weight(1f))
if (uiState.orbitalPos == null) {
ElevatedCard(modifier = Modifier.fillMaxSize()) {
EmptyListCard(message = "")
}
}
uiState.orbitalPos?.let { position ->
RadarViewCompose(
item = position,
items = uiState.satTrack,
azimElev = uiState.orientationValues,
shouldShowSweep = uiState.shouldShowSweep,
shouldUseCompass = uiState.shouldUseCompass,
modifier = Modifier.align(Alignment.Center)
)
Column(
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 6.dp, vertical = 4.dp)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
RadarTextTop(
position.azimuth,
stringResource(R.string.radar_az_text),
true
)
RadarTextTop(
position.elevation,
stringResource(R.string.radar_el_text),
false
)
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
RadarTextBottom(
position.altitude,
stringResource(R.string.radar_alt_text),
true
)
RadarTextBottom(
position.distance,
stringResource(R.string.radar_dist_text),
false
)
}
}
}
}
}
ElevatedCard(modifier = Modifier.weight(1f)) {
if (uiState.transmitters.isEmpty()) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier.padding(32.dp)
) {
Text(text = """¯\_(ツ)_/¯""", fontSize = 32.sp)
Text(
text = stringResource(R.string.empty_list_message),
fontSize = 21.sp,
textAlign = TextAlign.Center
)
Text(
text = stringResource(R.string.radar_no_data),
fontSize = 18.sp,
textAlign = TextAlign.Center
)
}
}
} else {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
TransmittersList(
transmitters = uiState.transmitters,
selectedUuid = uiState.selectedTransmitterUuid,
onSelect = { uuid ->
if (uiState.selectedTransmitterUuid != null) {
uiState.sendAction(RadarAction.SelectTransmitter(uuid))
}
}
)
if (uiState.orbitalPos?.eclipsed == true) {
EclipsedIndicator()
}
}
}
}
} else { } else {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
ElevatedCard(modifier = Modifier.weight(1f)) { RadarCard(uiState, Modifier.weight(1f))
Box(contentAlignment = Alignment.Center) { TransmittersCard(uiState.transmitters, uiState.selectedTransmitterUuid, onAction, Modifier.weight(1f))
if (uiState.orbitalPos == null) {
ElevatedCard(modifier = Modifier.fillMaxSize()) {
EmptyListCard(message = "")
}
}
uiState.orbitalPos?.let { position ->
RadarViewCompose(
item = position,
items = uiState.satTrack,
azimElev = uiState.orientationValues,
shouldShowSweep = uiState.shouldShowSweep,
shouldUseCompass = uiState.shouldUseCompass,
modifier = Modifier.align(Alignment.Center)
)
Column(
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 6.dp, vertical = 4.dp)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
RadarTextTop(
position.azimuth,
stringResource(R.string.radar_az_text),
true
)
RadarTextTop(
position.elevation,
stringResource(R.string.radar_el_text),
false
)
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
RadarTextBottom(
position.altitude,
stringResource(R.string.radar_alt_text),
true
)
RadarTextBottom(
position.distance,
stringResource(R.string.radar_dist_text),
false
)
}
}
}
}
}
ElevatedCard(modifier = Modifier.weight(1f)) {
if (uiState.transmitters.isEmpty()) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier.padding(32.dp)
) {
Text(text = """¯\_(ツ)_/¯""", fontSize = 32.sp)
Text(
text = stringResource(R.string.empty_list_message),
fontSize = 21.sp,
textAlign = TextAlign.Center
)
Text(
text = stringResource(R.string.radar_no_data),
fontSize = 18.sp,
textAlign = TextAlign.Center
)
}
}
} else {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
TransmittersList(
transmitters = uiState.transmitters,
selectedUuid = uiState.selectedTransmitterUuid,
onSelect = { uuid ->
if (uiState.selectedTransmitterUuid != null) {
uiState.sendAction(RadarAction.SelectTransmitter(uuid))
}
}
)
if (uiState.orbitalPos?.eclipsed == true) {
EclipsedIndicator()
}
}
}
}
} }
} }
} }
} }
@Composable @Composable
private fun RadarTextTop(value: Double, text: String, isLeft: Boolean) { private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
val alignment = if (isLeft) Alignment.Start else Alignment.End val satellitePos = uiState.orbitalPos
val degValue = stringResource(R.string.radar_az_value, value.toDegrees()) val borderModifier = if (satellitePos?.aboveHorizon == true && satellitePos.eclipsed) {
Column(horizontalAlignment = alignment) { val infiniteTransition = rememberInfiniteTransition(label = "eclipsedBorder")
Text(text = degValue, fontSize = 18.sp) val borderAlpha by infiniteTransition.animateFloat(
Text(text = text, fontSize = 15.sp) initialValue = 1.0f,
targetValue = 0.0f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1000, delayMillis = 25, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "eclipsedBorderAlpha"
)
Modifier.border(
width = 0.5.dp,
color = MaterialTheme.colorScheme.primary.copy(alpha = borderAlpha),
shape = MaterialTheme.shapes.medium
)
} else {
Modifier
}
ElevatedCard(modifier = modifier.then(borderModifier)) {
Box(contentAlignment = Alignment.Center) {
val position = uiState.orbitalPos
if (position == null) {
ElevatedCard(modifier = Modifier.fillMaxSize()) {
EmptyListCard(message = "")
}
} else {
RadarViewCompose(
item = position,
items = uiState.satTrack,
azimElev = uiState.orientationValues,
shouldShowSweep = uiState.shouldShowSweep,
shouldUseCompass = uiState.shouldUseCompass,
modifier = Modifier.align(Alignment.Center),
sunPosition = uiState.sunPosition,
moonPosition = uiState.moonPosition,
)
PositionOverlay(position)
}
}
} }
} }
@Composable @Composable
private fun RadarTextBottom(value: Double, text: String, isLeft: Boolean) { private fun PositionOverlay(position: OrbitalPos) {
val alignment = if (isLeft) Alignment.Start else Alignment.End Column(
val degValue = stringResource(R.string.radar_alt_value, value) verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 6.dp, vertical = 4.dp)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
RadarLabel(
value = stringResource(R.string.radar_az_value, position.azimuth.toDegrees()),
label = stringResource(R.string.radar_az_text),
alignment = Alignment.Start,
labelFirst = false
)
RadarLabel(
value = stringResource(R.string.radar_az_value, position.elevation.toDegrees()),
label = stringResource(R.string.radar_el_text),
alignment = Alignment.End,
labelFirst = false
)
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
RadarLabel(
value = stringResource(R.string.radar_alt_value, position.altitude),
label = stringResource(R.string.radar_alt_text),
alignment = Alignment.Start,
labelFirst = true
)
RadarLabel(
value = stringResource(R.string.radar_alt_value, position.distance),
label = stringResource(R.string.radar_dist_text),
alignment = Alignment.End,
labelFirst = true
)
}
}
}
@Composable
private fun RadarLabel(
value: String,
label: String,
alignment: Alignment.Horizontal,
labelFirst: Boolean
) {
Column(horizontalAlignment = alignment) { Column(horizontalAlignment = alignment) {
Text(text = text, fontSize = 15.sp) if (labelFirst) {
Text(text = degValue, fontSize = 18.sp) Text(text = label, fontSize = 15.sp)
Text(text = value, fontSize = 18.sp)
} else {
Text(text = value, fontSize = 18.sp)
Text(text = label, fontSize = 15.sp)
}
}
}
@Composable
private fun TransmittersCard(
transmitters: List<SatRadio>,
selectedUuid: String?,
onAction: (RadarAction) -> Unit,
modifier: Modifier = Modifier
) {
ElevatedCard(modifier = modifier) {
if (transmitters.isEmpty()) {
EmptyTransmittersContent()
} else {
TransmittersList(
transmitters = transmitters,
selectedUuid = selectedUuid,
onSelect = { uuid ->
if (selectedUuid != null) {
onAction(RadarAction.SelectTransmitter(uuid))
}
}
)
}
}
}
@Composable
private fun EmptyTransmittersContent() {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier.padding(32.dp)
) {
Text(text = """¯\_(ツ)_/¯""", fontSize = 32.sp)
Text(
text = stringResource(R.string.empty_list_message),
fontSize = 21.sp,
textAlign = TextAlign.Center
)
Text(
text = stringResource(R.string.radar_no_data),
fontSize = 18.sp,
textAlign = TextAlign.Center
)
}
} }
} }
@@ -342,12 +291,13 @@ private fun TransmittersList(
onSelect: (String) -> Unit onSelect: (String) -> Unit
) { ) {
LazyColumn(modifier = Modifier.fillMaxSize()) { LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = transmitters, key = { item -> item.uuid }) { radio -> items(items = transmitters, key = { it.uuid }) { radio ->
TransmitterItem( TransmitterItem(
radio, radio = radio,
(selectedUuid != null), isClickable = selectedUuid != null,
(radio.uuid == selectedUuid) isSelected = radio.uuid == selectedUuid,
) { onSelect(radio.uuid) } onClick = { onSelect(radio.uuid) }
)
} }
} }
} }
@@ -362,42 +312,6 @@ private fun TransmitterItemPreview() {
MainTheme { TransmitterItem(transmitter, isClickable = true, isSelected = true, onClick = {}) } MainTheme { TransmitterItem(transmitter, isClickable = true, isSelected = true, onClick = {}) }
} }
@Composable
private fun EclipsedIndicator() {
val bgColor = MaterialTheme.colorScheme.primary
val bgShape = MaterialTheme.shapes.medium
val infiniteTransition = rememberInfiniteTransition()
val textAlpha = infiniteTransition.animateFloat(
initialValue = 1.0f,
targetValue = 0.0f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 1000,
delayMillis = 25,
easing = LinearEasing
),
repeatMode = RepeatMode.Reverse
)
)
Box(
modifier = Modifier
.fillMaxSize()
.alpha(textAlpha.value)
.border(width = 2.dp, color = bgColor, shape = bgShape),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(R.string.radar_eclipsed),
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.background,
modifier = Modifier
.background(color = bgColor, shape = bgShape)
.padding(12.dp)
)
}
}
@Composable @Composable
private fun TransmitterItem( private fun TransmitterItem(
radio: SatRadio, radio: SatRadio,
@@ -407,85 +321,92 @@ private fun TransmitterItem(
) { ) {
val title = if (radio.isInverted) "INVERTED: ${radio.info}" else radio.info val title = if (radio.isInverted) "INVERTED: ${radio.info}" else radio.info
val fullTitle = "$title - (${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"})" val fullTitle = "$title - (${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"})"
Surface( Column(
color = MaterialTheme.colorScheme.background,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.then(if (isClickable) Modifier.clickable { onClick() } else Modifier) .then(if (isClickable) Modifier.clickable { onClick() } else Modifier)
) { ) {
Surface(modifier = Modifier.padding(bottom = 2.dp)) { Column(
Column( horizontalAlignment = Alignment.CenterHorizontally,
horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier
modifier = Modifier .fillMaxWidth()
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
.padding(horizontal = 8.dp, vertical = 6.dp) .padding(horizontal = 8.dp, vertical = 6.dp)
) { ) {
Box { Box {
Text( Text(
text = fullTitle, text = fullTitle,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier color = MaterialTheme.colorScheme.onSurface,
.fillMaxWidth() modifier = Modifier
.padding(horizontal = 6.dp) .fillMaxWidth()
.infiniteMarquee() .padding(horizontal = 6.dp)
.infiniteMarquee()
)
if (isSelected) {
Icon(
painter = painterResource(id = R.drawable.ic_radios),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.background(color = MaterialTheme.colorScheme.surface)
) )
if (isSelected) {
Icon(
painter = painterResource(id = R.drawable.ic_radios),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.background(color = MaterialTheme.colorScheme.surface)
)
}
} }
FrequencyRow(radio = radio, isDownlink = true)
FrequencyRow(radio = radio, isDownlink = false)
} }
FrequencyRow(radio = radio, isDownlink = true)
FrequencyRow(radio = radio, isDownlink = false)
} }
HorizontalDivider(thickness = 2.dp, color = MaterialTheme.colorScheme.background)
} }
} }
@Composable @Composable
private fun FrequencyRow(radio: SatRadio, isDownlink: Boolean, modifier: Modifier = Modifier) { private fun FrequencyRow(radio: SatRadio, isDownlink: Boolean) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
val rotateMod = Modifier.rotate(if (isDownlink) 90f else -90f) val desc = if (isDownlink) stringResource(R.string.radar_downlink)
val weightMod = Modifier.weight(1f) else stringResource(R.string.radar_uplink)
val desc = if (isDownlink) stringResource(R.string.radar_downlink) else stringResource(R.string.radar_uplink)
Text( Text(
text = if (isDownlink) "D:" else "U:", text = if (isDownlink) "D:" else "U:",
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
fontSize = 18.sp, fontSize = 18.sp,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
modifier = modifier modifier = Modifier
.size(width = 24.dp, height = 24.dp) .size(24.dp)
.semantics { contentDescription = desc } .semantics { contentDescription = desc }
) )
FrequencyText(if (isDownlink) radio.downlinkLow else radio.uplinkLow, weightMod) FrequencyText(
frequency = if (isDownlink) radio.downlinkLow else radio.uplinkLow,
modifier = Modifier.weight(1f)
)
Text( Text(
text = "-", text = "-",
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
fontSize = 21.sp, fontSize = 21.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary
modifier = modifier )
FrequencyText(
frequency = if (isDownlink) radio.downlinkHigh else radio.uplinkHigh,
modifier = Modifier.weight(1f)
) )
FrequencyText(if (isDownlink) radio.downlinkHigh else radio.uplinkHigh, weightMod)
Icon( Icon(
painter = painterResource(id = R.drawable.ic_arrow), painter = painterResource(id = R.drawable.ic_arrow),
tint = MaterialTheme.colorScheme.onSurface, tint = MaterialTheme.colorScheme.onSurface,
contentDescription = null, contentDescription = null,
modifier = rotateMod.size(width = 24.dp, height = 24.dp) modifier = Modifier
.rotate(if (isDownlink) 90f else -90f)
.size(24.dp)
) )
} }
} }
@Composable @Composable
private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) { private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
val noLinkText = stringResource(R.string.radar_no_link) val text = frequency?.let {
val freqValue = frequency?.let { it / 1000000f } stringResource(id = R.string.radar_link_low, it / 1000000f)
} ?: stringResource(R.string.radar_no_link)
Text( Text(
text = freqValue?.let { stringResource(id = R.string.radar_link_low, it) } ?: noLinkText, text = text,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
fontSize = 21.sp, fontSize = 21.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
@@ -18,25 +18,29 @@
package com.rtbishop.look4sat.feature.radar package com.rtbishop.look4sat.feature.radar
import com.rtbishop.look4sat.core.domain.model.SatRadio 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.OrbitalPass
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
data class RadarState( data class RadarState(
val currentPass: OrbitalPass?, val currentPass: OrbitalPass? = null,
val currentTime: String, val currentTime: String = "00:00:00",
val isCurrentTimeAos: Boolean, val isTimeAos: Boolean = true,
val orientationValues: Pair<Float, Float>, val isLos: Boolean = false,
val orbitalPos: OrbitalPos?, val isUtc: Boolean = false,
val satTrack: List<OrbitalPos>, val orientationValues: Pair<Float, Float> = 0f to 0f,
val shouldShowSweep: Boolean, val orbitalPos: OrbitalPos? = null,
val shouldUseCompass: Boolean, val satTrack: List<OrbitalPos> = emptyList(),
val transmitters: List<SatRadio>, val shouldShowSweep: Boolean = false,
val selectedTransmitterUuid: String?, val shouldUseCompass: Boolean = false,
val selectedFrequency: Long?, val sunPosition: CelestialComputer.SunPosition? = null,
val sendAction: (RadarAction) -> Unit val moonPosition: CelestialComputer.MoonPosition? = null,
val transmitters: List<SatRadio> = emptyList(),
val selectedTransmitterUuid: String? = null,
val selectedFrequency: Long? = null
) )
sealed class RadarAction { sealed interface RadarAction {
data class AddToCalendar(val name: String, val aosTime: Long, val losTime: Long) : RadarAction() data class AddToCalendar(val name: String, val aosTime: Long, val losTime: Long) : RadarAction
data class SelectTransmitter(val uuid: String) : RadarAction() data class SelectTransmitter(val uuid: String) : RadarAction
} }
@@ -25,11 +25,14 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.PathEffect
@@ -42,17 +45,26 @@ import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.drawscope.translate 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.TextMeasurer
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.sp 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.OrbitalPos
import com.rtbishop.look4sat.core.domain.predict.PI_2 import com.rtbishop.look4sat.core.domain.predict.PI_2
import com.rtbishop.look4sat.core.domain.utility.toRadians import com.rtbishop.look4sat.core.domain.utility.toRadians
import com.rtbishop.look4sat.core.presentation.R
import kotlin.math.cos import kotlin.math.cos
import kotlin.math.sin import kotlin.math.sin
private const val CIRCLES = 3
private const val STROKE_WIDTH = 6f
private const val SWEEP_INCREMENT = 360f / 12f / 60f
@Composable @Composable
fun RadarViewCompose( fun RadarViewCompose(
item: OrbitalPos, item: OrbitalPos,
@@ -60,151 +72,103 @@ fun RadarViewCompose(
azimElev: Pair<Float, Float>, azimElev: Pair<Float, Float>,
shouldShowSweep: Boolean, shouldShowSweep: Boolean,
shouldUseCompass: Boolean, shouldUseCompass: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
sunPosition: CelestialComputer.SunPosition? = null,
moonPosition: CelestialComputer.MoonPosition? = null,
) { ) {
// val context = LocalContext.current
// val view = LocalView.current
val radarColor = MaterialTheme.colorScheme.secondary
val trackColor = MaterialTheme.colorScheme.primary
val aimColor = MaterialTheme.colorScheme.error val aimColor = MaterialTheme.colorScheme.error
val strokeWidth = 6f val primaryColor = MaterialTheme.colorScheme.primary
val radarColor = MaterialTheme.colorScheme.secondary
val sunColor = MaterialTheme.colorScheme.primary
val animTransition = rememberInfiniteTransition(label = "animScale") val animTransition = rememberInfiniteTransition(label = "animScale")
val animSpec = infiniteRepeatable<Float>(tween(1000)) val animScale by animTransition.animateFloat(
val animScale = animTransition.animateFloat(16f, 64f, animSpec, label = "animScale") initialValue = 16f,
// val aimThreshold = 0.05f targetValue = 64f,
animationSpec = infiniteRepeatable(tween(1000)),
label = "animScale"
)
val measurer = rememberTextMeasurer() val measurer = rememberTextMeasurer()
val sweepDegrees = remember { mutableFloatStateOf(0f) } val sunPainter = painterResource(R.drawable.ic_sun)
val trackLastRadius = remember { mutableStateOf(0f) } val moonPainter = painterResource(R.drawable.ic_moon)
val trackPath = remember { mutableStateOf(Path()) } var sweepDegrees by remember { mutableFloatStateOf(0f) }
val trackEffect = remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) } var cachedRadius by remember { mutableFloatStateOf(0f) }
// val soundPool = remember { mutableStateOf<SoundPool?>(null) } var trackPath by remember { mutableStateOf(Path()) }
// val beepSoundId = remember { mutableIntStateOf(0) } var trackEffect by remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) }
// val aimTargetDifference = remember { mutableFloatStateOf(0f) }
// LaunchedEffect(item.azimuth, item.elevation, azimElev.first, azimElev.second) {
// val aimAzimuthRadians = azimElev.first.toDouble().toRadians()
// val aimElevationRadians = abs(min(azimElev.second, 0f)).toDouble().toRadians()
// // radius of 0.5 makes the aimTargetDifference range 0.0 to 1.0
// val radius = 0.5
// val aimX = sph2CartX(aimAzimuthRadians, aimElevationRadians, radius)
// val aimY = sph2CartY(aimAzimuthRadians, aimElevationRadians, radius)
// val satX = sph2CartX(item.azimuth, item.elevation, radius)
// val satY = sph2CartY(item.azimuth, item.elevation, radius)
// aimTargetDifference.floatValue = sqrt((satX - aimX).pow(2) + (satY - aimY).pow(2))
// val minPlaybackRate = 0.5f
// val maxPlaybackRate = 2.0f
// val playbackRate =
// maxPlaybackRate - (aimTargetDifference.floatValue / (maxPlaybackRate - minPlaybackRate))
// soundPool.value?.setRate(beepSoundId.intValue, playbackRate)
// }
//
// LaunchedEffect(aimTargetDifference.floatValue < aimThreshold) {
// if (aimTargetDifference.floatValue < aimThreshold) {
// view.performHapticFeedback(HapticFeedbackConstantsCompat.VIRTUAL_KEY)
// soundPool.value?.pause(beepSoundId.intValue)
// } else {
// soundPool.value?.resume(beepSoundId.intValue)
// }
// }
//
// LaunchedEffect(item.elevation > 0) {
// if(item.elevation > 0) {
// soundPool.value?.resume(beepSoundId.intValue)
// } else {
// soundPool.value?.pause(beepSoundId.intValue)
// }
// }
//
// DisposableEffect(Unit) {
// val audioAttributes = AudioAttributes.Builder()
// .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION)
// .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
// .build()
// soundPool.value = SoundPool.Builder()
// .setMaxStreams(1)
// .setAudioAttributes(audioAttributes)
// .build()
// beepSoundId.intValue = soundPool.value?.load(context, R.raw.beep, 1) ?: 0
// soundPool.value?.setOnLoadCompleteListener { soundPool, _, status ->
// if (status == 0) {
// soundPool.play(beepSoundId.intValue, 0.5f, 0.5f, 0, -1, 1f)
// }
// }
// onDispose {
// soundPool.value?.release()
// }
// }
Canvas(modifier = modifier.aspectRatio(1f)) { Canvas(modifier = modifier.aspectRatio(1f)) {
val radius = (size.minDimension / 2f) * 0.95f val radius = size.minDimension / 2f * 0.95f
if(radius != trackLastRadius.value) { if (radius != cachedRadius) {
trackPath.value = createTrackPath(items, radius) trackPath = createTrackPath(items, radius)
trackEffect.value = createTrackEffect(trackPath.value) trackEffect = createTrackEffect(trackPath)
trackLastRadius.value = radius cachedRadius = radius
} }
rotate(if (shouldUseCompass) -azimElev.first else 0f) { rotate(if (shouldUseCompass) -azimElev.first else 0f) {
if (shouldShowSweep) { if (shouldShowSweep) drawSweep(center, sweepDegrees, radius, primaryColor)
drawSweep(center, sweepDegrees.floatValue, radius, trackColor) drawRadar(radius, radarColor)
} drawElevationLabels(radius, primaryColor, measurer)
drawRadar(radius, radarColor, strokeWidth, 3)
drawInfo(radius, trackColor, measurer, 3)
translate(center.x, center.y) { translate(center.x, center.y) {
drawTrack(trackPath.value, trackEffect.value, aimColor, trackColor) drawTrack(trackPath, trackEffect, aimColor, primaryColor)
if (item.elevation > 0) { if (item.elevation > 0) {
drawPosition(item, radius, animScale.value, trackColor) drawPosition(item, radius, animScale, primaryColor)
} }
if (shouldUseCompass) { sunPosition?.let { sun ->
drawAim(azimElev.first, azimElev.second, radius, strokeWidth, aimColor) 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)
} }
sweepDegrees.floatValue = (sweepDegrees.floatValue + 360 / 12.0f / 60) % 360 sweepDegrees = (sweepDegrees + SWEEP_INCREMENT) % 360f
} }
} }
} }
private fun DrawScope.drawRadar(radius: Float, color: Color, width: Float, circles: Int) { private fun DrawScope.drawRadar(radius: Float, color: Color) {
for (i in 0 until circles) { val step = radius / CIRCLES
val circleRadius = radius - radius / circles.toFloat() * i.toFloat() for (i in 0 until CIRCLES) {
drawCircle(color, circleRadius, style = Stroke(width)) drawCircle(color, radius - step * i, style = Stroke(STROKE_WIDTH))
} }
drawLine(color, center.copy(x = center.x - radius), center.copy(x = center.x + radius), width) drawLine(color, Offset(center.x - radius, center.y), Offset(center.x + radius, center.y), STROKE_WIDTH)
drawLine(color, center.copy(y = center.y - radius), center.copy(y = center.y + radius), width) drawLine(color, Offset(center.x, center.y - radius), Offset(center.x, center.y + radius), STROKE_WIDTH)
} }
private fun DrawScope.drawInfo(radius: Float, color: Color, measurer: TextMeasurer, circles: Int) { private fun DrawScope.drawElevationLabels(radius: Float, color: Color, measurer: TextMeasurer) {
for (i in 0 until circles) { val step = radius / CIRCLES
val textY = (radius - radius / circles * i) - 32f val degStep = 90 / CIRCLES
val textDeg = " ${(90 / circles) * (circles - i)}°" val style = TextStyle(color, 15.sp)
drawText(measurer, textDeg, center.copy(y = textY), style = TextStyle(color, 15.sp)) for (i in 0 until CIRCLES) {
val textY = (radius - step * i) - 32f
drawText(measurer, " ${degStep * (CIRCLES - i)}°", Offset(center.x, textY), style = style)
} }
} }
private fun DrawScope.drawTrack(path: Path, effect: PathEffect, color: Color, effectColor: Color) { private fun DrawScope.drawTrack(path: Path, effect: PathEffect, color: Color, effectColor: Color) {
drawPath(path, color, style = Stroke(6f)) drawPath(path, color, style = Stroke(STROKE_WIDTH))
drawPath(path, effectColor, style = Stroke(pathEffect = effect)) drawPath(path, effectColor, style = Stroke(pathEffect = effect))
} }
private fun DrawScope.drawPosition(item: OrbitalPos, radius: Float, posRadius: Float, color: Color) { private fun DrawScope.drawPosition(item: OrbitalPos, radius: Float, posRadius: Float, color: Color) {
val satX = sph2CartX(item.azimuth, item.elevation, radius.toDouble()) val pos = sph2Cart(item.azimuth, item.elevation, radius.toDouble())
val satY = sph2CartY(item.azimuth, item.elevation, radius.toDouble()) drawCircle(color, 16f, pos)
drawCircle(color, 16f, center.copy(satX, -satY)) if (!item.eclipsed) drawCircle(color.copy(alpha = 1 - (posRadius / 64f)), posRadius, pos)
drawCircle(color.copy(alpha = 1 - (posRadius / 64f)), posRadius, center.copy(satX, -satY))
} }
private fun DrawScope.drawAim(azim: Float, elev: Float, radius: Float, width: Float, color: Color) { private fun DrawScope.drawAim(azim: Float, elev: Float, radius: Float, color: Color) {
val size = 36f val size = 36f
val azimRadians = azim.toDouble().toRadians() val azimRad = azim.toDouble().toRadians()
val tempElevRadians = elev.toDouble().toRadians() val elevRad = elev.toDouble().toRadians().coerceAtMost(0.0)
val elevRadians = if (tempElevRadians > 0.0) 0.0 else tempElevRadians val pos = sph2Cart(azimRad, -elevRad, radius.toDouble())
val aimX = sph2CartX(azimRadians, -elevRadians, radius.toDouble()) drawLine(color, Offset(pos.x - size, pos.y), Offset(pos.x + size, pos.y), STROKE_WIDTH)
val aimY = sph2CartY(azimRadians, -elevRadians, radius.toDouble()) drawLine(color, Offset(pos.x, pos.y - size), Offset(pos.x, pos.y + size), STROKE_WIDTH)
try { drawCircle(color, size / 2, pos, style = Stroke(STROKE_WIDTH))
drawLine(color, center.copy(aimX - size, -aimY), center.copy(aimX + size, -aimY), width)
drawLine(color, center.copy(aimX, -aimY - size), center.copy(aimX, -aimY + size), width)
drawCircle(color, size / 2, center.copy(aimX, -aimY), style = Stroke(width))
} catch (exception: Exception) {
println(exception)
}
} }
private fun DrawScope.drawSweep(center: Offset, degrees: Float, radius: Float, color: Color) { private fun DrawScope.drawSweep(center: Offset, degrees: Float, radius: Float, color: Color) {
@@ -216,37 +180,55 @@ private fun DrawScope.drawSweep(center: Offset, degrees: Float, radius: Float, c
private fun createTrackPath(positions: List<OrbitalPos>, radius: Float): Path { private fun createTrackPath(positions: List<OrbitalPos>, radius: Float): Path {
val trackPath = Path() val trackPath = Path()
positions.forEachIndexed { index, satPos -> positions.forEachIndexed { index, pos ->
val passX = sph2CartX(satPos.azimuth, satPos.elevation, radius.toDouble()) val offset = sph2Cart(pos.azimuth, pos.elevation, radius.toDouble())
val passY = sph2CartY(satPos.azimuth, satPos.elevation, radius.toDouble()) if (index == 0) trackPath.moveTo(offset.x, offset.y) else trackPath.lineTo(offset.x, offset.y)
if (index == 0) trackPath.moveTo(passX, -passY) else trackPath.lineTo(passX, -passY)
} }
return trackPath return trackPath
} }
private fun createTrackEffect(trackPath: Path): PathEffect { private fun createTrackEffect(trackPath: Path): PathEffect {
val shape = Path()
val shapeRadius = 24f val shapeRadius = 24f
val angle = 120.0.toRadians() val angle = 120.0.toRadians()
shape.moveTo((shapeRadius * cos(angle)).toFloat(), (shapeRadius * sin(angle)).toFloat()) val shape = Path().apply {
for (i in 1 until 3) { moveTo((shapeRadius * cos(angle)).toFloat(), (shapeRadius * sin(angle)).toFloat())
val x = (shapeRadius * cos(angle - angle * i)).toFloat() for (i in 1 until 3) {
val y = (shapeRadius * sin(angle - angle * i)).toFloat() lineTo(
shape.lineTo(x, y) x = (shapeRadius * cos(angle - angle * i)).toFloat(),
y = (shapeRadius * sin(angle - angle * i)).toFloat()
)
}
close()
} }
shape.close()
val trackLength = PathMeasure().apply { setPath(trackPath, false) }.length val trackLength = PathMeasure().apply { setPath(trackPath, false) }.length
val advance = trackLength / 2f return PathEffect.stampedPathEffect(shape, trackLength / 2f, trackLength / 4f, StampedPathEffectStyle.Rotate)
val phase = trackLength / 4f
return PathEffect.stampedPathEffect(shape, advance, phase, StampedPathEffectStyle.Rotate)
} }
private fun sph2CartX(azim: Double, elev: Double, r: Double): Float { private fun DrawScope.drawBodyIcon(
val radius = r * (PI_2 - elev) / PI_2 azimDeg: Double,
return (radius * cos(PI_2 - azim)).toFloat() 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 sph2CartY(azim: Double, elev: Double, r: Double): Float { private fun sph2Cart(azim: Double, elev: Double, r: Double): Offset {
val radius = r * (PI_2 - elev) / PI_2 val radius = r * (PI_2 - elev) / PI_2
return (radius * sin(PI_2 - azim)).toFloat() return Offset(
x = (radius * cos(PI_2 - azim)).toFloat(),
y = -(radius * sin(PI_2 - azim)).toFloat()
)
} }
@@ -17,25 +17,19 @@
*/ */
package com.rtbishop.look4sat.feature.radar package com.rtbishop.look4sat.feature.radar
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.model.SatRadio 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.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.repository.BtService import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ExtendedParams import com.rtbishop.look4sat.core.domain.repository.IReporter
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.IReporterRepo
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.repository.WithoutExtParams
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
import com.rtbishop.look4sat.core.domain.utility.round import com.rtbishop.look4sat.core.domain.utility.round
import com.rtbishop.look4sat.core.domain.utility.toDegrees import com.rtbishop.look4sat.core.domain.utility.toDegrees
@@ -43,79 +37,82 @@ import com.rtbishop.look4sat.core.domain.utility.toTimerString
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class RadarViewModel( class RadarViewModel(
private val savedStateHandle: SavedStateHandle, private val bluetoothReporter: IReporter,
private val bluetoothReporter: IReporterRepo<WithoutExtParams>, private val networkReporter: IReporter,
private val networkReporter: IReporterRepo<ExtendedParams>,
private val satelliteRepo: ISatelliteRepo, private val satelliteRepo: ISatelliteRepo,
private val settingsRepo: ISettingsRepo, private val settingsRepo: ISettingsRepo,
private val sensorsRepo: ISensorsRepo, private val sensorsRepo: ISensorsRepo,
private val addToCalendar: IAddToCalendar private val addToCalendar: IAddToCalendar
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(
RadarState(
currentPass = null,
currentTime = "00:00:00",
isCurrentTimeAos = true,
orientationValues = sensorsRepo.orientation.value,
orbitalPos = null,
satTrack = emptyList(),
shouldShowSweep = settingsRepo.otherSettings.value.stateOfSweep,
shouldUseCompass = settingsRepo.otherSettings.value.stateOfSensors,
selectedTransmitterUuid = null,
selectedFrequency = null,
transmitters = emptyList(),
sendAction = ::handleAction,
)
)
private val stationPos = settingsRepo.stationPosition.value private val stationPos = settingsRepo.stationPosition.value
private val magDeclination = sensorsRepo.getMagDeclination(stationPos) private val magDeclination = sensorsRepo.getMagDeclination(stationPos)
private val _uiState = MutableStateFlow(
RadarState(
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
orientationValues = sensorsRepo.orientation.value,
shouldShowSweep = settingsRepo.otherSettings.value.stateOfSweep,
shouldUseCompass = settingsRepo.otherSettings.value.stateOfSensors
)
)
val uiState: StateFlow<RadarState> = _uiState val uiState: StateFlow<RadarState> = _uiState
init { init {
// Compass sensor collection
if (settingsRepo.otherSettings.value.stateOfSensors) { if (settingsRepo.otherSettings.value.stateOfSensors) {
viewModelScope.launch { viewModelScope.launch {
sensorsRepo.enableSensor() sensorsRepo.enableSensor()
sensorsRepo.orientation.collect { data -> sensorsRepo.orientation.collect { data ->
val orientationValues = Pair(data.first + magDeclination, data.second) val orientationValues = (data.first + magDeclination) to data.second
_uiState.update { it.copy(orientationValues = orientationValues) } _uiState.update { it.copy(orientationValues = orientationValues) }
} }
} }
} }
// React to UTC setting changes
viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings ->
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
}
}
// Resolve which pass we're tracking and start the tick loop
viewModelScope.launch { viewModelScope.launch {
val catNum = savedStateHandle.get<Int>("catNum") ?: 0
val aosTime = savedStateHandle.get<Long>("aosTime") ?: 0L
val passes = satelliteRepo.passes.value val passes = satelliteRepo.passes.value
val pass = passes.find { pass -> pass.catNum == catNum && pass.aosTime == aosTime } val (catNum, aosTime) = satelliteRepo.selectedPass.value
val currentPass = pass ?: passes.firstOrNull() val satPass = passes.find { it.catNum == catNum && it.aosTime == aosTime } ?: passes.firstOrNull()
currentPass?.let { satPass -> satPass?.let { pass ->
_uiState.update { it.copy(currentPass = satPass) } _uiState.update { it.copy(currentPass = pass) }
val transmitters = satelliteRepo.getRadiosWithId(satPass.catNum) val transmitters = satelliteRepo.getRadiosWithId(pass.catNum)
// Compute track once (it doesn't change for a given pass)
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) { while (isActive) {
val timeNow = System.currentTimeMillis() val timeNow = System.currentTimeMillis()
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow) val pos = satelliteRepo.getPosition(pass.orbitalObject, stationPos, timeNow)
when { val sunPos = CelestialComputer.getSunPosition(stationPos, timeNow)
satPass.isDeepSpace -> { val moonPos = CelestialComputer.getMoonPosition(stationPos, timeNow)
val time = 0L.toTimerString() val (time, isAos) = computeTimer(pass.isDeepSpace, pass.aosTime, pass.losTime, timeNow)
_uiState.update { it.copy(currentTime = time, isCurrentTimeAos = false) } val isLos = !pass.isDeepSpace && timeNow > pass.losTime
} _uiState.update {
satPass.aosTime > timeNow -> { it.copy(
val time = satPass.aosTime.minus(timeNow).toTimerString() currentTime = time,
_uiState.update { it.copy(currentTime = time, isCurrentTimeAos = true) } isTimeAos = isAos,
} isLos = isLos,
else -> { orbitalPos = pos,
val time = satPass.losTime.minus(timeNow).toTimerString() sunPosition = sunPos,
_uiState.update { it.copy(currentTime = time, isCurrentTimeAos = false) } moonPosition = moonPos
} )
} }
processRadios(transmitters, satPass.orbitalObject, timeNow) processRadios(transmitters, pass.orbitalObject, timeNow)
sendPassData(satPass, pos, satPass.orbitalObject) sendPassData(pos)
sendPassDataBT(pos)
delay(1000) delay(1000)
} }
} }
@@ -127,75 +124,65 @@ class RadarViewModel(
super.onCleared() super.onCleared()
} }
private fun handleAction(action: RadarAction) { fun onAction(action: RadarAction) {
when (action) { when (action) {
is RadarAction.AddToCalendar -> addToCalendar(action.name, action.aosTime, action.losTime) is RadarAction.AddToCalendar -> addToCalendar(action.name, action.aosTime, action.losTime)
is RadarAction.SelectTransmitter -> { _uiState.update { it.copy(selectedTransmitterUuid = action.uuid) } } is RadarAction.SelectTransmitter -> _uiState.update { it.copy(selectedTransmitterUuid = action.uuid) }
} }
} }
private suspend fun sendPassData(orbitalPass: OrbitalPass, orbitalPos: OrbitalPos, orbitalObject: OrbitalObject) { private fun computeTimer(isDeepSpace: Boolean, aosTime: Long, losTime: Long, timeNow: Long): Pair<String, Boolean> {
var track: List<OrbitalPos> = emptyList() return when {
if (!orbitalPass.isDeepSpace) { isDeepSpace -> 0L.toTimerString() to false
track = satelliteRepo.getTrack( aosTime > timeNow -> (aosTime - timeNow).toTimerString() to true
orbitalObject, stationPos, orbitalPass.aosTime, orbitalPass.losTime else -> (losTime - timeNow).toTimerString() to false
)
}
_uiState.update { it.copy(orbitalPos = orbitalPos, satTrack = track) }
viewModelScope.launch {
if (settingsRepo.rcSettings.value.rotatorState) {
val server = settingsRepo.rcSettings.value.rotatorAddress
val port = settingsRepo.rcSettings.value.rotatorPort.toInt()
val format = settingsRepo.rcSettings.value.rotatorFormat
val azimuth = orbitalPos.azimuth.toDegrees().round(2)
val elevation = orbitalPos.elevation.toDegrees().round(2)
networkReporter.reportRotation(format, azimuth, elevation, ExtendedParams(server, port))
}
if (settingsRepo.rcSettings.value.frequencyState) {
_uiState.value.selectedFrequency?.let { freq ->
val server = settingsRepo.rcSettings.value.frequencyAddress
val port = settingsRepo.rcSettings.value.frequencyPort.toInt()
val format = settingsRepo.rcSettings.value.frequencyFormat
networkReporter.reportFrequency(format,freq, ExtendedParams(server, port))
}
}
} }
} }
private fun sendPassDataBT(orbitalPos: OrbitalPos) { private fun sendPassData(orbitalPos: OrbitalPos) {
viewModelScope.launch { val rc = settingsRepo.rcSettings.value
if (settingsRepo.rcSettings.value.bluetoothRotatorState) { sendReporterData(
val btRotatorDevice = settingsRepo.rcSettings.value.bluetoothRotatorAddress networkReporter, orbitalPos,
if (bluetoothReporter.isConnected(BtService.ROTATOR)) { rc.rotatorState, rc.rotatorFormat,
val format = settingsRepo.rcSettings.value.bluetoothRotatorFormat rc.frequencyState, rc.frequencyFormat
val azimuth = orbitalPos.azimuth.toDegrees().round(0) )
val elevation = orbitalPos.elevation.toDegrees().round(0) sendReporterData(
bluetoothReporter.reportRotation(format, azimuth, elevation, WithoutExtParams(0)) bluetoothReporter, orbitalPos,
} else if (!bluetoothReporter.isConnecting(BtService.ROTATOR)) { rc.bluetoothRotatorState, rc.bluetoothRotatorFormat,
// Log.i("BTReporter", "BTReporter (rotator): Attempting to connect...") rc.bluetoothFrequencyState, rc.bluetoothFrequencyFormat
bluetoothReporter.connect(BtService.ROTATOR,btRotatorDevice) )
} }
}
if (settingsRepo.rcSettings.value.bluetoothFrequencyState) { private fun sendReporterData(
val btFrequencyDevice = settingsRepo.rcSettings.value.bluetoothFrequencyAddress reporter: IReporter,
if (bluetoothReporter.isConnected(BtService.FREQUENCY)) { orbitalPos: OrbitalPos,
val format = settingsRepo.rcSettings.value.bluetoothFrequencyFormat rotatorEnabled: Boolean,
val frequency = _uiState.value.selectedFrequency rotatorFormat: String,
frequency?.let { freq -> frequencyEnabled: Boolean,
bluetoothReporter.reportFrequency(format, freq, WithoutExtParams(0)) frequencyFormat: String
} ) {
} else if (!bluetoothReporter.isConnecting(BtService.FREQUENCY)) { if (rotatorEnabled) {
// Log.i("BTReporter", "BTReporter (frequency): Attempting to connect...") val azimuth = orbitalPos.azimuth.toDegrees().round(2)
bluetoothReporter.connect(BtService.FREQUENCY, btFrequencyDevice) val elevation = orbitalPos.elevation.toDegrees().round(2)
} reporter.reportRotation(rotatorFormat, azimuth, elevation)
}
if (frequencyEnabled) {
_uiState.value.selectedFrequency?.let { freq ->
reporter.reportFrequency(frequencyFormat, freq)
} }
} }
} }
private suspend fun processRadios(radios: List<SatRadio>, orbitalObject: OrbitalObject, time: Long) { private suspend fun processRadios(radios: List<SatRadio>, orbitalObject: OrbitalObject, time: Long) {
val transmitters = satelliteRepo.getRadios(orbitalObject, stationPos, radios, time) val transmitters = satelliteRepo.getRadios(orbitalObject, stationPos, radios, time)
val isFreqEnabled =
settingsRepo.rcSettings.value.frequencyState || settingsRepo.rcSettings.value.bluetoothFrequencyState
_uiState.update { state -> _uiState.update { state ->
if (!settingsRepo.rcSettings.value.frequencyState && !settingsRepo.rcSettings.value.bluetoothFrequencyState) { if (!isFreqEnabled) {
// Skip update if nothing changed
if (state.transmitters == transmitters && state.selectedTransmitterUuid == null && state.selectedFrequency == null) {
return@update state
}
return@update state.copy( return@update state.copy(
transmitters = transmitters, transmitters = transmitters,
selectedTransmitterUuid = null, selectedTransmitterUuid = null,
@@ -208,34 +195,29 @@ class RadarViewModel(
val low = radio.downlinkLow val low = radio.downlinkLow
val high = radio.downlinkHigh val high = radio.downlinkHigh
when { when {
low != null && high != null -> low != null && high != null -> (low + high) / 2
(low + high) / 2 low != null -> low
low != null ->
low
else -> null else -> null
} }
} }
state.copy( // Skip update if nothing changed
transmitters = transmitters, if (state.transmitters == transmitters && state.selectedTransmitterUuid == selectedUuid && state.selectedFrequency == freq) {
selectedTransmitterUuid = selectedUuid, return@update state
selectedFrequency = freq }
) state.copy(transmitters = transmitters, selectedTransmitterUuid = selectedUuid, selectedFrequency = freq)
} }
} }
companion object { companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory { fun factory(container: IMainContainer) = viewModelFactory {
val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY
initializer { initializer {
val container = (this[applicationKey] as IContainerProvider).getMainContainer()
RadarViewModel( RadarViewModel(
createSavedStateHandle(), bluetoothReporter = container.provideBluetoothReporter(),
container.provideBluetoothReporter(), networkReporter = container.provideNetworkReporter(),
container.provideNetworkReporter(), satelliteRepo = container.satelliteRepo,
container.satelliteRepo, settingsRepo = container.settingsRepo,
container.settingsRepo, sensorsRepo = container.provideSensorsRepo(),
container.provideSensorsRepo(), addToCalendar = container.provideAddToCalendar()
container.provideAddToCalendar()
) )
} }
} }
+7
View File
@@ -0,0 +1,7 @@
plugins {
alias(libs.plugins.convention.featurePlugin)
}
android {
namespace = "com.rtbishop.look4sat.feature.radiocontrol"
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest>
</manifest>
@@ -0,0 +1,392 @@
/*
* 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.radiocontrol
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.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 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.TimerRow
import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.getDefaultPass
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import com.rtbishop.look4sat.core.presentation.layoutPadding
import java.util.Locale
@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
private fun RadioControlScreen(
uiState: RadioControlState,
onAction: (RadioControlAction) -> Unit,
navigateUp: () -> Unit
) {
Column(
modifier = Modifier
.layoutPadding()
.keepScreenOn(),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
val currentPass = uiState.currentPass ?: getDefaultPass()
val isVertical = isVerticalLayout()
if (isVertical) {
TopBar {
IconCard(action = navigateUp, resId = R.drawable.ic_back)
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isCurrentTimeAos)
IconCard(action = navigateUp, resId = R.drawable.ic_back)
}
TopBar { NextPassRow(pass = currentPass) }
} else {
TopBar {
IconCard(action = navigateUp, resId = R.drawable.ic_back)
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isCurrentTimeAos)
NextPassRow(pass = currentPass, modifier = Modifier.weight(1f))
IconCard(action = navigateUp, resId = R.drawable.ic_back)
}
}
RadioPanel(panel = uiState.txPanel)
RadioPanel(panel = uiState.rxPanel)
PositionRow(
azimuth = uiState.azimuth,
elevation = uiState.elevation,
distance = uiState.distance
)
val selectedTransponder = uiState.transponders.find {
it.uuid == uiState.selectedTransponderUuid
}
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
item {
TransponderSelector(
transponders = uiState.transponders,
selectedUuid = uiState.selectedTransponderUuid,
onAction = onAction
)
}
if (selectedTransponder?.uplinkMode?.uppercase() == "FM") {
item {
CtcssSelector(
ctcssTone = uiState.ctcssTone,
onAction = onAction
)
}
}
if (uiState.txBaseFrequencyHz != null) {
item {
FrequencyTuner(
txBaseFrequencyHz = uiState.txBaseFrequencyHz,
selectedTransponder = selectedTransponder,
onAction = onAction
)
}
}
uiState.errorMessage?.let { msg ->
item {
Text(
text = msg,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(8.dp)
)
}
}
}
ControlButtons(
isConnected = uiState.txPanel.isConnected || uiState.rxPanel.isConnected,
isTracking = uiState.isTracking,
onAction = onAction
)
}
}
@Composable
private fun RadioPanel(panel: RadioPanelState) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.background(if (panel.isConnected) Color(0xFF4CAF50) else Color(0xFFE57373))
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = panel.label, fontSize = 14.sp)
}
Text(
text = panel.frequencyDisplay,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = panel.mode ?: "--",
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
}
}
@Composable
private fun PositionRow(azimuth: String, elevation: String, distance: String) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "Az", fontSize = 12.sp)
Text(text = "$azimuth°", fontSize = 16.sp, fontWeight = FontWeight.Medium)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "El", fontSize = 12.sp)
Text(text = "$elevation°", fontSize = 16.sp, fontWeight = FontWeight.Medium)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "Dist", fontSize = 12.sp)
Text(text = "$distance km", fontSize = 16.sp, fontWeight = FontWeight.Medium)
}
}
}
}
@Composable
private fun TransponderSelector(
transponders: List<SatRadio>,
selectedUuid: String?,
onAction: (RadioControlAction) -> Unit
) {
if (transponders.isEmpty()) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Text(
text = "No transponders with uplink+downlink available",
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
)
}
return
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
transponders.forEach { radio ->
TransponderItem(
radio = radio,
isSelected = radio.uuid == selectedUuid,
onSelect = { onAction(RadioControlAction.SelectTransponder(radio.uuid)) }
)
}
}
}
@Composable
private fun TransponderItem(radio: SatRadio, isSelected: Boolean, onSelect: () -> Unit) {
val invLabel = if (radio.isInverted) " INV" else ""
val modeLabel = "${radio.uplinkMode ?: "--"}/${radio.downlinkMode ?: "--"}$invLabel"
Surface(
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surface,
shape = MaterialTheme.shapes.small,
modifier = Modifier
.fillMaxWidth()
.clickable { onSelect() }
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
) {
Text(text = radio.info, modifier = Modifier.weight(1f))
Text(text = modeLabel, fontSize = 13.sp, fontWeight = FontWeight.Medium)
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun CtcssSelector(
ctcssTone: Double?,
onAction: (RadioControlAction) -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)) {
Text(text = "CTCSS Tone", color = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.height(4.dp))
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
FilterChip(
selected = ctcssTone == null,
onClick = { onAction(RadioControlAction.SetCtcssTone(null)) },
label = { Text("Off") }
)
RadioControlViewModel.CTCSS_TONES.forEach { tone ->
FilterChip(
selected = ctcssTone == tone,
onClick = { onAction(RadioControlAction.SetCtcssTone(tone)) },
label = { Text(String.format(Locale.ENGLISH, "%.1f", tone)) }
)
}
}
}
}
}
private val FREQ_ADJUSTMENTS =
listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
@Composable
private fun FrequencyTuner(
txBaseFrequencyHz: Long,
selectedTransponder: SatRadio?,
onAction: (RadioControlAction) -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
) {
Text(text = "TX Base Frequency", color = MaterialTheme.colorScheme.primary)
if (selectedTransponder != null) {
val upLow = selectedTransponder.uplinkLow
val upHigh = selectedTransponder.uplinkHigh
val dnLow = selectedTransponder.downlinkLow
val dnHigh = selectedTransponder.downlinkHigh
if (upLow != null && upHigh != null && upLow != upHigh) {
Text(
text = "UP: ${RadioControlViewModel.formatFrequency(upLow)} - ${RadioControlViewModel.formatFrequency(upHigh)}",
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (dnLow != null && dnHigh != null && dnLow != dnHigh) {
Text(
text = "DN: ${RadioControlViewModel.formatFrequency(dnLow)} - ${RadioControlViewModel.formatFrequency(dnHigh)}",
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "${RadioControlViewModel.formatFrequency(txBaseFrequencyHz)} MHz",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
FREQ_ADJUSTMENTS.forEach { (delta, label) ->
CardButton(
onClick = { onAction(RadioControlAction.AdjustTxFrequency(delta)) },
text = label,
modifier = Modifier.weight(1f)
)
}
}
}
}
}
@Composable
private fun ControlButtons(
isConnected: Boolean,
isTracking: Boolean,
onAction: (RadioControlAction) -> Unit
) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
if (!isConnected) {
CardButton(
onClick = { onAction(RadioControlAction.ConnectRadios) },
text = "Connect",
modifier = Modifier.weight(1f)
)
} else {
CardButton(
onClick = { onAction(RadioControlAction.DisconnectRadios) },
text = "Disconnect",
modifier = Modifier.weight(1f)
)
}
CardButton(
onClick = { onAction(RadioControlAction.ToggleTracking) },
text = if (isTracking) "Stop Tracking" else "Start Tracking",
modifier = Modifier.weight(1f)
)
}
}
@@ -0,0 +1,56 @@
/*
* 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.radiocontrol
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
data class RadioPanelState(
val label: String,
val isConnected: Boolean,
val frequencyHz: Long?,
val frequencyDisplay: String,
val mode: String?
)
data class RadioControlState(
val currentPass: OrbitalPass?,
val currentTime: String,
val isCurrentTimeAos: Boolean,
val azimuth: String,
val elevation: String,
val distance: String,
val txPanel: RadioPanelState,
val rxPanel: RadioPanelState,
val transponders: List<SatRadio>,
val selectedTransponderUuid: String?,
val txBaseFrequencyHz: Long?,
val ctcssTone: Double?,
val isTracking: Boolean,
val errorMessage: String?
)
sealed interface RadioControlAction {
data class SelectTransponder(val uuid: String) : RadioControlAction
data class SetTxFrequency(val frequencyHz: Long) : RadioControlAction
data class AdjustTxFrequency(val deltaHz: Long) : RadioControlAction
data class SetCtcssTone(val toneHz: Double?) : RadioControlAction
data object ToggleTracking : RadioControlAction
data object ConnectRadios : RadioControlAction
data object DisconnectRadios : RadioControlAction
}
@@ -0,0 +1,194 @@
/*
* 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.radiocontrol
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
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
import com.rtbishop.look4sat.core.domain.utility.toDegrees
import com.rtbishop.look4sat.core.domain.utility.toTimerString
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.Locale
class RadioControlViewModel(
private val trackingService: IRadioTrackingService,
private val satelliteRepo: ISatelliteRepo,
settingsRepo: ISettingsRepo
) : ViewModel() {
private val stationPos = settingsRepo.stationPosition.value
private var currentPass: OrbitalPass? = null
private var transponders: List<SatRadio> = emptyList()
private val _uiState = MutableStateFlow(
RadioControlState(
currentPass = null,
currentTime = "00:00:00",
isCurrentTimeAos = true,
azimuth = "0.0",
elevation = "0.0",
distance = "0.0",
txPanel = RadioPanelState("TX (Uplink)", false, null, "---", null),
rxPanel = RadioPanelState("RX (Downlink)", false, null, "---", null),
transponders = emptyList(),
selectedTransponderUuid = null,
txBaseFrequencyHz = null,
ctcssTone = null,
isTracking = false,
errorMessage = null
)
)
val uiState: StateFlow<RadioControlState> = _uiState
init {
// Resolve pass and load transponders
viewModelScope.launch {
val passes = satelliteRepo.passes.value
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 = 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 == 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(pass.orbitalObject, stationPos, timeNow)
val (timeStr, isAos) = computeTimer(pass.isDeepSpace, pass.aosTime, pass.losTime, timeNow)
_uiState.update { state ->
state.copy(
currentTime = timeStr,
isCurrentTimeAos = isAos,
azimuth = String.format(Locale.ENGLISH, "%.1f", pos.azimuth.toDegrees()),
elevation = String.format(Locale.ENGLISH, "%.1f", pos.elevation.toDegrees()),
distance = String.format(Locale.ENGLISH, "%.0f", pos.distance)
)
}
delay(1000)
}
}
}
// Observe service state for radio-specific updates (panels, frequencies, tracking status)
viewModelScope.launch {
trackingService.state.collect { svc ->
_uiState.update { state ->
state.copy(
txPanel = RadioPanelState(
label = "TX (Uplink)",
isConnected = svc.txConnected,
frequencyHz = svc.txFrequencyHz,
frequencyDisplay = svc.txFrequencyHz?.let { formatFrequency(it) } ?: "---",
mode = svc.txMode
),
rxPanel = RadioPanelState(
label = "RX (Downlink)",
isConnected = svc.rxConnected,
frequencyHz = svc.rxFrequencyHz,
frequencyDisplay = svc.rxFrequencyHz?.let { formatFrequency(it) } ?: "---",
mode = svc.rxMode
),
txBaseFrequencyHz = svc.txBaseFrequencyHz,
ctcssTone = svc.ctcssTone,
isTracking = svc.isActive,
selectedTransponderUuid = svc.selectedTransponder?.uuid,
errorMessage = svc.errorMessage
)
}
}
}
}
private fun computeTimer(isDeepSpace: Boolean, aosTime: Long, losTime: Long, timeNow: Long): Pair<String, Boolean> {
return when {
isDeepSpace -> 0L.toTimerString() to false
aosTime > timeNow -> (aosTime - timeNow).toTimerString() to true
else -> (losTime - timeNow).toTimerString() to false
}
}
fun onAction(action: RadioControlAction) {
when (action) {
is RadioControlAction.SelectTransponder -> {
val transponder = transponders.find { it.uuid == action.uuid } ?: return
trackingService.setTransponder(transponder)
}
is RadioControlAction.SetTxFrequency -> trackingService.setTxBaseFrequency(action.frequencyHz)
is RadioControlAction.AdjustTxFrequency -> trackingService.adjustTxBaseFrequency(action.deltaHz)
is RadioControlAction.SetCtcssTone -> trackingService.setCtcssTone(action.toneHz)
RadioControlAction.ToggleTracking -> {
val svc = trackingService.state.value
if (svc.isActive) {
trackingService.stopTracking()
} else {
val pass = currentPass ?: return
val transponder = svc.selectedTransponder ?: return
trackingService.startTracking(pass, transponder, svc.txBaseFrequencyHz)
}
}
RadioControlAction.ConnectRadios -> viewModelScope.launch { trackingService.connectRadios() }
RadioControlAction.DisconnectRadios -> viewModelScope.launch { trackingService.disconnectRadios() }
}
}
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,
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
)
fun formatFrequency(frequencyHz: Long): String {
if (frequencyHz <= 0) return "---"
val mhz = frequencyHz / 1_000_000
val khz = (frequencyHz % 1_000_000) / 1_000
val hz = frequencyHz % 1_000
return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz)
}
fun factory(container: IMainContainer) = viewModelFactory {
initializer {
RadioControlViewModel(
trackingService = container.radioTrackingService,
satelliteRepo = container.satelliteRepo,
settingsRepo = container.settingsRepo
)
}
}
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
plugins { plugins {
alias(libs.plugins.convention.composeFeaturePlugin) alias(libs.plugins.convention.featurePlugin)
} }
android { android {
@@ -30,7 +30,7 @@ import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -51,14 +51,14 @@ private fun MultiTypesDialogPreview() {
} }
@Composable @Composable
fun MultiTypesDialog( internal fun MultiTypesDialog(
allTypes: List<String>, types: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit allTypes: List<String>, types: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit
) { ) {
val selected = remember { mutableStateListOf<String>().apply { addAll(types) } } val selected = remember { mutableStateOf(types.toSet()) }
val select = { type: String -> val toggle = { type: String ->
if (selected.contains(type)) selected.remove(type) else selected.add(type) selected.value = if (type in selected.value) selected.value - type else selected.value + type
} }
val onAccept = { accept(selected.toList()) } val onAccept = { accept(selected.value.toList()) }
SharedDialog(title = stringResource(R.string.sat_type_title), onCancel = cancel, onAccept = onAccept) { SharedDialog(title = stringResource(R.string.sat_type_title), onCancel = cancel, onAccept = onAccept) {
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Adaptive(240.dp), columns = GridCells.Adaptive(240.dp),
@@ -73,7 +73,7 @@ fun MultiTypesDialog(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
.clickable { select(item) } .clickable { toggle(item) }
) { ) {
Text( Text(
text = "${index + 1}).", text = "${index + 1}).",
@@ -89,7 +89,7 @@ fun MultiTypesDialog(
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Checkbox( Checkbox(
checked = selected.contains(item), checked = item in selected.value,
onCheckedChange = null, onCheckedChange = null,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
) )
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
@@ -32,10 +33,10 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -43,6 +44,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
@@ -55,9 +57,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.rtbishop.look4sat.core.domain.model.SatItem 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.CardLoadingIndicator
import com.rtbishop.look4sat.core.presentation.EmptyListCard import com.rtbishop.look4sat.core.presentation.EmptyListCard
import com.rtbishop.look4sat.core.presentation.IconCard import com.rtbishop.look4sat.core.presentation.IconCard
@@ -65,66 +66,107 @@ import com.rtbishop.look4sat.core.presentation.InfoDialog
import com.rtbishop.look4sat.core.presentation.MainTheme import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.PrimaryIconCard import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
import com.rtbishop.look4sat.core.presentation.R 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.TopBar
import com.rtbishop.look4sat.core.presentation.infiniteMarquee import com.rtbishop.look4sat.core.presentation.infiniteMarquee
import com.rtbishop.look4sat.core.presentation.isVerticalLayout import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import com.rtbishop.look4sat.core.presentation.layoutPadding import com.rtbishop.look4sat.core.presentation.layoutPadding
fun NavGraphBuilder.satellitesDestination(navigateUp: () -> Unit) { @Composable
composable(Screen.Satellites.route) { fun SatellitesDestination(navigateUp: () -> Unit) {
val viewModel = viewModel( val context = LocalContext.current
modelClass = SatellitesViewModel::class.java, factory = SatellitesViewModel.Factory val container = (context.applicationContext as IContainerProvider).getMainContainer()
) val viewModel: SatellitesViewModel = viewModel(factory = SatellitesViewModel.factory(container))
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
SatellitesScreen(uiState, navigateUp) SatellitesScreen(uiState, viewModel::onAction, navigateUp)
}
} }
@Composable @Composable
private fun SatellitesScreen(uiState: SatellitesState, navigateUp: () -> Unit) { private fun SatellitesScreen(
val toggleDialog = { uiState.takeAction(SatellitesAction.ToggleTypesDialog) } uiState: SatellitesState,
onAction: (SatellitesAction) -> Unit,
navigateUp: () -> Unit
) {
if (uiState.isDialogShown) { if (uiState.isDialogShown) {
MultiTypesDialog(allTypes = uiState.typesList, types = uiState.currentTypes, toggleDialog) { MultiTypesDialog(
uiState.takeAction(SatellitesAction.SelectTypes(it)) allTypes = uiState.typesList,
} types = uiState.currentTypes,
cancel = { onAction(SatellitesAction.ToggleTypesDialog) },
accept = { onAction(SatellitesAction.SelectTypes(it)) }
)
} }
if (uiState.shouldSeeWarning) { if (uiState.shouldSeeWarning) {
InfoDialog( InfoDialog(
stringResource(R.string.sat_warning_title), stringResource(R.string.sat_warning_title),
stringResource(R.string.sat_warning_message) stringResource(R.string.sat_warning_message)
) { ) {
uiState.takeAction(SatellitesAction.DismissWarning) onAction(SatellitesAction.DismissWarning)
} }
} }
val unselectAll = { uiState.takeAction(SatellitesAction.UnselectAll) }
val selectAll = { uiState.takeAction(SatellitesAction.SelectAll) } val saveAndNavigateUp = {
val setQuery = { newQuery: String -> uiState.takeAction(SatellitesAction.SearchFor(newQuery)) } onAction(SatellitesAction.SaveSelection)
val saveSelection = { uiState.takeAction(SatellitesAction.SaveSelection).also { navigateUp() } } navigateUp()
}
val primCardCd = stringResource(R.string.btn_accept) val primCardCd = stringResource(R.string.btn_accept)
val primCardMod = Modifier.semantics { contentDescription = primCardCd }
val clearAllCd = stringResource(R.string.sat_clear_all) val clearAllCd = stringResource(R.string.sat_clear_all)
val clearAllMod = Modifier.semantics { contentDescription = clearAllCd }
val selectAllCd = stringResource(R.string.sat_select_all) val selectAllCd = stringResource(R.string.sat_select_all)
val selectAllMod = Modifier.semantics { contentDescription = selectAllCd }
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (isVerticalLayout()) { if (isVerticalLayout()) {
TopBar { TopBar {
TypeCard(types = uiState.currentTypes, toggleDialog, modifier = Modifier.weight(1f)) TypeCard(
PrimaryIconCard(onClick = saveSelection, resId = R.drawable.ic_done, modifier = primCardMod) types = uiState.currentTypes,
onClick = { onAction(SatellitesAction.ToggleTypesDialog) },
modifier = Modifier.weight(1f)
)
PrimaryIconCard(
onClick = saveAndNavigateUp,
resId = R.drawable.ic_done,
modifier = Modifier.semantics { contentDescription = primCardCd }
)
} }
TopBar { TopBar {
SearchBar(setQuery = { setQuery(it) }, modifier = Modifier.weight(1f)) SearchBar(
IconCard(action = unselectAll, resId = R.drawable.ic_check_off, modifier = clearAllMod) onQueryChange = { onAction(SatellitesAction.SearchFor(it)) },
IconCard(action = selectAll, resId = R.drawable.ic_check_on, modifier = selectAllMod) modifier = Modifier.weight(1f)
)
IconCard(
action = { onAction(SatellitesAction.UnselectAll) },
resId = R.drawable.ic_check_off,
modifier = Modifier.semantics { contentDescription = clearAllCd }
)
IconCard(
action = { onAction(SatellitesAction.SelectAll) },
resId = R.drawable.ic_check_on,
modifier = Modifier.semantics { contentDescription = selectAllCd }
)
} }
} else { } else {
TopBar { TopBar {
PrimaryIconCard(onClick = saveSelection, resId = R.drawable.ic_done, modifier = primCardMod) PrimaryIconCard(
TypeCard(types = uiState.currentTypes, toggleDialog, modifier = Modifier.weight(1f)) onClick = saveAndNavigateUp,
SearchBar(setQuery = { setQuery(it) }, modifier = Modifier.weight(1f)) resId = R.drawable.ic_done,
IconCard(action = unselectAll, resId = R.drawable.ic_check_off, modifier = clearAllMod) modifier = Modifier.semantics { contentDescription = primCardCd }
IconCard(action = selectAll, resId = R.drawable.ic_check_on, modifier = selectAllMod) )
TypeCard(
types = uiState.currentTypes,
onClick = { onAction(SatellitesAction.ToggleTypesDialog) },
modifier = Modifier.weight(1f)
)
SearchBar(
onQueryChange = { onAction(SatellitesAction.SearchFor(it)) },
modifier = Modifier.weight(1f)
)
IconCard(
action = { onAction(SatellitesAction.UnselectAll) },
resId = R.drawable.ic_check_off,
modifier = Modifier.semantics { contentDescription = clearAllCd }
)
IconCard(
action = { onAction(SatellitesAction.SelectAll) },
resId = R.drawable.ic_check_on,
modifier = Modifier.semantics { contentDescription = selectAllCd }
)
} }
} }
ElevatedCard(modifier = Modifier.fillMaxSize()) { ElevatedCard(modifier = Modifier.fillMaxSize()) {
@@ -133,7 +175,7 @@ private fun SatellitesScreen(uiState: SatellitesState, navigateUp: () -> Unit) {
uiState.isLoading -> CardLoadingIndicator() uiState.isLoading -> CardLoadingIndicator()
uiState.itemsList.isEmpty() -> EmptyListCard(message = emptyMessage) uiState.itemsList.isEmpty() -> EmptyListCard(message = emptyMessage)
else -> SatellitesCard(uiState.itemsList) { id, isTicked -> else -> SatellitesCard(uiState.itemsList) { id, isTicked ->
uiState.takeAction(SatellitesAction.SelectSingle(id, isTicked)) onAction(SatellitesAction.SelectSingle(id, isTicked))
} }
} }
} }
@@ -141,11 +183,11 @@ private fun SatellitesScreen(uiState: SatellitesState, navigateUp: () -> Unit) {
} }
@Composable @Composable
private fun SearchBar(setQuery: (String) -> Unit, modifier: Modifier = Modifier) { private fun SearchBar(onQueryChange: (String) -> Unit, modifier: Modifier = Modifier) {
val currentQuery = rememberSaveable { mutableStateOf("") } val currentQuery = rememberSaveable { mutableStateOf("") }
val setNewQuery = { newValue: String -> val updateQuery = { newValue: String ->
currentQuery.value = newValue currentQuery.value = newValue
setQuery(newValue) onQueryChange(newValue)
} }
ElevatedCard(modifier = modifier.height(48.dp)) { ElevatedCard(modifier = modifier.height(48.dp)) {
Row( Row(
@@ -156,9 +198,11 @@ private fun SearchBar(setQuery: (String) -> Unit, modifier: Modifier = Modifier)
Icon(painter = painterResource(id = R.drawable.ic_search), contentDescription = null) Icon(painter = painterResource(id = R.drawable.ic_search), contentDescription = null)
BasicTextField( BasicTextField(
value = currentQuery.value, value = currentQuery.value,
onValueChange = { setNewQuery(it) }, onValueChange = updateQuery,
singleLine = true, singleLine = true,
modifier = modifier.padding(start = 12.dp), modifier = Modifier
.weight(1f)
.padding(start = 12.dp),
textStyle = TextStyle( textStyle = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
lineHeight = 20.sp, lineHeight = 20.sp,
@@ -179,7 +223,7 @@ private fun SearchBar(setQuery: (String) -> Unit, modifier: Modifier = Modifier)
}, },
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface) cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface)
) )
IconButton(onClick = { setNewQuery("") }) { IconButton(onClick = { updateQuery("") }) {
val clearCd = stringResource(R.string.sat_search_clear) val clearCd = stringResource(R.string.sat_search_clear)
Icon(painter = painterResource(id = R.drawable.ic_close), contentDescription = clearCd) Icon(painter = painterResource(id = R.drawable.ic_close), contentDescription = clearCd)
} }
@@ -220,39 +264,40 @@ private fun TypeCard(types: List<String>, onClick: () -> Unit, modifier: Modifie
@Composable @Composable
private fun SatellitePreview() { private fun SatellitePreview() {
val satItem = SatItem(44444, "Ultra Super Mega long satellite name", true) val satItem = SatItem(44444, "Ultra Super Mega long satellite name", true)
MainTheme { Satellite(item = satItem, onSelected = { _, _ -> run {} }, modifier = Modifier) } MainTheme { Satellite(item = satItem, onSelected = { _, _ -> }) }
} }
@Composable @Composable
private fun Satellite(item: SatItem, onSelected: (Int, Boolean) -> Unit, modifier: Modifier) { private fun Satellite(
item: SatItem,
onSelected: (Int, Boolean) -> Unit,
modifier: Modifier = Modifier
) {
val passSatId = stringResource(id = R.string.pass_satId, item.catnum) val passSatId = stringResource(id = R.string.pass_satId, item.catnum)
Surface( Column(modifier = modifier.clickable { onSelected(item.catnum, item.isSelected) }) {
color = MaterialTheme.colorScheme.background, Row(
modifier = modifier.clickable { onSelected(item.catnum, item.isSelected) }) { verticalAlignment = Alignment.CenterVertically,
Surface(modifier = Modifier.padding(bottom = 1.dp)) { modifier = Modifier
Row( .fillMaxWidth()
verticalAlignment = Alignment.CenterVertically, .background(MaterialTheme.colorScheme.surface)
modifier = Modifier .padding(start = 14.dp, top = 8.dp, end = 12.dp, bottom = 8.dp)
.background(MaterialTheme.colorScheme.surface) ) {
.padding(start = 14.dp, top = 8.dp, end = 12.dp, bottom = 8.dp) Text(text = "$passSatId - ", color = MaterialTheme.colorScheme.primary)
) { Text(
Text( text = item.name,
text = "$passSatId - ", color = MaterialTheme.colorScheme.primary modifier = Modifier.weight(1f),
) fontWeight = FontWeight.Medium,
Text( maxLines = 1,
text = item.name, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurface
fontWeight = FontWeight.Medium, )
maxLines = 1, Checkbox(
overflow = TextOverflow.Ellipsis checked = item.isSelected,
) onCheckedChange = null,
Checkbox( modifier = Modifier.padding(start = 6.dp)
checked = item.isSelected, )
onCheckedChange = null,
modifier = Modifier.padding(start = 6.dp)
)
}
} }
HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.background)
} }
} }
@@ -264,11 +309,11 @@ private fun SatellitesPreview() {
SatItem(44444, "ISS", true), SatItem(44444, "ISS", true),
SatItem(88888, "Starlink", false) SatItem(88888, "Starlink", false)
) )
MainTheme { SatellitesCard(entries) { _, _ -> run {} } } MainTheme { SatellitesCard(entries) { _, _ -> } }
} }
@Composable @Composable
fun SatellitesCard(items: List<SatItem>, onSelected: (Int, Boolean) -> Unit) { private fun SatellitesCard(items: List<SatItem>, onSelected: (Int, Boolean) -> Unit) {
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp)) { LazyVerticalGrid(columns = GridCells.Adaptive(320.dp)) {
items(items = items, key = { item -> item.catnum }) { entry -> items(items = items, key = { item -> item.catnum }) { entry ->
Satellite(entry, onSelected, Modifier.animateItem()) Satellite(entry, onSelected, Modifier.animateItem())
@@ -20,22 +20,21 @@ package com.rtbishop.look4sat.feature.satellites
import com.rtbishop.look4sat.core.domain.model.SatItem import com.rtbishop.look4sat.core.domain.model.SatItem
data class SatellitesState( data class SatellitesState(
val isDialogShown: Boolean, val isDialogShown: Boolean = false,
val isLoading: Boolean, val isLoading: Boolean = true,
val shouldSeeWarning: Boolean, val shouldSeeWarning: Boolean = false,
val itemsList: List<SatItem>, val itemsList: List<SatItem> = emptyList(),
val currentTypes: List<String>, val currentTypes: List<String> = emptyList(),
val typesList: List<String>, val typesList: List<String> = emptyList()
val takeAction: (SatellitesAction) -> Unit
) )
sealed class SatellitesAction { sealed interface SatellitesAction {
data object DismissWarning : SatellitesAction() data object DismissWarning : SatellitesAction
data object SaveSelection : SatellitesAction() data object SaveSelection : SatellitesAction
data class SearchFor(val query: String) : SatellitesAction() data class SearchFor(val query: String) : SatellitesAction
data object SelectAll : SatellitesAction() data object SelectAll : SatellitesAction
data class SelectSingle(val id: Int, val isTicked: Boolean) : SatellitesAction() data class SelectSingle(val id: Int, val isTicked: Boolean) : SatellitesAction
data class SelectTypes(val types: List<String>) : SatellitesAction() data class SelectTypes(val types: List<String>) : SatellitesAction
data object ToggleTypesDialog : SatellitesAction() data object ToggleTypesDialog : SatellitesAction
data object UnselectAll : SatellitesAction() data object UnselectAll : SatellitesAction
} }
@@ -18,14 +18,12 @@
package com.rtbishop.look4sat.feature.satellites package com.rtbishop.look4sat.feature.satellites
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.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.ISelectionRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
@@ -40,20 +38,16 @@ class SatellitesViewModel(
private val defaultTypes = selectionRepo.getCurrentTypes() private val defaultTypes = selectionRepo.getCurrentTypes()
private val _uiState = MutableStateFlow( private val _uiState = MutableStateFlow(
SatellitesState( SatellitesState(
isDialogShown = false,
isLoading = true, isLoading = true,
shouldSeeWarning = settingsRepo.otherSettings.value.shouldSeeWarning, shouldSeeWarning = settingsRepo.otherSettings.value.shouldSeeWarning,
itemsList = emptyList(),
currentTypes = defaultTypes, currentTypes = defaultTypes,
typesList = selectionRepo.getTypesList(), typesList = selectionRepo.getTypesList()
takeAction = ::handleAction
) )
) )
val uiState: StateFlow<SatellitesState> = _uiState val uiState: StateFlow<SatellitesState> = _uiState
init { init {
viewModelScope.launch { viewModelScope.launch {
delay(1000)
selectionRepo.setQuery(String()) selectionRepo.setQuery(String())
selectionRepo.setTypes(defaultTypes) selectionRepo.setTypes(defaultTypes)
selectionRepo.getEntriesFlow().collectLatest { items -> selectionRepo.getEntriesFlow().collectLatest { items ->
@@ -67,7 +61,7 @@ class SatellitesViewModel(
} }
} }
private fun handleAction(action: SatellitesAction) { fun onAction(action: SatellitesAction) {
when (action) { when (action) {
SatellitesAction.DismissWarning -> settingsRepo.setWarningDismissed() SatellitesAction.DismissWarning -> settingsRepo.setWarningDismissed()
SatellitesAction.SaveSelection -> saveSelection() SatellitesAction.SaveSelection -> saveSelection()
@@ -94,20 +88,20 @@ class SatellitesViewModel(
private fun selectTypes(types: List<String>) = viewModelScope.launch { private fun selectTypes(types: List<String>) = viewModelScope.launch {
selectionRepo.setTypes(types) selectionRepo.setTypes(types)
_uiState.value = _uiState.value.copy(currentTypes = types, isDialogShown = false) _uiState.update { it.copy(currentTypes = types, isDialogShown = false) }
} }
private fun toggleTypesDialog() { private fun toggleTypesDialog() {
val currentDialogState = _uiState.value.isDialogShown _uiState.update { it.copy(isDialogShown = !it.isDialogShown) }
_uiState.value = _uiState.value.copy(isDialogShown = currentDialogState.not())
} }
companion object { companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory { fun factory(container: IMainContainer) = viewModelFactory {
val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY
initializer { initializer {
val container = (this[applicationKey] as IContainerProvider).getMainContainer() SatellitesViewModel(
SatellitesViewModel(container.selectionRepo, container.settingsRepo) selectionRepo = container.selectionRepo,
settingsRepo = container.settingsRepo
)
} }
} }
} }
+1 -1
View File
@@ -1,5 +1,5 @@
plugins { plugins {
alias(libs.plugins.convention.composeFeaturePlugin) alias(libs.plugins.convention.featurePlugin)
} }
android { android {
@@ -17,6 +17,9 @@
*/ */
package com.rtbishop.look4sat.feature.settings package com.rtbishop.look4sat.feature.settings
import android.bluetooth.BluetoothManager
import android.content.Context
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -28,14 +31,18 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.model.RCSettings import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.presentation.CardButton import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.LocalSpacing import com.rtbishop.look4sat.core.presentation.LocalSpacing
import com.rtbishop.look4sat.core.presentation.MainTheme import com.rtbishop.look4sat.core.presentation.MainTheme
@@ -214,14 +221,14 @@ fun PreviewNetworkOutputDialog() {
frequencyState = false, frequencyState = false,
frequencyAddress = "127.0.0.1", frequencyAddress = "127.0.0.1",
frequencyPort = "4532", frequencyPort = "4532",
frequencyFormat = $$"set_freq $FREQ", frequencyFormat = $$"F $FREQ",
bluetoothRotatorState = false, bluetoothRotatorState = false,
bluetoothRotatorFormat = $$"W$AZ $EL", bluetoothRotatorFormat = $$"P $AZ $EL",
bluetoothRotatorName = "Default", bluetoothRotatorName = "Default",
bluetoothRotatorAddress = "00:0C:BF:13:80:5D", bluetoothRotatorAddress = "00:0C:BF:13:80:5D",
bluetoothFrequencyState = false, bluetoothFrequencyState = false,
bluetoothFrequencyAddress = "00:0C:BF:13:80:5D", bluetoothFrequencyAddress = "00:0C:BF:13:80:5D",
bluetoothFrequencyFormat = $$"FA$FREQ" bluetoothFrequencyFormat = $$"F $FREQ"
), ),
onDismiss = {}, onDismiss = {},
onSave = { _, _, _, _, _, _, _, _ -> } onSave = { _, _, _, _, _, _, _, _ -> }
@@ -240,23 +247,21 @@ fun NetworkOutputDialog(
) { ) {
val padding = LocalSpacing.current.large val padding = LocalSpacing.current.large
val rotatorState = rememberSaveable { mutableStateOf(initialSettings.rotatorState) } val rotatorState = rememberSaveable { mutableStateOf(initialSettings.rotatorState) }
val rotatorAddress = rememberSaveable { mutableStateOf(initialSettings.rotatorAddress) } val rotatorAddress = rememberSaveable {
val rotatorPort = rememberSaveable { mutableStateOf(initialSettings.rotatorPort) } mutableStateOf("${initialSettings.rotatorAddress}:${initialSettings.rotatorPort}")
}
val rotatorFormat = rememberSaveable { mutableStateOf(initialSettings.rotatorFormat) } val rotatorFormat = rememberSaveable { mutableStateOf(initialSettings.rotatorFormat) }
val frequencyState = rememberSaveable { mutableStateOf(initialSettings.frequencyState) } val frequencyState = rememberSaveable { mutableStateOf(initialSettings.frequencyState) }
val frequencyAddress = rememberSaveable { mutableStateOf(initialSettings.frequencyAddress) } val frequencyAddress = rememberSaveable {
val frequencyPort = rememberSaveable { mutableStateOf(initialSettings.frequencyPort) } mutableStateOf("${initialSettings.frequencyAddress}:${initialSettings.frequencyPort}")
}
val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.frequencyFormat) } val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.frequencyFormat) }
val onAccept = { val onAccept = {
val (rotIp, rotPort) = splitAddress(rotatorAddress.value)
val (freqIp, freqPort) = splitAddress(frequencyAddress.value)
onSave( onSave(
rotatorState.value, rotatorState.value, rotIp, rotPort, rotatorFormat.value,
rotatorAddress.value, frequencyState.value, freqIp, freqPort, frequencyFormat.value
rotatorPort.value,
rotatorFormat.value,
frequencyState.value,
frequencyAddress.value,
frequencyPort.value,
frequencyFormat.value
) )
onDismiss() onDismiss()
} }
@@ -266,93 +271,42 @@ fun NetworkOutputDialog(
onAccept = onAccept onAccept = onAccept
) { ) {
Column(modifier = Modifier.padding(horizontal = padding)) { Column(modifier = Modifier.padding(horizontal = padding)) {
Row( OutputChannelSection(
horizontalArrangement = Arrangement.SpaceBetween, switchLabel = stringResource(R.string.prefs_net_rotator_switch),
verticalAlignment = Alignment.CenterVertically, enabled = rotatorState.value,
modifier = Modifier.fillMaxWidth() onEnabledChange = { rotatorState.value = it },
) { address = rotatorAddress.value,
Text(stringResource(R.string.prefs_net_rotator_switch)) onAddressChange = { rotatorAddress.value = it },
Switch( addressLabel = stringResource(R.string.prefs_net_rotator_address_hint),
checked = rotatorState.value, format = rotatorFormat.value,
onCheckedChange = { rotatorState.value = it } onFormatChange = { rotatorFormat.value = it },
) formatLabel = stringResource(R.string.prefs_net_rotator_format_hint)
}
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = rotatorAddress.value,
onValueChange = { rotatorAddress.value = it },
singleLine = true,
label = { Text(stringResource(R.string.prefs_net_rotator_ip_hint)) },
modifier = Modifier.weight(1.5f),
enabled = rotatorState.value
)
OutlinedTextField(
value = rotatorPort.value,
onValueChange = { rotatorPort.value = it },
singleLine = true,
label = { Text(stringResource(R.string.prefs_net_rotator_port_hint)) },
modifier = Modifier.weight(1f),
enabled = rotatorState.value
)
}
Spacer(modifier = Modifier.height(6.dp))
OutlinedTextField(
value = rotatorFormat.value,
onValueChange = { rotatorFormat.value = it },
singleLine = true,
label = { Text(stringResource(R.string.prefs_net_rotator_format_hint)) },
modifier = Modifier.fillMaxWidth(),
enabled = rotatorState.value
) )
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
Row( OutputChannelSection(
horizontalArrangement = Arrangement.SpaceBetween, switchLabel = stringResource(R.string.prefs_net_frequency_switch),
verticalAlignment = Alignment.CenterVertically, enabled = frequencyState.value,
modifier = Modifier.fillMaxWidth() onEnabledChange = { frequencyState.value = it },
) { address = frequencyAddress.value,
Text(stringResource(R.string.prefs_net_frequency_switch)) onAddressChange = { frequencyAddress.value = it },
Switch( addressLabel = stringResource(R.string.prefs_net_frequency_address_hint),
checked = frequencyState.value, format = frequencyFormat.value,
onCheckedChange = { frequencyState.value = it } onFormatChange = { frequencyFormat.value = it },
) formatLabel = stringResource(R.string.prefs_net_frequency_format_hint)
}
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = frequencyAddress.value,
onValueChange = { frequencyAddress.value = it },
singleLine = true,
label = { Text(stringResource(R.string.prefs_net_frequency_ip_hint)) },
modifier = Modifier.weight(1.5f),
enabled = frequencyState.value
)
OutlinedTextField(
value = frequencyPort.value,
onValueChange = { frequencyPort.value = it },
singleLine = true,
label = { Text(stringResource(R.string.prefs_net_frequency_port_hint)) },
modifier = Modifier.weight(1f),
enabled = frequencyState.value
)
}
Spacer(modifier = Modifier.height(6.dp))
OutlinedTextField(
value = frequencyFormat.value,
onValueChange = { frequencyFormat.value = it },
maxLines = 2,
label = { Text(stringResource(R.string.prefs_net_frequency_format_hint)) },
modifier = Modifier.fillMaxWidth(),
enabled = frequencyState.value
) )
} }
} }
} }
private fun splitAddress(address: String): Pair<String, String> {
val lastColon = address.lastIndexOf(':')
return if (lastColon >= 0) {
address.substring(0, lastColon) to address.substring(lastColon + 1)
} else {
address to ""
}
}
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
fun PreviewBluetoothOutputDialog() { fun PreviewBluetoothOutputDialog() {
@@ -366,14 +320,14 @@ fun PreviewBluetoothOutputDialog() {
frequencyState = false, frequencyState = false,
frequencyAddress = "127.0.0.1", frequencyAddress = "127.0.0.1",
frequencyPort = "4532", frequencyPort = "4532",
frequencyFormat = $$"set_freq $FREQ", frequencyFormat = $$"F $FREQ",
bluetoothRotatorState = false, bluetoothRotatorState = false,
bluetoothRotatorFormat = $$"W$AZ $EL", bluetoothRotatorFormat = $$"P $AZ $EL",
bluetoothRotatorName = "Default", bluetoothRotatorName = "Default",
bluetoothRotatorAddress = "00:0C:BF:13:80:5D", bluetoothRotatorAddress = "00:0C:BF:13:80:5D",
bluetoothFrequencyState = false, bluetoothFrequencyState = false,
bluetoothFrequencyAddress = "00:0C:BF:13:80:5D", bluetoothFrequencyAddress = "00:0C:BF:13:80:5D",
bluetoothFrequencyFormat = $$"FA$FREQ" bluetoothFrequencyFormat = $$"F $FREQ"
), ),
onDismiss = {}, onDismiss = {},
onSave = { _, _, _, _, _, _ -> } onSave = { _, _, _, _, _, _ -> }
@@ -399,12 +353,8 @@ fun BluetoothOutputDialog(
val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyFormat) } val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyFormat) }
val onAccept = { val onAccept = {
onSave( onSave(
rotatorState.value, rotatorState.value, rotatorAddress.value, rotatorFormat.value,
rotatorAddress.value, frequencyState.value, frequencyAddress.value, frequencyFormat.value
rotatorFormat.value,
frequencyState.value,
frequencyAddress.value,
frequencyFormat.value
) )
onDismiss() onDismiss()
} }
@@ -412,6 +362,127 @@ fun BluetoothOutputDialog(
title = stringResource(R.string.prefs_bt_title), title = stringResource(R.string.prefs_bt_title),
onCancel = onDismiss, onCancel = onDismiss,
onAccept = onAccept onAccept = onAccept
) {
Column(modifier = Modifier.padding(horizontal = padding)) {
OutputChannelSection(
switchLabel = stringResource(R.string.prefs_bt_rotator_switch),
enabled = rotatorState.value,
onEnabledChange = { rotatorState.value = it },
address = rotatorAddress.value,
onAddressChange = { rotatorAddress.value = it },
addressLabel = stringResource(R.string.prefs_bt_rotator_device_hint),
format = rotatorFormat.value,
onFormatChange = { rotatorFormat.value = it },
formatLabel = stringResource(R.string.prefs_bt_rotator_output_hint)
)
Spacer(modifier = Modifier.height(6.dp))
OutputChannelSection(
switchLabel = stringResource(R.string.prefs_bt_frequency_switch),
enabled = frequencyState.value,
onEnabledChange = { frequencyState.value = it },
address = frequencyAddress.value,
onAddressChange = { frequencyAddress.value = it },
addressLabel = stringResource(R.string.prefs_bt_frequency_device_hint),
format = frequencyFormat.value,
onFormatChange = { frequencyFormat.value = it },
formatLabel = stringResource(R.string.prefs_bt_frequency_output_hint)
)
}
}
}
/**
* Reusable section for a switch-toggled output channel with address and format fields.
* Used by both Network and Bluetooth output dialogs.
*/
@Composable
private fun OutputChannelSection(
switchLabel: String,
enabled: Boolean,
onEnabledChange: (Boolean) -> Unit,
address: String,
onAddressChange: (String) -> Unit,
addressLabel: String,
format: String,
onFormatChange: (String) -> Unit,
formatLabel: String
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(switchLabel)
Switch(checked = enabled, onCheckedChange = onEnabledChange)
}
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = address,
onValueChange = onAddressChange,
singleLine = true,
label = { Text(addressLabel) },
modifier = Modifier.weight(0.6f),
enabled = enabled
)
OutlinedTextField(
value = format,
onValueChange = onFormatChange,
singleLine = true,
label = { Text(formatLabel) },
modifier = Modifier.weight(0.4f),
enabled = enabled
)
}
}
@Composable
fun RadioControlDialog(
initialSettings: RadioControlSettings,
onDismiss: () -> Unit,
onSave: (RadioControlSettings) -> Unit
) {
val context = androidx.compose.ui.platform.LocalContext.current
val padding = LocalSpacing.current.large
val baudRates = listOf(4800, 9600, 38400)
val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) }
val radioModel = rememberSaveable { mutableStateOf(initialSettings.radioModel) }
val txAddress = rememberSaveable { mutableStateOf(initialSettings.txRadioAddress) }
val rxAddress = rememberSaveable { mutableStateOf(initialSettings.rxRadioAddress) }
val txName = rememberSaveable { mutableStateOf(initialSettings.txRadioName) }
val rxName = rememberSaveable { mutableStateOf(initialSettings.rxRadioName) }
val baudRate = rememberSaveable { mutableIntStateOf(initialSettings.baudRate) }
val selectingFor = rememberSaveable { mutableStateOf("") } // "tx", "rx", or ""
val pairedDevices = remember {
try {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
manager.adapter?.bondedDevices?.map { Pair(it.name ?: "Unknown", it.address) } ?: emptyList()
} catch (_: SecurityException) {
emptyList()
}
}
val onAccept = {
onSave(
RadioControlSettings(
enabled = enabled.value,
radioModel = radioModel.value,
txRadioAddress = txAddress.value,
rxRadioAddress = rxAddress.value,
txRadioName = txName.value,
rxRadioName = rxName.value,
baudRate = baudRate.intValue
)
)
onDismiss()
}
SharedDialog(
title = stringResource(R.string.rc_settings_title),
onCancel = onDismiss,
onAccept = onAccept
) { ) {
Column(modifier = Modifier.padding(horizontal = padding)) { Column(modifier = Modifier.padding(horizontal = padding)) {
Row( Row(
@@ -419,65 +490,107 @@ fun BluetoothOutputDialog(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text(stringResource(R.string.prefs_bt_rotator_switch)) Text(stringResource(R.string.rc_enable_switch))
Switch( Switch(checked = enabled.value, onCheckedChange = { enabled.value = it })
checked = rotatorState.value,
onCheckedChange = { rotatorState.value = it }
)
} }
Row( Spacer(modifier = Modifier.height(6.dp))
horizontalArrangement = Arrangement.spacedBy(6.dp),
// Radio model selection
Text(
stringResource(R.string.rc_radio_model),
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium,
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
RadioControlSettings.SUPPORTED_RADIOS.forEach { model ->
androidx.compose.material3.FilterChip(
selected = radioModel.value == model,
onClick = { radioModel.value = model },
label = { Text(model, fontSize = 12.sp) },
enabled = enabled.value
)
}
}
Spacer(modifier = Modifier.height(6.dp))
// TX Radio selection
Text("TX Radio (Uplink)", fontWeight = androidx.compose.ui.text.font.FontWeight.Medium)
if (txAddress.value.isNotBlank()) {
Text("${txName.value} - ${txAddress.value}", fontSize = 13.sp)
}
CardButton(
onClick = { selectingFor.value = "tx" },
text = "Select TX Device",
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { )
OutlinedTextField( Spacer(modifier = Modifier.height(6.dp))
value = rotatorAddress.value,
onValueChange = { rotatorAddress.value = it }, // RX Radio selection
singleLine = true, Text("RX Radio (Downlink)", fontWeight = androidx.compose.ui.text.font.FontWeight.Medium)
label = { Text(stringResource(R.string.prefs_bt_rotator_device_hint)) }, if (rxAddress.value.isNotBlank()) {
modifier = Modifier.weight(1.5f), Text("${rxName.value} - ${rxAddress.value}", fontSize = 13.sp)
enabled = rotatorState.value
)
OutlinedTextField(
value = rotatorFormat.value,
onValueChange = { rotatorFormat.value = it },
singleLine = true,
label = { Text(stringResource(R.string.prefs_bt_rotator_output_hint)) },
modifier = Modifier.weight(1f),
enabled = rotatorState.value
)
} }
CardButton(
onClick = { selectingFor.value = "rx" },
text = "Select RX Device",
modifier = Modifier.fillMaxWidth()
)
// Paired devices list (shown when selecting)
if (selectingFor.value.isNotBlank()) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "Paired Bluetooth Devices:",
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium,
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
)
if (pairedDevices.isEmpty()) {
Text("No paired devices found. Pair your BT adapter in Android Bluetooth settings first.")
} else {
pairedDevices.forEach { (name, address) ->
androidx.compose.material3.Surface(
modifier = Modifier
.fillMaxWidth()
.clickable {
if (selectingFor.value == "tx") {
txAddress.value = address
txName.value = name
} else {
rxAddress.value = address
rxName.value = name
}
selectingFor.value = ""
}
.padding(vertical = 4.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(name, modifier = Modifier.weight(1f))
Text(address, fontSize = 12.sp)
}
}
}
}
}
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
Row( Row(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text(stringResource(R.string.prefs_bt_frequency_switch)) Text("Baud Rate:")
Switch( Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
checked = frequencyState.value, baudRates.forEach { rate ->
onCheckedChange = { frequencyState.value = it } CardButton(
) onClick = { baudRate.intValue = rate },
} text = if (rate == baudRate.intValue) "[$rate]" else rate.toString(),
Row( modifier = Modifier
horizontalArrangement = Arrangement.spacedBy(6.dp), )
modifier = Modifier.fillMaxWidth() }
) { }
OutlinedTextField(
value = frequencyAddress.value,
onValueChange = { frequencyAddress.value = it },
singleLine = true,
label = { Text(stringResource(R.string.prefs_bt_frequency_device_hint)) },
modifier = Modifier.weight(1.5f),
enabled = frequencyState.value
)
OutlinedTextField(
value = frequencyFormat.value,
onValueChange = { frequencyFormat.value = it },
singleLine = true,
label = { Text(stringResource(R.string.prefs_bt_frequency_output_hint)) },
modifier = Modifier.weight(1f),
enabled = frequencyState.value
)
} }
} }
} }
@@ -42,11 +42,16 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -56,16 +61,14 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.rtbishop.look4sat.core.domain.model.OtherSettings import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.CardButton import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.IconCard import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.MainTheme import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.PrimaryIconCard import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
import com.rtbishop.look4sat.core.presentation.R 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.ScreenColumn
import com.rtbishop.look4sat.core.presentation.TopBar import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.infiniteMarquee import com.rtbishop.look4sat.core.presentation.infiniteMarquee
@@ -74,184 +77,105 @@ import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
fun NavGraphBuilder.settingsDestination() { @Composable
composable(Screen.Settings.route) { fun SettingsDestination() {
val viewModel = viewModel( val context = LocalContext.current
modelClass = SettingsViewModel::class.java, val container = (context.applicationContext as IContainerProvider).getMainContainer()
factory = SettingsViewModel.Factory val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container))
) val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value SettingsScreen(uiState, viewModel::onAction)
SettingsScreen(uiState)
}
} }
@Composable @Composable
private fun SettingsScreen(uiState: SettingsState) { private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) {
val locationContract = ActivityResultContracts.RequestMultiplePermissions() val dialogs = rememberDialogVisibility()
val locationError = stringResource(R.string.prefs_loc_gps_error) val permissions = rememberSettingsPermissions(
val locationPermCoarse = Manifest.permission.ACCESS_COARSE_LOCATION sendAction = onAction,
val locationPermFine = Manifest.permission.ACCESS_FINE_LOCATION onBluetoothGranted = { dialogs.bluetooth = true }
val locationRequest = rememberLauncherForActivityResult(locationContract) { permissions -> )
when {
permissions[locationPermFine] == true -> uiState.sendAction(SettingsAction.SetGpsPosition)
permissions[locationPermCoarse] == true -> uiState.sendAction(SettingsAction.SetGpsPosition)
else -> uiState.sendSystemAction(SystemAction.ShowToast(locationError))
}
}
val contentContract = ActivityResultContracts.GetContent()
val contentRequestForTle = rememberLauncherForActivityResult(contentContract) { uri ->
uri?.let { uiState.sendAction(SettingsAction.UpdateTLEFromFile(uri.toString())) }
}
val contentRequestForTransceivers = rememberLauncherForActivityResult(contentContract) { uri ->
uri?.let { uiState.sendAction(SettingsAction.UpdateTransceiversFromFile(uri.toString())) }
}
// Position settings // Dialogs
val posSettings = uiState.positionSettings if (dialogs.position) {
val setGpsPos = { locationRequest.launch(arrayOf(locationPermCoarse, locationPermFine)) } PositionDialog(
val setGeoPos = { lat: Double, lon: Double -> uiState.positionSettings.stationPos.latitude,
uiState.sendAction(SettingsAction.SetGeoPosition(lat, lon)) uiState.positionSettings.stationPos.longitude,
dismiss = { dialogs.position = false },
save = { lat, lon -> onAction(SettingsAction.SetGeoPosition(lat, lon)) }
)
} }
val setQthPos = { locator: String -> if (dialogs.locator) {
uiState.sendAction(SettingsAction.SetQthPosition(locator)) LocatorDialog(
uiState.positionSettings.stationPos.qthLocator,
dismiss = { dialogs.locator = false },
save = { onAction(SettingsAction.SetQthPosition(it)) }
)
} }
val dismissPos = { uiState.sendAction(SettingsAction.DismissPosMessages) } if (dialogs.dataSources) {
val posDialogState = rememberSaveable { mutableStateOf(false) }
val showPosDialog = { posDialogState.value = posDialogState.value.not() }
if (posDialogState.value) {
PositionDialog(posSettings.stationPos.latitude, posSettings.stationPos.longitude, showPosDialog, setGeoPos)
}
val locDialogState = rememberSaveable { mutableStateOf(false) }
val showLocDialog = { locDialogState.value = locDialogState.value.not() }
if (locDialogState.value) {
LocatorDialog(posSettings.stationPos.qthLocator, showLocDialog, setQthPos)
}
// Data sources dialog
val dataSourcesDialogState = rememberSaveable { mutableStateOf(false) }
val showDataSourcesDialog = { dataSourcesDialogState.value = true }
val dismissDataSourcesDialog = { dataSourcesDialogState.value = false }
if (dataSourcesDialogState.value) {
DataSourcesDialog( DataSourcesDialog(
useCustomTle = uiState.dataSourcesSettings.useCustomTLE, useCustomTle = uiState.dataSourcesSettings.useCustomTLE,
useCustomTransceivers = uiState.dataSourcesSettings.useCustomTransceivers, useCustomTransceivers = uiState.dataSourcesSettings.useCustomTransceivers,
tleUrl = uiState.dataSourcesSettings.tleUrl, tleUrl = uiState.dataSourcesSettings.tleUrl,
transceiversUrl = uiState.dataSourcesSettings.transceiversUrl, transceiversUrl = uiState.dataSourcesSettings.transceiversUrl,
onImportTle = { contentRequestForTle.launch("*/*") }, onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false },
onImportTransceivers = { contentRequestForTransceivers.launch("*/*") }, onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false },
onDismiss = dismissDataSourcesDialog, onDismiss = { dialogs.dataSources = false },
onSave = { onSave = { useCustomTle, useCustomTransceivers, tleUrl, transceiversUrl ->
useCustomTle, useCustomTransceivers, tleUrl, transceiversUrl -> val current = uiState.dataSourcesSettings
if (!useCustomTle || tleUrl.isNotBlank()) { val newSettings = current.copy(
uiState.sendDataSourcesAction(DataSourcesAction.SetUseCustomTle(useCustomTle)) useCustomTLE = if (!useCustomTle || tleUrl.isNotBlank()) useCustomTle else current.useCustomTLE,
uiState.sendDataSourcesAction(DataSourcesAction.SetTleUrl(tleUrl)) tleUrl = if (!useCustomTle || tleUrl.isNotBlank()) tleUrl else current.tleUrl,
} useCustomTransceivers = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) useCustomTransceivers else current.useCustomTransceivers,
if (!useCustomTransceivers || transceiversUrl.isNotBlank()) { transceiversUrl = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) transceiversUrl else current.transceiversUrl
uiState.sendDataSourcesAction(DataSourcesAction.SetUseCustomTransceivers(useCustomTransceivers)) )
uiState.sendDataSourcesAction(DataSourcesAction.SetTransceiversUrl(transceiversUrl)) if (newSettings != current) onAction(SettingsAction.UpdateDataSources(newSettings))
} if (useCustomTle || useCustomTransceivers) onAction(SettingsAction.UpdateFromWeb)
if (useCustomTle || useCustomTransceivers) {
uiState.sendAction(SettingsAction.UpdateFromWeb)
}
} }
) )
} }
if (dialogs.network) {
// Data settings
val dataSettings = uiState.dataSettings
val updateFromWeb: () -> Unit = { uiState.sendAction(SettingsAction.UpdateFromWeb) }
val clearAllData: () -> Unit = { uiState.sendAction(SettingsAction.ClearAllData) }
// RC settings
val rcSettings = uiState.rcSettings
val setRotatorState = { value: Boolean -> uiState.sendRCAction(RCAction.SetRotatorState(value)) }
val setRotatorAddress = { value: String -> uiState.sendRCAction(RCAction.SetRotatorAddress(value)) }
val setRotatorPort = { value: String -> uiState.sendRCAction(RCAction.SetRotatorPort(value)) }
val setRotatorFormat = { value: String -> uiState.sendRCAction(RCAction.SetRotatorFormat(value)) }
val setFrequencyState = { value: Boolean -> uiState.sendRCAction(RCAction.SetFrequencyState(value)) }
val setFrequencyAddress = { value: String -> uiState.sendRCAction(RCAction.SetFrequencyAddress(value)) }
val setFrequencyPort = { value: String -> uiState.sendRCAction(RCAction.SetFrequencyPort(value)) }
val setFrequencyFormat = { value: String -> uiState.sendRCAction(RCAction.SetFrequencyFormat(value)) }
val setBluetoothRotatorState = { value: Boolean -> uiState.sendRCAction(RCAction.SetBluetoothRotatorState(value)) }
val setBluetoothRotatorAddress = { value: String -> uiState.sendRCAction(RCAction.SetBluetoothRotatorAddress(value)) }
val setBluetoothRotatorFormat = { value: String -> uiState.sendRCAction(RCAction.SetBluetoothRotatorFormat(value)) }
val setBluetoothFrequencyState = { value: Boolean -> uiState.sendRCAction(RCAction.SetBluetoothFrequencyState(value)) }
val setBluetoothFrequencyAddress = { value: String -> uiState.sendRCAction(RCAction.SetBluetoothFrequencyAddress(value)) }
val setBluetoothFrequencyFormat = { value: String -> uiState.sendRCAction(RCAction.SetBluetoothFrequencyFormat(value)) }
// Network data output
val networkDialogState = rememberSaveable { mutableStateOf(false) }
val showNetworkDialog = { networkDialogState.value = true }
val dismissNetworkDialog = { networkDialogState.value = false }
if (networkDialogState.value) {
NetworkOutputDialog( NetworkOutputDialog(
initialSettings = rcSettings, initialSettings = uiState.rcSettings,
onDismiss = dismissNetworkDialog, onDismiss = { dialogs.network = false },
onSave = { rotatorState, onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt ->
rotatorAddress, onAction(
rotatorPort, SettingsAction.UpdateRC(
rotatorFormat, uiState.rcSettings.copy(
frequencyState, rotatorState = rotState, rotatorAddress = rotAddr,
frequencyAddress, rotatorPort = rotPort, rotatorFormat = rotFmt,
frequencyPort, frequencyState = freqState, frequencyAddress = freqAddr,
frequencyFormat -> frequencyPort = freqPort, frequencyFormat = freqFmt
setRotatorState(rotatorState) )
setRotatorAddress(rotatorAddress) )
setRotatorPort(rotatorPort) )
setRotatorFormat(rotatorFormat)
setFrequencyState(frequencyState)
setFrequencyAddress(frequencyAddress)
setFrequencyPort(frequencyPort)
setFrequencyFormat(frequencyFormat)
} }
) )
} }
if (dialogs.bluetooth) {
// Bluetooth data output
val bluetoothDialogState = rememberSaveable { mutableStateOf(false) }
val bluetoothContract = ActivityResultContracts.RequestPermission()
val bluetoothError = stringResource(R.string.prefs_bt_perm_error)
val bluetoothPerm = when {
Build.VERSION.SDK_INT < Build.VERSION_CODES.S -> Manifest.permission.BLUETOOTH
else -> Manifest.permission.BLUETOOTH_CONNECT
}
val bluetoothRequest = rememberLauncherForActivityResult(bluetoothContract) { isGranted ->
if (!isGranted)
{
uiState.sendRCAction(RCAction.SetBluetoothRotatorState(false))
uiState.sendRCAction(RCAction.SetBluetoothFrequencyState(false))
uiState.sendSystemAction(SystemAction.ShowToast(bluetoothError))
}
else { bluetoothDialogState.value = true }
}
val showBluetoothDialog = { bluetoothRequest.launch(bluetoothPerm) }
val dismissBluetoothDialog = { bluetoothDialogState.value = false }
if (bluetoothDialogState.value) {
BluetoothOutputDialog( BluetoothOutputDialog(
initialSettings = rcSettings, initialSettings = uiState.rcSettings,
onDismiss = dismissBluetoothDialog, onDismiss = { dialogs.bluetooth = false },
onSave = { rotatorState, onSave = { rotState, rotAddr, rotFmt, freqState, freqAddr, freqFmt ->
rotatorAddress, onAction(
rotatorFormat, SettingsAction.UpdateRC(
frequencyState, uiState.rcSettings.copy(
frequencyAddress, bluetoothRotatorState = rotState, bluetoothRotatorAddress = rotAddr,
frequencyFormat -> bluetoothRotatorFormat = rotFmt, bluetoothFrequencyState = freqState,
setBluetoothRotatorState(rotatorState) bluetoothFrequencyAddress = freqAddr, bluetoothFrequencyFormat = freqFmt
setBluetoothRotatorAddress(rotatorAddress) )
setBluetoothRotatorFormat(rotatorFormat) )
setBluetoothFrequencyState(frequencyState) )
setBluetoothFrequencyAddress(frequencyAddress)
setBluetoothFrequencyFormat(frequencyFormat)
} }
) )
} }
if (dialogs.radioControl) {
RadioControlDialog(
initialSettings = uiState.radioControlSettings,
onDismiss = { dialogs.radioControl = false },
onSave = { onAction(SettingsAction.UpdateRadioControl(it)) }
)
}
// Other settings // URLs for top bar
val otherSettings = uiState.otherSettings
val toggleUtc = { value: Boolean -> uiState.sendAction(SettingsAction.ToggleUtc(value)) }
val toggleUpdate = { value: Boolean -> uiState.sendAction(SettingsAction.ToggleUpdate(value)) }
val toggleSweep = { value: Boolean -> uiState.sendAction(SettingsAction.ToggleSweep(value)) }
val toggleSensor = { value: Boolean -> uiState.sendAction(SettingsAction.ToggleSensor(value)) }
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val appUrl = stringResource(R.string.prefs_app_url) val appUrl = stringResource(R.string.prefs_app_url)
val donateUrl = stringResource(R.string.prefs_donate_url) val donateUrl = stringResource(R.string.prefs_donate_url)
@@ -261,32 +185,63 @@ private fun SettingsScreen(uiState: SettingsState) {
val gitHubUrl = stringResource(R.string.prefs_github_url) val gitHubUrl = stringResource(R.string.prefs_github_url)
val licenseUrl = stringResource(R.string.prefs_license_url) val licenseUrl = stringResource(R.string.prefs_license_url)
val privacyUrl = stringResource(R.string.prefs_privacy_url) val privacyUrl = stringResource(R.string.prefs_privacy_url)
val safeOpenUri: (String) -> Unit = { url ->
try { uriHandler.openUri(url) } catch (_: Exception) {}
}
ScreenColumn( ScreenColumn(
topBar = { isVerticalLayout -> topBar = { isVerticalLayout ->
if (isVerticalLayout) { if (isVerticalLayout) {
TopBar { TopBar {
TopCard(onClick = { uriHandler.openUri(appUrl) }, version = uiState.appVersionName, modifier = Modifier.weight(1f)) TopCard(
PrimaryIconCard(onClick = { uriHandler.openUri(donateUrl) }, resId = R.drawable.ic_pound) onClick = { safeOpenUri(appUrl) },
version = uiState.appVersionName,
modifier = Modifier.weight(1f)
)
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
} }
TopBar { TopBar {
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) { 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(
BotCard(onClick = { uriHandler.openUri(gitHubUrl) }, R.drawable.ic_github, gitHubTitle, modifier = Modifier.weight(1f)) 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 = { safeOpenUri(licenseUrl) }, resId = R.drawable.ic_license)
IconCard(action = { uriHandler.openUri(privacyUrl) }, resId = R.drawable.ic_policy) IconCard(action = { safeOpenUri(privacyUrl) }, resId = R.drawable.ic_policy)
} }
} else { } else {
TopBar { TopBar {
PrimaryIconCard(onClick = { uriHandler.openUri(donateUrl) }, resId = R.drawable.ic_pound) PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
TopCard(onClick = { uriHandler.openUri(appUrl) }, version = uiState.appVersionName, modifier = Modifier.weight(1f)) TopCard(
onClick = { safeOpenUri(appUrl) },
version = uiState.appVersionName,
modifier = Modifier.weight(1f)
)
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) { 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(
BotCard(onClick = { uriHandler.openUri(gitHubUrl) }, R.drawable.ic_github, gitHubTitle, modifier = Modifier.weight(1f)) 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 = { safeOpenUri(licenseUrl) }, resId = R.drawable.ic_license)
IconCard(action = { uriHandler.openUri(privacyUrl) }, resId = R.drawable.ic_policy) IconCard(action = { safeOpenUri(privacyUrl) }, resId = R.drawable.ic_policy)
} }
} }
} }
@@ -299,11 +254,31 @@ private fun SettingsScreen(uiState: SettingsState) {
modifier = Modifier.clip(MaterialTheme.shapes.medium) modifier = Modifier.clip(MaterialTheme.shapes.medium)
) { ) {
item { item {
LocationCard(posSettings, setGpsPos, showPosDialog, showLocDialog, dismissPos, uiState.sendSystemAction) LocationCard(
settings = uiState.positionSettings,
setGpsPos = permissions.launchLocation,
showPosDialog = { dialogs.position = true },
showLocDialog = { dialogs.locator = true },
dismissPosMessage = { onAction(SettingsAction.DismissPosMessages) },
onAction = onAction
)
} }
item { DataCard(dataSettings, updateFromWeb, clearAllData, showDataSourcesDialog) } item {
item(span = { GridItemSpan(maxLineSpan) }) { OutputCard({ showNetworkDialog() }, { showBluetoothDialog() }) } DataCard(
item { OtherCard(otherSettings, toggleUtc, toggleUpdate, toggleSweep, toggleSensor) } settings = uiState.dataSettings,
updateFromWeb = { onAction(SettingsAction.UpdateFromWeb) },
clearAllData = { onAction(SettingsAction.ClearAllData) },
showDataSourcesDialog = { dialogs.dataSources = true }
)
}
item(span = { GridItemSpan(maxLineSpan) }) {
OutputCard(
onNetworkClick = { dialogs.network = true },
onBluetoothClick = permissions.launchBluetooth,
onRadioControlClick = { dialogs.radioControl = true }
)
}
item { OtherCard(uiState.otherSettings, onAction) }
item { CardCredits() } item { CardCredits() }
} }
} }
@@ -324,7 +299,7 @@ private fun LocationCard(
showPosDialog: () -> Unit, showPosDialog: () -> Unit,
showLocDialog: () -> Unit, showLocDialog: () -> Unit,
dismissPosMessage: () -> Unit, dismissPosMessage: () -> Unit,
sendSystemAction: (SystemAction) -> Unit onAction: (SettingsAction) -> Unit
) { ) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) { ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column( Column(
@@ -339,7 +314,7 @@ private fun LocationCard(
UpdateIndicator(isUpdating = settings.isUpdating, Modifier.weight(1f)) UpdateIndicator(isUpdating = settings.isUpdating, Modifier.weight(1f))
} }
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
Text(text = setUpdateTime(updateTime = settings.stationPos.timestamp)) Text(text = formatUpdateTime(updateTime = settings.stationPos.timestamp))
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = "Lat: ${settings.stationPos.latitude}°") Text(text = "Lat: ${settings.stationPos.latitude}°")
@@ -371,7 +346,7 @@ private fun LocationCard(
if (settings.messageResId != 0) { if (settings.messageResId != 0) {
val errorString = stringResource(id = settings.messageResId) val errorString = stringResource(id = settings.messageResId)
LaunchedEffect(key1 = settings.messageResId) { LaunchedEffect(key1 = settings.messageResId) {
sendSystemAction(SystemAction.ShowToast(errorString)) onAction(SettingsAction.ShowToast(errorString))
dismissPosMessage() dismissPosMessage()
} }
} }
@@ -404,7 +379,7 @@ private fun DataCard(
UpdateIndicator(isUpdating = settings.isUpdating, Modifier.weight(1f)) UpdateIndicator(isUpdating = settings.isUpdating, Modifier.weight(1f))
} }
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
Text(text = setUpdateTime(updateTime = settings.timestamp)) Text(text = formatUpdateTime(updateTime = settings.timestamp))
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.prefs_data_entries, settings.entriesTotal)) Text(text = stringResource(R.string.prefs_data_entries, settings.entriesTotal))
@@ -434,12 +409,13 @@ private fun DataCard(
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
private fun OutputCardPreview() = MainTheme { OutputCard({}, {}) } private fun OutputCardPreview() = MainTheme { OutputCard({}, {}, {}) }
@Composable @Composable
private fun OutputCard( private fun OutputCard(
onNetworkClick: () -> Unit, onNetworkClick: () -> Unit,
onBluetoothClick: () -> Unit onBluetoothClick: () -> Unit,
onRadioControlClick: () -> Unit
) { ) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) { ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) { Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
@@ -459,6 +435,11 @@ private fun OutputCard(
text = stringResource(id = R.string.prefs_bt_output), text = stringResource(id = R.string.prefs_bt_output),
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
CardButton(
onClick = onRadioControlClick,
text = stringResource(id = R.string.prefs_cat_output),
modifier = Modifier.weight(1f)
)
} }
} }
} }
@@ -473,73 +454,66 @@ private fun OtherCardPreview() = MainTheme {
stateOfSweep = true, stateOfSweep = true,
stateOfUtc = false, stateOfUtc = false,
stateOfLightTheme = false, stateOfLightTheme = false,
stateOfNightMode = false,
shouldSeeWarning = false, shouldSeeWarning = false,
shouldSeeWhatsNew = false shouldSeeWhatsNew = false
) )
OtherCard(settings = values, {}, {}, {}, {}) OtherCard(settings = values) {}
} }
@Composable @Composable
private fun OtherCard( private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
settings: OtherSettings,
toggleUtc: (Boolean) -> Unit,
toggleUpdate: (Boolean) -> Unit,
toggleSweep: (Boolean) -> Unit,
toggleSensor: (Boolean) -> Unit
) {
ElevatedCard( ElevatedCard(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(220.dp) .height(268.dp)
) { ) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) { Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text( Text(
text = stringResource(id = R.string.prefs_other_title), text = stringResource(id = R.string.prefs_other_title),
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
Row( SwitchRow(R.string.prefs_other_switch_utc, settings.stateOfUtc) {
horizontalArrangement = Arrangement.SpaceBetween, onAction(SettingsAction.ToggleUtc(it))
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = R.string.prefs_other_switch_utc))
Switch(checked = settings.stateOfUtc, onCheckedChange = { toggleUtc(it) })
} }
Row( SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
horizontalArrangement = Arrangement.SpaceBetween, onAction(SettingsAction.ToggleUpdate(it))
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = R.string.prefs_other_switch_update))
Switch(checked = settings.stateOfAutoUpdate, onCheckedChange = { toggleUpdate(it) })
} }
Row( SwitchRow(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
horizontalArrangement = Arrangement.SpaceBetween, onAction(SettingsAction.ToggleSweep(it))
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = R.string.prefs_other_switch_sweep))
Switch(checked = settings.stateOfSweep, onCheckedChange = { toggleSweep(it) })
} }
Row( SwitchRow(R.string.prefs_other_switch_sensors, settings.stateOfSensors) {
horizontalArrangement = Arrangement.SpaceBetween, onAction(SettingsAction.ToggleSensor(it))
verticalAlignment = Alignment.CenterVertically, }
modifier = Modifier.fillMaxWidth() SwitchRow(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
) { onAction(SettingsAction.ToggleNightMode(it))
Text(text = stringResource(id = R.string.prefs_other_switch_sensors))
Switch(checked = settings.stateOfSensors, onCheckedChange = { toggleSensor(it) })
} }
} }
} }
} }
@Composable @Composable
private fun setUpdateTime(updateTime: Long): String { private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
val updateDate = if (updateTime != 0L) { Row(
val timePattern = stringResource(id = R.string.prefs_updated_time) horizontalArrangement = Arrangement.SpaceBetween,
SimpleDateFormat(timePattern, Locale.getDefault()).format(Date(updateTime)) verticalAlignment = Alignment.CenterVertically,
} else { modifier = Modifier.fillMaxWidth()
stringResource(id = R.string.pass_time_placeholder) ) {
Text(text = stringResource(id = labelResId))
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
@Composable
private fun formatUpdateTime(updateTime: Long): String {
val timePattern = stringResource(id = R.string.prefs_updated_time)
val placeholder = stringResource(id = R.string.pass_time_placeholder)
val updateDate = remember(updateTime) {
if (updateTime != 0L) {
SimpleDateFormat(timePattern, Locale.getDefault()).format(Date(updateTime))
} else {
placeholder
}
} }
return stringResource(id = R.string.prefs_updated_title, updateDate) return stringResource(id = R.string.prefs_updated_title, updateDate)
} }
@@ -564,7 +538,7 @@ private fun CardCredits(modifier: Modifier = Modifier) {
ElevatedCard( ElevatedCard(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.height(220.dp) .height(268.dp)
) { ) {
Column( Column(
verticalArrangement = Arrangement.SpaceBetween, verticalArrangement = Arrangement.SpaceBetween,
@@ -638,3 +612,91 @@ private fun BotCard(onClick: () -> Unit, resId: Int, text: String, modifier: Mod
} }
} }
} }
// region Dialog visibility state holder
@Stable
private class DialogVisibility {
var position by mutableStateOf(false)
var locator by mutableStateOf(false)
var dataSources by mutableStateOf(false)
var network by mutableStateOf(false)
var bluetooth by mutableStateOf(false)
var radioControl by mutableStateOf(false)
}
@Composable
private fun rememberDialogVisibility(): DialogVisibility {
return rememberSaveable(saver = run {
androidx.compose.runtime.saveable.Saver(
save = {
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl)
},
restore = {
DialogVisibility().apply {
position = it[0]; locator = it[1]; dataSources = it[2]
network = it[3]; bluetooth = it[4]; radioControl = it[5]
}
}
)
}) { DialogVisibility() }
}
// endregion
// region Permission launchers holder
@Stable
private class SettingsPermissions(
val launchLocation: () -> Unit,
val launchTleImport: () -> Unit,
val launchTransceiverImport: () -> Unit,
val launchBluetooth: () -> Unit
)
@Composable
private fun rememberSettingsPermissions(
sendAction: (SettingsAction) -> Unit,
onBluetoothGranted: () -> Unit
): SettingsPermissions {
val locationError = stringResource(R.string.prefs_loc_gps_error)
val locationRequest = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
val fine = permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true
val coarse = permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true
if (fine || coarse) sendAction(SettingsAction.SetGpsPosition)
else sendAction(SettingsAction.ShowToast(locationError))
}
val tleRequest = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let { sendAction(SettingsAction.UpdateTLEFromFile(it.toString())) }
}
val transceiversRequest = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let { sendAction(SettingsAction.UpdateTransceiversFromFile(it.toString())) }
}
val bluetoothError = stringResource(R.string.prefs_bt_perm_error)
val bluetoothPerm = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S)
Manifest.permission.BLUETOOTH else Manifest.permission.BLUETOOTH_CONNECT
val bluetoothRequest = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) onBluetoothGranted()
else sendAction(SettingsAction.ShowToast(bluetoothError))
}
return remember {
SettingsPermissions(
launchLocation = {
locationRequest.launch(
arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION)
)
},
launchTleImport = { tleRequest.launch("*/*") },
launchTransceiverImport = { transceiversRequest.launch("*/*") },
launchBluetooth = { bluetoothRequest.launch(bluetoothPerm) }
)
}
}
// endregion
@@ -20,8 +20,8 @@ package com.rtbishop.look4sat.feature.settings
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.OtherSettings import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
data class PositionSettings( data class PositionSettings(
val isUpdating: Boolean, val stationPos: GeoPos, val messageResId: Int val isUpdating: Boolean, val stationPos: GeoPos, val messageResId: Int
@@ -32,62 +32,43 @@ data class DataSettings(
) )
data class SettingsState( data class SettingsState(
val nextPass: OrbitalPass,
val nextTime: String,
val isNextTimeAos: Boolean,
val appVersionName: String, val appVersionName: String,
val positionSettings: PositionSettings, val positionSettings: PositionSettings,
val dataSettings: DataSettings, val dataSettings: DataSettings,
val otherSettings: OtherSettings, val otherSettings: OtherSettings,
val rcSettings: RCSettings, val rcSettings: RCSettings,
val dataSourcesSettings: DataSourcesSettings, val radioControlSettings: RadioControlSettings,
val sendAction: (SettingsAction) -> Unit, val dataSourcesSettings: DataSourcesSettings
val sendRCAction: (RCAction) -> Unit,
val sendSystemAction: (SystemAction) -> Unit,
val sendDataSourcesAction: (DataSourcesAction) -> Unit
) )
sealed class SettingsAction { sealed interface SettingsAction {
data object SetGpsPosition : SettingsAction() // Position
data class SetGeoPosition(val latitude: Double, val longitude: Double) : SettingsAction() data object SetGpsPosition : SettingsAction
data class SetQthPosition(val locator: String) : SettingsAction() data class SetGeoPosition(val latitude: Double, val longitude: Double) : SettingsAction
data object DismissPosMessages : SettingsAction() data class SetQthPosition(val locator: String) : SettingsAction
data object UpdateFromWeb : SettingsAction() data object DismissPosMessages : SettingsAction
data class UpdateTLEFromFile(val uri: String) : SettingsAction()
data class UpdateTransceiversFromFile(val uri: String) : SettingsAction()
data object ClearAllData : SettingsAction()
data class ToggleUtc(val value: Boolean) : SettingsAction()
data class ToggleUpdate(val value: Boolean) : SettingsAction()
data class ToggleSweep(val value: Boolean) : SettingsAction()
data class ToggleSensor(val value: Boolean) : SettingsAction()
data class ToggleLightTheme(val value: Boolean) : SettingsAction()
}
sealed class SystemAction { // Data
data class ShowToast(val message: String) : SystemAction() data object UpdateFromWeb : SettingsAction
} data class UpdateTLEFromFile(val uri: String) : SettingsAction
data class UpdateTransceiversFromFile(val uri: String) : SettingsAction
data object ClearAllData : SettingsAction
sealed class RCAction { // Toggles
data class SetRotatorState(val value: Boolean) : RCAction() data class ToggleUtc(val value: Boolean) : SettingsAction
data class SetRotatorAddress(val value: String) : RCAction() data class ToggleUpdate(val value: Boolean) : SettingsAction
data class SetRotatorPort(val value: String) : RCAction() data class ToggleSweep(val value: Boolean) : SettingsAction
data class SetRotatorFormat(val value: String) : RCAction() data class ToggleSensor(val value: Boolean) : SettingsAction
data class SetFrequencyState(val value: Boolean) : RCAction() data class ToggleLightTheme(val value: Boolean) : SettingsAction
data class SetFrequencyAddress(val value: String) : RCAction() data class ToggleNightMode(val value: Boolean) : SettingsAction
data class SetFrequencyPort(val value: String) : RCAction()
data class SetFrequencyFormat(val value: String) : RCAction()
data class SetBluetoothRotatorState(val value: Boolean) : RCAction()
data class SetBluetoothRotatorFormat(val value: String) : RCAction()
data class SetBluetoothRotatorName(val value: String) : RCAction()
data class SetBluetoothRotatorAddress(val value: String) : RCAction()
data class SetBluetoothFrequencyState(val value: Boolean) : RCAction()
data class SetBluetoothFrequencyFormat(val value: String) : RCAction()
data class SetBluetoothFrequencyAddress(val value: String) : RCAction()
}
sealed class DataSourcesAction { // Remote control
data class SetUseCustomTle(val value: Boolean) : DataSourcesAction() data class UpdateRC(val settings: RCSettings) : SettingsAction
data class SetUseCustomTransceivers(val value: Boolean) : DataSourcesAction() data class UpdateRadioControl(val settings: RadioControlSettings) : SettingsAction
data class SetTleUrl(val value: String) : DataSourcesAction()
data class SetTransceiversUrl(val value: String) : DataSourcesAction() // Data sources
data class UpdateDataSources(val settings: DataSourcesSettings) : SettingsAction
// System
data class ShowToast(val message: String) : SettingsAction
} }
@@ -18,97 +18,61 @@
package com.rtbishop.look4sat.feature.settings package com.rtbishop.look4sat.feature.settings
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.usecase.IShowToast import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import com.rtbishop.look4sat.core.domain.utility.toTimerString
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.getDefaultPass
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class SettingsViewModel( class SettingsViewModel(
private val databaseRepo: IDatabaseRepo, private val databaseRepo: IDatabaseRepo,
private val satelliteRepo: ISatelliteRepo,
private val settingsRepo: ISettingsRepo, private val settingsRepo: ISettingsRepo,
private val showToast: IShowToast private val showToast: IShowToast
) : ViewModel() { ) : ViewModel() {
private val defaultPosSettings = PositionSettings(false, settingsRepo.stationPosition.value, 0) private val defaultPosSettings = PositionSettings(false, settingsRepo.stationPosition.value, 0)
private val defaultDataSettings = DataSettings(false, 0, 0, 0L) private val defaultDataSettings = DataSettings(false, 0, 0, 0L)
private val defaultDataSourcesSettings = DataSourcesSettings(
useCustomTLE = false,
useCustomTransceivers = false,
tleUrl = settingsRepo.dataSourcesSettings.value.tleUrl,
transceiversUrl = settingsRepo.dataSourcesSettings.value.transceiversUrl
)
private val _uiState = MutableStateFlow( private val _uiState = MutableStateFlow(
SettingsState( SettingsState(
nextTime = "00:00:00",
isNextTimeAos = true,
nextPass = getDefaultPass(),
appVersionName = settingsRepo.appVersionName, appVersionName = settingsRepo.appVersionName,
positionSettings = defaultPosSettings, positionSettings = defaultPosSettings,
dataSettings = defaultDataSettings, dataSettings = defaultDataSettings,
otherSettings = settingsRepo.otherSettings.value, otherSettings = settingsRepo.otherSettings.value,
rcSettings = settingsRepo.rcSettings.value, rcSettings = settingsRepo.rcSettings.value,
dataSourcesSettings = defaultDataSourcesSettings, radioControlSettings = settingsRepo.radioControlSettings.value,
sendAction = ::handleAction, dataSourcesSettings = settingsRepo.dataSourcesSettings.value
sendRCAction = ::handleAction,
sendSystemAction = ::handleAction,
sendDataSourcesAction = ::handleAction
) )
) )
private var processing: Job? = null
val uiState: StateFlow<SettingsState> = _uiState val uiState: StateFlow<SettingsState> = _uiState
init { init {
viewModelScope.launch { viewModelScope.launch {
satelliteRepo.passes.collectLatest { passes -> settingsRepo.stationPosition.collect { geoPos ->
processing?.cancelAndJoin() _uiState.update {
processing = viewModelScope.launch { it.copy(positionSettings = it.positionSettings.copy(isUpdating = false, stationPos = geoPos))
while (isActive) {
val timeNow = System.currentTimeMillis()
val newPasses = satelliteRepo.processPasses(passes, timeNow)
setPassInfo(newPasses, timeNow)
delay(1000)
}
} }
} }
} }
viewModelScope.launch {
settingsRepo.stationPosition.collect { geoPos ->
val newPosSettings = _uiState.value.positionSettings.copy(
isUpdating = false, stationPos = geoPos
)
_uiState.update { it.copy(positionSettings = newPosSettings) }
}
}
viewModelScope.launch { viewModelScope.launch {
settingsRepo.databaseState.collect { state -> settingsRepo.databaseState.collect { state ->
val newDataSettings = _uiState.value.dataSettings.copy( _uiState.update {
isUpdating = false, it.copy(
entriesTotal = state.numberOfSatellites, dataSettings = DataSettings(
radiosTotal = state.numberOfRadios, false,
timestamp = state.updateTimestamp state.numberOfSatellites,
) state.numberOfRadios,
_uiState.update { it.copy(dataSettings = newDataSettings) } state.updateTimestamp
)
)
}
} }
} }
viewModelScope.launch { viewModelScope.launch {
@@ -126,177 +90,109 @@ class SettingsViewModel(
_uiState.update { it.copy(dataSourcesSettings = settings) } _uiState.update { it.copy(dataSourcesSettings = settings) }
} }
} }
viewModelScope.launch {
settingsRepo.radioControlSettings.collect { settings ->
_uiState.update { it.copy(radioControlSettings = settings) }
}
}
} }
private fun handleAction(action: SettingsAction) {
fun onAction(action: SettingsAction) {
when (action) { when (action) {
// Position
SettingsAction.SetGpsPosition -> setGpsPosition() SettingsAction.SetGpsPosition -> setGpsPosition()
is SettingsAction.SetGeoPosition -> setGeoPosition(action.latitude, action.longitude) is SettingsAction.SetGeoPosition -> setGeoPosition(action.latitude, action.longitude)
is SettingsAction.SetQthPosition -> setQthPosition(action.locator) is SettingsAction.SetQthPosition -> setQthPosition(action.locator)
SettingsAction.DismissPosMessages -> dismissPosMessage() SettingsAction.DismissPosMessages -> dismissPosMessage()
SettingsAction.UpdateFromWeb -> updateFromWeb() // Data
is SettingsAction.UpdateTLEFromFile -> updateTLEFromFile(action.uri) SettingsAction.UpdateFromWeb -> runDataUpdate { databaseRepo.updateFromRemote() }
is SettingsAction.UpdateTransceiversFromFile -> updateTransceiversFromFile(action.uri) is SettingsAction.UpdateTLEFromFile -> runDataUpdate { databaseRepo.updateTLEFromFile(action.uri) }
SettingsAction.ClearAllData -> clearAllData() is SettingsAction.UpdateTransceiversFromFile -> runDataUpdate {
is SettingsAction.ToggleUtc -> settingsRepo.setStateOfUtc(action.value) databaseRepo.updateTransceiversFromFile(
is SettingsAction.ToggleUpdate -> settingsRepo.setStateOfAutoUpdate(action.value) action.uri
is SettingsAction.ToggleSweep -> settingsRepo.setStateOfSweep(action.value) )
is SettingsAction.ToggleSensor -> settingsRepo.setStateOfSensors(action.value) }
is SettingsAction.ToggleLightTheme -> settingsRepo.setStateOfLightTheme(action.value) SettingsAction.ClearAllData -> viewModelScope.launch { databaseRepo.clearAllData() }
// Toggles
is SettingsAction.ToggleUtc -> settingsRepo.updateOtherSettings { it.copy(stateOfUtc = action.value) }
is SettingsAction.ToggleUpdate -> settingsRepo.updateOtherSettings { it.copy(stateOfAutoUpdate = action.value) }
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)
is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(action.settings)
// System
is SettingsAction.ShowToast -> showToast(action.message)
} }
} }
private fun handleAction(action: RCAction) { // region Position helpers — consolidated from 3 near-identical functions
when (action) {
is RCAction.SetRotatorState -> settingsRepo.setRotatorState(action.value)
is RCAction.SetRotatorAddress -> settingsRepo.setRotatorAddress(action.value)
is RCAction.SetRotatorPort -> settingsRepo.setRotatorPort(action.value)
is RCAction.SetRotatorFormat -> settingsRepo.setRotatorFormat(action.value)
is RCAction.SetFrequencyState -> settingsRepo.setFrequencyState(action.value)
is RCAction.SetFrequencyAddress -> settingsRepo.setFrequencyAddress(action.value)
is RCAction.SetFrequencyPort -> settingsRepo.setFrequencyPort(action.value)
is RCAction.SetFrequencyFormat -> settingsRepo.setFrequencyFormat(action.value)
is RCAction.SetBluetoothRotatorState -> settingsRepo.setBluetoothRotatorState(action.value)
is RCAction.SetBluetoothRotatorAddress -> settingsRepo.setBluetoothRotatorAddress(action.value)
is RCAction.SetBluetoothRotatorFormat -> settingsRepo.setBluetoothRotatorFormat(action.value)
is RCAction.SetBluetoothRotatorName -> settingsRepo.setBluetoothRotatorName(action.value)
is RCAction.SetBluetoothFrequencyState -> settingsRepo.setBluetoothFrequencyState(action.value)
is RCAction.SetBluetoothFrequencyFormat -> settingsRepo.setBluetoothFrequencyFormat(action.value)
is RCAction.SetBluetoothFrequencyAddress -> settingsRepo.setBluetoothFrequencyAddress(action.value)
}
}
private fun handleAction(action: SystemAction) {
when (action) {
is SystemAction.ShowToast -> showToast(action.message)
}
}
private fun handleAction(action: DataSourcesAction) {
when (action) {
is DataSourcesAction.SetUseCustomTle -> settingsRepo.setUseCustomTle(action.value)
is DataSourcesAction.SetUseCustomTransceivers -> settingsRepo.setUseCustomTransceivers(action.value)
is DataSourcesAction.SetTleUrl -> settingsRepo.setTleUrl(action.value)
is DataSourcesAction.SetTransceiversUrl -> settingsRepo.setTransceiversUrl(action.value)
}
}
private fun setGpsPosition() { private fun setGpsPosition() {
if (settingsRepo.setStationPosition()) { updatePosition(R.string.prefs_loc_gps_error) { settingsRepo.setStationPosition() }
val messageResId = R.string.prefs_loc_success
val newPosSettings = _uiState.value.positionSettings.copy(
isUpdating = true, messageResId = messageResId
)
_uiState.update { it.copy(positionSettings = newPosSettings) }
} else {
val errorResId = R.string.prefs_loc_gps_error
val newPosSettings = _uiState.value.positionSettings.copy(
isUpdating = false, messageResId = errorResId
)
_uiState.update { it.copy(positionSettings = newPosSettings) }
}
} }
private fun setGeoPosition(latitude: Double, longitude: Double) { private fun setGeoPosition(latitude: Double, longitude: Double) {
if (settingsRepo.setStationPosition(latitude, longitude, 0.0)) { updatePosition(R.string.prefs_loc_input_error) { settingsRepo.setStationPosition(latitude, longitude, 0.0) }
val messageResId = R.string.prefs_loc_success
val newPosSettings = _uiState.value.positionSettings.copy(
isUpdating = false, messageResId = messageResId
)
_uiState.update { it.copy(positionSettings = newPosSettings) }
} else {
val errorResId = R.string.prefs_loc_input_error
val newPosSettings = _uiState.value.positionSettings.copy(
isUpdating = false, messageResId = errorResId
)
_uiState.update { it.copy(positionSettings = newPosSettings) }
}
} }
private fun setQthPosition(locator: String) { private fun setQthPosition(locator: String) {
if (settingsRepo.setStationPosition(locator)) { updatePosition(R.string.prefs_loc_qth_error) { settingsRepo.setStationPosition(locator) }
val messageResId = R.string.prefs_loc_success }
val newPosSettings = _uiState.value.positionSettings.copy(
isUpdating = false, messageResId = messageResId /**
) * Common position update logic. Calls [action], emits success or error message.
_uiState.update { it.copy(positionSettings = newPosSettings) } */
} else { private inline fun updatePosition(errorResId: Int, action: () -> Boolean) {
val errorResId = R.string.prefs_loc_qth_error val success = action()
val newPosSettings = _uiState.value.positionSettings.copy( val resId = if (success) R.string.prefs_loc_success else errorResId
isUpdating = false, messageResId = errorResId val isUpdating = success // GPS update is async; geo/qth are immediate
) _uiState.update {
_uiState.update { it.copy(positionSettings = newPosSettings) } it.copy(positionSettings = it.positionSettings.copy(isUpdating = isUpdating, messageResId = resId))
} }
} }
private fun dismissPosMessage() { private fun dismissPosMessage() {
val newPosSettings = _uiState.value.positionSettings.copy( _uiState.update {
isUpdating = false, messageResId = 0 it.copy(positionSettings = it.positionSettings.copy(isUpdating = false, messageResId = 0))
) }
_uiState.update { it.copy(positionSettings = newPosSettings) }
} }
private fun updateFromWeb() = viewModelScope.launch { // endregion
// region Data update helpers — consolidated from 3 near-identical functions
/**
* Common data update logic. Sets isUpdating=true, runs the suspending [block],
* and resets isUpdating=false on failure.
*/
private fun runDataUpdate(block: suspend () -> Unit) = viewModelScope.launch {
try { try {
val newDataSettings = _uiState.value.dataSettings.copy(isUpdating = true) _uiState.update {
_uiState.update { it.copy(dataSettings = newDataSettings) } it.copy(dataSettings = it.dataSettings.copy(isUpdating = true))
databaseRepo.updateFromRemote() }
block()
} catch (exception: Exception) { } catch (exception: Exception) {
val newDataSettings = _uiState.value.dataSettings.copy(isUpdating = false) _uiState.update {
_uiState.update { it.copy(dataSettings = newDataSettings) } it.copy(dataSettings = it.dataSettings.copy(isUpdating = false))
}
println(exception) println(exception)
} }
} }
private fun updateTLEFromFile(uri: String) = viewModelScope.launch { // endregion
try {
val newDataSettings = _uiState.value.dataSettings.copy(isUpdating = true)
_uiState.update { it.copy(dataSettings = newDataSettings) }
databaseRepo.updateTLEFromFile(uri)
} catch (exception: Exception) {
val newDataSettings = _uiState.value.dataSettings.copy(isUpdating = false)
_uiState.update { it.copy(dataSettings = newDataSettings) }
println(exception)
}
}
private fun updateTransceiversFromFile(uri: String) = viewModelScope.launch {
try {
val newDataSettings = _uiState.value.dataSettings.copy(isUpdating = true)
_uiState.update { it.copy(dataSettings = newDataSettings) }
databaseRepo.updateTransceiversFromFile(uri)
} catch (exception: Exception) {
val newDataSettings = _uiState.value.dataSettings.copy(isUpdating = false)
_uiState.update { it.copy(dataSettings = newDataSettings) }
println(exception)
}
}
private fun setPassInfo(passes: List<OrbitalPass>, timeNow: Long) {
if (passes.isEmpty()) return
try {
val nextPass = passes.first { it.aosTime.minus(timeNow) > 0 }
val time = nextPass.aosTime.minus(timeNow).toTimerString()
_uiState.update { it.copy(nextPass = nextPass, nextTime = time, isNextTimeAos = true) }
} catch (_: NoSuchElementException) {
val lastPass = passes.last()
val time = lastPass.losTime.minus(timeNow).toTimerString()
_uiState.update { it.copy(nextPass = lastPass, nextTime = time, isNextTimeAos = false) }
}
}
private fun clearAllData() = viewModelScope.launch { databaseRepo.clearAllData() }
companion object { companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory { fun factory(container: IMainContainer) = viewModelFactory {
val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY
initializer { initializer {
val container = (this[applicationKey] as IContainerProvider).getMainContainer()
SettingsViewModel( SettingsViewModel(
container.databaseRepo, databaseRepo = container.databaseRepo,
container.satelliteRepo, settingsRepo = container.settingsRepo,
container.settingsRepo, showToast = container.provideShowToast()
container.provideShowToast()
) )
} }
} }
+30 -31
View File
@@ -1,9 +1,9 @@
[versions] [versions]
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionCode = "411" appVersionCode = "432"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.1.1"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionName = "4.3.2"
#noinspection GradleDependency,UnusedVersionCatalogEntry
compileSdk = "36" compileSdk = "36"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
minSdk = "24" minSdk = "24"
@@ -12,21 +12,23 @@ jdkVersion = "17"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
packageName = "com.rtbishop.look4sat" packageName = "com.rtbishop.look4sat"
android-gradle-plugin = "9.0.1" android-gradle-plugin = "9.2.1"
google-ksp = "2.3.6"
kotlin = "2.3.20"
androidx-core-ktx = "1.18.0" androidx-core-ktx = "1.18.0"
androidx-core-splashscreen = "1.2.0" androidx-core-splashscreen = "1.2.0"
androidx-room = "2.8.4" androidx-room = "2.8.4"
compose-bom = "2026.03.00" compose-bom = "2026.05.01"
compose-activity = "1.13.0" compose-activity = "1.13.0"
compose-lifecycle = "2.10.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-okhttp = "5.3.2"
other-osmdroid = "6.1.20" other-osmdroid = "6.1.20"
@@ -50,32 +52,32 @@ androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } 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 = { 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-runtime = { group = "androidx.compose.runtime", name = "runtime" }
compose-tooling = { group = "androidx.compose.ui", name = "ui-tooling-preview" } compose-tooling = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
compose-activity = { module = "androidx.activity:activity-compose", version.ref = "compose-activity" } compose-activity = { module = "androidx.activity:activity-compose", version.ref = "compose-activity" }
compose-lifecycle = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "compose-lifecycle" } #noinspection UnusedVersionCatalogEntry
compose-navigation = { module = "androidx.navigation:navigation-compose", version.ref = "compose-navigation" } compose-navigation3 = { module = "androidx.navigation3:navigation3-ui", version.ref = "compose-navigation3" }
compose-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "compose-lifecycle" } 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-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
compose-debug-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } 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 #noinspection UnusedVersionCatalogEntry
other-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "other-okhttp" } other-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "other-okhttp" }
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
other-osmdroid = { module = "org.osmdroid:osmdroid-android", version.ref = "other-osmdroid" } 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" } test-junit4 = { module = "junit:junit", version.ref = "test-junit4" }
androidTest-junit = { module = "androidx.test.ext:junit", version.ref = "androidTest-junit" } androidTest-junit = { module = "androidx.test.ext:junit", version.ref = "androidTest-junit" }
@@ -87,20 +89,17 @@ android-library = { id = "com.android.library", version.ref = "android-gradle-pl
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
google-ksp = { id = "com.google.devtools.ksp", version.ref = "google-ksp" } google-ksp = { id = "com.google.devtools.ksp", version.ref = "google-ksp" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } 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 # plugins defined by this project
convention-androidAppPlugin = { id = "com.rtbishop.look4sat.convention.androidAppPlugin" } convention-applicationPlugin = { id = "com.rtbishop.look4sat.convention.applicationPlugin" }
convention-androidLibraryPlugin = { id = "com.rtbishop.look4sat.convention.androidLibraryPlugin" } convention-coreDataPlugin = { id = "com.rtbishop.look4sat.convention.coreDataPlugin" }
convention-composeFeaturePlugin = { id = "com.rtbishop.look4sat.convention.composeFeaturePlugin" } convention-coreDomainPlugin = { id = "com.rtbishop.look4sat.convention.coreDomainPlugin" }
convention-composeLibraryPlugin = { id = "com.rtbishop.look4sat.convention.composeLibraryPlugin" } convention-corePresentationPlugin = { id = "com.rtbishop.look4sat.convention.corePresentationPlugin" }
convention-kotlinLibraryPlugin = { id = "com.rtbishop.look4sat.convention.kotlinLibraryPlugin" } convention-featurePlugin = { id = "com.rtbishop.look4sat.convention.featurePlugin" }
[bundles] [bundles]
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
composeAll = [ composeAll = ["compose-runtime", "compose-tooling", "compose-activity", "compose-material3", "compose-viewmodel"]
"compose-animation", "compose-runtime", "compose-tooling",
"compose-activity", "compose-lifecycle", "compose-material3",
"compose-material3-navigation", "compose-navigation", "compose-viewmodel"
]
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
composeDebug = ["compose-debug-manifest", "compose-debug-tooling"] composeDebug = ["compose-debug-manifest", "compose-debug-tooling"]
#noinspection UnusedVersionCatalogEntry #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 distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists 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 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
+1
View File
@@ -24,6 +24,7 @@ include(
":feature:map", ":feature:map",
":feature:passes", ":feature:passes",
":feature:radar", ":feature:radar",
":feature:radiocontrol",
":feature:satellites", ":feature:satellites",
":feature:settings" ":feature:settings"
) )