Compare commits

..
19 Commits
Author SHA1 Message Date
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
82 changed files with 4783 additions and 2776 deletions

No files matched your search

+1 -1
View File
@@ -1,3 +1,3 @@
plugins {
alias(libs.plugins.convention.androidAppPlugin)
alias(libs.plugins.convention.applicationPlugin)
}
@@ -17,9 +17,28 @@
*/
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.fadeIn
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.MaterialTheme
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.NavigationSuiteType
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.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.compose.NavHost
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.hasEnoughHeight
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.feature.map.mapDestination
import com.rtbishop.look4sat.feature.passes.passesDestination
import com.rtbishop.look4sat.feature.radar.radarDestination
import com.rtbishop.look4sat.feature.radiocontrol.radioControlDestination
import com.rtbishop.look4sat.feature.satellites.satellitesDestination
import com.rtbishop.look4sat.feature.settings.settingsDestination
@@ -47,6 +78,12 @@ fun MainScreen(navController: NavHostController = rememberNavController()) {
val items = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings)
val currentDestination = navController.currentBackStackEntryAsState().value?.destination?.route
val startDestination = Screen.Passes.route
// Observe radio tracking state for the status bar
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
NavigationSuiteScaffold(
navigationSuiteItems = {
items.forEach {
@@ -71,20 +108,78 @@ fun MainScreen(navController: NavHostController = rememberNavController()) {
else -> NavigationSuiteType.ShortNavigationBarMedium
}
) {
Column {
NavHost(
navController = navController,
startDestination = startDestination,
enterTransition = { fadeIn(animationSpec = tween(350)) },
exitTransition = { fadeOut(animationSpec = tween(350)) }
exitTransition = { fadeOut(animationSpec = tween(350)) },
modifier = Modifier.weight(1f)
) {
satellitesDestination { navController.navigateUp() }
passesDestination { catNum: Int, aosTime: Long ->
val radarRoute = "${Screen.Radar.route}?catNum=${catNum}&aosTime=${aosTime}"
navController.navigate(radarRoute)
}
radarDestination { 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()
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 {
plugins {
register("androidAppPlugin") {
id = libs.plugins.convention.androidAppPlugin.get().pluginId
implementationClass = "com.rtbishop.look4sat.convention.AndroidAppPlugin"
register("applicationPlugin") {
id = libs.plugins.convention.applicationPlugin.get().pluginId
implementationClass = "com.rtbishop.look4sat.convention.ApplicationPlugin"
}
register("androidLibraryPlugin") {
id = libs.plugins.convention.androidLibraryPlugin.get().pluginId
implementationClass = "com.rtbishop.look4sat.convention.AndroidLibraryPlugin"
register("coreDataPlugin") {
id = libs.plugins.convention.coreDataPlugin.get().pluginId
implementationClass = "com.rtbishop.look4sat.convention.CoreDataPlugin"
}
register("composeFeaturePlugin") {
id = libs.plugins.convention.composeFeaturePlugin.get().pluginId
implementationClass = "com.rtbishop.look4sat.convention.ComposeFeaturePlugin"
register("coreDomainPlugin") {
id = libs.plugins.convention.coreDomainPlugin.get().pluginId
implementationClass = "com.rtbishop.look4sat.convention.CoreDomainPlugin"
}
register("composeLibraryPlugin") {
id = libs.plugins.convention.composeLibraryPlugin.get().pluginId
implementationClass = "com.rtbishop.look4sat.convention.ComposeLibraryPlugin"
register("corePresentationPlugin") {
id = libs.plugins.convention.corePresentationPlugin.get().pluginId
implementationClass = "com.rtbishop.look4sat.convention.CorePresentationPlugin"
}
register("kotlinLibraryPlugin") {
id = libs.plugins.convention.kotlinLibraryPlugin.get().pluginId
implementationClass = "com.rtbishop.look4sat.convention.KotlinLibraryPlugin"
register("featurePlugin") {
id = libs.plugins.convention.featurePlugin.get().pluginId
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
@Suppress("Unused")
internal class AndroidLibraryPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
alias(libs.plugins.google.ksp)
}
setupCommonLibrary()
setupKotlinToolchain()
setupAndroidTestDependencies()
setupTestDependencies()
dependencies {
IMPLEMENTATION(project(":core:domain"))
IMPLEMENTATION(libs.androidx.core.ktx)
IMPLEMENTATION(libs.androidx.room.asProvider())
IMPLEMENTATION(libs.androidx.room.runtime)
KSP(libs.androidx.room.compiler)
IMPLEMENTATION(libs.other.coroutines)
IMPLEMENTATION(libs.other.okhttp)
}
internal class CoreDataPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
applyPlugin(libs.plugins.google.ksp)
setupAndroidLib()
setupKotlin()
dependencies {
implementation(project(":core:domain"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.room)
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
@Suppress("Unused")
internal class KotlinLibraryPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
alias(libs.plugins.kotlin.jvm)
}
setupKotlinToolchain()
setupTestDependencies()
dependencies {
IMPLEMENTATION(libs.other.coroutines)
IMPLEMENTATION(libs.other.json)
}
internal class CoreDomainPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
applyPlugin(libs.plugins.kotlin.jvm)
setupKotlin()
dependencies {
implementation(libs.other.coroutines)
implementation(libs.other.json)
}
}
}
@@ -22,18 +22,14 @@ import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
@Suppress("Unused")
internal class ComposeFeaturePlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
setupCommonLibrary()
setupComposeFeature()
setupKotlinToolchain()
setupAndroidTestDependencies()
setupTestDependencies()
dependencies {
IMPLEMENTATION(project(":core:domain"))
IMPLEMENTATION(project(":core:presentation"))
}
internal class CorePresentationPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
setupAndroidLib()
setupCompose()
setupKotlin()
dependencies {
implementation(project(":core:domain"))
implementation(libs.androidx.core.splashscreen)
}
}
}
@@ -22,18 +22,14 @@ import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
@Suppress("Unused")
internal class ComposeLibraryPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
setupCommonLibrary()
setupComposeFeature()
setupKotlinToolchain()
setupAndroidTestDependencies()
setupTestDependencies()
dependencies {
IMPLEMENTATION(project(":core:domain"))
IMPLEMENTATION(libs.androidx.core.splashscreen)
}
internal class FeaturePlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
setupAndroidLib()
setupCompose()
setupKotlin()
dependencies {
implementation(project(":core:domain"))
implementation(project(":core:presentation"))
}
}
}
@@ -21,7 +21,7 @@ import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.dsl.CommonExtension
import org.gradle.accessors.dm.LibrariesForLibs
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.kotlin.dsl.accessors.runtime.extensionOf
import org.gradle.kotlin.dsl.configure
@@ -29,23 +29,30 @@ import org.gradle.kotlin.dsl.dependencies
import org.gradle.plugin.use.PluginDependency
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
get(): LibrariesForLibs = extensionOf(this, "libs") as LibrariesForLibs
internal fun PluginManager.alias(notation: Provider<PluginDependency>) {
apply(notation.get().pluginId)
internal fun Project.applyPlugin(notation: Provider<PluginDependency>) {
pluginManager.apply(notation.get().pluginId)
}
internal fun Project.setupAndroidApplication() {
with(pluginManager) {
alias(libs.plugins.android.application)
}
internal fun DependencyHandler.implementation(dependencyNotation: Any) =
add("implementation", dependencyNotation)
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> {
namespace = libs.versions.packageName.get()
compileSdk = libs.versions.compileSdk.get().toInt()
@@ -55,9 +62,6 @@ internal fun Project.setupAndroidApplication() {
versionCode = libs.versions.appVersionCode.get().toInt()
versionName = libs.versions.appVersionName.get()
}
buildFeatures {
compose = true
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
@@ -76,43 +80,32 @@ internal fun Project.setupAndroidApplication() {
}
}
internal fun Project.setupCommonLibrary() {
with(pluginManager) {
alias(libs.plugins.android.library)
}
internal fun Project.setupAndroidLib() {
applyPlugin(libs.plugins.android.library)
extensions.configure<CommonExtension> {
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig.minSdk = libs.versions.minSdk.get().toInt()
}
dependencies {
androidTestImplementation(libs.bundles.androidTest)
}
}
internal fun Project.setupComposeFeature() {
with(pluginManager) {
alias(libs.plugins.compose.compiler)
}
internal fun Project.setupCompose() {
applyPlugin(libs.plugins.compose.compiler)
extensions.configure<CommonExtension> {
buildFeatures.compose = true
}
dependencies {
IMPLEMENTATION(platform(libs.compose.bom))
IMPLEMENTATION(libs.bundles.composeAll)
DEBUG_IMPLEMENTATION(libs.bundles.composeDebug)
implementation(platform(libs.compose.bom))
implementation(libs.bundles.composeAll)
debugImplementation(libs.bundles.composeDebug)
}
}
internal fun Project.setupKotlinToolchain() {
internal fun Project.setupKotlin() {
kotlinExtension.jvmToolchain(libs.versions.jdkVersion.get().toInt())
}
internal fun Project.setupAndroidTestDependencies() {
dependencies {
ANDROID_TEST_IMPLEMENTATION(libs.bundles.androidTest)
}
}
internal fun Project.setupTestDependencies() {
dependencies {
TEST_IMPLEMENTATION(libs.test.coroutines)
TEST_IMPLEMENTATION(libs.test.junit4)
testImplementation(libs.bundles.unitTest)
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
plugins {
alias(libs.plugins.convention.androidLibraryPlugin)
alias(libs.plugins.convention.coreDataPlugin)
}
android {
@@ -20,121 +20,112 @@ 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.BtService
import com.rtbishop.look4sat.core.domain.repository.IReporterRepo
import com.rtbishop.look4sat.core.domain.repository.WithoutExtParams
import com.rtbishop.look4sat.core.domain.repository.IReporter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.OutputStream
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(
private val bluetoothManager: BluetoothManager,
private val reporterScope: CoroutineScope
) : IReporterRepo<WithoutExtParams> {
private val reporterScope: CoroutineScope,
private val rotatorDeviceId: String,
private val frequencyDeviceId: String
) : IReporter {
private val tag = "BTReporter"
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 sppId: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
private val writeMutex = Mutex()
override fun isConnected(service: BtService): Boolean {
val deviceId = serviceToDevice[service] ?: return false
return deviceConnections[deviceId]?.connected == true
}
private var rotatorSocket: BluetoothSocket? = null
private var rotatorStream: OutputStream? = null
private var rotatorConnected = false
private var rotatorConnecting = false
override fun isConnecting(service: BtService): Boolean {
val deviceId = serviceToDevice[service] ?: return false
return deviceConnections[deviceId]?.connecting == true
}
private var frequencySocket: BluetoothSocket? = null
private var frequencyStream: OutputStream? = null
private var frequencyConnected = false
private var frequencyConnecting = false
override fun connect(service: BtService, deviceId: String) {
serviceToDevice[service] = deviceId
val connection = deviceConnections.getOrPut(deviceId) {
DeviceConnection()
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(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 {
connection.connecting = true
val device = bluetoothManager.adapter.getRemoteDevice(deviceId)
val socket = device.createInsecureRfcommSocketToServiceRecord(sppid)
rotatorConnecting = true
val device = bluetoothManager.adapter.getRemoteDevice(rotatorDeviceId)
val socket = device.createInsecureRfcommSocketToServiceRecord(sppId)
socket.connect()
connection.socket = socket
connection.outputStream = socket.outputStream
connection.connected = true
Log.i(tag, "$tag: Connected to $deviceId")
rotatorSocket = socket
rotatorStream = socket.outputStream
rotatorConnected = true
Log.i(tag, "Rotator connected to $rotatorDeviceId")
} catch (e: Exception) {
Log.e(tag, "$tag: ${e.message}")
connection.connected = false
Log.e(tag, "Rotator connect error: ${e.message}")
rotatorConnected = false
} finally {
connection.connecting = false
rotatorConnecting = false
}
}
}
private suspend fun write(service: BtService, buffer: String) {
val deviceId = serviceToDevice[service] ?: return
val connection = deviceConnections[deviceId] ?: return
if (!connection.connected) return
private fun ensureFrequencyConnected() {
if (frequencyConnected || frequencyConnecting || frequencyDeviceId.isBlank()) return
reporterScope.launch {
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 {
writeMutex.withLock {
connection.outputStream?.write(buffer.toByteArray())
stream?.write(data.toByteArray())
}
} catch (e: Exception) {
Log.e(tag, "$tag: Write failed ${e.message}")
connection.connected = false
Log.e(tag, "Write error: ${e.message}")
onError()
}
}
override fun reportRotation(format: String, azimuth: Double, elevation: Double, params: WithoutExtParams) {
reporterScope.launch {
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"
}
}
private fun String.unescapeControlChars(): String =
replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
}
@@ -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
import com.rtbishop.look4sat.core.domain.repository.BtService
import com.rtbishop.look4sat.core.domain.repository.ExtendedParams
import com.rtbishop.look4sat.core.domain.repository.IReporterRepo
import com.rtbishop.look4sat.core.domain.repository.IReporter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -29,89 +26,94 @@ import java.net.InetSocketAddress
import java.nio.ByteBuffer
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()
fun isConnected(service: NetworkService): Boolean {
val addr = serviceToAddress[service] ?: return false
return connections[addr]?.connected == true
private var rotatorSocket: SocketChannel? = null
private var rotatorConnected = false
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 {
return connections.getOrPut(addr) { SocketConnection() }
override fun reportFrequency(format: String, frequency: Long) {
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) {
val addr = "$server:$port"
serviceToAddress[service] = addr
val connection = getOrCreateConnection(addr)
if (connection.connected || connection.connecting) return
connection.connectionJob = reporterScope.launch {
private fun ensureRotatorConnected() {
if (rotatorConnected || rotatorConnecting || rotatorServer.isBlank()) return
reporterScope.launch {
try {
connection.connecting = true
val socket = SocketChannel.open(InetSocketAddress(server, port))
connection.socket = socket
connection.connected = true
println("NetworkReporter: $service connected to $addr")
rotatorConnecting = true
rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
rotatorConnected = true
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
} catch (e: Exception) {
println("NetworkReporter connect error: ${e.message}")
connection.connected = false
println("NetworkReporter rotator connect error: ${e.message}")
rotatorConnected = false
} finally {
connection.connecting = false
rotatorConnecting = false
}
}
}
private suspend fun write(service: NetworkService, command: String) {
val addr = serviceToAddress[service] ?: return
val connection = connections[addr] ?: return
if (!connection.connected) return
private fun ensureFrequencyConnected() {
if (frequencyConnected || frequencyConnecting || frequencyServer.isBlank()) return
reporterScope.launch {
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 {
writeMutex.withLock {
val buffer = ByteBuffer.wrap("\\$command\n".toByteArray())
connection.socket?.write(buffer)
val buffer = ByteBuffer.wrap("$command\n".toByteArray())
socket?.write(buffer)
}
} catch (e: Exception) {
println("NetworkReporter write error: ${e.message}")
connection.connected = false
onError()
}
}
override fun isConnected(service: BtService): Boolean = false
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)
}
}
private fun String.unescapeControlChars(): String =
replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
}
@@ -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 com.rtbishop.look4sat.core.data.database.Look4SatDb
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.RadioTrackingService
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
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.usecase.AddToCalendar
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.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.ISelectionRepo
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
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.IRemoteSource
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 satelliteRepo = provideSatelliteRepo()
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 provideShowToast(): IShowToast = ShowToast(context)
override fun provideBluetoothReporter(): IReporterRepo<WithoutExtParams> {
override fun provideBluetoothReporter(): IReporter {
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> {
return NetworkReporter(CoroutineScope(Dispatchers.IO))
override fun provideNetworkReporter(): IReporter {
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 {
@@ -18,7 +18,6 @@
package com.rtbishop.look4sat.core.data.repository
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.repository.IDatabaseRepo
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 kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
import java.io.InputStream
import java.util.zip.ZipInputStream
class DatabaseRepo(
@@ -39,78 +40,57 @@ class DatabaseRepo(
private val settingsRepo: ISettingsRepo
) : IDatabaseRepo {
private companion object {
val tleTypes = setOf("Amsat", "R4UAB", "Other")
val zippedTleTypes = setOf("McCants", "Classified")
}
override suspend fun updateTLEFromFile(uri: String) = withContext(dispatcher) {
val importedSatellites = remoteSource.getFileStream(uri)?.let { dataParser.parseTLEStream(it) }
importedSatellites?.let { localSource.insertEntries(it) }
remoteSource.getFileStream(uri)?.let { stream ->
val entries = dataParser.parseTLEStream(stream)
localSource.insertEntries(entries)
settingsRepo.setSatelliteTypeIds("Other", entries.map { it.catnum })
}
setUpdateSuccessful(System.currentTimeMillis())
}
override suspend fun updateTransceiversFromFile(uri: String) = withContext(dispatcher) {
val radios = remoteSource.getFileStream(uri)?.let { dataParser.parseJSONStream(it) }
radios?.let {
if(it.isNotEmpty()) {
remoteSource.getFileStream(uri)
?.let { dataParser.parseJSONStream(it) }
?.takeIf { it.isNotEmpty() }
?.let {
localSource.deleteRadios()
localSource.insertRadios(it)
}
}
setUpdateSuccessful(System.currentTimeMillis())
}
override suspend fun updateFromRemote() = withContext(dispatcher) {
val importedEntries = mutableListOf<OrbitalData>()
val importedRadios = mutableListOf<SatRadio>()
val tleUrls = Sources.satelliteDataUrls.toMutableMap().apply {
if (settingsRepo.dataSourcesSettings.value.useCustomTLE) {
this["Other"] = settingsRepo.dataSourcesSettings.value.tleUrl
}
}
val jobsMap = tleUrls
.filter { (_, url) -> url.isNotEmpty() }
.mapValues { (_, url) -> async { remoteSource.getNetworkStream(url) } }
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
val tleUrls = buildMap {
putAll(Sources.satelliteDataUrls)
if (dataSourcesSettings.useCustomTLE) put("Other", dataSourcesSettings.tleUrl)
}.filterValues { it.isNotEmpty() }
val radioUrls = buildList {
add(Sources.RADIO_DATA_URL)
if (settingsRepo.dataSourcesSettings.value.useCustomTransceivers) {
add(settingsRepo.dataSourcesSettings.value.transceiversUrl)
}
if (dataSourcesSettings.useCustomTransceivers) add(dataSourcesSettings.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" -> {
// unzip and parse tle stream
val unzipped = ZipInputStream(stream).apply { nextEntry }
val satellites = dataParser.parseTLEStream(unzipped)
val catnums = satellites.map { it.catnum }
settingsRepo.setSatelliteTypeIds(type, catnums)
importedEntries.addAll(satellites)
}
// launch all network requests concurrently
val tleJobs = tleUrls.map { (type, url) -> async { type to remoteSource.getNetworkStream(url) } }
val radioJobs = radioUrls.map { url -> async { remoteSource.getNetworkStream(url) } }
else -> {
// parse csv stream
val satellites = dataParser.parseCSVStream(stream)
val catnums = satellites.map { it.catnum }
settingsRepo.setSatelliteTypeIds(type, catnums)
importedEntries.addAll(satellites)
}
}
// parse satellite data
val importedEntries = tleJobs.awaitAll().flatMap { (type, stream) ->
stream?.let { parseSatelliteStream(type, it) }.orEmpty().also { satellites ->
settingsRepo.setSatelliteTypeIds(type, satellites.map { it.catnum })
}
}
jobRadios.values.forEach { job ->
job.await()?.let {
importedRadios.addAll(dataParser.parseJSONStream(it))
}
}
// insert
// parse radio data
val importedRadios = radioJobs.awaitAll().filterNotNull().flatMap { dataParser.parseJSONStream(it) }
localSource.insertEntries(importedEntries)
localSource.insertRadios(importedRadios)
setUpdateSuccessful(System.currentTimeMillis())
@@ -122,9 +102,15 @@ class DatabaseRepo(
setUpdateSuccessful(0L)
}
private suspend fun setUpdateSuccessful(timestamp: Long) = withContext(dispatcher) {
val numberOfRadios = localSource.getRadiosTotal()
val numberOfSatellites = localSource.getEntriesTotal()
settingsRepo.updateDatabaseState(DatabaseState(numberOfRadios, numberOfSatellites, timestamp))
private suspend fun parseSatelliteStream(type: String, stream: InputStream): List<OrbitalData> = when (type) {
in tleTypes -> dataParser.parseTLEStream(stream)
in zippedTleTypes -> dataParser.parseTLEStream(ZipInputStream(stream).apply { nextEntry })
else -> dataParser.parseCSVStream(stream)
}
private suspend fun setUpdateSuccessful(timestamp: Long) {
settingsRepo.updateDatabaseState(
DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp)
)
}
}
@@ -28,6 +28,9 @@ import com.rtbishop.look4sat.core.domain.source.ILocalSource
import com.rtbishop.look4sat.core.domain.utility.round
import com.rtbishop.look4sat.core.domain.utility.toDegrees
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
@@ -51,8 +54,7 @@ class SatelliteRepo(
settingsRepo.selectedIds.collect { selectedIds ->
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
val (hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value
val timeNow = 1000 * ((System.currentTimeMillis() + 500) / 1000)
calculatePasses(timeNow, hoursAhead, minElevation, modes)
calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
}
}
@@ -62,7 +64,8 @@ class SatelliteRepo(
override suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos> {
return withContext(dispatcher) {
val positions = mutableListOf<OrbitalPos>()
val estimatedSize = ((end - start) / 15000).toInt() + 1
val positions = ArrayList<OrbitalPos>(estimatedSize)
var currentTime = start
while (currentTime < end) {
positions.add(sat.getPosition(pos, currentTime))
@@ -80,44 +83,49 @@ class SatelliteRepo(
): List<SatRadio> {
return withContext(dispatcher) {
val satPos = sat.getPosition(pos, time)
val copiedList = radios.map { it.copy() }
copiedList.forEach { transmitter ->
transmitter.downlinkLow?.let { transmitter.downlinkLow = satPos.getDownlinkFreq(it) }
transmitter.downlinkHigh?.let { transmitter.downlinkHigh = satPos.getDownlinkFreq(it) }
transmitter.uplinkLow?.let { transmitter.uplinkLow = satPos.getUplinkFreq(it) }
transmitter.uplinkHigh?.let { transmitter.uplinkHigh = satPos.getUplinkFreq(it) }
radios.map { transmitter ->
transmitter.copy(
downlinkLow = transmitter.downlinkLow?.let { satPos.getDownlinkFreq(it) },
downlinkHigh = transmitter.downlinkHigh?.let { satPos.getDownlinkFreq(it) },
uplinkLow = transmitter.uplinkLow?.let { 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>) {
if (_satellites.value.isNotEmpty()) {
// Normalize to the start of the current minute so that coarse 60-second stepping
// in getLeoPass always begins from the same phase, producing stable AOS/LOS times
val normalizedTime = time / 60_000L * 60_000L
val currentSatellites = _satellites.value
if (currentSatellites.isNotEmpty()) {
withContext(dispatcher) {
val newPasses = mutableListOf<OrbitalPass>()
val idsWithModes = localStorage.getIdsWithModes(modes)
_satellites.value.forEach { satellite ->
if (idsWithModes.isEmpty() || satellite.data.catnum in idsWithModes) {
newPasses.addAll(satellite.getPasses(settingsRepo.stationPosition.value, time, hoursAhead))
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) }
newPasses.sortBy { it.aosTime }
_passes.update { newPasses }
}
} else {
_passes.update { emptyList() }
@@ -149,12 +157,6 @@ class SatelliteRepo(
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 {
val satPos = sat.getPosition(pos, time)
val aos = time - 24 * 60L * 60L * 1000L
@@ -170,72 +172,64 @@ class SatelliteRepo(
var calendarTimeMillis = time
var elevation: Double
var maxElevation = 0.0
var alt = 0.0 // var tcaAz = 0.0
// rewind 1/4 of an orbit
if (rewind) calendarTimeMillis += -quarterOrbitMin * 60L * 1000L
if (rewind) calendarTimeMillis -= quarterOrbitMin * 60L * 1000L
var satPos = sat.getPosition(pos, calendarTimeMillis)
if (satPos.elevation > 0.0) {
// Use lightweight elevation check for coarse searching
if (sat.getElevation(pos, calendarTimeMillis) > 0.0) {
// move forward in 30 second intervals until the sat goes below the horizon
do {
calendarTimeMillis += 30 * 1000L
satPos = sat.getPosition(pos, calendarTimeMillis)
} while (satPos.elevation > 0.0)
} while (sat.getElevation(pos, calendarTimeMillis) > 0.0)
// move forward 3/4 of an orbit
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 {
calendarTimeMillis += 60L * 1000L
satPos = sat.getPosition(pos, calendarTimeMillis)
elevation = satPos.elevation
if (elevation > maxElevation) {
maxElevation = elevation
alt = satPos.altitude // tcaAz = satPos.azimuth.toDegrees()
}
} while (satPos.elevation < 0.0)
elevation = sat.getElevation(pos, calendarTimeMillis)
if (elevation > maxElevation) maxElevation = elevation
} while (elevation < 0.0)
// refine to 1 second
calendarTimeMillis += -60L * 1000L
// refine AOS to ~500ms precision
calendarTimeMillis -= 60L * 1000L
do {
calendarTimeMillis += 1L * 500L
satPos = sat.getPosition(pos, calendarTimeMillis)
elevation = satPos.elevation
if (elevation > maxElevation) {
maxElevation = elevation
alt = satPos.altitude // tcaAz = satPos.azimuth.toDegrees()
}
} while (satPos.elevation < 0.0)
calendarTimeMillis += 500L
elevation = sat.getElevation(pos, calendarTimeMillis)
if (elevation > maxElevation) maxElevation = elevation
} while (elevation < 0.0)
val aos = 1000 * ((satPos.time + 500) / 1000)
val aosAz = satPos.azimuth.toDegrees().round(1)
// Get full position for AOS data (azimuth, altitude)
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 {
calendarTimeMillis += 30L * 1000L
satPos = sat.getPosition(pos, calendarTimeMillis)
elevation = satPos.elevation
if (elevation > maxElevation) {
maxElevation = elevation
alt = satPos.altitude // tcaAz = satPos.azimuth.toDegrees()
}
} while (satPos.elevation > 0.0)
elevation = sat.getElevation(pos, calendarTimeMillis)
if (elevation > maxElevation) maxElevation = elevation
} while (elevation > 0.0)
// refine to 1 second
calendarTimeMillis += -30L * 1000L
// refine LOS to ~500ms precision
calendarTimeMillis -= 30L * 1000L
do {
calendarTimeMillis += 1L * 500L
satPos = sat.getPosition(pos, calendarTimeMillis)
elevation = satPos.elevation
if (elevation > maxElevation) {
maxElevation = elevation
alt = satPos.altitude // tcaAz = satPos.azimuth.toDegrees()
}
} while (satPos.elevation > 0.0)
calendarTimeMillis += 500L
elevation = sat.getElevation(pos, calendarTimeMillis)
if (elevation > maxElevation) maxElevation = elevation
} while (elevation > 0.0)
// Get full position for LOS data (azimuth, altitude)
val losPos = sat.getFullPosition(pos, calendarTimeMillis)
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)
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 currentTypes = MutableStateFlow(settingsRepo.selectedTypes.value)
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> ->
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 ->
itemsWithTypes.map { items -> items.filterByQuery(query) }
itemsWithTypes.map { items -> filterByQuery(items, query) }
}
override fun getCurrentTypes() = currentTypes.value
@@ -54,7 +66,7 @@ class SelectionRepo(
}
override suspend fun getEntriesFlow() = withContext(dispatcher) {
val selectedIds = settingsRepo.selectedIds.value
val selectedIds = settingsRepo.selectedIds.value.toHashSet()
currentItems.value = localSource.getEntriesList().map { item ->
item.copy(isSelected = item.catnum in selectedIds)
}
@@ -66,17 +78,19 @@ class SelectionRepo(
settingsRepo.setSelectedTypes(types)
}
override suspend fun setQuery(query: String) = withContext(dispatcher) {
override suspend fun setQuery(query: String) {
currentQuery.value = query
}
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) {
val idSet = ids.toHashSet()
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)
}
private suspend fun List<SatItem>.filterByTypes(types: List<String>) = withContext(dispatcher) {
if (types.isEmpty()) return@withContext this@filterByTypes
val catnums = settingsRepo.getSatelliteTypesIds(types)
if (catnums.isEmpty()) return@withContext this@filterByTypes
return@withContext this@filterByTypes.filter { item -> item.catnum in catnums }
}
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()) }
/**
* Bulk selection using a pre-built Set for O(1) lookups.
*/
private suspend fun setSelection(idSet: Set<Int>, isTicked: Boolean) = withContext(dispatcher) {
currentItems.value = currentItems.value.map { item ->
if (item.catnum in idSet) item.copy(isSelected = isTicked) else item
}
}
/**
* 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 sensor: Sensor?,
private val windowManager: WindowManager
) :
SensorEventListener, ISensorsRepo {
) : SensorEventListener, ISensorsRepo {
private val _orientation = MutableStateFlow(Pair(0f, 0f))
private val rotationMatrix = FloatArray(9)
private val tempMatrix = FloatArray(9)
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 fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
val latitude = geoPos.latitude.toFloat()
val longitude = geoPos.longitude.toFloat()
return GeomagneticField(latitude, longitude, geoPos.altitude.toFloat(), time).declination
return GeomagneticField(
geoPos.latitude.toFloat(),
geoPos.longitude.toFloat(),
geoPos.altitude.toFloat(),
time
).declination
}
override fun enableSensor() {
hasInitialReading = false
sensor?.let { sensorManager.registerListener(this, it, 8000) }
}
override fun disableSensor() = sensorManager.unregisterListener(this)
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
sensorAccuracy = accuracy
}
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
override fun onSensorChanged(event: SensorEvent) = when {
sensorAccuracy == SensorManager.SENSOR_STATUS_UNRELIABLE -> {}
event.sensor == sensor -> updateOrientation(event.values)
else -> {}
override fun onSensorChanged(event: SensorEvent) {
if (event.sensor == sensor) updateOrientation(event.values)
}
private fun getDisplayRotation(): Int {
return try {
@Suppress("DEPRECATION")
windowManager.defaultDisplay.rotation
} catch (e: Exception) {
} catch (_: Exception) {
Surface.ROTATION_0
}
}
private fun transformRotationMatrix(rotationMatrix: FloatArray, rotation: Int) {
val tempMatrix = FloatArray(9)
when (rotation) {
Surface.ROTATION_0 -> {
SensorManager.remapCoordinateSystem(rotationMatrix, SensorManager.AXIS_X, SensorManager.AXIS_Y, tempMatrix)
System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
}
Surface.ROTATION_90 -> {
SensorManager.remapCoordinateSystem(rotationMatrix, SensorManager.AXIS_Y, SensorManager.AXIS_MINUS_X, tempMatrix)
SensorManager.remapCoordinateSystem(tempMatrix, SensorManager.AXIS_MINUS_X, SensorManager.AXIS_Y, rotationMatrix)
}
Surface.ROTATION_180 -> {
SensorManager.remapCoordinateSystem(rotationMatrix, SensorManager.AXIS_MINUS_X, SensorManager.AXIS_MINUS_Y, tempMatrix)
System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
}
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)
}
private fun remapForRotation(rotation: Int) {
val remapped = when (rotation) {
Surface.ROTATION_90 -> SensorManager.remapCoordinateSystem(
rotationMatrix, SensorManager.AXIS_Y, SensorManager.AXIS_MINUS_X, tempMatrix
)
Surface.ROTATION_180 -> SensorManager.remapCoordinateSystem(
rotationMatrix, SensorManager.AXIS_MINUS_X, SensorManager.AXIS_MINUS_Y, tempMatrix
)
Surface.ROTATION_270 -> SensorManager.remapCoordinateSystem(
rotationMatrix, SensorManager.AXIS_MINUS_Y, SensorManager.AXIS_X, tempMatrix
)
else -> false
}
if (remapped) System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
}
private fun updateOrientation(rotationVector: FloatArray) {
SensorManager.getRotationMatrixFromVector(rotationMatrix, rotationVector)
transformRotationMatrix(rotationMatrix, getDisplayRotation())
remapForRotation(getDisplayRotation())
SensorManager.getOrientation(rotationMatrix, orientationValues)
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 roundedAzimuth = round(magneticAzimuth * 10) / 10
val roundedPitch = round(pitch * 10) / 10
_orientation.value = Pair(roundedAzimuth, roundedPitch)
if (!hasInitialReading) {
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.PassesSettings
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.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.utility.positionToQth
@@ -246,82 +247,51 @@ class SettingsRepo(
//endregion
//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())
override val rcSettings: StateFlow<RCSettings> = _rcSettings
override fun setBluetoothRotatorAddress(value: String) {
preferences.edit { putString(keyBluetoothRotatorAddress, value) }
_rcSettings.update { it.copy(bluetoothRotatorAddress = value) }
}
override fun setBluetoothRotatorFormat(value: String) {
preferences.edit { putString(keyBluetoothRotatorFormat, value) }
_rcSettings.update { it.copy(bluetoothRotatorFormat = value) }
}
override fun setBluetoothRotatorName(value: String) {
preferences.edit { putString(keyBluetoothRotatorName, value) }
_rcSettings.update { it.copy(bluetoothRotatorName = value) }
}
override fun setBluetoothRotatorState(value: Boolean) {
preferences.edit { putBoolean(keyBluetoothRotatorState, value) }
_rcSettings.update { it.copy(bluetoothRotatorState = value) }
}
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) }
override fun updateRCSettings(settings: RCSettings) {
preferences.edit {
putBoolean(keyRotatorState, settings.rotatorState)
putString(keyRotatorAddress, settings.rotatorAddress)
putString(keyRotatorPort, settings.rotatorPort)
putString(keyRotatorFormat, settings.rotatorFormat)
putBoolean(keyFrequencyState, settings.frequencyState)
putString(keyFrequencyAddress, settings.frequencyAddress)
putString(keyFrequencyPort, settings.frequencyPort)
putString(keyFrequencyFormat, settings.frequencyFormat)
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
putString(keyBluetoothRotatorAddress, settings.bluetoothRotatorAddress)
putBoolean(keyBluetoothFrequencyState, settings.bluetoothFrequencyState)
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
}
_rcSettings.value = settings
}
private fun getRCSettings(): RCSettings = RCSettings(
@@ -332,14 +302,14 @@ class SettingsRepo(
frequencyState = preferences.getBoolean(keyFrequencyState, false),
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
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),
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"W$AZ $EL",
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
bluetoothRotatorAddress = preferences.getString(keyBluetoothRotatorAddress, null) ?: "00:0C:BF:13:80:5D",
bluetoothFrequencyState = preferences.getBoolean(keyBluetoothFrequencyState, false),
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
@@ -347,39 +317,20 @@ class SettingsRepo(
private val _otherSettings = MutableStateFlow(getOtherSettings())
override val otherSettings: StateFlow<OtherSettings> = _otherSettings
override fun setStateOfAutoUpdate(value: Boolean) {
preferences.edit { putBoolean(keyStateOfAutoUpdate, value) }
_otherSettings.update { it.copy(stateOfAutoUpdate = value) }
}
override fun setStateOfSensors(value: Boolean) {
preferences.edit { putBoolean(keyStateOfSensors, value) }
_otherSettings.update { it.copy(stateOfSensors = value) }
}
override fun setStateOfSweep(value: Boolean) {
preferences.edit { putBoolean(keyStateOfSweep, value) }
_otherSettings.update { it.copy(stateOfSweep = value) }
}
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) }
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) {
_otherSettings.update { current ->
val new = transform(current)
preferences.edit {
putBoolean(keyStateOfAutoUpdate, new.stateOfAutoUpdate)
putBoolean(keyStateOfSensors, new.stateOfSensors)
putBoolean(keyStateOfSweep, new.stateOfSweep)
putBoolean(keyStateOfUtc, new.stateOfUtc)
putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
}
new
}
}
private fun getOtherSettings(): OtherSettings = OtherSettings(
@@ -397,24 +348,14 @@ class SettingsRepo(
private val _dataSourcesSettings = MutableStateFlow(getDataSourcesSettings())
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings
override fun setUseCustomTle(value: Boolean) {
preferences.edit { putBoolean(keyUseCustomTle, value) }
_dataSourcesSettings.update { it.copy(useCustomTLE = value) }
}
override fun setUseCustomTransceivers(value: Boolean) {
preferences.edit { putBoolean(keyUseCustomTransceivers, value) }
_dataSourcesSettings.update { it.copy(useCustomTransceivers = value) }
}
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) }
override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
preferences.edit {
putBoolean(keyUseCustomTle, settings.useCustomTLE)
putBoolean(keyUseCustomTransceivers, settings.useCustomTransceivers)
putString(keyTleUrl, settings.tleUrl)
putString(keyTransceiversUrl, settings.transceiversUrl)
}
_dataSourcesSettings.value = settings
}
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
@@ -424,4 +365,40 @@ class SettingsRepo(
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: ""
)
//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 {
alias(libs.plugins.convention.kotlinLibraryPlugin)
alias(libs.plugins.convention.coreDomainPlugin)
}
@@ -63,3 +63,20 @@ data class DataSourcesSettings(
val tleUrl: 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) {
synchronized(this) {
val temp = DoubleArray(12)
val xmdf = data.xmo + dsv.xmdot * tSince
val tsq = tSince * tSince
val templ = t2cof * tsq
dsv.xll = xmdf + dsv.xnodp * templ
dsv.omgadf = data.omegao + dsv.omgdot * tSince
val xnoddf = data.xnodeo + dsv.xnodot * tSince
dsv.xnode = xnoddf + xnodcf * tsq
val tempa = 1.0 - c1 * tSince
val tempe = data.bstar * c4 * tSince
dsv.xn = dsv.xnodp
dsv.t = tSince
deep.dpsec(data)
val a = (XKE / dsv.xn).pow(TWO_THIRDS) * tempa * tempa
dsv.em -= tempe
deep.dpper()
val xl = dsv.xll + dsv.omgadf + dsv.xnode
val beta = sqrt(1.0 - dsv.em * dsv.em)
dsv.xn = XKE / a.pow(1.5)
// Long period periodics
val axn = dsv.em * cos(dsv.omgadf)
temp[0] = invert(a * beta * beta)
val xll = temp[0] * xlcof * axn
val aynl = temp[0] * aycof
val xlt = xl + xll
val ayn = dsv.em * sin(dsv.omgadf) + aynl
// Solve Kepler's equation
val capu = mod2PI(xlt - dsv.xnode)
temp[2] = capu
converge(temp, axn, ayn, capu)
calculatePosAndVel(temp, a, axn, ayn)
calculatePhase(xlt, dsv.xnode, dsv.omgadf)
}
val temp = DoubleArray(12)
val xmdf = data.xmo + dsv.xmdot * tSince
val tsq = tSince * tSince
val templ = t2cof * tsq
dsv.xll = xmdf + dsv.xnodp * templ
dsv.omgadf = data.omegao + dsv.omgdot * tSince
val xnoddf = data.xnodeo + dsv.xnodot * tSince
dsv.xnode = xnoddf + xnodcf * tsq
val tempa = 1.0 - c1 * tSince
val tempe = data.bstar * c4 * tSince
dsv.xn = dsv.xnodp
dsv.t = tSince
deep.dpsec(data)
val a = (XKE / dsv.xn).pow(TWO_THIRDS) * tempa * tempa
dsv.em -= tempe
deep.dpper()
val xl = dsv.xll + dsv.omgadf + dsv.xnode
val beta = sqrt(1.0 - dsv.em * dsv.em)
dsv.xn = XKE / a.pow(1.5)
// Long period periodics
val axn = dsv.em * cos(dsv.omgadf)
temp[0] = invert(a * beta * beta)
val xll = temp[0] * xlcof * axn
val aynl = temp[0] * aycof
val xlt = xl + xll
val ayn = dsv.em * sin(dsv.omgadf) + aynl
// Solve Kepler's equation
val capu = mod2PI(xlt - dsv.xnode)
temp[2] = capu
converge(temp, axn, ayn, capu)
calculatePosAndVel(temp, a, axn, ayn)
calculatePhase(xlt, dsv.xnode, dsv.omgadf)
}
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) {
synchronized(this) {
val temp = DoubleArray(9)
val xmdf = data.xmo + xmdot * tSince
val omgadf = data.omegao + omgdot * tSince
val xnoddf = data.xnodeo + xnodot * tSince
var omega = omgadf
var xmp = xmdf
val tsq = sqr(tSince)
val xnode = xnoddf + xnodcf * tsq
val bstar = data.bstar
var tempa = 1.0 - c1 * tSince
var tempe = bstar * c4 * tSince
var templ = t2cof * tsq
if (!sgp4Simple) {
val delomg = omgcof * tSince
val delm = xmcof * ((1.0 + eta * cos(xmdf)).pow(3.0) - delmo)
temp[0] = delomg + delm
xmp = xmdf + temp[0]
omega = omgadf - temp[0]
val tcube = tsq * tSince
val tfour = tSince * tcube
tempa = tempa - d2 * tsq - d3 * tcube - d4 * tfour
tempe += bstar * c5 * (sin(xmp) - sinmo)
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 temp = DoubleArray(9)
val xmdf = data.xmo + xmdot * tSince
val omgadf = data.omegao + omgdot * tSince
val xnoddf = data.xnodeo + xnodot * tSince
var omega = omgadf
var xmp = xmdf
val tsq = tSince * tSince
val xnode = xnoddf + xnodcf * tsq
val bstar = data.bstar
var tempa = 1.0 - c1 * tSince
var tempe = bstar * c4 * tSince
var templ = t2cof * tsq
if (!sgp4Simple) {
val delomg = omgcof * tSince
val delm = xmcof * ((1.0 + eta * cos(xmdf)).pow(3.0) - delmo)
temp[0] = delomg + delm
xmp = xmdf + temp[0]
omega = omgadf - temp[0]
val tcube = tsq * tSince
val tfour = tSince * tcube
tempa = tempa - d2 * tsq - d3 * tcube - d4 * tfour
tempe += bstar * c5 * (sin(xmp) - sinmo)
templ += t3cof * tcube + tfour * (t4cof + tSince * t5cof)
}
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(
@@ -42,6 +42,27 @@ abstract class OrbitalObject(val data: OrbitalData) {
var qoms24 = 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 {
return if (data.meanmo < 1e-8) false
else {
@@ -57,15 +78,13 @@ abstract class OrbitalObject(val data: OrbitalData) {
orbitalPos = OrbitalPos()
// Date/time at which the position and velocity were calculated
julUTC = calcCurrentDaynum(time) + 2444238.5
// Convert satellite's epoch time to Julian and calculate time since epoch in minutes
val julEpoch = juliandDateOfEpoch(data.epoch)
// Calculate time since epoch in minutes
val tsince = (julUTC - julEpoch) * MIN_PER_DAY
calculateSDP4orSGP4(tsince)
// Scale position and velocity vectors to km and km/sec
convertSatState(position, velocity)
// Calculate velocity of satellite
magnitude(velocity)
val squintVector = Vector4()
// Angles in rads, dist in km, vel in km/S. Calculate sat Az, El, Range and Range-rate.
calculateObs(julUTC, position, velocity, pos, squintVector)
calculateLatLonAlt(julUTC)
@@ -75,6 +94,38 @@ abstract class OrbitalObject(val data: OrbitalData) {
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 {
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
@@ -117,38 +168,34 @@ abstract class OrbitalObject(val data: OrbitalData) {
gsPos: GeoPos,
squintVector: Vector4
) {
val obsPos = Vector4()
val obsVel = Vector4()
val range = Vector4()
val rgvel = Vector4()
calculateUserPosVel(julianUTC, gsPos, obsPos, obsVel)
range.setXYZ(
rangeVector.setXYZ(
positionVector.x - obsPos.x,
positionVector.y - obsPos.y,
positionVector.z - obsPos.z
)
// Save these values globally for calculating squint angles later
squintVector.setXYZ(range.x, range.y, range.z)
rgvel.setXYZ(
squintVector.setXYZ(rangeVector.x, rangeVector.y, rangeVector.z)
rgvelVector.setXYZ(
velocityVector.x - obsVel.x,
velocityVector.y - obsVel.y,
velocityVector.z - obsVel.z
)
magnitude(range)
val sinLat = sin(DEG2RAD * gsPos.latitude)
val cosLat = cos(DEG2RAD * gsPos.latitude)
magnitude(rangeVector)
val sinLat = cachedSinLat
val cosLat = cachedCosLat
val sinTheta = sin(gsPosTheta)
val cosTheta = cos(gsPosTheta)
val topS = sinLat * cosTheta * range.x + sinLat * sinTheta * range.y - cosLat * range.z
val topE = -sinTheta * range.x + cosTheta * range.y
val topZ = cosLat * cosTheta * range.x + cosLat * sinTheta * range.y + sinLat * range.z
val topS = sinLat * cosTheta * rangeVector.x + sinLat * sinTheta * rangeVector.y - cosLat * rangeVector.z
val topE = -sinTheta * rangeVector.x + cosTheta * rangeVector.y
val topZ = cosLat * cosTheta * rangeVector.x + cosLat * sinTheta * rangeVector.y + sinLat * rangeVector.z
var azim = atan(-topE / topS)
if (topS > 0.0) azim += PI
if (azim < 0.0) azim += TWO_PI
orbitalPos.azimuth = azim
orbitalPos.elevation = asin(topZ / range.w)
orbitalPos.distance = range.w
orbitalPos.distanceRate = dot(range, rgvel) / range.w
orbitalPos.elevation = asin(topZ / rangeVector.w)
orbitalPos.distance = rangeVector.w
orbitalPos.distanceRate = dot(rangeVector, rgvelVector) / rangeVector.w
var elevation = orbitalPos.elevation / TWO_PI * 360.0
if (elevation > 90) elevation = 180 - elevation
orbitalPos.aboveHorizon = elevation - 0 > EPSILON
@@ -163,12 +210,24 @@ abstract class OrbitalObject(val data: OrbitalData) {
) {
val mFactor = 7.292115E-5
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
val achcp = (EARTH_RADIUS * c + gsPos.altitude / 1000.0) * cos(DEG2RAD * gsPos.latitude)
// Cache trig and position factors when observer position changes
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(
achcp * cos(gsPosTheta), achcp * sin(gsPosTheta),
(EARTH_RADIUS * sq + gsPos.altitude / 1000.0) * sin(DEG2RAD * gsPos.latitude)
cachedObsAchFactor * cos(gsPosTheta),
cachedObsAchFactor * sin(gsPosTheta),
cachedObsZFactor
)
obsVel.setXYZ(-mFactor * obsPos.y, mFactor * obsPos.x, 0.0)
magnitude(obsPos)
@@ -368,7 +427,7 @@ abstract class OrbitalObject(val data: OrbitalData) {
* The function Delta_ET has been added to allow calculations on the
* position of the sun. It provides the difference between UT (approximately
* the same as UTC) and ET (now referred to as TDT) This function is based
* on 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.
*
* Values determined using data from 1950-1991 in the 1990 Astronomical
@@ -25,7 +25,7 @@ data class OrbitalPass(
val altitude: Int = 1000,
val maxElevation: Double = 75.0,
val orbitalObject: OrbitalObject,
var progress: Float = 0.0f
val progress: Float = 0.0f
) {
val catNum: Int = orbitalObject.data.catnum
val name: String = orbitalObject.data.name
@@ -21,7 +21,6 @@ import kotlin.math.acos
import kotlin.math.asin
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
@@ -50,23 +49,32 @@ data class OrbitalPos(
}
fun getOrbitalVelocity(): Double {
val earthG = 6.674 * 10.0.pow(-11)
val earthM = 5.98 * 10.0.pow(24)
val radius = 6.37 * 10.0.pow(6) + altitude * 10.0.pow(3)
return sqrt(earthG * earthM / radius) / 1000
val radius = EARTH_RADIUS_M + altitude * 1000.0
return sqrt(GM_EARTH / radius) / 1000.0
}
fun getRangeCircle(): List<GeoPos> {
val rangeCirclePoints = mutableListOf<GeoPos>()
val beta = acos(EARTH_RADIUS / (EARTH_RADIUS + altitude)) // * EARTH_RADIUS = radiusKm
val pointCount = 721
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) {
val rads = azimuth * DEG2RAD
val lat = asin(sin(latitude) * cos(beta) + (cos(latitude) * sin(beta) * cos(rads)))
val lon = (longitude + atan2(
sin(rads) * sin(beta) * cos(latitude), cos(beta) - sin(latitude) * sin(lat)
))
val sinRads = sin(rads)
val cosRads = cos(rads)
val lat = asin(sinLat * cosBeta + cosLat * sinBeta * cosRads)
val lon = longitude + atan2(sinRads * sinBeta * cosLat, cosBeta - sinLat * sin(lat))
rangeCirclePoints.add(GeoPos(lat * RAD2DEG, lon * RAD2DEG))
}
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
fun provideAddToCalendar(): IAddToCalendar
fun provideShowToast(): IShowToast
fun provideBluetoothReporter(): IReporterRepo<WithoutExtParams>
fun provideNetworkReporter(): IReporterRepo<ExtendedParams>
fun provideBluetoothReporter(): IReporter
fun provideNetworkReporter(): IReporter
fun provideSensorsRepo(): ISensorsRepo
fun provideTxRadioController(): IRadioController
fun provideRxRadioController(): IRadioController
val radioTrackingService: IRadioTrackingService
}
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
interface IReporterParams
interface IReporterRepo<T : IReporterParams> {
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)
interface IReporter {
fun reportRotation(format: String, azimuth: Double, elevation: Double)
fun reportFrequency(format: String, frequency: Long)
}
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,27 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import kotlinx.coroutines.flow.StateFlow
interface ISatelliteRepo {
val passes: StateFlow<List<OrbitalPass>>
/** All selected OrbitalObjects, updated when the selection changes. */
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>>
/** Load satellite objects from DB based on the current selection. */
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>
suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List<SatRadio>, time: Long): List<SatRadio>
suspend fun processPasses(passList: List<OrbitalPass>, time: Long): List<OrbitalPass>
/** Recalculate passes with the given filter parameters. */
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.PassesSettings
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 kotlinx.coroutines.flow.StateFlow
@@ -57,39 +58,23 @@ interface ISettingsRepo {
//region # RC settings
val rcSettings: StateFlow<RCSettings>
fun setBluetoothRotatorAddress(value: String)
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)
fun updateRCSettings(settings: RCSettings)
//endregion
//region # Other settings
val otherSettings: StateFlow<OtherSettings>
fun setStateOfAutoUpdate(value: Boolean)
fun setStateOfSensors(value: Boolean)
fun setStateOfSweep(value: Boolean)
fun setStateOfUtc(value: Boolean)
fun setStateOfLightTheme(value: Boolean)
fun setWarningDismissed()
fun setWhatsNewDismissed()
fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings)
fun setWarningDismissed() = updateOtherSettings { it.copy(shouldSeeWarning = false) }
fun setWhatsNewDismissed() = updateOtherSettings { it.copy(shouldSeeWhatsNew = false) }
//endregion
//region # Transceivers settings
val dataSourcesSettings: StateFlow<DataSourcesSettings>
fun setUseCustomTle(value: Boolean)
fun setUseCustomTransceivers(value: Boolean)
fun setTleUrl(value: String)
fun setTransceiversUrl(value: String)
fun updateDataSourcesSettings(settings: DataSourcesSettings)
//endregion
//region # Radio control settings
val radioControlSettings: StateFlow<RadioControlSettings>
fun updateRadioControlSettings(settings: RadioControlSettings)
//endregion
}
@@ -29,126 +29,94 @@ import kotlin.math.pow
class DataParser(private val dispatcher: CoroutineDispatcher) {
suspend fun parseCSVStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
val parsedItems = mutableListOf<OrbitalData>()
stream.bufferedReader().useLines { lines ->
lines.forEachIndexed { index, line ->
if (index != 0) {
val values = line.split(",")
parseCSV(values)?.let { tle -> parsedItems.add(tle) }
}
}
lines.drop(1).mapNotNull { parseCSV(it.split(",")) }.toList()
}
return@withContext parsedItems
}
suspend fun parseTLEStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
val tleStrings = mutableListOf(String(), String(), String())
val parsedItems = mutableListOf<OrbitalData>()
var lineIndex = 0
stream.bufferedReader().forEachLine { line ->
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
stream.bufferedReader().readLines()
.chunked(3)
.filter { it.size == 3 && it[1].startsWith("1") && it[2].startsWith("2") }
.mapNotNull { parseTLE(it) }
}
suspend fun parseJSONStream(stream: InputStream): List<SatRadio> = withContext(dispatcher) {
val parsedItems = mutableListOf<SatRadio>()
try {
runCatching {
val jsonArray = JSONArray(stream.bufferedReader().readText())
for (index in 0 until jsonArray.length()) {
val jsonObject = jsonArray.getJSONObject(index)
parseJSON(jsonObject)?.let { parsedItems.add(it) }
}
return@withContext parsedItems
} catch (_: Exception) {
return@withContext parsedItems
}
(0 until jsonArray.length()).mapNotNull { parseJSON(jsonArray.getJSONObject(it)) }
}.getOrDefault(emptyList())
}
fun isLeapYear(year: Int): Boolean {
return ((year % 4 == 0) && (year % 100 != 0)) || (year % 400 == 0)
}
fun isLeapYear(year: Int): Boolean = (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 year = values[2].substring(0, 4)
val month = values[2].substring(5, 7)
val dayOfMonth = values[2].substring(8, 10)
val dayInt = getDayOfYear(year.toInt(), month.toInt(), dayOfMonth.toInt())
val day = if (dayInt < 10) "00$dayInt" else if (dayInt < 100) "0$dayInt" else "$dayInt"
val hour = values[2].substring(11, 13).toInt() * 3600000 // ms in one hour
val min = values[2].substring(14, 16).toInt() * 60000 // ms in one minute
val sec = values[2].substring(17, 19).toInt() * 1000 // ms in one second
val ms = values[2].substring(20, 26).toInt() / 1000.0 // microseconds to ms
val frac = ((hour + min + sec + ms) / 86400000.0).toString()
val epoch = "${year.substring(2)}$day${frac.substring(1)}".toDouble()
val meanmo = values[3].toDouble()
val eccn = values[4].toDouble()
val incl = values[5].toDouble()
val raan = values[6].toDouble()
val argper = values[7].toDouble()
val meanan = values[8].toDouble()
val catnum = values[11].toInt()
val bstar = values[14].toDouble()
OrbitalData(name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar)
} catch (exception: Exception) {
println("CSV parsing exception: $exception")
null
}
val timestamp = values[2]
val year = timestamp.substring(0, 4)
val month = timestamp.substring(5, 7).toInt()
val dayOfMonth = timestamp.substring(8, 10).toInt()
val dayInt = getDayOfYear(year.toInt(), month, dayOfMonth)
val day = dayInt.toString().padStart(3, '0')
val hour = timestamp.substring(11, 13).toInt() * 3600000
val min = timestamp.substring(14, 16).toInt() * 60000
val sec = timestamp.substring(17, 19).toInt() * 1000
val ms = timestamp.substring(20, 26).toInt() / 1000.0
val frac = ((hour + min + sec + ms) / 86400000.0).toString().substring(1)
val epoch = "${year.substring(2)}$day$frac".toDouble()
OrbitalData(
name = name,
epoch = epoch,
meanmo = values[3].toDouble(),
eccn = values[4].toDouble(),
incl = values[5].toDouble(),
raan = values[6].toDouble(),
argper = values[7].toDouble(),
meanan = values[8].toDouble(),
catnum = values[11].toInt(),
bstar = values[14].toDouble()
)
}.onFailure { println("CSV parsing exception: $it") }.getOrNull()
private fun parseTLE(tle: List<String>): OrbitalData? = try {
val name: String = tle[0].trim()
val epoch: Double = tle[1].substring(18, 32).toDouble()
val meanmo: Double = tle[2].substring(52, 63).toDouble()
val eccn: Double = tle[2].substring(26, 33).toDouble() / 10000000.0
val incl: Double = tle[2].substring(8, 16).toDouble()
val raan: Double = tle[2].substring(17, 25).toDouble()
val argper: Double = tle[2].substring(34, 42).toDouble()
val meanan: Double = tle[2].substring(43, 51).toDouble()
val catnum: Int = tle[1].substring(2, 7).trim().toInt()
val bstar: Double = 1.0e-5 * tle[1].substring(53, 59).toDouble() / 10.0.pow(tle[1].substring(60, 61).toDouble())
OrbitalData(name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar)
} catch (exception: Exception) {
println("TLE parsing exception: $exception")
null
}
private fun parseTLE(tle: List<String>): OrbitalData? = runCatching {
val line1 = tle[1]
val line2 = tle[2]
OrbitalData(
name = tle[0].trim(),
epoch = line1.substring(18, 32).toDouble(),
meanmo = line2.substring(52, 63).toDouble(),
eccn = line2.substring(26, 33).toDouble() / 1e7,
incl = line2.substring(8, 16).toDouble(),
raan = line2.substring(17, 25).toDouble(),
argper = line2.substring(34, 42).toDouble(),
meanan = line2.substring(43, 51).toDouble(),
catnum = line1.substring(2, 7).trim().toInt(),
bstar = 1e-5 * line1.substring(53, 59).toDouble() / 10.0.pow(line1.substring(60, 61).toDouble())
)
}.onFailure { println("TLE parsing exception: $it") }.getOrNull()
private fun parseJSON(json: JSONObject): SatRadio? = try {
val uuid = json.getString("uuid")
val info = json.getString("description")
val alive = json.getBoolean("alive")
val dlinkLow = if (json.isNull("downlink_low")) null else json.getLong("downlink_low")
val dlinkHigh = if (json.isNull("downlink_high")) null else json.getLong("downlink_high")
val dlinkMode = if (json.isNull("mode")) null else json.getString("mode")
val ulinkLow = if (json.isNull("uplink_low")) null else json.getLong("uplink_low")
val ulinkHigh = if (json.isNull("uplink_high")) null else json.getLong("uplink_high")
val ulinkMode = if (json.isNull("uplink_mode")) null else json.getString("uplink_mode")
val inverted = json.getBoolean("invert")
val catnum = if (json.isNull("norad_cat_id")) null else json.getInt("norad_cat_id")
SatRadio(uuid, info, alive, dlinkLow, dlinkHigh, dlinkMode, ulinkLow, ulinkHigh, ulinkMode, inverted, catnum)
} catch (exception: Exception) {
println("JSON parsing exception: $exception")
null
}
private fun parseJSON(json: JSONObject): SatRadio? = runCatching {
SatRadio(
uuid = json.getString("uuid"),
info = json.getString("description"),
isAlive = json.getBoolean("alive"),
downlinkLow = json.optLongOrNull("downlink_low"),
downlinkHigh = json.optLongOrNull("downlink_high"),
downlinkMode = json.optStringOrNull("mode"),
uplinkLow = json.optLongOrNull("uplink_low"),
uplinkHigh = json.optLongOrNull("uplink_high"),
uplinkMode = json.optStringOrNull("uplink_mode"),
isInverted = json.getBoolean("invert"),
catnum = json.optIntOrNull("norad_cat_id")
)
}.onFailure { println("JSON parsing exception: $it") }.getOrNull()
private fun JSONObject.optStringOrNull(key: String): String? = if (isNull(key)) null else getString(key)
private fun JSONObject.optLongOrNull(key: String): Long? = if (isNull(key)) null else getLong(key)
private fun JSONObject.optIntOrNull(key: String): Int? = if (isNull(key)) null else getInt(key)
private fun getDayOfYear(year: Int, month: Int, dayOfMonth: Int): Int {
if (month == 1) return dayOfMonth
val daysArray = arrayOf(31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
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
val daysInMonth = intArrayOf(31, if (isLeapYear(year)) 29 else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
return daysInMonth.take(month - 1).sum() + dayOfMonth
}
}
@@ -21,10 +21,11 @@ import java.util.Locale
import java.util.concurrent.TimeUnit
fun Long.toTimerString(): String {
val millis = coerceAtLeast(0L)
val format = "%02d:%02d:%02d"
val hours = TimeUnit.MILLISECONDS.toHours(this)
val minutes = TimeUnit.MILLISECONDS.toMinutes(this) % 60
val seconds = TimeUnit.MILLISECONDS.toSeconds(this) % 60
val hours = TimeUnit.MILLISECONDS.toHours(millis)
val minutes = TimeUnit.MILLISECONDS.toMinutes(millis) % 60
val seconds = TimeUnit.MILLISECONDS.toSeconds(millis) % 60
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 {
alias(libs.plugins.convention.composeLibraryPlugin)
alias(libs.plugins.convention.corePresentationPlugin)
}
android {
@@ -45,6 +45,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
@@ -62,8 +63,7 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val sdfTime = SimpleDateFormat("HH:mm:ss", Locale.ENGLISH)
import java.util.TimeZone
@Composable
@Preview(showBackground = true)
@@ -76,19 +76,23 @@ private fun TopBarPreview() = MainTheme {
}
@Composable
fun TopBar(content: @Composable (RowScope.() -> Unit)) {
fun TopBar(content: @Composable RowScope.() -> Unit) {
Row(
modifier = Modifier.height(48.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) { content() }
verticalAlignment = Alignment.CenterVertically,
content = content
)
}
@Composable
fun RowScope.TimerRow(timeString: String, isTimeAos: Boolean) {
val colorScheme = MaterialTheme.colorScheme
val aosColor = if (isTimeAos) colorScheme.primary else colorScheme.onSurface
val losColor = if (!isTimeAos) colorScheme.primary else colorScheme.onSurface
val (aosColor, losColor) = if (isTimeAos) {
colorScheme.primary to colorScheme.onSurface
} else {
colorScheme.onSurface to colorScheme.primary
}
ElevatedCard(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -100,7 +104,7 @@ fun RowScope.TimerRow(timeString: String, isTimeAos: Boolean) {
text = timeString,
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
color = colorScheme.primary
)
Text(text = "LOS", fontSize = 16.sp, fontWeight = FontWeight.Medium, color = losColor)
}
@@ -114,7 +118,13 @@ private fun NextPassRowPreview() = MainTheme {
}
@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(
modifier = modifier
.height(48.dp)
@@ -124,13 +134,11 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier) {
verticalArrangement = Arrangement.spacedBy((-2).dp),
modifier = Modifier
.fillMaxSize()
// .background(color = MaterialTheme.colorScheme.background)
.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) {
Text(
text = "$passSatId - ",
text = "${stringResource(R.string.pass_satId, pass.catNum)} - ",
color = MaterialTheme.colorScheme.primary
)
Text(
@@ -144,7 +152,7 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier) {
overflow = TextOverflow.Ellipsis
)
Icon(
painter = painterResource(id = R.drawable.ic_elevation),
painter = painterResource(R.drawable.ic_elevation),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
@@ -160,47 +168,30 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier) {
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = sdfTime.format(Date(pass.aosTime)),
fontSize = 15.sp,
modifier = Modifier.weight(1f)
)
}
Text(
text = sdfTime.format(Date(pass.aosTime)),
fontSize = 15.sp,
modifier = Modifier.weight(1f)
)
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(id = R.drawable.ic_altitude),
painter = painterResource(R.drawable.ic_altitude),
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "${pass.altitude} km",
fontSize = 15.sp
)
}
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 = "${pass.altitude} km", 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
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
ElevatedButton(
onClick = { onClick() }, colors = ButtonDefaults.buttonColors(
onClick = onClick,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
), shape = MaterialTheme.shapes.small, modifier = modifier,
),
shape = MaterialTheme.shapes.small,
modifier = modifier,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp)
) { Text(text = text, fontSize = 16.sp, textAlign = TextAlign.Center) }
}
@Composable
fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier) {
val clickableMod = Modifier.clickable { action() }
ElevatedCard(modifier = Modifier.size(48.dp)) {
Box(modifier = clickableMod.fillMaxSize(), contentAlignment = Alignment.Center) {
Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier)
}
Box(
modifier = Modifier
.clickable(onClick = action)
.fillMaxSize(),
contentAlignment = Alignment.Center
) { Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier) }
}
}
@Composable
fun PrimaryIconCard(modifier: Modifier = Modifier, resId: Int, onClick: () -> Unit) {
val clickableMod = modifier.clickable { onClick() }
ElevatedCard(
modifier = Modifier.size(102.dp, 48.dp),
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.primary)
) {
Box(modifier = clickableMod.fillMaxSize(), contentAlignment = Alignment.Center) {
Icon(painter = painterResource(id = resId), contentDescription = null)
}
Box(
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 {
val orbitalData = OrbitalData(
""" ¯\_(ツ)_/¯ ⚠️""",
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0,
0.0
)
val satellite = NearEarthObject(orbitalData)
return OrbitalPass(0L, 0.0, Long.MAX_VALUE, 0.0, 0, 0.0, satellite, 0f)
}
private val defaultOrbitalData = OrbitalData(
name = """ ¯\_(ツ)_/¯ ⚠️""",
epoch = 0.0, meanmo = 0.0, eccn = 0.0, incl = 0.0,
raan = 0.0, argper = 0.0, meanan = 0.0, catnum = 0, bstar = 0.0
)
fun getDefaultPass(): OrbitalPass = OrbitalPass(
aosTime = 0L, aosAzimuth = 0.0, losTime = Long.MAX_VALUE, losAzimuth = 0.0,
altitude = 0, maxElevation = 0.0, orbitalObject = NearEarthObject(defaultOrbitalData), progress = 0f
)
@Composable
fun SharedDialog(
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
) {
val padding = LocalSpacing.current.large
Dialog(onDismissRequest = { onCancel() }) {
ElevatedCard {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(padding)
) {
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))
}
}
DialogShell(title = title, titleFontSize = 16, onDismissRequest = onCancel) {
content()
Row(modifier = Modifier.padding(start = it, bottom = it, end = it)) {
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
Spacer(modifier = Modifier.weight(1f))
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
}
}
}
@Composable
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
Dialog(onDismissRequest = {}) {
Dialog(onDismissRequest = onDismissRequest) {
ElevatedCard {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
@@ -319,54 +317,36 @@ fun InfoDialog(title: String, text: String, onDismiss: () -> Unit) {
) {
Text(
text = title,
fontSize = 18.sp,
fontSize = titleFontSize.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = padding, top = padding, end = padding)
)
Text(
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)
)
}
content(padding)
}
}
}
}
@Composable
fun hasEnoughHeight(): Boolean {
return currentWindowAdaptiveInfo().windowSizeClass.isHeightAtLeastBreakpoint(480)
}
fun hasEnoughHeight(): Boolean =
currentWindowAdaptiveInfo().windowSizeClass.isHeightAtLeastBreakpoint(480)
@Composable
fun hasEnoughWidth(): Boolean {
return currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(600)
}
fun hasEnoughWidth(): Boolean =
currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(600)
@Composable
fun isVerticalLayout(): Boolean {
return currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(600).not()
}
fun isVerticalLayout(): Boolean = !hasEnoughWidth()
@Composable
fun Modifier.infiniteMarquee(): Modifier {
return basicMarquee(iterations = Int.MAX_VALUE, spacing = MarqueeSpacing(16.dp))
}
fun Modifier.infiniteMarquee(): Modifier =
basicMarquee(iterations = Int.MAX_VALUE, spacing = MarqueeSpacing(16.dp))
@Composable
fun Modifier.layoutPadding(): Modifier {
val statusBarMod = this.statusBarsPadding()
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
@@ -375,15 +355,15 @@ fun ScreenColumn(
floatingBar: @Composable () -> Unit = {},
content: @Composable (Boolean) -> Unit = {}
) {
val isVerticalLayout = isVerticalLayout()
val isVertical = isVerticalLayout()
Surface(color = MaterialTheme.colorScheme.background) {
Box(modifier = Modifier.layoutPadding(), contentAlignment = Alignment.BottomCenter) {
Column(verticalArrangement = Arrangement.spacedBy(LocalSpacing.current.extraSmall)) {
topBar(isVerticalLayout)
topBar(isVertical)
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surfaceContainer
) { content(isVerticalLayout) }
) { content(isVertical) }
}
floatingBar()
}
@@ -394,21 +374,14 @@ fun ScreenColumn(
fun TopBar(
isVerticalLayout: Boolean,
startAction: @Composable () -> Unit,
topInfo: @Composable (RowScope.() -> Unit),
bottomInfo: @Composable (RowScope.() -> Unit),
topInfo: @Composable RowScope.() -> Unit,
bottomInfo: @Composable RowScope.() -> 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) {
topBarRow { startAction().also { topInfo() }.also { endAction() } }
topBarRow { bottomInfo() }
TopBar { startAction(); topInfo(); endAction() }
TopBar { bottomInfo() }
} 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 Map : Screen("map", R.drawable.ic_map, R.string.nav_map)
data object Settings : Screen("settings", R.drawable.ic_settings, R.string.nav_prefs)
data object RadioControl : Screen("radiocontrol", R.drawable.ic_radios, R.string.nav_radiocontrol)
}
@@ -118,12 +118,10 @@
<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_ip_hint">Dirección IP</string>
<string name="prefs_net_rotator_port_hint">Puerto</string>
<string name="prefs_net_rotator_address_hint">IP:Puerto</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_ip_hint">Dirección IP</string>
<string name="prefs_net_frequency_port_hint">Puerto</string>
<string name="prefs_net_frequency_address_hint">IP:Puerto</string>
<string name="prefs_net_frequency_format_hint">Formato de datos</string>
<string name="prefs_bt_title">Salida por Bluetooth</string>
@@ -117,12 +117,10 @@
<string name="prefs_net_title">Вывод сетевых данных</string>
<string name="prefs_net_rotator_switch">Включить вывод ротатора</string>
<string name="prefs_net_rotator_ip_hint">IP адрес</string>
<string name="prefs_net_rotator_port_hint">Порт</string>
<string name="prefs_net_rotator_address_hint">IP:Порт</string>
<string name="prefs_net_rotator_format_hint">Формат</string>
<string name="prefs_net_frequency_switch">Включить вывод частоты</string>
<string name="prefs_net_frequency_ip_hint">IP адрес</string>
<string name="prefs_net_frequency_port_hint">Порт</string>
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
<string name="prefs_net_frequency_format_hint">Формат</string>
<string name="prefs_bt_title">Вывод данных Bluetooth</string>
@@ -118,12 +118,10 @@
<string name="prefs_net_title">ජාල ප්‍රතිදානය</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_port_hint">Port</string>
<string name="prefs_net_rotator_address_hint">IP:Port</string>
<string name="prefs_net_rotator_format_hint">දත්ත අකාරය</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_port_hint">Port</string>
<string name="prefs_net_frequency_address_hint">IP:Port</string>
<string name="prefs_net_frequency_format_hint">දත්ත අකාරය</string>
<string name="prefs_bt_title">Bluetooth දත්ත ප්‍රතිදානය</string>
@@ -118,12 +118,10 @@
<string name="prefs_net_title">Вивід мережевих даних</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_port_hint">Порт</string>
<string name="prefs_net_rotator_address_hint">IP:Порт</string>
<string name="prefs_net_rotator_format_hint">Формат даних</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_port_hint">Порт</string>
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
<string name="prefs_net_frequency_format_hint">Формат даних</string>
<string name="prefs_bt_title">Вивід даних Bluetooth</string>
@@ -110,12 +110,10 @@
<string name="prefs_net_title">网络数据输出</string>
<string name="prefs_net_rotator_switch">启用方位俯仰角输出</string>
<string name="prefs_net_rotator_ip_hint">IP地址</string>
<string name="prefs_net_rotator_port_hint">端口</string>
<string name="prefs_net_rotator_address_hint">IP:端口</string>
<string name="prefs_net_rotator_format_hint">数据格式</string>
<string name="prefs_net_frequency_switch">启用多普勒频率输出</string>
<string name="prefs_net_frequency_ip_hint">IP地址</string>
<string name="prefs_net_frequency_port_hint">端口</string>
<string name="prefs_net_frequency_address_hint">IP:端口</string>
<string name="prefs_net_frequency_format_hint">数据格式</string>
<string name="prefs_bt_title">蓝牙数据输出</string>
@@ -48,8 +48,11 @@
\n\nPlease update the database at least weekly to get accurate predictions.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false">
* Londre7 fixed the track path creation issue for split screen
as well as enabled the compass data usage for landscape orientation
* 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 -->
@@ -144,15 +147,24 @@
<string name="prefs_data_output_title">Data output</string>
<string name="prefs_net_output">Network</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_rotator_switch">Enable rotation output</string>
<string name="prefs_net_rotator_ip_hint">IP address</string>
<string name="prefs_net_rotator_port_hint">Port</string>
<string name="prefs_net_rotator_address_hint">IP:Port</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_ip_hint">IP address</string>
<string name="prefs_net_frequency_port_hint">Port</string>
<string name="prefs_net_frequency_address_hint">IP:Port</string>
<string name="prefs_net_frequency_format_hint">Format</string>
<string name="prefs_bt_title">Bluetooth data output</string>
@@ -1,2 +1,6 @@
* Fixed track path creation issue for split screens (#199) by londre7
* Enabled using compass data for landscape orientation (#202) by londre7
* Switched to short-form commands in NetworkReporter (#206) by theojalba
* Added CAT radio control for full-duplex SAT operation (#207) by jcmerg
* Added normalized time for passes AOS/LOS, fixed refresh state bug
* Added multiple performance tweaks, improved passes calculation
* Simplified data output code, aligned with HamLib defaults
* Fixed UTC pass time problem mentioned in issue #201
@@ -1,2 +1,6 @@
* Fixed track path creation issue for split screens (#199) by londre7
* Enabled using compass data for landscape orientation (#202) by londre7
* Switched to short-form commands in NetworkReporter (#206) by theojalba
* Added CAT radio control for full-duplex SAT operation (#207) by jcmerg
* Added normalized time for passes AOS/LOS, fixed refresh state bug
* Added multiple performance tweaks, improved passes calculation
* Simplified data output code, aligned with HamLib defaults
* Fixed UTC pass time problem mentioned in issue #201
+1 -1
View File
@@ -1,5 +1,5 @@
plugins {
alias(libs.plugins.convention.composeFeaturePlugin)
alias(libs.plugins.convention.featurePlugin)
}
android {
@@ -24,6 +24,7 @@ import android.graphics.ColorMatrixColorFilter
import android.graphics.Paint
import android.graphics.Rect
import android.graphics.drawable.Drawable
import androidx.collection.LruCache
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -40,7 +41,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
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.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 maxLat = MapView.getTileSystem().maxLatitude
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()
setShadowLayer(3f, 3f, 3f, Color.BLACK)
}
private val labelRect = Rect()
private val iconCache = LruCache<String, Drawable>(128)
fun NavGraphBuilder.mapDestination() {
composable(Screen.Map.route) {
val viewModel = viewModel(MapViewModel::class.java, factory = MapViewModel.Factory)
val uiState = viewModel.uiState.collectAsStateWithLifecycle()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val mapView = rememberMapViewWithLifecycle()
MapScreen(uiState, mapView)
MapScreen(uiState, viewModel::onAction, mapView)
}
}
@Composable
private fun MapScreen(uiState: State<MapState>, 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) }
private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView: MapView) {
val rotateMod = Modifier.rotate(180f)
val timeString = uiState.value.mapData?.aosTime ?: "00:00:00"
val isTimeAos = uiState.value.mapData?.isTimeAos ?: true
LaunchedEffect(uiState.value.track) {
val latitude = uiState.value.track?.get(0)?.get(0)?.latitude ?: 0.0
val longitude = uiState.value.track?.get(0)?.get(0)?.longitude ?: 0.0
mapView.controller.animateTo(GeoPoint(latitude, longitude))
val timeString = uiState.mapData?.aosTime ?: "00:00:00"
val isTimeAos = uiState.mapData?.isTimeAos ?: true
LaunchedEffect(uiState.track) {
val firstPos = uiState.track?.firstOrNull()?.firstOrNull() ?: return@LaunchedEffect
mapView.controller.animateTo(GeoPoint(firstPos.latitude, firstPos.longitude))
}
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (isVerticalLayout()) {
val isVertical = isVerticalLayout()
if (isVertical) {
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)
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 {
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)
NextPassRow(pass = uiState.value.orbitalPass, modifier = Modifier.weight(1f))
IconCard(action = selectNext, resId = R.drawable.ic_arrow)
NextPassRow(pass = uiState.orbitalPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
IconCard(action = { onAction(MapAction.SelectNext) }, resId = R.drawable.ic_arrow)
}
}
ElevatedCard(modifier = Modifier.weight(1f)) {
Box(contentAlignment = Alignment.BottomCenter) {
AndroidView({ mapView }) { mapView ->
uiState.value.stationPosition?.let { setStationPosition(it, mapView) }
uiState.value.positions?.let { setPositions(it, mapView, onItemClick) }
uiState.value.track?.let { setSatelliteTrack(it, mapView) }
uiState.value.footprint?.let { setFootprint(it, mapView) }
AndroidView({ mapView }) { view ->
uiState.stationPosition?.let { setStationPosition(it, view) }
uiState.track?.let { setSatelliteTrack(it, view) }
uiState.footprint?.let { setFootprint(it, view) }
uiState.positions?.let { setPositions(it, view) { item -> onAction(MapAction.SelectItem(item)) } }
view.invalidate()
}
uiState.value.mapData?.let { mapData ->
if (isVerticalLayout()) MapDataCard(mapData) else MapDataCards(mapData)
uiState.mapData?.let { mapData ->
if (isVertical) MapDataCard(mapData) else MapDataCards(mapData)
}
}
}
}
}
// region Map data composables
@Composable
private fun MapDataCard(data: MapData) {
val textColor = MaterialTheme.colorScheme.primary
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)
Card(colors = cardColors) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.map_azimuth, data.azimuth), color = textColor)
Text(text = stringResource(R.string.map_elevation, data.elevation), color = textColor)
}
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.map_altitude, data.altitude))
Text(text = stringResource(R.string.map_distance, data.range))
}
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.map_latitude, data.osmPos.latitude), color = textColor)
Text(text = stringResource(R.string.map_longitude, data.osmPos.longitude), color = textColor)
}
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.map_qth, data.qthLoc))
Text(text = stringResource(R.string.map_phase, data.phase))
}
MapDataRow(
stringResource(R.string.map_azimuth, data.azimuth) to textColor,
stringResource(R.string.map_elevation, data.elevation) to textColor
)
MapDataRow(
stringResource(R.string.map_altitude, data.altitude) to null,
stringResource(R.string.map_distance, data.range) to null
)
MapDataRow(
stringResource(R.string.map_latitude, data.osmPos.latitude) to textColor,
stringResource(R.string.map_longitude, data.osmPos.longitude) to textColor
)
MapDataRow(
stringResource(R.string.map_qth, data.qthLoc) to null,
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
private fun MapDataCards(data: MapData) {
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
val paddingMod = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.width(160.dp)
val osmText = stringResource(R.string.map_copyright)
val textColor = MaterialTheme.colorScheme.primary
Box(modifier = Modifier.fillMaxSize()) {
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 = 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)) {
Column(horizontalAlignment = Alignment.End, modifier = paddingMod) {
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) {
try {
Marker(mapView).apply {
setInfoWindow(null)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
icon = ContextCompat.getDrawable(mapView.context, R.drawable.ic_position)
position = GeoPoint(stationPos.latitude, stationPos.longitude)
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 {
val overlay = mapView.overlays[OVERLAY_STATION]
if (overlay is Marker) {
overlay.position = GeoPoint(stationPos.latitude, stationPos.longitude)
} else {
mapView.overlays[OVERLAY_STATION] = Marker(mapView).apply {
setInfoWindow(null)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
icon = getCustomTextIcon(it.key.data.name, mapView)
try {
position = GeoPoint(it.value.latitude, it.value.longitude)
} catch (exception: IllegalArgumentException) {
println(exception.stackTraceToString())
}
setOnMarkerClickListener { _, _ ->
action(it.key)
return@setOnMarkerClickListener true
}
markers.add(this)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
icon = ContextCompat.getDrawable(mapView.context, R.drawable.ic_position)
position = GeoPoint(stationPos.latitude, stationPos.longitude)
}
}
mapView.overlays[3] = markers
mapView.invalidate()
} catch (exception: Exception) {
println(exception)
} catch (e: Exception) {
println(e)
}
}
private fun getCustomTextIcon(textLabel: String, mapView: MapView): Drawable {
textPaint.getTextBounds(textLabel, 0, textLabel.length, labelRect)
/** Pool of reusable Marker objects keyed by satellite name, to avoid re-creation every frame */
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 width = labelRect.width() + iconSize * 2f
val height = textPaint.textSize * 3f + iconSize * 2f
val bitmap = createBitmap(width.toInt(), height.toInt())
Canvas(bitmap).run {
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) {
val trackOverlay = FolderOverlay()
try {
satTrack.forEach { track ->
val trackPoints = track.map { GeoPoint(it.latitude, it.longitude) }
Polyline().apply {
setPoints(trackPoints)
setPoints(track.map { GeoPoint(it.latitude, it.longitude) })
outlinePaint.set(trackPaint)
trackOverlay.add(this)
}
}
mapView.overlays[1] = trackOverlay
} catch (exception: Exception) {
println(exception)
mapView.overlays[OVERLAY_TRACK] = trackOverlay
} catch (e: 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) {
val footprintPoints = orbitalPos.getRangeCircle().map { GeoPoint(it.latitude, it.longitude) }
try {
val footprintOverlay = Polyline().apply {
outlinePaint.set(footprintPaint)
setPoints(footprintPoints)
val rangeCircle = orbitalPos.getRangeCircle()
// Lazily initialize the reusable point list and polyline
var pts = footprintPoints
if (pts == null || pts.size != rangeCircle.size) {
pts = ArrayList(rangeCircle.size)
for (gp in rangeCircle) pts.add(GeoPoint(gp.latitude, gp.longitude))
footprintPoints = pts
} else {
// Update coordinates in-place — zero allocations
for (i in rangeCircle.indices) {
pts[i].latitude = rangeCircle[i].latitude
pts[i].longitude = rangeCircle[i].longitude
}
}
mapView.overlays[2] = footprintOverlay
} catch (exception: Exception) {
println(exception)
val polyline = footprintPolyline ?: Polyline().apply {
outlinePaint.set(footprintPaint)
footprintPolyline = this
}
polyline.setPoints(pts)
mapView.overlays[OVERLAY_FOOTPRINT] = polyline
} catch (e: Exception) {
println(e)
}
}
// endregion
// region MapView lifecycle
@Composable
private fun rememberMapViewWithLifecycle(): MapView {
val tileSource = XYTileSource("tiles", 0, 6, 256, ".webp", emptyArray<String>())
val context = LocalContext.current
val mapView = remember { MapView(context) }.apply {
setMultiTouchControls(true)
setUseDataConnection(false)
setTileSource(tileSource)
minZoomLevel = getMinZoom(resources.displayMetrics.heightPixels)
maxZoomLevel = 7.0
controller.setCenter(GeoPoint(48.8575, 6.3514))
controller.setZoom(minZoomLevel + 2)
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
overlayManager.tilesOverlay.loadingBackgroundColor = Color.TRANSPARENT
overlayManager.tilesOverlay.loadingLineColor = Color.TRANSPARENT
overlayManager.tilesOverlay.setColorFilter(getColorFilter())
setScrollableAreaLimitLatitude(maxLat, minLat, 0)
// add overlays: 0 - GSP, 1 - SatTrack, 2 - SatFootprint, 3 - SatIcons
overlays.addAll(Array(4) { FolderOverlay() })
val isVertical = isVerticalLayout()
val mapView = remember {
MapView(context).apply {
setMultiTouchControls(true)
setUseDataConnection(false)
setTileSource(tileSource)
minZoomLevel = getMinZoom(resources.displayMetrics.heightPixels, isVertical)
maxZoomLevel = 7.0
controller.setCenter(GeoPoint(48.8575, 6.3514))
controller.setZoom(minZoomLevel + 2)
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
overlayManager.tilesOverlay.loadingBackgroundColor = Color.TRANSPARENT
overlayManager.tilesOverlay.loadingLineColor = Color.TRANSPARENT
overlayManager.tilesOverlay.setColorFilter(createColorFilter())
setScrollableAreaLimitLatitude(maxLat, minLat, 0)
overlays.addAll(Array(OVERLAY_COUNT) { FolderOverlay() })
}
}
// Makes MapView follow the lifecycle of this composable
val lifecycleObserver = rememberMapViewLifecycleObserver(mapView)
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) {
@@ -348,8 +434,7 @@ private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapVie
}
}
@Composable
private fun getColorFilter(): ColorMatrixColorFilter {
private fun createColorFilter(): ColorMatrixColorFilter {
val grayScale = ColorMatrix().apply { setSaturation(0f) }
val negative = ColorMatrix(
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)
}
@Composable
private fun getMinZoom(screenHeight: Int): Double {
if (!isVerticalLayout()) return 3.5
private fun getMinZoom(screenHeight: Int, isVertical: Boolean): Double {
if (!isVertical) return 3.5
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
data class MapState(
val mapData: MapData?,
val isLightUi: Boolean,
val stationPosition: GeoPos?,
val mapData: MapData? = null,
val isLightUi: Boolean = false,
val isUtc: Boolean = false,
val stationPosition: GeoPos? = null,
val orbitalPass: OrbitalPass,
val track: List<List<GeoPos>>?,
val footprint: OrbitalPos?,
val positions: Map<OrbitalObject, GeoPos>?,
val sendAction: (MapAction) -> Unit
val track: List<List<GeoPos>>? = null,
val footprint: OrbitalPos? = null,
val positions: Map<OrbitalObject, GeoPos>? = null
)
sealed class MapAction {
data object SelectPrev: MapAction()
data object SelectNext: MapAction()
data class SelectItem(val item: OrbitalObject): MapAction()
data class SelectDefaultItem(val catnum: Int): MapAction()
sealed interface MapAction {
data object SelectPrev : MapAction
data object SelectNext : MapAction
data class SelectItem(val item: OrbitalObject) : MapAction
data class SelectDefaultItem(val catnum: Int) : MapAction
}
data class MapData(
@@ -24,6 +24,8 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
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.presentation.getDefaultPass
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.Date
class MapViewModel(private val satelliteRepo: ISatelliteRepo, settingsRepo: ISettingsRepo) :
class MapViewModel(private val satelliteRepo: ISatelliteRepo, private val settingsRepo: ISettingsRepo) :
ViewModel() {
private val stationPos = settingsRepo.stationPosition.value
private val defaultPass = getDefaultPass()
private val _uiState = MutableStateFlow(
MapState(
mapData = null,
isLightUi = settingsRepo.otherSettings.value.stateOfLightTheme,
stationPosition = null,
orbitalPass = defaultPass,
track = null,
footprint = null,
positions = null,
sendAction = ::handleAction
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
orbitalPass = defaultPass
)
)
private var allPasses = satelliteRepo.passes.value
@@ -68,10 +69,15 @@ class MapViewModel(private val satelliteRepo: ISatelliteRepo, settingsRepo: ISet
val uiState: StateFlow<MapState> = _uiState
init {
viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings ->
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
}
}
selectDefaultSatellite(-1)
}
private fun handleAction(action: MapAction) {
fun onAction(action: MapAction) {
when (action) {
MapAction.SelectPrev -> scrollSelection(true)
MapAction.SelectNext -> scrollSelection(false)
@@ -120,15 +126,133 @@ class MapViewModel(private val satelliteRepo: ISatelliteRepo, settingsRepo: ISet
getSatTrack(orbitalObject, stationPos, dateNow)
while (isActive) {
dateNow.time = System.currentTimeMillis()
getPositions(allSatellites, stationPos, dateNow)
getSatFootprint(orbitalObject, stationPos, dateNow)
getSatData(orbitalObject, stationPos, dateNow)
updateMapState(orbitalObject, allSatellites, stationPos, dateNow)
delay(updateFreq)
}
}
}
}
/**
* Combined update: computes all satellite positions in parallel, then derives
* the selected satellite's footprint and info panel data from the same batch,
* and emits a single state update to avoid redundant recompositions.
*/
private suspend fun updateMapState(
selected: OrbitalObject,
orbitalObjects: List<OrbitalObject>,
pos: GeoPos,
date: Date
) {
// 1. Compute all positions in parallel, collecting full OrbitalPos for the selected sat
val positionsMap = HashMap<OrbitalObject, GeoPos>(orbitalObjects.size)
var selectedSatPos: OrbitalPos? = null
coroutineScope {
// Chunk satellites to balance parallelism vs coroutine overhead
val chunkSize = maxOf(1, orbitalObjects.size / PARALLEL_CHUNKS)
val deferreds = orbitalObjects.chunked(chunkSize).map { chunk ->
async {
val localPositions = ArrayList<Pair<OrbitalObject, GeoPos>>(chunk.size)
var localSelectedPos: OrbitalPos? = null
for (satellite in chunk) {
val satPos = satelliteRepo.getPosition(satellite, pos, date.time)
val osmLat = clipLat(satPos.latitude.toDegrees())
val osmLon = clipLon(satPos.longitude.toDegrees())
localPositions.add(satellite to GeoPos(osmLat, osmLon))
if (satellite === selected) {
localSelectedPos = satPos
}
}
Pair(localPositions, localSelectedPos)
}
}
for ((localPositions, localSelectedPos) in deferreds.awaitAll()) {
for (pair in localPositions) {
positionsMap[pair.first] = pair.second
}
if (localSelectedPos != null) {
selectedSatPos = localSelectedPos
}
}
}
// 2. Derive footprint 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) {
val satTracks = mutableListOf<List<GeoPos>>()
val currentTrack = mutableListOf<GeoPos>()
@@ -158,71 +282,10 @@ class MapViewModel(private val satelliteRepo: ISatelliteRepo, settingsRepo: ISet
_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 {
/** Number of parallel chunks for satellite position computation */
private const val PARALLEL_CHUNKS = 4
val Factory: ViewModelProvider.Factory = viewModelFactory {
val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY
initializer {
+1 -1
View File
@@ -1,5 +1,5 @@
plugins {
alias(libs.plugins.convention.composeFeaturePlugin)
alias(libs.plugins.convention.featurePlugin)
}
android {
@@ -32,12 +32,11 @@ import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableDoubleStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -61,6 +60,8 @@ private val allModes = listOf(
"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
@Composable
private fun PassesDialogPreview() {
@@ -68,27 +69,28 @@ private fun PassesDialogPreview() {
}
@Composable
fun PassesDialog(hours: Int, elevation: Double, cancel: () -> Unit, accept: (Int, Double) -> Unit) {
val hoursValue = remember { mutableIntStateOf(hours) }
internal fun PassesDialog(hours: Int, elevation: Double, cancel: () -> Unit, accept: (Int, Double) -> Unit) {
val hoursIndex = remember { mutableIntStateOf(hourSteps.indexOfFirst { it >= hours }.coerceAtLeast(0)) }
val elevationValueNew = remember { mutableDoubleStateOf(elevation) }
val onAccept = {
accept(hoursValue.intValue, elevationValueNew.doubleValue).also { cancel() }
accept(hourSteps[hoursIndex.intValue], elevationValueNew.doubleValue).also { cancel() }
}
SharedDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) {
SliderRow(
title = stringResource(R.string.pass_filter_elev),
value = elevationValueNew.doubleValue,
valuePostfix = "°",
displayValue = "${elevationValueNew.doubleValue.toInt()}°",
valueResId = R.drawable.ic_elevation,
valueRange = 0f..60f
) { elevationValueNew.doubleValue = it.toDouble() }
SliderRow(
title = stringResource(R.string.pass_filter_hours),
value = hoursValue.intValue.toDouble(),
valuePostfix = "h",
value = hoursIndex.intValue.toDouble(),
displayValue = "${hourSteps[hoursIndex.intValue]}h",
valueResId = R.drawable.ic_clock,
valueRange = 1f..240f
) { hoursValue.intValue = it.toInt() }
valueRange = 0f..(hourSteps.size - 1).toFloat(),
steps = hourSteps.size - 2
) { hoursIndex.intValue = it.toInt().coerceIn(0, hourSteps.size - 1) }
}
}
@@ -96,9 +98,10 @@ fun PassesDialog(hours: Int, elevation: Double, cancel: () -> Unit, accept: (Int
private fun SliderRow(
title: String,
value: Double,
valuePostfix: String,
displayValue: String,
valueResId: Int,
valueRange: ClosedFloatingPointRange<Float>,
steps: Int = 0,
onChange: (Float) -> Unit
) {
Column(
@@ -123,13 +126,13 @@ private fun SliderRow(
modifier = Modifier.size(20.dp)
)
Text(
text = "${value.toInt()}$valuePostfix",
text = displayValue,
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
}
Slider(value = value.toFloat(), onValueChange = onChange, valueRange = valueRange)
Slider(value = value.toFloat(), onValueChange = onChange, valueRange = valueRange, steps = steps)
}
}
@@ -140,12 +143,12 @@ private fun RadiosDialogPreview() {
}
@Composable
fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit) {
val selected = remember { mutableStateListOf<String>().apply { addAll(modes) } }
val select = { mode: String ->
if (selected.contains(mode)) selected.remove(mode) else selected.add(mode)
internal fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit) {
val selected = remember { mutableStateOf(modes.toSet()) }
val toggle = { mode: String ->
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) {
LazyVerticalGrid(
columns = GridCells.Adaptive(240.dp),
@@ -156,32 +159,30 @@ fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List<String>)
verticalArrangement = Arrangement.spacedBy(1.dp)
) {
itemsIndexed(allModes) { index, item ->
Surface {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.background(MaterialTheme.colorScheme.surface)
.clickable { select(item) }
) {
Text(
text = "${index + 1}).",
modifier = Modifier.padding(start = 16.dp, end = 8.dp),
fontWeight = FontWeight.Normal,
color = MaterialTheme.colorScheme.primary
)
Text(
text = item,
modifier = Modifier.weight(1f),
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Checkbox(
checked = selected.contains(item),
onCheckedChange = null,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.background(MaterialTheme.colorScheme.surface)
.clickable { toggle(item) }
) {
Text(
text = "${index + 1}).",
modifier = Modifier.padding(start = 16.dp, end = 8.dp),
fontWeight = FontWeight.Normal,
color = MaterialTheme.colorScheme.primary
)
Text(
text = item,
modifier = Modifier.weight(1f),
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Checkbox(
checked = item in selected.value,
onCheckedChange = null,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
}
}
@@ -35,15 +35,16 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
@@ -76,9 +77,7 @@ import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val sdfDate = SimpleDateFormat("EEE dd MMM", Locale.ENGLISH)
private val sdfTime = SimpleDateFormat("HH:mm:ss", Locale.ENGLISH)
import java.util.TimeZone
fun NavGraphBuilder.passesDestination(navigateToRadar: (Int, Long) -> Unit) {
composable(Screen.Passes.route) {
@@ -87,27 +86,31 @@ fun NavGraphBuilder.passesDestination(navigateToRadar: (Int, Long) -> Unit) {
factory = PassesViewModel.Factory
)
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
PassesScreen(uiState, navigateToRadar)
PassesScreen(uiState, viewModel::onAction, navigateToRadar)
}
}
@Composable
private fun PassesScreen(uiState: PassesState, navigateToRadar: (Int, Long) -> Unit) {
val refreshList = { uiState.takeAction(PassesAction.RefreshPasses) }
val showPassesDialog = { uiState.takeAction(PassesAction.TogglePassesDialog) }
val showRadiosDialog = { uiState.takeAction(PassesAction.ToggleRadiosDialog) }
private fun PassesScreen(
uiState: PassesState,
onAction: (PassesAction) -> Unit,
navigateToRadar: (Int, Long) -> Unit
) {
if (uiState.isPassesDialogShown) {
PassesDialog(
hours = uiState.hours,
elevation = uiState.elevation,
cancel = showPassesDialog
cancel = { onAction(PassesAction.TogglePassesDialog) }
) { hours, elevation ->
uiState.takeAction(PassesAction.FilterPasses(hours, elevation))
onAction(PassesAction.FilterPasses(hours, elevation))
}
}
if (uiState.isRadiosDialogShown) {
RadiosDialog(modes = uiState.modes, cancel = showRadiosDialog) { modes ->
uiState.takeAction(PassesAction.FilterRadios(modes))
RadiosDialog(
modes = uiState.modes,
cancel = { onAction(PassesAction.ToggleRadiosDialog) }
) { modes ->
onAction(PassesAction.FilterRadios(modes))
}
}
if (uiState.shouldSeeWhatsNew) {
@@ -115,7 +118,7 @@ private fun PassesScreen(uiState: PassesState, navigateToRadar: (Int, Long) -> U
title = stringResource(R.string.pass_whatsnew_title),
text = stringResource(R.string.pass_whatsnew_message)
) {
uiState.takeAction(PassesAction.DismissWhatsNew)
onAction(PassesAction.DismissWhatsNew)
}
}
val gridState = rememberLazyGridState()
@@ -124,25 +127,26 @@ private fun PassesScreen(uiState: PassesState, navigateToRadar: (Int, Long) -> U
TopBar(
isVerticalLayout = isVerticalLayout,
startAction = {
IconCard(action = showPassesDialog, resId = R.drawable.ic_filter)
IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter)
},
topInfo = {
TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos)
},
bottomInfo = {
NextPassRow(pass = uiState.nextPass)
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
},
endAction = {
IconCard(action = showRadiosDialog, resId = R.drawable.ic_radios)
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
}
)
}
) { _ ->
PassesList(
isRefreshing = uiState.isRefreshing,
isUtc = uiState.isUtc,
passes = uiState.itemsList,
navigateToRadar = navigateToRadar,
refreshPasses = refreshList,
refreshPasses = { onAction(PassesAction.RefreshPasses) },
gridState = gridState
)
}
@@ -152,6 +156,7 @@ private fun PassesScreen(uiState: PassesState, navigateToRadar: (Int, Long) -> U
@Composable
private fun PassesList(
isRefreshing: Boolean,
isUtc: Boolean,
passes: List<OrbitalPass>,
navigateToRadar: (Int, Long) -> Unit,
refreshPasses: () -> Unit,
@@ -183,11 +188,12 @@ private fun PassesList(
modifier = Modifier.fillMaxSize()
) {
items(items = passes, key = { item -> item.catNum + item.aosTime }) { pass ->
NearEarthPass(
PassItem(
pass = pass,
navigateToRadar = navigateToRadar,
modifier = Modifier.animateItem(),
isVerticalLayout = isVerticalLayout
isVerticalLayout = isVerticalLayout,
isUtc = isUtc
)
}
}
@@ -202,7 +208,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 satellite = DeepSpaceObject(data)
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)
@@ -211,126 +217,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 satellite = NearEarthObject(data)
val pass = OrbitalPass(1L, 180.0, 10L, 360.0, 36650, 45.0, satellite, 0.5f)
MainTheme { NearEarthPass(pass = pass, { _, _ -> }) }
MainTheme { PassItem(pass = pass, { _, _ -> }) }
}
@Composable
private fun NearEarthPass(
private fun PassItem(
pass: OrbitalPass,
navigateToRadar: (Int, Long) -> Unit,
modifier: Modifier = Modifier,
isVerticalLayout: Boolean = true
isVerticalLayout: Boolean = true,
isUtc: Boolean = false
) {
val passSatId = stringResource(id = R.string.pass_satId, pass.catNum)
val horizontalPadding = if (isVerticalLayout) 6.dp else 10.dp
Surface(color = MaterialTheme.colorScheme.background, modifier = modifier) {
Surface(modifier = Modifier
.padding(bottom = 2.dp)
.clickable { navigateToRadar(pass.catNum, pass.aosTime) }) {
Column(
verticalArrangement = Arrangement.spacedBy(1.dp),
modifier = Modifier
.background(color = MaterialTheme.colorScheme.surface)
.padding(horizontal = horizontalPadding, vertical = 4.dp)
val timeZone = remember(isUtc) {
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
}
val sdfDate = remember(isUtc) {
SimpleDateFormat("EEE dd MMM", Locale.ENGLISH).also { it.timeZone = timeZone }
}
val sdfTime = remember(isUtc) {
SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone }
}
val aosDateStr = remember(pass.aosTime, isUtc) { sdfDate.format(Date(pass.aosTime)) }
val aosTimeStr = remember(pass.aosTime, isUtc) { sdfTime.format(Date(pass.aosTime)) }
val losTimeStr = remember(pass.losTime, isUtc) { sdfTime.format(Date(pass.losTime)) }
Column(
modifier = modifier.clickable { navigateToRadar(pass.catNum, pass.aosTime) }
) {
Column(
verticalArrangement = Arrangement.spacedBy(1.dp),
modifier = Modifier
.fillMaxWidth()
.background(color = MaterialTheme.colorScheme.surface)
.padding(horizontal = horizontalPadding, vertical = 4.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "$passSatId - ",
color = MaterialTheme.colorScheme.primary
)
Text(
text = pass.name,
modifier = Modifier
.weight(1f)
.padding(end = 6.dp)
.infiniteMarquee(),
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface
)
Icon(
painter = painterResource(id = R.drawable.ic_elevation),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "${pass.maxElevation}°",
color = MaterialTheme.colorScheme.primary
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "$passSatId - ",
color = MaterialTheme.colorScheme.primary
)
Text(
text = pass.name,
modifier = Modifier
.weight(1f)
.padding(end = 6.dp)
.infiniteMarquee(),
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
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,
color = MaterialTheme.colorScheme.onSurface
)
} else {
Text(text = aosDateStr, fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface)
}
}
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(id = R.drawable.ic_elevation),
painter = painterResource(id = R.drawable.ic_altitude),
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))
Text(
text = "${pass.maxElevation}°",
color = MaterialTheme.colorScheme.primary
)
Text(text = "${pass.altitude} km", fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.End,
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 = if (pass.isDeepSpace) defaultTime else sdfTime.format(Date(pass.aosTime)),
fontSize = 15.sp
)
LinearProgressIndicator(
progress = { if (pass.isDeepSpace) 100f else pass.progress },
drawStopIndicator = {},
modifier = modifier.fillMaxWidth(0.75f)
)
Text(
text = if (pass.isDeepSpace) defaultTime else sdfTime.format(Date(pass.losTime)),
fontSize = 15.sp
text = stringResource(
id = R.string.pass_aosLos,
pass.aosAzimuth.toInt(),
pass.losAzimuth.toInt()
),
fontSize = 15.sp,
color = MaterialTheme.colorScheme.onSurface
)
}
}
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,25 @@ package com.rtbishop.look4sat.feature.passes
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
data class PassesState(
val isPassesDialogShown: Boolean,
val isRadiosDialogShown: Boolean,
val isRefreshing: Boolean,
val isUtc: Boolean,
val isPassesDialogShown: Boolean = false,
val isRadiosDialogShown: Boolean = false,
val isRefreshing: Boolean = true,
val isUtc: Boolean = false,
val nextPass: OrbitalPass,
val nextTime: String,
val isNextTimeAos: Boolean,
val hours: Int,
val elevation: Double,
val modes: List<String>,
val itemsList: List<OrbitalPass>,
val shouldSeeWhatsNew: Boolean,
val takeAction: (PassesAction) -> Unit
val nextTime: String = "00:00:00",
val isNextTimeAos: Boolean = true,
val hours: Int = 24,
val elevation: Double = 16.0,
val modes: List<String> = emptyList(),
val itemsList: List<OrbitalPass> = emptyList(),
val shouldSeeWhatsNew: Boolean = false
)
sealed class PassesAction {
data object DismissWhatsNew : PassesAction()
data class FilterPasses(val hoursAhead: Int, val minElevation: Double) : PassesAction()
data class FilterRadios(val modes: List<String>) : PassesAction()
data object RefreshPasses : PassesAction()
data object TogglePassesDialog : PassesAction()
data object ToggleRadiosDialog : PassesAction()
sealed interface PassesAction {
data object DismissWhatsNew : PassesAction
data class FilterPasses(val hoursAhead: Int, val minElevation: Double) : PassesAction
data class FilterRadios(val modes: List<String>) : PassesAction
data object RefreshPasses : PassesAction
data object TogglePassesDialog : PassesAction
data object ToggleRadiosDialog : PassesAction
}
@@ -27,14 +27,14 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
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.presentation.getDefaultPass
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@@ -44,97 +44,130 @@ class PassesViewModel(
private val settingsRepo: ISettingsRepo
) : ViewModel() {
private val defaultPass = getDefaultPass()
private val _uiState = MutableStateFlow(
PassesState(
isPassesDialogShown = false,
isRadiosDialogShown = false,
isRefreshing = true,
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
nextTime = "00:00:00",
isNextTimeAos = true,
nextPass = getDefaultPass(),
nextPass = defaultPass,
hours = settingsRepo.passesSettings.value.hoursAhead,
elevation = settingsRepo.passesSettings.value.minElevation,
modes = settingsRepo.passesSettings.value.selectedModes,
itemsList = emptyList(),
shouldSeeWhatsNew = settingsRepo.otherSettings.value.shouldSeeWhatsNew,
takeAction = ::handleAction
shouldSeeWhatsNew = settingsRepo.otherSettings.value.shouldSeeWhatsNew
)
)
private var processing: Job? = null
val uiState: StateFlow<PassesState> = _uiState
init {
// Show refreshing indicator whenever the selected satellites list changes
viewModelScope.launch {
delay(1000)
satelliteRepo.passes.collectLatest { passes ->
processing?.cancelAndJoin()
processing = viewModelScope.launch {
while (isActive) {
val timeNow = System.currentTimeMillis()
val newPasses = satelliteRepo.processPasses(passes, timeNow)
setPassInfo(newPasses, timeNow)
_uiState.update { it.copy(isRefreshing = false, itemsList = newPasses) }
delay(1000)
}
settingsRepo.selectedIds.collectLatest { selectedIds ->
_uiState.update { it.copy(isRefreshing = true) }
if (selectedIds.isEmpty()) {
// No satellites selected — show indicator briefly, then stop
delay(1000)
_uiState.update { it.copy(isRefreshing = false) }
}
// For non-empty selections, the passes collector below will clear isRefreshing
}
}
// Stop refreshing whenever new passes arrive (from initial load, selection change, or filter)
viewModelScope.launch {
satelliteRepo.passes.drop(1).collect { _ ->
_uiState.update { it.copy(isRefreshing = false) }
}
}
// Local tick loop — computes pass progress and countdown timer every second
viewModelScope.launch {
while (isActive) {
val timeNow = System.currentTimeMillis()
val processed = computePassProgress(satelliteRepo.passes.value, timeNow)
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
_uiState.update {
it.copy(
itemsList = processed,
nextPass = nextPass,
nextTime = nextTime,
isNextTimeAos = isAos
)
}
delay(1000)
}
}
viewModelScope.launch {
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) {
PassesAction.DismissWhatsNew -> settingsRepo.setWhatsNewDismissed()
is PassesAction.FilterPasses -> applyFilter(action.hoursAhead, action.minElevation, uiState.value.modes)
is PassesAction.FilterRadios -> applyFilter(uiState.value.hours, uiState.value.elevation, action.modes)
is PassesAction.FilterPasses -> applyFilter(action.hoursAhead, action.minElevation, _uiState.value.modes)
is PassesAction.FilterRadios -> applyFilter(_uiState.value.hours, _uiState.value.elevation, action.modes)
PassesAction.RefreshPasses -> refreshPasses()
PassesAction.TogglePassesDialog -> toggleFilterDialog()
PassesAction.ToggleRadiosDialog -> toggleRadiosDialog()
PassesAction.TogglePassesDialog -> _uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) }
PassesAction.ToggleRadiosDialog -> _uiState.update { it.copy(isRadiosDialogShown = !it.isRadiosDialogShown) }
}
}
/** Computes live progress for each pass, filtering out expired ones. */
private fun computePassProgress(passList: List<OrbitalPass>, time: Long): List<OrbitalPass> {
val result = ArrayList<OrbitalPass>(passList.size)
for (pass in passList) {
if (!pass.isDeepSpace && time > pass.aosTime) {
val deltaNow = time.minus(pass.aosTime).toFloat()
val deltaTotal = pass.losTime.minus(pass.aosTime).toFloat()
val newProgress = (deltaNow / deltaTotal).round(2)
if (newProgress >= 1.0f) continue
if (newProgress != pass.progress) {
result.add(pass.copy(progress = newProgress))
} else {
result.add(pass)
}
} else {
result.add(pass)
}
}
return result
}
/** Resolves the next upcoming or active pass and its countdown timer. */
private fun resolveNextPass(
passes: List<OrbitalPass>,
timeNow: Long
): Triple<OrbitalPass, String, Boolean> {
val upcoming = passes.firstOrNull { it.aosTime > timeNow }
if (upcoming != null) {
return Triple(upcoming, (upcoming.aosTime - timeNow).toTimerString(), true)
}
if (passes.isNotEmpty()) {
val lastPass = passes.last()
return Triple(lastPass, (lastPass.losTime - timeNow).toTimerString(), false)
}
return Triple(defaultPass, "00:00:00", true)
}
private fun applyFilter(hoursAhead: Int, minElevation: Double, modes: List<String>) = viewModelScope.launch {
_uiState.update { it.copy(isRefreshing = true) }
processing?.cancelAndJoin()
settingsRepo.setPassesSettings(PassesSettings(hoursAhead, minElevation, modes))
_uiState.update { it.copy(hours = hoursAhead, elevation = minElevation, modes = modes) }
satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
_uiState.update { it.copy(isRefreshing = false) }
}
private fun refreshPasses() = viewModelScope.launch {
_uiState.update { it.copy(isRefreshing = true) }
processing?.cancelAndJoin()
val (hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value
satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
_uiState.update { it.copy(isRefreshing = false) }
}
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 {
val Factory: ViewModelProvider.Factory = viewModelFactory {
+1 -1
View File
@@ -1,5 +1,5 @@
plugins {
alias(libs.plugins.convention.composeFeaturePlugin)
alias(libs.plugins.convention.featurePlugin)
}
android {
@@ -37,14 +37,14 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
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.alpha
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.keepScreenOn
import androidx.compose.ui.res.painterResource
@@ -62,6 +62,7 @@ import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.utility.toDegrees
import com.rtbishop.look4sat.core.presentation.EmptyListCard
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.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 radarArgs = listOf(
navArgument("catNum") { defaultValue = 0 },
@@ -84,254 +88,216 @@ fun NavGraphBuilder.radarDestination(navigateUp: () -> Unit) {
)
composable(radarRoute, radarArgs) {
val viewModel = viewModel(RadarViewModel::class.java, factory = RadarViewModel.Factory)
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
RadarScreen(uiState, navigateUp)
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
RadarScreen(uiState, viewModel::onAction, navigateUp, navigateToRadioControl)
}
}
@Composable
private fun RadarScreen(uiState: RadarState, navigateUp: () -> Unit) {
// BluetoothCIV.init(LocalContext.current)
val addToCalendar: () -> Unit = {
uiState.currentPass?.let { pass ->
uiState.sendAction(RadarAction.AddToCalendar(pass.name, pass.aosTime, pass.losTime))
}
}
private fun RadarScreen(
uiState: RadarState,
onAction: (RadarAction) -> Unit,
navigateUp: () -> Unit,
navigateToRadioControl: (Int, Long) -> Unit
) {
val upcomingPass = uiState.currentPass ?: getDefaultPass()
if (upcomingPass.losTime < System.currentTimeMillis()) navigateUp()
val addToCalendar: () -> Unit = {
uiState.currentPass?.let { pass ->
onAction(RadarAction.AddToCalendar(pass.name, pass.aosTime, pass.losTime))
}
}
val openRadioControl: () -> Unit = {
uiState.currentPass?.let { pass ->
navigateToRadioControl(pass.catNum, pass.aosTime)
}
}
Column(
modifier = Modifier
.layoutPadding()
.keepScreenOn(), verticalArrangement = Arrangement.spacedBy(6.dp)
.keepScreenOn(),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
if (isVerticalLayout()) {
val isVertical = isVerticalLayout()
if (isVertical) {
TopBar {
IconCard(action = navigateUp, resId = R.drawable.ic_back)
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isCurrentTimeAos)
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 {
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)
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()) {
ElevatedCard(modifier = Modifier.weight(1f)) {
Box(contentAlignment = Alignment.Center) {
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()
}
}
}
}
if (isVertical) {
RadarCard(uiState, Modifier.weight(1f))
TransmittersCard(uiState.transmitters, uiState.selectedTransmitterUuid, onAction, Modifier.weight(1f))
} else {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
ElevatedCard(modifier = Modifier.weight(1f)) {
Box(contentAlignment = Alignment.Center) {
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()
}
}
}
}
RadarCard(uiState, Modifier.weight(1f))
TransmittersCard(uiState.transmitters, uiState.selectedTransmitterUuid, onAction, Modifier.weight(1f))
}
}
}
}
@Composable
private fun RadarTextTop(value: Double, text: String, isLeft: Boolean) {
val alignment = if (isLeft) Alignment.Start else Alignment.End
val degValue = stringResource(R.string.radar_az_value, value.toDegrees())
Column(horizontalAlignment = alignment) {
Text(text = degValue, fontSize = 18.sp)
Text(text = text, fontSize = 15.sp)
private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
val isEclipsed = uiState.orbitalPos?.eclipsed == true
val borderModifier = if (isEclipsed) {
val infiniteTransition = rememberInfiniteTransition(label = "eclipsedBorder")
val borderAlpha by infiniteTransition.animateFloat(
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
private fun RadarTextBottom(value: Double, text: String, isLeft: Boolean) {
val alignment = if (isLeft) Alignment.Start else Alignment.End
val degValue = stringResource(R.string.radar_alt_value, value)
private fun PositionOverlay(position: OrbitalPos) {
Column(
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) {
Text(text = text, fontSize = 15.sp)
Text(text = degValue, fontSize = 18.sp)
if (labelFirst) {
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
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = transmitters, key = { item -> item.uuid }) { radio ->
items(items = transmitters, key = { it.uuid }) { radio ->
TransmitterItem(
radio,
(selectedUuid != null),
(radio.uuid == selectedUuid)
) { onSelect(radio.uuid) }
radio = radio,
isClickable = selectedUuid != null,
isSelected = radio.uuid == selectedUuid,
onClick = { onSelect(radio.uuid) }
)
}
}
}
@@ -362,42 +329,6 @@ private fun TransmitterItemPreview() {
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
private fun TransmitterItem(
radio: SatRadio,
@@ -407,85 +338,92 @@ private fun TransmitterItem(
) {
val title = if (radio.isInverted) "INVERTED: ${radio.info}" else radio.info
val fullTitle = "$title - (${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"})"
Surface(
color = MaterialTheme.colorScheme.background,
Column(
modifier = Modifier
.fillMaxWidth()
.then(if (isClickable) Modifier.clickable { onClick() } else Modifier)
) {
Surface(modifier = Modifier.padding(bottom = 2.dp)) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier
.background(MaterialTheme.colorScheme.surface)
.padding(horizontal = 8.dp, vertical = 6.dp)
) {
Box {
Text(
text = fullTitle,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 6.dp)
.infiniteMarquee()
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.padding(horizontal = 8.dp, vertical = 6.dp)
) {
Box {
Text(
text = fullTitle,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.fillMaxWidth()
.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
private fun FrequencyRow(radio: SatRadio, isDownlink: Boolean, modifier: Modifier = Modifier) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier.fillMaxWidth()) {
val rotateMod = Modifier.rotate(if (isDownlink) 90f else -90f)
val weightMod = Modifier.weight(1f)
val desc = if (isDownlink) stringResource(R.string.radar_downlink) else stringResource(R.string.radar_uplink)
private fun FrequencyRow(radio: SatRadio, isDownlink: Boolean) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
val desc = if (isDownlink) stringResource(R.string.radar_downlink)
else stringResource(R.string.radar_uplink)
Text(
text = if (isDownlink) "D:" else "U:",
textAlign = TextAlign.Center,
fontSize = 18.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = modifier
.size(width = 24.dp, height = 24.dp)
modifier = Modifier
.size(24.dp)
.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 = "-",
textAlign = TextAlign.Center,
fontSize = 21.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = modifier
color = MaterialTheme.colorScheme.primary
)
FrequencyText(
frequency = if (isDownlink) radio.downlinkHigh else radio.uplinkHigh,
modifier = Modifier.weight(1f)
)
FrequencyText(if (isDownlink) radio.downlinkHigh else radio.uplinkHigh, weightMod)
Icon(
painter = painterResource(id = R.drawable.ic_arrow),
tint = MaterialTheme.colorScheme.onSurface,
contentDescription = null,
modifier = rotateMod.size(width = 24.dp, height = 24.dp)
modifier = Modifier
.rotate(if (isDownlink) 90f else -90f)
.size(24.dp)
)
}
}
@Composable
private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
val noLinkText = stringResource(R.string.radar_no_link)
val freqValue = frequency?.let { it / 1000000f }
val text = frequency?.let {
stringResource(id = R.string.radar_link_low, it / 1000000f)
} ?: stringResource(R.string.radar_no_link)
Text(
text = freqValue?.let { stringResource(id = R.string.radar_link_low, it) } ?: noLinkText,
text = text,
textAlign = TextAlign.Center,
fontSize = 21.sp,
fontWeight = FontWeight.Bold,
@@ -22,21 +22,21 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
data class RadarState(
val currentPass: OrbitalPass?,
val currentTime: String,
val isCurrentTimeAos: Boolean,
val orientationValues: Pair<Float, Float>,
val orbitalPos: OrbitalPos?,
val satTrack: List<OrbitalPos>,
val shouldShowSweep: Boolean,
val shouldUseCompass: Boolean,
val transmitters: List<SatRadio>,
val selectedTransmitterUuid: String?,
val selectedFrequency: Long?,
val sendAction: (RadarAction) -> Unit
val currentPass: OrbitalPass? = null,
val currentTime: String = "00:00:00",
val isCurrentTimeAos: Boolean = true,
val isUtc: Boolean = false,
val orientationValues: Pair<Float, Float> = 0f to 0f,
val orbitalPos: OrbitalPos? = null,
val satTrack: List<OrbitalPos> = emptyList(),
val shouldShowSweep: Boolean = false,
val shouldUseCompass: Boolean = false,
val transmitters: List<SatRadio> = emptyList(),
val selectedTransmitterUuid: String? = null,
val selectedFrequency: Long? = null
)
sealed class RadarAction {
data class AddToCalendar(val name: String, val aosTime: Long, val losTime: Long) : RadarAction()
data class SelectTransmitter(val uuid: String) : RadarAction()
sealed interface RadarAction {
data class AddToCalendar(val name: String, val aosTime: Long, val losTime: Long) : RadarAction
data class SelectTransmitter(val uuid: String) : RadarAction
}
@@ -17,6 +17,8 @@
*/
package com.rtbishop.look4sat.feature.radar
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
@@ -25,9 +27,11 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
@@ -53,6 +57,10 @@ import com.rtbishop.look4sat.core.domain.utility.toRadians
import kotlin.math.cos
import kotlin.math.sin
private const val CIRCLES = 3
private const val STROKE_WIDTH = 6f
private const val SWEEP_INCREMENT = 360f / 12f / 60f
@Composable
fun RadarViewCompose(
item: OrbitalPos,
@@ -62,149 +70,102 @@ fun RadarViewCompose(
shouldUseCompass: Boolean,
modifier: Modifier = Modifier
) {
// val context = LocalContext.current
// val view = LocalView.current
val radarColor = MaterialTheme.colorScheme.secondary
val trackColor = MaterialTheme.colorScheme.primary
val aimColor = MaterialTheme.colorScheme.error
val strokeWidth = 6f
val animTransition = rememberInfiniteTransition(label = "animScale")
val animSpec = infiniteRepeatable<Float>(tween(1000))
val animScale = animTransition.animateFloat(16f, 64f, animSpec, label = "animScale")
// val aimThreshold = 0.05f
val animScale by animTransition.animateFloat(
initialValue = 16f,
targetValue = 64f,
animationSpec = infiniteRepeatable(tween(1000)),
label = "animScale"
)
val eclipsedGlowAlpha by animTransition.animateFloat(
initialValue = 1.0f,
targetValue = 0.0f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1000, delayMillis = 25, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "eclipsedGlowAlpha"
)
val measurer = rememberTextMeasurer()
val sweepDegrees = remember { mutableFloatStateOf(0f) }
val trackLastRadius = remember { mutableStateOf(0f) }
val trackPath = remember { mutableStateOf(Path()) }
val trackEffect = 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()
// }
// }
var sweepDegrees by remember { mutableFloatStateOf(0f) }
var cachedRadius by remember { mutableFloatStateOf(0f) }
var trackPath by remember { mutableStateOf(Path()) }
var trackEffect by remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) }
val isEclipsed = item.eclipsed
Canvas(modifier = modifier.aspectRatio(1f)) {
val radius = (size.minDimension / 2f) * 0.95f
if(radius != trackLastRadius.value) {
trackPath.value = createTrackPath(items, radius)
trackEffect.value = createTrackEffect(trackPath.value)
trackLastRadius.value = radius
val radius = size.minDimension / 2f * 0.95f
if (radius != cachedRadius) {
trackPath = createTrackPath(items, radius)
trackEffect = createTrackEffect(trackPath)
cachedRadius = radius
}
rotate(if (shouldUseCompass) -azimElev.first else 0f) {
if (shouldShowSweep) {
drawSweep(center, sweepDegrees.floatValue, radius, trackColor)
}
drawRadar(radius, radarColor, strokeWidth, 3)
drawInfo(radius, trackColor, measurer, 3)
if (shouldShowSweep) drawSweep(center, sweepDegrees, radius, trackColor)
drawRadar(radius, radarColor)
drawElevationLabels(radius, trackColor, measurer)
translate(center.x, center.y) {
drawTrack(trackPath.value, trackEffect.value, aimColor, trackColor)
drawTrack(trackPath, trackEffect, aimColor, trackColor)
if (item.elevation > 0) {
drawPosition(item, radius, animScale.value, trackColor)
}
if (shouldUseCompass) {
drawAim(azimElev.first, azimElev.second, radius, strokeWidth, aimColor)
if (isEclipsed) {
drawEclipsedPosition(item, radius, eclipsedGlowAlpha, trackColor)
} else {
drawPosition(item, radius, animScale, trackColor)
}
}
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) {
for (i in 0 until circles) {
val circleRadius = radius - radius / circles.toFloat() * i.toFloat()
drawCircle(color, circleRadius, style = Stroke(width))
private fun DrawScope.drawRadar(radius: Float, color: Color) {
val step = radius / CIRCLES
for (i in 0 until CIRCLES) {
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, center.copy(y = center.y - radius), center.copy(y = center.y + radius), width)
drawLine(color, Offset(center.x - radius, center.y), Offset(center.x + radius, center.y), STROKE_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) {
for (i in 0 until circles) {
val textY = (radius - radius / circles * i) - 32f
val textDeg = " ${(90 / circles) * (circles - i)}°"
drawText(measurer, textDeg, center.copy(y = textY), style = TextStyle(color, 15.sp))
private fun DrawScope.drawElevationLabels(radius: Float, color: Color, measurer: TextMeasurer) {
val step = radius / CIRCLES
val degStep = 90 / CIRCLES
val 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) {
drawPath(path, color, style = Stroke(6f))
drawPath(path, color, style = Stroke(STROKE_WIDTH))
drawPath(path, effectColor, style = Stroke(pathEffect = effect))
}
private fun DrawScope.drawPosition(item: OrbitalPos, radius: Float, posRadius: Float, color: Color) {
val satX = sph2CartX(item.azimuth, item.elevation, radius.toDouble())
val satY = sph2CartY(item.azimuth, item.elevation, radius.toDouble())
drawCircle(color, 16f, center.copy(satX, -satY))
drawCircle(color.copy(alpha = 1 - (posRadius / 64f)), posRadius, center.copy(satX, -satY))
val pos = sph2Cart(item.azimuth, item.elevation, radius.toDouble())
drawCircle(color, 16f, pos)
drawCircle(color.copy(alpha = 1 - (posRadius / 64f)), posRadius, pos)
}
private fun DrawScope.drawAim(azim: Float, elev: Float, radius: Float, width: Float, color: Color) {
private fun DrawScope.drawEclipsedPosition(item: OrbitalPos, radius: Float, glowAlpha: Float, color: Color) {
val pos = sph2Cart(item.azimuth, item.elevation, radius.toDouble())
drawCircle(color.copy(alpha = glowAlpha), 16f, pos)
}
private fun DrawScope.drawAim(azim: Float, elev: Float, radius: Float, color: Color) {
val size = 36f
val azimRadians = azim.toDouble().toRadians()
val tempElevRadians = elev.toDouble().toRadians()
val elevRadians = if (tempElevRadians > 0.0) 0.0 else tempElevRadians
val aimX = sph2CartX(azimRadians, -elevRadians, radius.toDouble())
val aimY = sph2CartY(azimRadians, -elevRadians, radius.toDouble())
try {
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)
}
val azimRad = azim.toDouble().toRadians()
val elevRad = elev.toDouble().toRadians().coerceAtMost(0.0)
val pos = sph2Cart(azimRad, -elevRad, radius.toDouble())
drawLine(color, Offset(pos.x - size, pos.y), Offset(pos.x + size, pos.y), STROKE_WIDTH)
drawLine(color, Offset(pos.x, pos.y - size), Offset(pos.x, pos.y + size), STROKE_WIDTH)
drawCircle(color, size / 2, pos, style = Stroke(STROKE_WIDTH))
}
private fun DrawScope.drawSweep(center: Offset, degrees: Float, radius: Float, color: Color) {
@@ -216,37 +177,34 @@ private fun DrawScope.drawSweep(center: Offset, degrees: Float, radius: Float, c
private fun createTrackPath(positions: List<OrbitalPos>, radius: Float): Path {
val trackPath = Path()
positions.forEachIndexed { index, satPos ->
val passX = sph2CartX(satPos.azimuth, satPos.elevation, radius.toDouble())
val passY = sph2CartY(satPos.azimuth, satPos.elevation, radius.toDouble())
if (index == 0) trackPath.moveTo(passX, -passY) else trackPath.lineTo(passX, -passY)
positions.forEachIndexed { index, pos ->
val offset = sph2Cart(pos.azimuth, pos.elevation, radius.toDouble())
if (index == 0) trackPath.moveTo(offset.x, offset.y) else trackPath.lineTo(offset.x, offset.y)
}
return trackPath
}
private fun createTrackEffect(trackPath: Path): PathEffect {
val shape = Path()
val shapeRadius = 24f
val angle = 120.0.toRadians()
shape.moveTo((shapeRadius * cos(angle)).toFloat(), (shapeRadius * sin(angle)).toFloat())
for (i in 1 until 3) {
val x = (shapeRadius * cos(angle - angle * i)).toFloat()
val y = (shapeRadius * sin(angle - angle * i)).toFloat()
shape.lineTo(x, y)
val shape = Path().apply {
moveTo((shapeRadius * cos(angle)).toFloat(), (shapeRadius * sin(angle)).toFloat())
for (i in 1 until 3) {
lineTo(
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 advance = trackLength / 2f
val phase = trackLength / 4f
return PathEffect.stampedPathEffect(shape, advance, phase, StampedPathEffectStyle.Rotate)
return PathEffect.stampedPathEffect(shape, trackLength / 2f, trackLength / 4f, 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
return (radius * cos(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()
return Offset(
x = (radius * cos(PI_2 - azim)).toFloat(),
y = -(radius * sin(PI_2 - azim)).toFloat()
)
}
@@ -26,16 +26,12 @@ 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.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
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.IReporterRepo
import com.rtbishop.look4sat.core.domain.repository.IReporter
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
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.utility.round
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.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
class RadarViewModel(
private val savedStateHandle: SavedStateHandle,
private val bluetoothReporter: IReporterRepo<WithoutExtParams>,
private val networkReporter: IReporterRepo<ExtendedParams>,
private val bluetoothReporter: IReporter,
private val networkReporter: IReporter,
private val satelliteRepo: ISatelliteRepo,
private val settingsRepo: ISettingsRepo,
private val sensorsRepo: ISensorsRepo,
private val addToCalendar: IAddToCalendar
) : 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 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
init {
// Compass sensor collection
if (settingsRepo.otherSettings.value.stateOfSensors) {
viewModelScope.launch {
sensorsRepo.enableSensor()
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) }
}
}
}
// 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 {
val catNum = savedStateHandle.get<Int>("catNum") ?: 0
val aosTime = savedStateHandle.get<Long>("aosTime") ?: 0L
val passes = satelliteRepo.passes.value
val pass = passes.find { pass -> pass.catNum == catNum && pass.aosTime == aosTime }
val currentPass = pass ?: passes.firstOrNull()
val currentPass = passes.find { it.catNum == catNum && it.aosTime == aosTime }
?: passes.firstOrNull()
currentPass?.let { satPass ->
_uiState.update { it.copy(currentPass = satPass) }
val transmitters = satelliteRepo.getRadiosWithId(satPass.catNum)
// 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) {
val timeNow = System.currentTimeMillis()
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow)
when {
satPass.isDeepSpace -> {
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) }
}
}
val (time, isAos) = computeTimer(satPass.isDeepSpace, satPass.aosTime, satPass.losTime, timeNow)
_uiState.update { it.copy(currentTime = time, isCurrentTimeAos = isAos, orbitalPos = pos) }
processRadios(transmitters, satPass.orbitalObject, timeNow)
sendPassData(satPass, pos, satPass.orbitalObject)
sendPassDataBT(pos)
sendPassData(pos)
delay(1000)
}
}
@@ -127,75 +119,65 @@ class RadarViewModel(
super.onCleared()
}
private fun handleAction(action: RadarAction) {
fun onAction(action: RadarAction) {
when (action) {
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) {
var track: List<OrbitalPos> = emptyList()
if (!orbitalPass.isDeepSpace) {
track = satelliteRepo.getTrack(
orbitalObject, stationPos, orbitalPass.aosTime, orbitalPass.losTime
)
}
_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 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
}
}
private fun sendPassDataBT(orbitalPos: OrbitalPos) {
viewModelScope.launch {
if (settingsRepo.rcSettings.value.bluetoothRotatorState) {
val btRotatorDevice = settingsRepo.rcSettings.value.bluetoothRotatorAddress
if (bluetoothReporter.isConnected(BtService.ROTATOR)) {
val format = settingsRepo.rcSettings.value.bluetoothRotatorFormat
val azimuth = orbitalPos.azimuth.toDegrees().round(0)
val elevation = orbitalPos.elevation.toDegrees().round(0)
bluetoothReporter.reportRotation(format, azimuth, elevation, WithoutExtParams(0))
} else if (!bluetoothReporter.isConnecting(BtService.ROTATOR)) {
// Log.i("BTReporter", "BTReporter (rotator): Attempting to connect...")
bluetoothReporter.connect(BtService.ROTATOR,btRotatorDevice)
}
}
if (settingsRepo.rcSettings.value.bluetoothFrequencyState) {
val btFrequencyDevice = settingsRepo.rcSettings.value.bluetoothFrequencyAddress
if (bluetoothReporter.isConnected(BtService.FREQUENCY)) {
val format = settingsRepo.rcSettings.value.bluetoothFrequencyFormat
val frequency = _uiState.value.selectedFrequency
frequency?.let { freq ->
bluetoothReporter.reportFrequency(format, freq, WithoutExtParams(0))
}
} else if (!bluetoothReporter.isConnecting(BtService.FREQUENCY)) {
// Log.i("BTReporter", "BTReporter (frequency): Attempting to connect...")
bluetoothReporter.connect(BtService.FREQUENCY, btFrequencyDevice)
}
private fun sendPassData(orbitalPos: OrbitalPos) {
val rc = settingsRepo.rcSettings.value
sendReporterData(
networkReporter, orbitalPos,
rc.rotatorState, rc.rotatorFormat,
rc.frequencyState, rc.frequencyFormat
)
sendReporterData(
bluetoothReporter, orbitalPos,
rc.bluetoothRotatorState, rc.bluetoothRotatorFormat,
rc.bluetoothFrequencyState, rc.bluetoothFrequencyFormat
)
}
private fun sendReporterData(
reporter: IReporter,
orbitalPos: OrbitalPos,
rotatorEnabled: Boolean,
rotatorFormat: String,
frequencyEnabled: Boolean,
frequencyFormat: String
) {
if (rotatorEnabled) {
val azimuth = orbitalPos.azimuth.toDegrees().round(2)
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) {
val transmitters = satelliteRepo.getRadios(orbitalObject, stationPos, radios, time)
val isFreqEnabled =
settingsRepo.rcSettings.value.frequencyState || settingsRepo.rcSettings.value.bluetoothFrequencyState
_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(
transmitters = transmitters,
selectedTransmitterUuid = null,
@@ -208,18 +190,16 @@ class RadarViewModel(
val low = radio.downlinkLow
val high = radio.downlinkHigh
when {
low != null && high != null ->
(low + high) / 2
low != null ->
low
low != null && high != null -> (low + high) / 2
low != null -> low
else -> null
}
}
state.copy(
transmitters = transmitters,
selectedTransmitterUuid = selectedUuid,
selectedFrequency = freq
)
// Skip update if nothing changed
if (state.transmitters == transmitters && state.selectedTransmitterUuid == selectedUuid && state.selectedFrequency == freq) {
return@update state
}
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,
private val settingsRepo: ISettingsRepo
) : ViewModel() {
private var currentPass: OrbitalPass? = null
private var transponders: List<SatRadio> = emptyList()
private val stationPos = settingsRepo.stationPosition.value
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 {
alias(libs.plugins.convention.composeFeaturePlugin)
alias(libs.plugins.convention.featurePlugin)
}
android {
@@ -30,7 +30,7 @@ import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -51,14 +51,14 @@ private fun MultiTypesDialogPreview() {
}
@Composable
fun MultiTypesDialog(
internal fun MultiTypesDialog(
allTypes: List<String>, types: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit
) {
val selected = remember { mutableStateListOf<String>().apply { addAll(types) } }
val select = { type: String ->
if (selected.contains(type)) selected.remove(type) else selected.add(type)
val selected = remember { mutableStateOf(types.toSet()) }
val toggle = { type: String ->
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) {
LazyVerticalGrid(
columns = GridCells.Adaptive(240.dp),
@@ -73,7 +73,7 @@ fun MultiTypesDialog(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.background(MaterialTheme.colorScheme.surface)
.clickable { select(item) }
.clickable { toggle(item) }
) {
Text(
text = "${index + 1}).",
@@ -89,7 +89,7 @@ fun MultiTypesDialog(
overflow = TextOverflow.Ellipsis
)
Checkbox(
checked = selected.contains(item),
checked = item in selected.value,
onCheckedChange = null,
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
@@ -32,10 +33,10 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
@@ -77,54 +78,97 @@ fun NavGraphBuilder.satellitesDestination(navigateUp: () -> Unit) {
modelClass = SatellitesViewModel::class.java, factory = SatellitesViewModel.Factory
)
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
SatellitesScreen(uiState, navigateUp)
SatellitesScreen(uiState, viewModel::onAction, navigateUp)
}
}
@Composable
private fun SatellitesScreen(uiState: SatellitesState, navigateUp: () -> Unit) {
val toggleDialog = { uiState.takeAction(SatellitesAction.ToggleTypesDialog) }
private fun SatellitesScreen(
uiState: SatellitesState,
onAction: (SatellitesAction) -> Unit,
navigateUp: () -> Unit
) {
if (uiState.isDialogShown) {
MultiTypesDialog(allTypes = uiState.typesList, types = uiState.currentTypes, toggleDialog) {
uiState.takeAction(SatellitesAction.SelectTypes(it))
}
MultiTypesDialog(
allTypes = uiState.typesList,
types = uiState.currentTypes,
cancel = { onAction(SatellitesAction.ToggleTypesDialog) },
accept = { onAction(SatellitesAction.SelectTypes(it)) }
)
}
if (uiState.shouldSeeWarning) {
InfoDialog(
stringResource(R.string.sat_warning_title),
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 setQuery = { newQuery: String -> uiState.takeAction(SatellitesAction.SearchFor(newQuery)) }
val saveSelection = { uiState.takeAction(SatellitesAction.SaveSelection).also { navigateUp() } }
val saveAndNavigateUp = {
onAction(SatellitesAction.SaveSelection)
navigateUp()
}
val primCardCd = stringResource(R.string.btn_accept)
val primCardMod = Modifier.semantics { contentDescription = primCardCd }
val clearAllCd = stringResource(R.string.sat_clear_all)
val clearAllMod = Modifier.semantics { contentDescription = clearAllCd }
val selectAllCd = stringResource(R.string.sat_select_all)
val selectAllMod = Modifier.semantics { contentDescription = selectAllCd }
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (isVerticalLayout()) {
TopBar {
TypeCard(types = uiState.currentTypes, toggleDialog, modifier = Modifier.weight(1f))
PrimaryIconCard(onClick = saveSelection, resId = R.drawable.ic_done, modifier = primCardMod)
TypeCard(
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 {
SearchBar(setQuery = { setQuery(it) }, modifier = Modifier.weight(1f))
IconCard(action = unselectAll, resId = R.drawable.ic_check_off, modifier = clearAllMod)
IconCard(action = selectAll, resId = R.drawable.ic_check_on, modifier = selectAllMod)
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 }
)
}
} else {
TopBar {
PrimaryIconCard(onClick = saveSelection, resId = R.drawable.ic_done, modifier = primCardMod)
TypeCard(types = uiState.currentTypes, toggleDialog, modifier = Modifier.weight(1f))
SearchBar(setQuery = { setQuery(it) }, modifier = Modifier.weight(1f))
IconCard(action = unselectAll, resId = R.drawable.ic_check_off, modifier = clearAllMod)
IconCard(action = selectAll, resId = R.drawable.ic_check_on, modifier = selectAllMod)
PrimaryIconCard(
onClick = saveAndNavigateUp,
resId = R.drawable.ic_done,
modifier = Modifier.semantics { contentDescription = primCardCd }
)
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()) {
@@ -133,7 +177,7 @@ private fun SatellitesScreen(uiState: SatellitesState, navigateUp: () -> Unit) {
uiState.isLoading -> CardLoadingIndicator()
uiState.itemsList.isEmpty() -> EmptyListCard(message = emptyMessage)
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
private fun SearchBar(setQuery: (String) -> Unit, modifier: Modifier = Modifier) {
private fun SearchBar(onQueryChange: (String) -> Unit, modifier: Modifier = Modifier) {
val currentQuery = rememberSaveable { mutableStateOf("") }
val setNewQuery = { newValue: String ->
val updateQuery = { newValue: String ->
currentQuery.value = newValue
setQuery(newValue)
onQueryChange(newValue)
}
ElevatedCard(modifier = modifier.height(48.dp)) {
Row(
@@ -156,9 +200,11 @@ private fun SearchBar(setQuery: (String) -> Unit, modifier: Modifier = Modifier)
Icon(painter = painterResource(id = R.drawable.ic_search), contentDescription = null)
BasicTextField(
value = currentQuery.value,
onValueChange = { setNewQuery(it) },
onValueChange = updateQuery,
singleLine = true,
modifier = modifier.padding(start = 12.dp),
modifier = Modifier
.weight(1f)
.padding(start = 12.dp),
textStyle = TextStyle(
fontSize = 16.sp,
lineHeight = 20.sp,
@@ -179,7 +225,7 @@ private fun SearchBar(setQuery: (String) -> Unit, modifier: Modifier = Modifier)
},
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface)
)
IconButton(onClick = { setNewQuery("") }) {
IconButton(onClick = { updateQuery("") }) {
val clearCd = stringResource(R.string.sat_search_clear)
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
private fun SatellitePreview() {
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
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)
Surface(
color = MaterialTheme.colorScheme.background,
modifier = modifier.clickable { onSelected(item.catnum, item.isSelected) }) {
Surface(modifier = Modifier.padding(bottom = 1.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.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 = item.name,
modifier = Modifier.weight(1f),
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Checkbox(
checked = item.isSelected,
onCheckedChange = null,
modifier = Modifier.padding(start = 6.dp)
)
}
Column(modifier = modifier.clickable { onSelected(item.catnum, item.isSelected) }) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.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 = item.name,
modifier = Modifier.weight(1f),
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface
)
Checkbox(
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(88888, "Starlink", false)
)
MainTheme { SatellitesCard(entries) { _, _ -> run {} } }
MainTheme { SatellitesCard(entries) { _, _ -> } }
}
@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)) {
items(items = items, key = { item -> item.catnum }) { entry ->
Satellite(entry, onSelected, Modifier.animateItem())
@@ -20,22 +20,21 @@ package com.rtbishop.look4sat.feature.satellites
import com.rtbishop.look4sat.core.domain.model.SatItem
data class SatellitesState(
val isDialogShown: Boolean,
val isLoading: Boolean,
val shouldSeeWarning: Boolean,
val itemsList: List<SatItem>,
val currentTypes: List<String>,
val typesList: List<String>,
val takeAction: (SatellitesAction) -> Unit
val isDialogShown: Boolean = false,
val isLoading: Boolean = true,
val shouldSeeWarning: Boolean = false,
val itemsList: List<SatItem> = emptyList(),
val currentTypes: List<String> = emptyList(),
val typesList: List<String> = emptyList()
)
sealed class SatellitesAction {
data object DismissWarning : SatellitesAction()
data object SaveSelection : SatellitesAction()
data class SearchFor(val query: String) : SatellitesAction()
data object SelectAll : SatellitesAction()
data class SelectSingle(val id: Int, val isTicked: Boolean) : SatellitesAction()
data class SelectTypes(val types: List<String>) : SatellitesAction()
data object ToggleTypesDialog : SatellitesAction()
data object UnselectAll : SatellitesAction()
sealed interface SatellitesAction {
data object DismissWarning : SatellitesAction
data object SaveSelection : SatellitesAction
data class SearchFor(val query: String) : SatellitesAction
data object SelectAll : SatellitesAction
data class SelectSingle(val id: Int, val isTicked: Boolean) : SatellitesAction
data class SelectTypes(val types: List<String>) : SatellitesAction
data object ToggleTypesDialog : 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.ISelectionRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
@@ -40,20 +39,16 @@ class SatellitesViewModel(
private val defaultTypes = selectionRepo.getCurrentTypes()
private val _uiState = MutableStateFlow(
SatellitesState(
isDialogShown = false,
isLoading = true,
shouldSeeWarning = settingsRepo.otherSettings.value.shouldSeeWarning,
itemsList = emptyList(),
currentTypes = defaultTypes,
typesList = selectionRepo.getTypesList(),
takeAction = ::handleAction
typesList = selectionRepo.getTypesList()
)
)
val uiState: StateFlow<SatellitesState> = _uiState
init {
viewModelScope.launch {
delay(1000)
selectionRepo.setQuery(String())
selectionRepo.setTypes(defaultTypes)
selectionRepo.getEntriesFlow().collectLatest { items ->
@@ -67,7 +62,7 @@ class SatellitesViewModel(
}
}
private fun handleAction(action: SatellitesAction) {
fun onAction(action: SatellitesAction) {
when (action) {
SatellitesAction.DismissWarning -> settingsRepo.setWarningDismissed()
SatellitesAction.SaveSelection -> saveSelection()
@@ -94,12 +89,11 @@ class SatellitesViewModel(
private fun selectTypes(types: List<String>) = viewModelScope.launch {
selectionRepo.setTypes(types)
_uiState.value = _uiState.value.copy(currentTypes = types, isDialogShown = false)
_uiState.update { it.copy(currentTypes = types, isDialogShown = false) }
}
private fun toggleTypesDialog() {
val currentDialogState = _uiState.value.isDialogShown
_uiState.value = _uiState.value.copy(isDialogShown = currentDialogState.not())
_uiState.update { it.copy(isDialogShown = !it.isDialogShown) }
}
companion object {
+1 -1
View File
@@ -1,5 +1,5 @@
plugins {
alias(libs.plugins.convention.composeFeaturePlugin)
alias(libs.plugins.convention.featurePlugin)
}
android {
@@ -17,6 +17,9 @@
*/
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.Column
import androidx.compose.foundation.layout.Row
@@ -28,14 +31,18 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
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.RadioControlSettings
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.LocalSpacing
import com.rtbishop.look4sat.core.presentation.MainTheme
@@ -214,14 +221,14 @@ fun PreviewNetworkOutputDialog() {
frequencyState = false,
frequencyAddress = "127.0.0.1",
frequencyPort = "4532",
frequencyFormat = $$"set_freq $FREQ",
frequencyFormat = $$"F $FREQ",
bluetoothRotatorState = false,
bluetoothRotatorFormat = $$"W$AZ $EL",
bluetoothRotatorFormat = $$"P $AZ $EL",
bluetoothRotatorName = "Default",
bluetoothRotatorAddress = "00:0C:BF:13:80:5D",
bluetoothFrequencyState = false,
bluetoothFrequencyAddress = "00:0C:BF:13:80:5D",
bluetoothFrequencyFormat = $$"FA$FREQ"
bluetoothFrequencyFormat = $$"F $FREQ"
),
onDismiss = {},
onSave = { _, _, _, _, _, _, _, _ -> }
@@ -240,23 +247,21 @@ fun NetworkOutputDialog(
) {
val padding = LocalSpacing.current.large
val rotatorState = rememberSaveable { mutableStateOf(initialSettings.rotatorState) }
val rotatorAddress = rememberSaveable { mutableStateOf(initialSettings.rotatorAddress) }
val rotatorPort = rememberSaveable { mutableStateOf(initialSettings.rotatorPort) }
val rotatorAddress = rememberSaveable {
mutableStateOf("${initialSettings.rotatorAddress}:${initialSettings.rotatorPort}")
}
val rotatorFormat = rememberSaveable { mutableStateOf(initialSettings.rotatorFormat) }
val frequencyState = rememberSaveable { mutableStateOf(initialSettings.frequencyState) }
val frequencyAddress = rememberSaveable { mutableStateOf(initialSettings.frequencyAddress) }
val frequencyPort = rememberSaveable { mutableStateOf(initialSettings.frequencyPort) }
val frequencyAddress = rememberSaveable {
mutableStateOf("${initialSettings.frequencyAddress}:${initialSettings.frequencyPort}")
}
val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.frequencyFormat) }
val onAccept = {
val (rotIp, rotPort) = splitAddress(rotatorAddress.value)
val (freqIp, freqPort) = splitAddress(frequencyAddress.value)
onSave(
rotatorState.value,
rotatorAddress.value,
rotatorPort.value,
rotatorFormat.value,
frequencyState.value,
frequencyAddress.value,
frequencyPort.value,
frequencyFormat.value
rotatorState.value, rotIp, rotPort, rotatorFormat.value,
frequencyState.value, freqIp, freqPort, frequencyFormat.value
)
onDismiss()
}
@@ -266,93 +271,42 @@ fun NetworkOutputDialog(
onAccept = onAccept
) {
Column(modifier = Modifier.padding(horizontal = padding)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.prefs_net_rotator_switch))
Switch(
checked = rotatorState.value,
onCheckedChange = { rotatorState.value = it }
)
}
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
OutputChannelSection(
switchLabel = stringResource(R.string.prefs_net_rotator_switch),
enabled = rotatorState.value,
onEnabledChange = { rotatorState.value = it },
address = rotatorAddress.value,
onAddressChange = { rotatorAddress.value = it },
addressLabel = stringResource(R.string.prefs_net_rotator_address_hint),
format = rotatorFormat.value,
onFormatChange = { rotatorFormat.value = it },
formatLabel = stringResource(R.string.prefs_net_rotator_format_hint)
)
Spacer(modifier = Modifier.height(6.dp))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.prefs_net_frequency_switch))
Switch(
checked = frequencyState.value,
onCheckedChange = { frequencyState.value = it }
)
}
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
OutputChannelSection(
switchLabel = stringResource(R.string.prefs_net_frequency_switch),
enabled = frequencyState.value,
onEnabledChange = { frequencyState.value = it },
address = frequencyAddress.value,
onAddressChange = { frequencyAddress.value = it },
addressLabel = stringResource(R.string.prefs_net_frequency_address_hint),
format = frequencyFormat.value,
onFormatChange = { frequencyFormat.value = it },
formatLabel = stringResource(R.string.prefs_net_frequency_format_hint)
)
}
}
}
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)
@Composable
fun PreviewBluetoothOutputDialog() {
@@ -366,14 +320,14 @@ fun PreviewBluetoothOutputDialog() {
frequencyState = false,
frequencyAddress = "127.0.0.1",
frequencyPort = "4532",
frequencyFormat = $$"set_freq $FREQ",
frequencyFormat = $$"F $FREQ",
bluetoothRotatorState = false,
bluetoothRotatorFormat = $$"W$AZ $EL",
bluetoothRotatorFormat = $$"P $AZ $EL",
bluetoothRotatorName = "Default",
bluetoothRotatorAddress = "00:0C:BF:13:80:5D",
bluetoothFrequencyState = false,
bluetoothFrequencyAddress = "00:0C:BF:13:80:5D",
bluetoothFrequencyFormat = $$"FA$FREQ"
bluetoothFrequencyFormat = $$"F $FREQ"
),
onDismiss = {},
onSave = { _, _, _, _, _, _ -> }
@@ -399,12 +353,8 @@ fun BluetoothOutputDialog(
val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyFormat) }
val onAccept = {
onSave(
rotatorState.value,
rotatorAddress.value,
rotatorFormat.value,
frequencyState.value,
frequencyAddress.value,
frequencyFormat.value
rotatorState.value, rotatorAddress.value, rotatorFormat.value,
frequencyState.value, frequencyAddress.value, frequencyFormat.value
)
onDismiss()
}
@@ -412,6 +362,127 @@ fun BluetoothOutputDialog(
title = stringResource(R.string.prefs_bt_title),
onCancel = onDismiss,
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)) {
Row(
@@ -419,65 +490,107 @@ fun BluetoothOutputDialog(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.prefs_bt_rotator_switch))
Switch(
checked = rotatorState.value,
onCheckedChange = { rotatorState.value = it }
)
Text(stringResource(R.string.rc_enable_switch))
Switch(checked = enabled.value, onCheckedChange = { enabled.value = it })
}
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
Spacer(modifier = Modifier.height(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()
) {
OutlinedTextField(
value = rotatorAddress.value,
onValueChange = { rotatorAddress.value = it },
singleLine = true,
label = { Text(stringResource(R.string.prefs_bt_rotator_device_hint)) },
modifier = Modifier.weight(1.5f),
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
)
)
Spacer(modifier = Modifier.height(6.dp))
// RX Radio selection
Text("RX Radio (Downlink)", fontWeight = androidx.compose.ui.text.font.FontWeight.Medium)
if (rxAddress.value.isNotBlank()) {
Text("${rxName.value} - ${rxAddress.value}", fontSize = 13.sp)
}
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))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.prefs_bt_frequency_switch))
Switch(
checked = frequencyState.value,
onCheckedChange = { frequencyState.value = it }
)
}
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_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
)
Text("Baud Rate:")
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
baudRates.forEach { rate ->
CardButton(
onClick = { baudRate.intValue = rate },
text = if (rate == baudRate.intValue) "[$rate]" else rate.toString(),
modifier = Modifier
)
}
}
}
}
}
@@ -42,8 +42,12 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -80,178 +84,101 @@ fun NavGraphBuilder.settingsDestination() {
modelClass = SettingsViewModel::class.java,
factory = SettingsViewModel.Factory
)
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
SettingsScreen(uiState)
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SettingsScreen(uiState, viewModel::onAction)
}
}
@Composable
private fun SettingsScreen(uiState: SettingsState) {
val locationContract = ActivityResultContracts.RequestMultiplePermissions()
val locationError = stringResource(R.string.prefs_loc_gps_error)
val locationPermCoarse = Manifest.permission.ACCESS_COARSE_LOCATION
val locationPermFine = Manifest.permission.ACCESS_FINE_LOCATION
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())) }
}
private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) {
val dialogs = rememberDialogVisibility()
val permissions = rememberSettingsPermissions(
sendAction = onAction,
onBluetoothGranted = { dialogs.bluetooth = true }
)
// Position settings
val posSettings = uiState.positionSettings
val setGpsPos = { locationRequest.launch(arrayOf(locationPermCoarse, locationPermFine)) }
val setGeoPos = { lat: Double, lon: Double ->
uiState.sendAction(SettingsAction.SetGeoPosition(lat, lon))
// Dialogs
if (dialogs.position) {
PositionDialog(
uiState.positionSettings.stationPos.latitude,
uiState.positionSettings.stationPos.longitude,
dismiss = { dialogs.position = false },
save = { lat, lon -> onAction(SettingsAction.SetGeoPosition(lat, lon)) }
)
}
val setQthPos = { locator: String ->
uiState.sendAction(SettingsAction.SetQthPosition(locator))
if (dialogs.locator) {
LocatorDialog(
uiState.positionSettings.stationPos.qthLocator,
dismiss = { dialogs.locator = false },
save = { onAction(SettingsAction.SetQthPosition(it)) }
)
}
val dismissPos = { uiState.sendAction(SettingsAction.DismissPosMessages) }
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) {
if (dialogs.dataSources) {
DataSourcesDialog(
useCustomTle = uiState.dataSourcesSettings.useCustomTLE,
useCustomTransceivers = uiState.dataSourcesSettings.useCustomTransceivers,
tleUrl = uiState.dataSourcesSettings.tleUrl,
transceiversUrl = uiState.dataSourcesSettings.transceiversUrl,
onImportTle = { contentRequestForTle.launch("*/*") },
onImportTransceivers = { contentRequestForTransceivers.launch("*/*") },
onDismiss = dismissDataSourcesDialog,
onSave = {
useCustomTle, useCustomTransceivers, tleUrl, transceiversUrl ->
if (!useCustomTle || tleUrl.isNotBlank()) {
uiState.sendDataSourcesAction(DataSourcesAction.SetUseCustomTle(useCustomTle))
uiState.sendDataSourcesAction(DataSourcesAction.SetTleUrl(tleUrl))
}
if (!useCustomTransceivers || transceiversUrl.isNotBlank()) {
uiState.sendDataSourcesAction(DataSourcesAction.SetUseCustomTransceivers(useCustomTransceivers))
uiState.sendDataSourcesAction(DataSourcesAction.SetTransceiversUrl(transceiversUrl))
}
if (useCustomTle || useCustomTransceivers) {
uiState.sendAction(SettingsAction.UpdateFromWeb)
}
onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false },
onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false },
onDismiss = { dialogs.dataSources = false },
onSave = { useCustomTle, useCustomTransceivers, tleUrl, transceiversUrl ->
val current = uiState.dataSourcesSettings
val newSettings = current.copy(
useCustomTLE = if (!useCustomTle || tleUrl.isNotBlank()) useCustomTle else current.useCustomTLE,
tleUrl = if (!useCustomTle || tleUrl.isNotBlank()) tleUrl else current.tleUrl,
useCustomTransceivers = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) useCustomTransceivers else current.useCustomTransceivers,
transceiversUrl = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) transceiversUrl else current.transceiversUrl
)
if (newSettings != current) onAction(SettingsAction.UpdateDataSources(newSettings))
if (useCustomTle || useCustomTransceivers) onAction(SettingsAction.UpdateFromWeb)
}
)
}
// 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) {
if (dialogs.network) {
NetworkOutputDialog(
initialSettings = rcSettings,
onDismiss = dismissNetworkDialog,
onSave = { rotatorState,
rotatorAddress,
rotatorPort,
rotatorFormat,
frequencyState,
frequencyAddress,
frequencyPort,
frequencyFormat ->
setRotatorState(rotatorState)
setRotatorAddress(rotatorAddress)
setRotatorPort(rotatorPort)
setRotatorFormat(rotatorFormat)
setFrequencyState(frequencyState)
setFrequencyAddress(frequencyAddress)
setFrequencyPort(frequencyPort)
setFrequencyFormat(frequencyFormat)
initialSettings = uiState.rcSettings,
onDismiss = { dialogs.network = false },
onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt ->
onAction(
SettingsAction.UpdateRC(
uiState.rcSettings.copy(
rotatorState = rotState, rotatorAddress = rotAddr,
rotatorPort = rotPort, rotatorFormat = rotFmt,
frequencyState = freqState, frequencyAddress = freqAddr,
frequencyPort = freqPort, frequencyFormat = freqFmt
)
)
)
}
)
}
// 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) {
if (dialogs.bluetooth) {
BluetoothOutputDialog(
initialSettings = rcSettings,
onDismiss = dismissBluetoothDialog,
onSave = { rotatorState,
rotatorAddress,
rotatorFormat,
frequencyState,
frequencyAddress,
frequencyFormat ->
setBluetoothRotatorState(rotatorState)
setBluetoothRotatorAddress(rotatorAddress)
setBluetoothRotatorFormat(rotatorFormat)
setBluetoothFrequencyState(frequencyState)
setBluetoothFrequencyAddress(frequencyAddress)
setBluetoothFrequencyFormat(frequencyFormat)
initialSettings = uiState.rcSettings,
onDismiss = { dialogs.bluetooth = false },
onSave = { rotState, rotAddr, rotFmt, freqState, freqAddr, freqFmt ->
onAction(
SettingsAction.UpdateRC(
uiState.rcSettings.copy(
bluetoothRotatorState = rotState, bluetoothRotatorAddress = rotAddr,
bluetoothRotatorFormat = rotFmt, bluetoothFrequencyState = freqState,
bluetoothFrequencyAddress = freqAddr, bluetoothFrequencyFormat = freqFmt
)
)
)
}
)
}
if (dialogs.radioControl) {
RadioControlDialog(
initialSettings = uiState.radioControlSettings,
onDismiss = { dialogs.radioControl = false },
onSave = { onAction(SettingsAction.UpdateRadioControl(it)) }
)
}
// Other settings
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)) }
// URLs for top bar
val uriHandler = LocalUriHandler.current
val appUrl = stringResource(R.string.prefs_app_url)
val donateUrl = stringResource(R.string.prefs_donate_url)
@@ -299,11 +226,31 @@ private fun SettingsScreen(uiState: SettingsState) {
modifier = Modifier.clip(MaterialTheme.shapes.medium)
) {
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(span = { GridItemSpan(maxLineSpan) }) { OutputCard({ showNetworkDialog() }, { showBluetoothDialog() }) }
item { OtherCard(otherSettings, toggleUtc, toggleUpdate, toggleSweep, toggleSensor) }
item {
DataCard(
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() }
}
}
@@ -324,7 +271,7 @@ private fun LocationCard(
showPosDialog: () -> Unit,
showLocDialog: () -> Unit,
dismissPosMessage: () -> Unit,
sendSystemAction: (SystemAction) -> Unit
onAction: (SettingsAction) -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
@@ -339,7 +286,7 @@ private fun LocationCard(
UpdateIndicator(isUpdating = settings.isUpdating, Modifier.weight(1f))
}
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))
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = "Lat: ${settings.stationPos.latitude}°")
@@ -371,7 +318,7 @@ private fun LocationCard(
if (settings.messageResId != 0) {
val errorString = stringResource(id = settings.messageResId)
LaunchedEffect(key1 = settings.messageResId) {
sendSystemAction(SystemAction.ShowToast(errorString))
onAction(SettingsAction.ShowToast(errorString))
dismissPosMessage()
}
}
@@ -404,7 +351,7 @@ private fun DataCard(
UpdateIndicator(isUpdating = settings.isUpdating, Modifier.weight(1f))
}
Spacer(modifier = Modifier.height(2.dp))
Text(text = setUpdateTime(updateTime = settings.timestamp))
Text(text = formatUpdateTime(updateTime = settings.timestamp))
Spacer(modifier = Modifier.height(2.dp))
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.prefs_data_entries, settings.entriesTotal))
@@ -434,12 +381,13 @@ private fun DataCard(
@Preview(showBackground = true)
@Composable
private fun OutputCardPreview() = MainTheme { OutputCard({}, {}) }
private fun OutputCardPreview() = MainTheme { OutputCard({}, {}, {}) }
@Composable
private fun OutputCard(
onNetworkClick: () -> Unit,
onBluetoothClick: () -> Unit
onBluetoothClick: () -> Unit,
onRadioControlClick: () -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
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),
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,
shouldSeeWhatsNew = false
)
OtherCard(settings = values, {}, {}, {}, {})
OtherCard(settings = values) {}
}
@Composable
private fun OtherCard(
settings: OtherSettings,
toggleUtc: (Boolean) -> Unit,
toggleUpdate: (Boolean) -> Unit,
toggleSweep: (Boolean) -> Unit,
toggleSensor: (Boolean) -> Unit
) {
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
@@ -497,49 +444,44 @@ private fun OtherCard(
text = stringResource(id = R.string.prefs_other_title),
color = MaterialTheme.colorScheme.primary
)
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = R.string.prefs_other_switch_utc))
Switch(checked = settings.stateOfUtc, onCheckedChange = { toggleUtc(it) })
SwitchRow(R.string.prefs_other_switch_utc, settings.stateOfUtc) {
onAction(SettingsAction.ToggleUtc(it))
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = R.string.prefs_other_switch_update))
Switch(checked = settings.stateOfAutoUpdate, onCheckedChange = { toggleUpdate(it) })
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
onAction(SettingsAction.ToggleUpdate(it))
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = R.string.prefs_other_switch_sweep))
Switch(checked = settings.stateOfSweep, onCheckedChange = { toggleSweep(it) })
SwitchRow(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
onAction(SettingsAction.ToggleSweep(it))
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = R.string.prefs_other_switch_sensors))
Switch(checked = settings.stateOfSensors, onCheckedChange = { toggleSensor(it) })
SwitchRow(R.string.prefs_other_switch_sensors, settings.stateOfSensors) {
onAction(SettingsAction.ToggleSensor(it))
}
}
}
}
@Composable
private fun setUpdateTime(updateTime: Long): String {
val updateDate = if (updateTime != 0L) {
val timePattern = stringResource(id = R.string.prefs_updated_time)
SimpleDateFormat(timePattern, Locale.getDefault()).format(Date(updateTime))
} else {
stringResource(id = R.string.pass_time_placeholder)
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
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)
}
@@ -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.OtherSettings
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.OrbitalPass
data class PositionSettings(
val isUpdating: Boolean, val stationPos: GeoPos, val messageResId: Int
@@ -32,62 +32,42 @@ data class DataSettings(
)
data class SettingsState(
val nextPass: OrbitalPass,
val nextTime: String,
val isNextTimeAos: Boolean,
val appVersionName: String,
val positionSettings: PositionSettings,
val dataSettings: DataSettings,
val otherSettings: OtherSettings,
val rcSettings: RCSettings,
val dataSourcesSettings: DataSourcesSettings,
val sendAction: (SettingsAction) -> Unit,
val sendRCAction: (RCAction) -> Unit,
val sendSystemAction: (SystemAction) -> Unit,
val sendDataSourcesAction: (DataSourcesAction) -> Unit
val radioControlSettings: RadioControlSettings,
val dataSourcesSettings: DataSourcesSettings
)
sealed class SettingsAction {
data object SetGpsPosition : SettingsAction()
data class SetGeoPosition(val latitude: Double, val longitude: Double) : SettingsAction()
data class SetQthPosition(val locator: String) : SettingsAction()
data object DismissPosMessages : SettingsAction()
data object UpdateFromWeb : 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 interface SettingsAction {
// Position
data object SetGpsPosition : SettingsAction
data class SetGeoPosition(val latitude: Double, val longitude: Double) : SettingsAction
data class SetQthPosition(val locator: String) : SettingsAction
data object DismissPosMessages : SettingsAction
sealed class SystemAction {
data class ShowToast(val message: String) : SystemAction()
}
// Data
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 {
data class SetRotatorState(val value: Boolean) : RCAction()
data class SetRotatorAddress(val value: String) : RCAction()
data class SetRotatorPort(val value: String) : RCAction()
data class SetRotatorFormat(val value: String) : RCAction()
data class SetFrequencyState(val value: Boolean) : RCAction()
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()
}
// Toggles
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 DataSourcesAction {
data class SetUseCustomTle(val value: Boolean) : DataSourcesAction()
data class SetUseCustomTransceivers(val value: Boolean) : DataSourcesAction()
data class SetTleUrl(val value: String) : DataSourcesAction()
data class SetTransceiversUrl(val value: String) : DataSourcesAction()
// Remote control
data class UpdateRC(val settings: RCSettings) : SettingsAction
data class UpdateRadioControl(val settings: RadioControlSettings) : SettingsAction
// 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.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
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.getDefaultPass
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
class SettingsViewModel(
private val databaseRepo: IDatabaseRepo,
private val satelliteRepo: ISatelliteRepo,
private val settingsRepo: ISettingsRepo,
private val showToast: IShowToast
) : ViewModel() {
private val defaultPosSettings = PositionSettings(false, settingsRepo.stationPosition.value, 0)
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(
SettingsState(
nextTime = "00:00:00",
isNextTimeAos = true,
nextPass = getDefaultPass(),
appVersionName = settingsRepo.appVersionName,
positionSettings = defaultPosSettings,
dataSettings = defaultDataSettings,
otherSettings = settingsRepo.otherSettings.value,
rcSettings = settingsRepo.rcSettings.value,
dataSourcesSettings = defaultDataSourcesSettings,
sendAction = ::handleAction,
sendRCAction = ::handleAction,
sendSystemAction = ::handleAction,
sendDataSourcesAction = ::handleAction
radioControlSettings = settingsRepo.radioControlSettings.value,
dataSourcesSettings = settingsRepo.dataSourcesSettings.value
)
)
private var processing: Job? = null
val uiState: StateFlow<SettingsState> = _uiState
init {
viewModelScope.launch {
satelliteRepo.passes.collectLatest { passes ->
processing?.cancelAndJoin()
processing = viewModelScope.launch {
while (isActive) {
val timeNow = System.currentTimeMillis()
val newPasses = satelliteRepo.processPasses(passes, timeNow)
setPassInfo(newPasses, timeNow)
delay(1000)
}
settingsRepo.stationPosition.collect { geoPos ->
_uiState.update {
it.copy(positionSettings = it.positionSettings.copy(isUpdating = false, stationPos = geoPos))
}
}
}
viewModelScope.launch {
settingsRepo.stationPosition.collect { geoPos ->
val newPosSettings = _uiState.value.positionSettings.copy(
isUpdating = false, stationPos = geoPos
)
_uiState.update { it.copy(positionSettings = newPosSettings) }
}
}
viewModelScope.launch {
settingsRepo.databaseState.collect { state ->
val newDataSettings = _uiState.value.dataSettings.copy(
isUpdating = false,
entriesTotal = state.numberOfSatellites,
radiosTotal = state.numberOfRadios,
timestamp = state.updateTimestamp
)
_uiState.update { it.copy(dataSettings = newDataSettings) }
_uiState.update {
it.copy(
dataSettings = DataSettings(
false,
state.numberOfSatellites,
state.numberOfRadios,
state.updateTimestamp
)
)
}
}
}
viewModelScope.launch {
@@ -126,166 +91,100 @@ class SettingsViewModel(
_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) {
// Position
SettingsAction.SetGpsPosition -> setGpsPosition()
is SettingsAction.SetGeoPosition -> setGeoPosition(action.latitude, action.longitude)
is SettingsAction.SetQthPosition -> setQthPosition(action.locator)
SettingsAction.DismissPosMessages -> dismissPosMessage()
SettingsAction.UpdateFromWeb -> updateFromWeb()
is SettingsAction.UpdateTLEFromFile -> updateTLEFromFile(action.uri)
is SettingsAction.UpdateTransceiversFromFile -> updateTransceiversFromFile(action.uri)
SettingsAction.ClearAllData -> clearAllData()
is SettingsAction.ToggleUtc -> settingsRepo.setStateOfUtc(action.value)
is SettingsAction.ToggleUpdate -> settingsRepo.setStateOfAutoUpdate(action.value)
is SettingsAction.ToggleSweep -> settingsRepo.setStateOfSweep(action.value)
is SettingsAction.ToggleSensor -> settingsRepo.setStateOfSensors(action.value)
is SettingsAction.ToggleLightTheme -> settingsRepo.setStateOfLightTheme(action.value)
// Data
SettingsAction.UpdateFromWeb -> runDataUpdate { databaseRepo.updateFromRemote() }
is SettingsAction.UpdateTLEFromFile -> runDataUpdate { databaseRepo.updateTLEFromFile(action.uri) }
is SettingsAction.UpdateTransceiversFromFile -> runDataUpdate {
databaseRepo.updateTransceiversFromFile(
action.uri
)
}
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) {
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)
}
}
// region Position helpers — consolidated from 3 near-identical functions
private fun setGpsPosition() {
if (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) }
}
updatePosition(R.string.prefs_loc_gps_error) { settingsRepo.setStationPosition() }
}
private fun setGeoPosition(latitude: Double, longitude: Double) {
if (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) }
}
updatePosition(R.string.prefs_loc_input_error) { settingsRepo.setStationPosition(latitude, longitude, 0.0) }
}
private fun setQthPosition(locator: String) {
if (settingsRepo.setStationPosition(locator)) {
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_qth_error
val newPosSettings = _uiState.value.positionSettings.copy(
isUpdating = false, messageResId = errorResId
)
_uiState.update { it.copy(positionSettings = newPosSettings) }
updatePosition(R.string.prefs_loc_qth_error) { settingsRepo.setStationPosition(locator) }
}
/**
* Common position update logic. Calls [action], emits success or error message.
*/
private inline fun updatePosition(errorResId: Int, action: () -> Boolean) {
val success = action()
val resId = if (success) R.string.prefs_loc_success else errorResId
val isUpdating = success // GPS update is async; geo/qth are immediate
_uiState.update {
it.copy(positionSettings = it.positionSettings.copy(isUpdating = isUpdating, messageResId = resId))
}
}
private fun dismissPosMessage() {
val newPosSettings = _uiState.value.positionSettings.copy(
isUpdating = false, messageResId = 0
)
_uiState.update { it.copy(positionSettings = newPosSettings) }
_uiState.update {
it.copy(positionSettings = it.positionSettings.copy(isUpdating = false, messageResId = 0))
}
}
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 {
val newDataSettings = _uiState.value.dataSettings.copy(isUpdating = true)
_uiState.update { it.copy(dataSettings = newDataSettings) }
databaseRepo.updateFromRemote()
_uiState.update {
it.copy(dataSettings = it.dataSettings.copy(isUpdating = true))
}
block()
} catch (exception: Exception) {
val newDataSettings = _uiState.value.dataSettings.copy(isUpdating = false)
_uiState.update { it.copy(dataSettings = newDataSettings) }
_uiState.update {
it.copy(dataSettings = it.dataSettings.copy(isUpdating = false))
}
println(exception)
}
}
private fun updateTLEFromFile(uri: String) = viewModelScope.launch {
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() }
// endregion
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
@@ -294,7 +193,6 @@ class SettingsViewModel(
val container = (this[applicationKey] as IContainerProvider).getMainContainer()
SettingsViewModel(
container.databaseRepo,
container.satelliteRepo,
container.settingsRepo,
container.provideShowToast()
)
+9 -9
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "411"
appVersionCode = "420"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.1.1"
appVersionName = "4.2.0"
#noinspection UnusedVersionCatalogEntry
compileSdk = "36"
#noinspection UnusedVersionCatalogEntry
@@ -12,7 +12,7 @@ jdkVersion = "17"
#noinspection UnusedVersionCatalogEntry
packageName = "com.rtbishop.look4sat"
android-gradle-plugin = "9.0.1"
android-gradle-plugin = "9.1.0"
google-ksp = "2.3.6"
kotlin = "2.3.20"
@@ -20,7 +20,7 @@ androidx-core-ktx = "1.18.0"
androidx-core-splashscreen = "1.2.0"
androidx-room = "2.8.4"
compose-bom = "2026.03.00"
compose-bom = "2026.03.01"
compose-activity = "1.13.0"
compose-lifecycle = "2.10.0"
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" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
# plugins defined by this project
convention-androidAppPlugin = { id = "com.rtbishop.look4sat.convention.androidAppPlugin" }
convention-androidLibraryPlugin = { id = "com.rtbishop.look4sat.convention.androidLibraryPlugin" }
convention-composeFeaturePlugin = { id = "com.rtbishop.look4sat.convention.composeFeaturePlugin" }
convention-composeLibraryPlugin = { id = "com.rtbishop.look4sat.convention.composeLibraryPlugin" }
convention-kotlinLibraryPlugin = { id = "com.rtbishop.look4sat.convention.kotlinLibraryPlugin" }
convention-applicationPlugin = { id = "com.rtbishop.look4sat.convention.applicationPlugin" }
convention-coreDataPlugin = { id = "com.rtbishop.look4sat.convention.coreDataPlugin" }
convention-coreDomainPlugin = { id = "com.rtbishop.look4sat.convention.coreDomainPlugin" }
convention-corePresentationPlugin = { id = "com.rtbishop.look4sat.convention.corePresentationPlugin" }
convention-featurePlugin = { id = "com.rtbishop.look4sat.convention.featurePlugin" }
[bundles]
#noinspection UnusedVersionCatalogEntry
+1
View File
@@ -24,6 +24,7 @@ include(
":feature:map",
":feature:passes",
":feature:radar",
":feature:radiocontrol",
":feature:satellites",
":feature:settings"
)