Compare commits

..
22 Commits
Author SHA1 Message Date
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
83 changed files with 5012 additions and 2792 deletions

No files matched your search

+1 -1
View File
@@ -1,3 +1,3 @@
plugins { plugins {
alias(libs.plugins.convention.androidAppPlugin) alias(libs.plugins.convention.applicationPlugin)
} }
@@ -17,9 +17,28 @@
*/ */
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.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -27,8 +46,18 @@ 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.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
@@ -36,9 +65,11 @@ import androidx.navigation.compose.rememberNavController
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.core.domain.repository.IContainerProvider
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.radiocontrol.radioControlDestination
import com.rtbishop.look4sat.feature.satellites.satellitesDestination import com.rtbishop.look4sat.feature.satellites.satellitesDestination
import com.rtbishop.look4sat.feature.settings.settingsDestination import com.rtbishop.look4sat.feature.settings.settingsDestination
@@ -47,6 +78,12 @@ fun MainScreen(navController: NavHostController = rememberNavController()) {
val items = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings) val items = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings)
val currentDestination = navController.currentBackStackEntryAsState().value?.destination?.route val currentDestination = navController.currentBackStackEntryAsState().value?.destination?.route
val startDestination = Screen.Passes.route val startDestination = Screen.Passes.route
// Observe radio tracking state for the status bar
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
NavigationSuiteScaffold( NavigationSuiteScaffold(
navigationSuiteItems = { navigationSuiteItems = {
items.forEach { items.forEach {
@@ -71,20 +108,78 @@ fun MainScreen(navController: NavHostController = rememberNavController()) {
else -> NavigationSuiteType.ShortNavigationBarMedium else -> NavigationSuiteType.ShortNavigationBarMedium
} }
) { ) {
Column {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = startDestination, startDestination = startDestination,
enterTransition = { fadeIn(animationSpec = tween(350)) }, enterTransition = { fadeIn(animationSpec = tween(350)) },
exitTransition = { fadeOut(animationSpec = tween(350)) } exitTransition = { fadeOut(animationSpec = tween(350)) },
modifier = Modifier.weight(1f)
) { ) {
satellitesDestination { navController.navigateUp() } satellitesDestination { navController.navigateUp() }
passesDestination { catNum: Int, aosTime: Long -> passesDestination { catNum: Int, aosTime: Long ->
val radarRoute = "${Screen.Radar.route}?catNum=${catNum}&aosTime=${aosTime}" val radarRoute = "${Screen.Radar.route}?catNum=${catNum}&aosTime=${aosTime}"
navController.navigate(radarRoute) navController.navigate(radarRoute)
} }
radarDestination { navController.navigateUp() } radarDestination(
navigateUp = { navController.navigateUp() },
navigateToRadioControl = { catNum, aosTime ->
val route = "${Screen.RadioControl.route}?catNum=$catNum&aosTime=$aosTime"
navController.navigate(route)
}
)
radioControlDestination { navController.navigateUp() }
mapDestination() mapDestination()
settingsDestination() settingsDestination()
} }
// Radio tracking status banner (above bottom navigation)
if (trackingState.isActive) {
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
val alpha by infiniteTransition.animateFloat(
initialValue = 1f, targetValue = 0.4f,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
), label = "pulseAlpha"
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.primaryContainer)
.clickable {
val pass = trackingState.currentPass
if (pass != null) {
val route = "${Screen.RadioControl.route}?catNum=${pass.catNum}&aosTime=${pass.aosTime}"
navController.navigate(route)
}
}
.padding(horizontal = 12.dp, vertical = 6.dp)
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(Color(0xFF4CAF50).copy(alpha = alpha))
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Tracking: ${trackingState.currentPass?.name ?: ""}",
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.weight(1f)
)
val txOk = if (trackingState.txConnected) "TX" else ""
val rxOk = if (trackingState.rxConnected) "RX" else ""
Text(
text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
} // end Column
} }
} }
+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"
} }
} }
} }
@@ -1,46 +0,0 @@
/*
* 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.convention
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
@Suppress("Unused")
internal class AndroidAppPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
setupAndroidApplication()
setupComposeFeature()
setupKotlinToolchain()
setupAndroidTestDependencies()
setupTestDependencies()
dependencies {
IMPLEMENTATION(project(":core:data"))
IMPLEMENTATION(project(":core:domain"))
IMPLEMENTATION(project(":core:presentation"))
IMPLEMENTATION(project(":feature:map"))
IMPLEMENTATION(project(":feature:passes"))
IMPLEMENTATION(project(":feature:radar"))
IMPLEMENTATION(project(":feature:satellites"))
IMPLEMENTATION(project(":feature:settings"))
IMPLEMENTATION(libs.androidx.core.splashscreen)
}
}
}
}
@@ -0,0 +1,44 @@
/*
* 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.convention
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
@Suppress("Unused")
internal class ApplicationPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
setupAndroidApp()
setupCompose()
setupKotlin()
dependencies {
implementation(project(":core:data"))
implementation(project(":core:domain"))
implementation(project(":core:presentation"))
implementation(project(":feature:map"))
implementation(project(":feature:passes"))
implementation(project(":feature:radar"))
implementation(project(":feature:radiocontrol"))
implementation(project(":feature:satellites"))
implementation(project(":feature:settings"))
implementation(libs.androidx.core.splashscreen)
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.other.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,13 @@ 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) { setupKotlin()
alias(libs.plugins.kotlin.jvm) dependencies {
} implementation(libs.other.coroutines)
setupKotlinToolchain() implementation(libs.other.json)
setupTestDependencies()
dependencies {
IMPLEMENTATION(libs.other.coroutines)
IMPLEMENTATION(libs.other.json)
}
} }
} }
} }
@@ -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 CorePresentationPlugin : 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(libs.androidx.core.splashscreen)
dependencies {
IMPLEMENTATION(project(":core:domain"))
IMPLEMENTATION(project(":core:presentation"))
}
} }
} }
} }
@@ -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 ComposeLibraryPlugin : 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(libs.androidx.core.splashscreen)
}
} }
} }
} }
@@ -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"
@@ -76,43 +80,32 @@ internal fun Project.setupAndroidApplication() {
} }
} }
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 -1
View File
@@ -1,5 +1,5 @@
plugins { plugins {
alias(libs.plugins.convention.androidLibraryPlugin) alias(libs.plugins.convention.coreDataPlugin)
} }
android { android {
@@ -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,57 @@ class DatabaseRepo(
private val settingsRepo: ISettingsRepo private val settingsRepo: ISettingsRepo
) : IDatabaseRepo { ) : IDatabaseRepo {
private companion object {
val tleTypes = setOf("Amsat", "R4UAB", "Other")
val zippedTleTypes = setOf("McCants", "Classified")
}
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(stream)
localSource.insertEntries(entries)
settingsRepo.setSatelliteTypeIds("Other", 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)
radios?.let { ?.let { dataParser.parseJSONStream(it) }
if(it.isNotEmpty()) { ?.takeIf { it.isNotEmpty() }
?.let {
localSource.deleteRadios() localSource.deleteRadios()
localSource.insertRadios(it) 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("Other", dataSourcesSettings.tleUrl)
this["Other"] = settingsRepo.dataSourcesSettings.value.tleUrl }.filterValues { it.isNotEmpty() }
}
}
val jobsMap = tleUrls
.filter { (_, url) -> url.isNotEmpty() }
.mapValues { (_, url) -> async { remoteSource.getNetworkStream(url) } }
val radioUrls = buildList { val radioUrls = buildList {
add(Sources.RADIO_DATA_URL) add(Sources.RADIO_DATA_URL)
if (settingsRepo.dataSourcesSettings.value.useCustomTransceivers) { if (dataSourcesSettings.useCustomTransceivers) add(dataSourcesSettings.transceiversUrl)
add(settingsRepo.dataSourcesSettings.value.transceiversUrl)
}
} }
val jobRadios = radioUrls.associateWith { url -> async { remoteSource.getNetworkStream(url) } }
// 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" -> { // launch all network requests concurrently
// unzip and parse tle stream val tleJobs = tleUrls.map { (type, url) -> async { type to remoteSource.getNetworkStream(url) } }
val unzipped = ZipInputStream(stream).apply { nextEntry } val radioJobs = radioUrls.map { url -> async { remoteSource.getNetworkStream(url) } }
val satellites = dataParser.parseTLEStream(unzipped)
val catnums = satellites.map { it.catnum }
settingsRepo.setSatelliteTypeIds(type, catnums)
importedEntries.addAll(satellites)
}
else -> { // parse satellite data
// parse csv stream val importedEntries = tleJobs.awaitAll().flatMap { (type, stream) ->
val satellites = dataParser.parseCSVStream(stream) stream?.let { parseSatelliteStream(type, it) }.orEmpty().also { satellites ->
val catnums = satellites.map { it.catnum } settingsRepo.setSatelliteTypeIds(type, satellites.map { it.catnum })
settingsRepo.setSatelliteTypeIds(type, catnums)
importedEntries.addAll(satellites)
}
}
} }
} }
jobRadios.values.forEach { job ->
job.await()?.let { // parse radio data
importedRadios.addAll(dataParser.parseJSONStream(it)) val importedRadios = radioJobs.awaitAll().filterNotNull().flatMap { dataParser.parseJSONStream(it) }
}
}
// insert
localSource.insertEntries(importedEntries) localSource.insertEntries(importedEntries)
localSource.insertRadios(importedRadios) localSource.insertRadios(importedRadios)
setUpdateSuccessful(System.currentTimeMillis()) setUpdateSuccessful(System.currentTimeMillis())
@@ -122,9 +102,15 @@ class DatabaseRepo(
setUpdateSuccessful(0L) setUpdateSuccessful(0L)
} }
private suspend fun setUpdateSuccessful(timestamp: Long) = withContext(dispatcher) { private suspend fun parseSatelliteStream(type: String, stream: InputStream): List<OrbitalData> = when (type) {
val numberOfRadios = localSource.getRadiosTotal() in tleTypes -> dataParser.parseTLEStream(stream)
val numberOfSatellites = localSource.getEntriesTotal() in zippedTleTypes -> dataParser.parseTLEStream(ZipInputStream(stream).apply { nextEntry })
settingsRepo.updateDatabaseState(DatabaseState(numberOfRadios, numberOfSatellites, timestamp)) else -> dataParser.parseCSVStream(stream)
}
private suspend fun setUpdateSuccessful(timestamp: Long) {
settingsRepo.updateDatabaseState(
DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp)
)
} }
} }
@@ -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,6 +46,9 @@ 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
@@ -50,9 +57,8 @@ class SatelliteRepo(
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 +68,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 +87,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 +160,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 +175,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"
@@ -120,6 +122,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 +130,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 +250,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 +305,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 +320,20 @@ 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(keyShouldSeeWarning, new.shouldSeeWarning)
override fun setStateOfSweep(value: Boolean) { putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
preferences.edit { putBoolean(keyStateOfSweep, value) } }
_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(
@@ -397,24 +351,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 +368,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)
} }
@@ -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>
@@ -63,3 +64,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"
)
}
}
@@ -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(
@@ -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)
@@ -368,7 +427,7 @@ abstract class OrbitalObject(val data: OrbitalData) {
* The function Delta_ET has been added to allow calculations on the * The function Delta_ET has been added to allow calculations on the
* position of the sun. It provides the difference between UT (approximately * 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 * 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 * on the least squares fit of data from 1950 to 1991 and will need to be
* updated periodically. * updated periodically.
* *
* Values determined using data from 1950-1991 in the 1990 Astronomical * Values determined using data from 1950-1991 in the 1990 Astronomical
@@ -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,32 @@ 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 sinRads = sin(rads)
val lon = (longitude + atan2( val cosRads = cos(rads)
sin(rads) * sin(beta) * cos(latitude), cos(beta) - sin(latitude) * sin(lat) val lat = asin(sinLat * cosBeta + cosLat * sinBeta * cosRads)
)) val lon = longitude + atan2(sinRads * sinBeta * cosLat, cosBeta - sinLat * 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,30 @@ 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>
/** 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
} }
@@ -29,126 +29,94 @@ import kotlin.math.pow
class DataParser(private val dispatcher: CoroutineDispatcher) { class DataParser(private val dispatcher: CoroutineDispatcher) {
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 jsonArray = JSONArray(stream.bufferedReader().readText()) val jsonArray = JSONArray(stream.bufferedReader().readText())
for (index in 0 until jsonArray.length()) { (0 until jsonArray.length()).mapNotNull { parseJSON(jsonArray.getJSONObject(it)) }
val jsonObject = jsonArray.getJSONObject(index) }.getOrDefault(emptyList())
parseJSON(jsonObject)?.let { parsedItems.add(it) }
}
return@withContext parsedItems
} catch (_: Exception) {
return@withContext parsedItems
}
} }
fun isLeapYear(year: Int): Boolean { fun isLeapYear(year: Int): Boolean = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
return ((year % 4 == 0) && (year % 100 != 0)) || (year % 400 == 0)
}
private fun parseCSV(values: List<String>): OrbitalData? = try { private fun parseCSV(values: List<String>): OrbitalData? = runCatching {
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 { private fun parseJSON(json: JSONObject): SatRadio? = runCatching {
val uuid = json.getString("uuid") SatRadio(
val info = json.getString("description") uuid = json.getString("uuid"),
val alive = json.getBoolean("alive") info = json.getString("description"),
val dlinkLow = if (json.isNull("downlink_low")) null else json.getLong("downlink_low") isAlive = json.getBoolean("alive"),
val dlinkHigh = if (json.isNull("downlink_high")) null else json.getLong("downlink_high") downlinkLow = json.optLongOrNull("downlink_low"),
val dlinkMode = if (json.isNull("mode")) null else json.getString("mode") downlinkHigh = json.optLongOrNull("downlink_high"),
val ulinkLow = if (json.isNull("uplink_low")) null else json.getLong("uplink_low") downlinkMode = json.optStringOrNull("mode"),
val ulinkHigh = if (json.isNull("uplink_high")) null else json.getLong("uplink_high") uplinkLow = json.optLongOrNull("uplink_low"),
val ulinkMode = if (json.isNull("uplink_mode")) null else json.getString("uplink_mode") uplinkHigh = json.optLongOrNull("uplink_high"),
val inverted = json.getBoolean("invert") uplinkMode = json.optStringOrNull("uplink_mode"),
val catnum = if (json.isNull("norad_cat_id")) null else json.getInt("norad_cat_id") isInverted = json.getBoolean("invert"),
SatRadio(uuid, info, alive, dlinkLow, dlinkHigh, dlinkMode, ulinkLow, ulinkHigh, ulinkMode, inverted, catnum) catnum = json.optIntOrNull("norad_cat_id")
} catch (exception: Exception) { )
println("JSON parsing exception: $exception") }.onFailure { println("JSON parsing exception: $it") }.getOrNull()
null
} private fun JSONObject.optStringOrNull(key: String): String? = if (isNull(key)) null else getString(key)
private fun JSONObject.optLongOrNull(key: String): Long? = if (isNull(key)) null else getLong(key)
private fun JSONObject.optIntOrNull(key: String): Int? = if (isNull(key)) null else getInt(key)
private fun getDayOfYear(year: Int, month: Int, dayOfMonth: Int): Int { private fun getDayOfYear(year: Int, month: Int, dayOfMonth: Int): Int {
if (month == 1) return dayOfMonth val daysInMonth = intArrayOf(31, if (isLeapYear(year)) 29 else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
val daysArray = arrayOf(31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) return daysInMonth.take(month - 1).sum() + dayOfMonth
var dayOfYear = 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)
} }
@@ -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
}
}
}
@@ -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.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
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,30 @@ 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( Icon(
painter = painterResource(id = R.drawable.ic_altitude), painter = painterResource(R.drawable.ic_altitude),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Text( Text(text = "${pass.altitude} km", fontSize = 15.sp)
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 +200,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 +260,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 +317,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 +355,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 +374,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() }
} }
} }
@@ -6,4 +6,5 @@ sealed class Screen(val route: String, val iconResId: Int, val titleResId: Int)
data object Radar : Screen("radar", R.drawable.ic_radar, R.string.nav_radar) data object Radar : Screen("radar", R.drawable.ic_radar, R.string.nav_radar)
data object Map : Screen("map", R.drawable.ic_map, R.string.nav_map) data object Map : Screen("map", R.drawable.ic_map, R.string.nav_map)
data object Settings : Screen("settings", R.drawable.ic_settings, R.string.nav_prefs) data object Settings : Screen("settings", R.drawable.ic_settings, R.string.nav_prefs)
data object RadioControl : Screen("radiocontrol", R.drawable.ic_radios, R.string.nav_radiocontrol)
} }
@@ -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">İlerideki saatler</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">Tutulumda</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">Tutulumda</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ür etmek istiyorum</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>
@@ -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,10 @@
\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 * Added Turkish translation, by Emre Can Akdaş (TA3ECR)
as well as enabled the compass data usage for landscape orientation \n* Added DeepSpace passes filter option to the dialog
\n* Fixed the radar blip disappearing while eclipsed
\n* Fixed (hopefully) the refresh indicator being stuck
</string> </string>
<!-- Radar screen --> <!-- Radar screen -->
@@ -144,15 +147,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>
@@ -1,2 +1,4 @@
* Fixed track path creation issue for split screens (#199) by londre7 * Added Turkish translation, by Emre Can Akdaş (TA3ECR)
* Enabled using compass data for landscape orientation (#202) by londre7 * Added DeepSpace passes filter option to the dialog
* Fixed the radar blip disappearing while eclipsed
* Fixed (hopefully) the refresh indicator being stuck
@@ -1,2 +1,4 @@
* Fixed track path creation issue for split screens (#199) by londre7 * Added Turkish translation, by Emre Can Akdaş (TA3ECR)
* Enabled using compass data for landscape orientation (#202) by londre7 * Added DeepSpace passes filter option to the dialog
* Fixed the radar blip disappearing while eclipsed
* Fixed (hopefully) the refresh indicator being stuck
+1 -1
View File
@@ -1,5 +1,5 @@
plugins { plugins {
alias(libs.plugins.convention.composeFeaturePlugin) alias(libs.plugins.convention.featurePlugin)
} }
android { android {
@@ -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
@@ -80,6 +81,13 @@ 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_COUNT = 4
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 +108,110 @@ 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)
fun NavGraphBuilder.mapDestination() { fun NavGraphBuilder.mapDestination() {
composable(Screen.Map.route) { composable(Screen.Map.route) {
val viewModel = viewModel(MapViewModel::class.java, factory = MapViewModel.Factory) val viewModel = viewModel(MapViewModel::class.java, factory = MapViewModel.Factory)
val uiState = viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val mapView = rememberMapViewWithLifecycle() val mapView = rememberMapViewWithLifecycle()
MapScreen(uiState, mapView) MapScreen(uiState, viewModel::onAction, mapView)
} }
} }
@Composable @Composable
private fun MapScreen(uiState: State<MapState>, mapView: MapView) { private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView: MapView) {
val onItemClick = { item: OrbitalObject -> uiState.value.sendAction(MapAction.SelectItem(item)) }
val selectPrev = { uiState.value.sendAction(MapAction.SelectPrev) }
val selectNext = { uiState.value.sendAction(MapAction.SelectNext) }
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)) } }
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 +232,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 +245,175 @@ 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) // Lazily initialize the reusable point list and polyline
setPoints(footprintPoints) var pts = footprintPoints
if (pts == null || pts.size != rangeCircle.size) {
pts = ArrayList(rangeCircle.size)
for (gp in rangeCircle) pts.add(GeoPoint(gp.latitude, gp.longitude))
footprintPoints = pts
} else {
// Update coordinates in-place — zero allocations
for (i in rangeCircle.indices) {
pts[i].latitude = rangeCircle[i].latitude
pts[i].longitude = rangeCircle[i].longitude
}
} }
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)
} }
} }
// 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 +434,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 +443,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,21 @@ 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
) )
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(
@@ -24,6 +24,8 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
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.OrbitalPass
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
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
@@ -34,30 +36,29 @@ 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(private val satelliteRepo: ISatelliteRepo, private val settingsRepo: ISettingsRepo) :
ViewModel() { 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 +69,15 @@ class MapViewModel(private val satelliteRepo: ISatelliteRepo, settingsRepo: ISet
val uiState: StateFlow<MapState> = _uiState val uiState: StateFlow<MapState> = _uiState
init { init {
viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings ->
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
}
}
selectDefaultSatellite(-1) selectDefaultSatellite(-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)
@@ -120,15 +126,133 @@ class MapViewModel(private val satelliteRepo: ISatelliteRepo, settingsRepo: ISet
getSatTrack(orbitalObject, stationPos, dateNow) getSatTrack(orbitalObject, stationPos, dateNow)
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)
getSatData(orbitalObject, stationPos, dateNow)
delay(updateFreq) 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 and info data from the already-computed selected position
val satPos = selectedSatPos ?: satelliteRepo.getPosition(selected, pos, date.time)
val footprint = satPos
val mapData = buildMapData(selected, satPos, date)
// 3. Single atomic state update — one recomposition per cycle
_uiState.update {
it.copy(
positions = positionsMap,
footprint = footprint,
mapData = mapData.first,
orbitalPass = mapData.second
)
}
}
/**
* 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,71 +282,10 @@ 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 {
/** Number of parallel chunks for satellite position computation */
private const val PARALLEL_CHUNKS = 4
val Factory: ViewModelProvider.Factory = viewModelFactory { val Factory: ViewModelProvider.Factory = viewModelFactory {
val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY
initializer { initializer {
+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)
}
}
@@ -35,15 +35,16 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState 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.res.painterResource import androidx.compose.ui.res.painterResource
@@ -76,9 +77,7 @@ 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)
private val sdfTime = SimpleDateFormat("HH:mm:ss", Locale.ENGLISH)
fun NavGraphBuilder.passesDestination(navigateToRadar: (Int, Long) -> Unit) { fun NavGraphBuilder.passesDestination(navigateToRadar: (Int, Long) -> Unit) {
composable(Screen.Passes.route) { composable(Screen.Passes.route) {
@@ -87,27 +86,32 @@ fun NavGraphBuilder.passesDestination(navigateToRadar: (Int, Long) -> Unit) {
factory = PassesViewModel.Factory factory = PassesViewModel.Factory
) )
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
PassesScreen(uiState, navigateToRadar) PassesScreen(uiState, viewModel::onAction, 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,7 +119,7 @@ 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() val gridState = rememberLazyGridState()
@@ -124,25 +128,26 @@ private fun PassesScreen(uiState: PassesState, navigateToRadar: (Int, Long) -> U
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,
navigateToRadar = navigateToRadar, navigateToRadar = navigateToRadar,
refreshPasses = refreshList, refreshPasses = { onAction(PassesAction.RefreshPasses) },
gridState = gridState gridState = gridState
) )
} }
@@ -152,6 +157,7 @@ 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>,
navigateToRadar: (Int, Long) -> Unit, navigateToRadar: (Int, Long) -> Unit,
refreshPasses: () -> Unit, refreshPasses: () -> Unit,
@@ -183,11 +189,12 @@ private fun PassesList(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
items(items = passes, key = { item -> item.catNum + item.aosTime }) { pass -> items(items = passes, key = { item -> item.catNum + item.aosTime }) { pass ->
NearEarthPass( PassItem(
pass = pass, pass = pass,
navigateToRadar = navigateToRadar, navigateToRadar = navigateToRadar,
modifier = Modifier.animateItem(), modifier = Modifier.animateItem(),
isVerticalLayout = isVerticalLayout isVerticalLayout = isVerticalLayout,
isUtc = isUtc
) )
} }
} }
@@ -202,7 +209,7 @@ 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 +218,143 @@ 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 sdfDate = remember(isUtc) {
Column( SimpleDateFormat("EEE dd MMM", Locale.ENGLISH).also { it.timeZone = timeZone }
verticalArrangement = Arrangement.spacedBy(1.dp), }
modifier = Modifier val sdfTime = remember(isUtc) {
.background(color = MaterialTheme.colorScheme.surface) SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone }
.padding(horizontal = horizontalPadding, vertical = 4.dp) }
val aosDateStr = remember(pass.aosTime, isUtc) { sdfDate.format(Date(pass.aosTime)) }
val aosTimeStr = remember(pass.aosTime, isUtc) { sdfTime.format(Date(pass.aosTime)) }
val losTimeStr = remember(pass.losTime, isUtc) { sdfTime.format(Date(pass.losTime)) }
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) { Row(
Text( modifier = Modifier.weight(1f),
text = "$passSatId - ", horizontalArrangement = Arrangement.Start,
color = MaterialTheme.colorScheme.primary verticalAlignment = Alignment.CenterVertically
) ) {
Text( if (pass.isDeepSpace) {
text = pass.name, Text(
modifier = Modifier text = stringResource(R.string.pass_deep_space),
.weight(1f) fontSize = 15.sp,
.padding(end = 6.dp) color = MaterialTheme.colorScheme.onSurface
.infiniteMarquee(), )
fontWeight = FontWeight.Medium, } else {
maxLines = 1, Text(text = aosDateStr, fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface)
overflow = TextOverflow.Ellipsis }
) }
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon( Icon(
painter = painterResource(id = R.drawable.ic_elevation), painter = painterResource(id = R.drawable.ic_altitude),
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp),
modifier = Modifier.size(16.dp) tint = MaterialTheme.colorScheme.onSurface
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Text( Text(text = "${pass.altitude} km", fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface)
text = "${pass.maxElevation}°",
color = MaterialTheme.colorScheme.primary
)
} }
Row( Row(
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth() verticalAlignment = Alignment.CenterVertically
) { ) {
Row(
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 = "${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
)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
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,26 @@ 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 shouldSeeWhatsNew: Boolean = false
) )
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
} }
@@ -27,10 +27,9 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
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
@@ -44,98 +43,131 @@ class PassesViewModel(
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) { // Local tick loop — computes pass progress and countdown timer every second
val timeNow = System.currentTimeMillis() viewModelScope.launch {
val newPasses = satelliteRepo.processPasses(passes, timeNow) while (isActive) {
setPassInfo(newPasses, timeNow) val timeNow = System.currentTimeMillis()
_uiState.update { it.copy(isRefreshing = false, itemsList = newPasses) } val showDeepSpace = _uiState.value.showDeepSpace
delay(1000) val allPasses = satelliteRepo.passes.value
} val filtered = if (showDeepSpace) allPasses else allPasses.filter { !it.isDeepSpace }
val processed = computePassProgress(filtered, timeNow)
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
_uiState.update {
it.copy(
itemsList = processed,
nextPass = nextPass,
nextTime = nextTime,
isNextTimeAos = isAos
)
} }
delay(1000)
} }
} }
viewModelScope.launch { viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings -> settingsRepo.otherSettings.collectLatest { settings ->
_uiState.update { it.copy(shouldSeeWhatsNew = settings.shouldSeeWhatsNew) } _uiState.update {
it.copy(
isUtc = settings.stateOfUtc,
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) }
} }
} }
private fun applyFilter(hoursAhead: Int, minElevation: Double, modes: List<String>) = viewModelScope.launch { /** Computes live progress for each pass, filtering out expired ones. */
_uiState.update { it.copy(isRefreshing = true) } private fun computePassProgress(passList: List<OrbitalPass>, time: Long): List<OrbitalPass> {
processing?.cancelAndJoin() val result = ArrayList<OrbitalPass>(passList.size)
settingsRepo.setPassesSettings(PassesSettings(hoursAhead, minElevation, modes)) for (pass in passList) {
_uiState.update { it.copy(hours = hoursAhead, elevation = minElevation, modes = modes) } 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 { val Factory: ViewModelProvider.Factory = viewModelFactory {
val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY
+1 -1
View File
@@ -1,5 +1,5 @@
plugins { plugins {
alias(libs.plugins.convention.composeFeaturePlugin) alias(libs.plugins.convention.featurePlugin)
} }
android { android {
@@ -37,14 +37,14 @@ 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.res.painterResource import androidx.compose.ui.res.painterResource
@@ -62,6 +62,7 @@ import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navArgument 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.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
@@ -76,7 +77,10 @@ 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) { fun NavGraphBuilder.radarDestination(
navigateUp: () -> Unit,
navigateToRadioControl: (Int, Long) -> Unit = { _, _ -> }
) {
val radarRoute = "${Screen.Radar.route}?catNum={catNum}&aosTime={aosTime}" val radarRoute = "${Screen.Radar.route}?catNum={catNum}&aosTime={aosTime}"
val radarArgs = listOf( val radarArgs = listOf(
navArgument("catNum") { defaultValue = 0 }, navArgument("catNum") { defaultValue = 0 },
@@ -84,254 +88,216 @@ fun NavGraphBuilder.radarDestination(navigateUp: () -> Unit) {
) )
composable(radarRoute, radarArgs) { composable(radarRoute, radarArgs) {
val viewModel = viewModel(RadarViewModel::class.java, factory = RadarViewModel.Factory) val viewModel = viewModel(RadarViewModel::class.java, factory = RadarViewModel.Factory)
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value val uiState by viewModel.uiState.collectAsStateWithLifecycle()
RadarScreen(uiState, navigateUp) RadarScreen(uiState, viewModel::onAction, navigateUp, navigateToRadioControl)
} }
} }
@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 -> navigateUp: () -> Unit,
uiState.sendAction(RadarAction.AddToCalendar(pass.name, pass.aosTime, pass.losTime)) navigateToRadioControl: (Int, Long) -> Unit
} ) {
}
val upcomingPass = uiState.currentPass ?: getDefaultPass() val upcomingPass = uiState.currentPass ?: getDefaultPass()
if (upcomingPass.losTime < System.currentTimeMillis()) navigateUp() if (upcomingPass.losTime < System.currentTimeMillis()) navigateUp()
val addToCalendar: () -> Unit = {
uiState.currentPass?.let { pass ->
onAction(RadarAction.AddToCalendar(pass.name, pass.aosTime, pass.losTime))
}
}
val openRadioControl: () -> Unit = {
uiState.currentPass?.let { pass ->
navigateToRadioControl(pass.catNum, pass.aosTime)
}
}
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.isCurrentTimeAos)
IconCard(action = openRadioControl, 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.isCurrentTimeAos)
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
IconCard(action = openRadioControl, 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 isEclipsed = uiState.orbitalPos?.eclipsed == true
val degValue = stringResource(R.string.radar_az_value, value.toDegrees()) val borderModifier = if (isEclipsed) {
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)
)
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 +308,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 +329,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 +338,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,
@@ -22,21 +22,21 @@ 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 isCurrentTimeAos: Boolean = true,
val orientationValues: Pair<Float, Float>, val isUtc: Boolean = false,
val orbitalPos: OrbitalPos?, val orientationValues: Pair<Float, Float> = 0f to 0f,
val satTrack: List<OrbitalPos>, val orbitalPos: OrbitalPos? = null,
val shouldShowSweep: Boolean, val satTrack: List<OrbitalPos> = emptyList(),
val shouldUseCompass: Boolean, val shouldShowSweep: Boolean = false,
val transmitters: List<SatRadio>, val shouldUseCompass: Boolean = false,
val selectedTransmitterUuid: String?, val transmitters: List<SatRadio> = emptyList(),
val selectedFrequency: Long?, val selectedTransmitterUuid: String? = null,
val sendAction: (RadarAction) -> Unit 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,9 +25,11 @@ 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.graphics.Color import androidx.compose.ui.graphics.Color
@@ -53,6 +55,10 @@ import com.rtbishop.look4sat.core.domain.utility.toRadians
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,
@@ -62,149 +68,83 @@ fun RadarViewCompose(
shouldUseCompass: Boolean, shouldUseCompass: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
// val context = LocalContext.current
// val view = LocalView.current
val radarColor = MaterialTheme.colorScheme.secondary val radarColor = MaterialTheme.colorScheme.secondary
val trackColor = MaterialTheme.colorScheme.primary val trackColor = MaterialTheme.colorScheme.primary
val aimColor = MaterialTheme.colorScheme.error val aimColor = MaterialTheme.colorScheme.error
val strokeWidth = 6f
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) } var sweepDegrees by remember { mutableFloatStateOf(0f) }
val trackLastRadius = remember { mutableStateOf(0f) } var cachedRadius by remember { mutableFloatStateOf(0f) }
val trackPath = remember { mutableStateOf(Path()) } var trackPath by remember { mutableStateOf(Path()) }
val trackEffect = remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) } var trackEffect by remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) }
// val soundPool = remember { mutableStateOf<SoundPool?>(null) }
// val beepSoundId = remember { mutableIntStateOf(0) }
// 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, trackColor)
drawSweep(center, sweepDegrees.floatValue, radius, trackColor) drawRadar(radius, radarColor)
} drawElevationLabels(radius, trackColor, 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, trackColor)
if (item.elevation > 0) { if (item.elevation > 0) {
drawPosition(item, radius, animScale.value, trackColor) drawPosition(item, radius, animScale, trackColor)
}
if (shouldUseCompass) {
drawAim(azimElev.first, azimElev.second, radius, strokeWidth, aimColor)
} }
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 +156,34 @@ 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 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 * cos(PI_2 - azim)).toFloat() return Offset(
} x = (radius * cos(PI_2 - azim)).toFloat(),
y = -(radius * sin(PI_2 - azim)).toFloat()
private fun sph2CartY(azim: Double, elev: Double, r: Double): Float { )
val radius = r * (PI_2 - elev) / PI_2
return (radius * sin(PI_2 - azim)).toFloat()
} }
@@ -26,16 +26,12 @@ 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.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.ExtendedParams
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.IReporterRepo 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.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 +39,75 @@ 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 savedStateHandle: SavedStateHandle,
private val bluetoothReporter: IReporterRepo<WithoutExtParams>, private val bluetoothReporter: IReporter,
private val networkReporter: IReporterRepo<ExtendedParams>, private val networkReporter: IReporter,
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 catNum = savedStateHandle.get<Int>("catNum") ?: 0
val aosTime = savedStateHandle.get<Long>("aosTime") ?: 0L 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 currentPass = passes.find { it.catNum == catNum && it.aosTime == aosTime }
val currentPass = pass ?: passes.firstOrNull() ?: passes.firstOrNull()
currentPass?.let { satPass -> currentPass?.let { satPass ->
_uiState.update { it.copy(currentPass = satPass) } _uiState.update { it.copy(currentPass = satPass) }
val transmitters = satelliteRepo.getRadiosWithId(satPass.catNum) val transmitters = satelliteRepo.getRadiosWithId(satPass.catNum)
// Compute track once (it doesn't change for a given pass)
if (!satPass.isDeepSpace) {
val track = satelliteRepo.getTrack(
satPass.orbitalObject, stationPos, satPass.aosTime, satPass.losTime
)
_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(satPass.orbitalObject, stationPos, timeNow)
when { val (time, isAos) = computeTimer(satPass.isDeepSpace, satPass.aosTime, satPass.losTime, timeNow)
satPass.isDeepSpace -> { _uiState.update { it.copy(currentTime = time, isCurrentTimeAos = isAos, orbitalPos = pos) }
val time = 0L.toTimerString()
_uiState.update { it.copy(currentTime = time, isCurrentTimeAos = false) }
}
satPass.aosTime > timeNow -> {
val time = satPass.aosTime.minus(timeNow).toTimerString()
_uiState.update { it.copy(currentTime = time, isCurrentTimeAos = true) }
}
else -> {
val time = satPass.losTime.minus(timeNow).toTimerString()
_uiState.update { it.copy(currentTime = time, isCurrentTimeAos = false) }
}
}
processRadios(transmitters, satPass.orbitalObject, timeNow) processRadios(transmitters, satPass.orbitalObject, timeNow)
sendPassData(satPass, pos, satPass.orbitalObject) sendPassData(pos)
sendPassDataBT(pos)
delay(1000) delay(1000)
} }
} }
@@ -127,75 +119,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,18 +190,16 @@ 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)
} }
} }
+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,398 @@
/*
* 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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.NextPassRow
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.Screen
import com.rtbishop.look4sat.core.presentation.TimerRow
import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.getDefaultPass
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import com.rtbishop.look4sat.core.presentation.layoutPadding
import java.util.Locale
fun NavGraphBuilder.radioControlDestination(navigateUp: () -> Unit) {
val route = "${Screen.RadioControl.route}?catNum={catNum}&aosTime={aosTime}"
val args = listOf(
navArgument("catNum") { defaultValue = 0 },
navArgument("aosTime") { defaultValue = 0L }
)
composable(route, args) {
val viewModel = viewModel(RadioControlViewModel::class.java, factory = RadioControlViewModel.Factory)
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
RadioControlScreen(uiState, viewModel::onAction, navigateUp)
}
}
@Composable
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,206 @@
/*
* 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.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.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(
savedStateHandle: SavedStateHandle,
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 {
val catNum = savedStateHandle.get<Int>("catNum") ?: 0
val aosTime = savedStateHandle.get<Long>("aosTime") ?: 0L
// Resolve pass and load transponders
viewModelScope.launch {
val passes = satelliteRepo.passes.value
val pass = passes.find { it.catNum == catNum && it.aosTime == aosTime }
?: passes.firstOrNull()
currentPass = pass
pass?.let { satPass ->
val allRadios = satelliteRepo.getRadiosWithId(satPass.catNum)
transponders = allRadios.filter { it.downlinkLow != null }
_uiState.update {
it.copy(currentPass = satPass, transponders = transponders)
}
// If service is already tracking this pass, sync the selected transponder
val svcState = trackingService.state.value
if (svcState.isActive && svcState.currentPass?.catNum == satPass.catNum) {
_uiState.update {
it.copy(selectedTransponderUuid = svcState.selectedTransponder?.uuid)
}
}
// Tick loop — timer and satellite position updates every second
while (isActive) {
val timeNow = System.currentTimeMillis()
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow)
val (timeStr, isAos) = computeTimer(satPass.isDeepSpace, satPass.aosTime, satPass.losTime, timeNow)
_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)
}
val Factory: ViewModelProvider.Factory = viewModelFactory {
val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY
initializer {
val container = (this[applicationKey] as IContainerProvider).getMainContainer()
RadioControlViewModel(
createSavedStateHandle(),
container.radioTrackingService,
container.satelliteRepo,
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
@@ -77,54 +78,97 @@ fun NavGraphBuilder.satellitesDestination(navigateUp: () -> Unit) {
modelClass = SatellitesViewModel::class.java, factory = SatellitesViewModel.Factory modelClass = SatellitesViewModel::class.java, factory = SatellitesViewModel.Factory
) )
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 +177,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 +185,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 +200,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 +225,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 +266,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 +311,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
} }
@@ -25,7 +25,6 @@ import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
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 +39,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 +62,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,12 +89,11 @@ 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 {
+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,8 +42,12 @@ 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
@@ -80,178 +84,101 @@ fun NavGraphBuilder.settingsDestination() {
modelClass = SettingsViewModel::class.java, modelClass = SettingsViewModel::class.java,
factory = SettingsViewModel.Factory factory = SettingsViewModel.Factory
) )
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SettingsScreen(uiState) SettingsScreen(uiState, viewModel::onAction)
} }
} }
@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)
@@ -299,11 +226,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 +271,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 +286,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 +318,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 +351,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 +381,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 +407,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)
)
} }
} }
} }
@@ -476,17 +429,11 @@ private fun OtherCardPreview() = MainTheme {
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()
@@ -497,49 +444,44 @@ private fun OtherCard(
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()
) {
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)
} }
@@ -638,3 +580,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,42 @@ 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 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
} }
@@ -22,93 +22,58 @@ 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.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.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,166 +91,100 @@ 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) }
// 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 { val Factory: ViewModelProvider.Factory = viewModelFactory {
@@ -294,7 +193,6 @@ class SettingsViewModel(
val container = (this[applicationKey] as IContainerProvider).getMainContainer() val container = (this[applicationKey] as IContainerProvider).getMainContainer()
SettingsViewModel( SettingsViewModel(
container.databaseRepo, container.databaseRepo,
container.satelliteRepo,
container.settingsRepo, container.settingsRepo,
container.provideShowToast() container.provideShowToast()
) )
+10 -10
View File
@@ -1,9 +1,9 @@
[versions] [versions]
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionCode = "411" appVersionCode = "421"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.1.1"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionName = "4.2.1"
#noinspection GradleDependency,UnusedVersionCatalogEntry
compileSdk = "36" compileSdk = "36"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
minSdk = "24" minSdk = "24"
@@ -12,7 +12,7 @@ jdkVersion = "17"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
packageName = "com.rtbishop.look4sat" packageName = "com.rtbishop.look4sat"
android-gradle-plugin = "9.0.1" android-gradle-plugin = "9.1.1"
google-ksp = "2.3.6" google-ksp = "2.3.6"
kotlin = "2.3.20" kotlin = "2.3.20"
@@ -20,7 +20,7 @@ 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.03.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-navigation = "2.9.7"
@@ -88,11 +88,11 @@ compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "
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" }
# 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
+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"
) )