Compare commits

..
11 Commits
Author SHA1 Message Date
Arty Bishop 7cdc2952dc v4.4.6 - AMSAT status page, fully customizable data sources 2026-08-13 09:47:26 +02:00
Arty Bishop bd51044690 Added custom frequency offset setting to network reporting 2026-08-12 20:17:26 +02:00
PingouinFerreux 1982c2d3dc Fixed broken star history chart in README (#240) 2026-08-12 18:22:53 +02:00
Arty Bishop 2f4e3f5802 Added the ability to fully customize data sources via import 2026-08-12 13:01:45 +02:00
Arty Bishop 24eebdef74 Consolidated app dialogs and tweaked bottom sheets 2026-08-11 14:14:14 +02:00
Arty Bishop 3f5b48f270 Integrated the AMSAT status page created by MCKero6423 2026-08-11 14:05:55 +02:00
atsunatsuandatsunatsu 060fa2dfcd Added remembering per-satellite doppler offset (#237)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-08-11 14:02:15 +02:00
atsunatsuandatsunatsu 8b5960282f Broaden linear transponder detection, logic fixes (#236)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-08-08 21:31:44 +02:00
mckero 10eb84690e Added AMSAT satellite status tracking page (#234) 2026-08-08 14:26:36 +02:00
bf25292bf8 Fixed recalculating Radar track on station position change (#235)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
Co-authored-by: wty2019wty <74123961+wty2019wty@users.noreply.github.com>
2026-08-08 14:10:55 +02:00
Arty Bishop 8fbfcb3712 v4.4.5 - Pass progress hotfix, swapped modes/filter dialogs 2026-08-05 13:14:50 +02:00
55 changed files with 1896 additions and 564 deletions

No files matched your search

+1
View File
@@ -64,3 +64,4 @@ fastlane/readme.md
/app/release/output-metadata.json /app/release/output-metadata.json
/app/release/ /app/release/
/.kotlin/sessions/ /.kotlin/sessions/
.hermes/
+4 -4
View File
@@ -36,10 +36,10 @@ It is now and always will be completely ad-free and open-source.
## Star History ## Star History
<a href="https://www.star-history.com/?repos=rt-bishop%2FLook4Sat&type=timeline&legend=top-left"> <a href="https://star-history.dera.page/#rt-bishop/Look4Sat&type=timeline&legend=top-left">
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" /> <source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" /> <source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" /> <img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
</picture> </picture>
</a> </a>
@@ -49,7 +49,7 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
observeNightFilterState() observeNightFilterState()
setContent { setContent {
MainTheme(isDarkTheme = true) { MainScreen() } MainTheme(isDarkTheme = true) { NavRoot() }
} }
} }
@@ -25,8 +25,6 @@ import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
@@ -44,7 +42,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
@@ -70,6 +68,7 @@ import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
import com.rtbishop.look4sat.core.presentation.ElevationThresholds import com.rtbishop.look4sat.core.presentation.ElevationThresholds
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
@@ -82,37 +81,42 @@ import com.rtbishop.look4sat.feature.passes.PassesDestination
import com.rtbishop.look4sat.feature.radar.RadarDestination import com.rtbishop.look4sat.feature.radar.RadarDestination
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
import com.rtbishop.look4sat.feature.settings.SettingsDestination import com.rtbishop.look4sat.feature.settings.SettingsDestination
import com.rtbishop.look4sat.feature.status.SatStatusDestination
@Composable @Composable
fun NavRoot(deeplink: String? = null) { fun NavRoot(deeplink: String? = null) {
val rootBackStack = rememberNavBackStack(Screen.Passes) val rootBackStack = rememberNavBackStack(Screen.Passes)
val deeplinkResolver = DeeplinkResolver() val deeplinkResolver = DeeplinkResolver()
LaunchedEffect(deeplink) { LaunchedEffect(deeplink) {
deeplink?.let { deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
val destination = deeplinkResolver.resolve(it) // rootBackStack.clear()
rootBackStack.add(destination)
}
} }
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() } val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f) val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it }) // Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
slideOutHorizontally(tween(300)) { -it / 3 }
// Reverse: outgoing slides out to the right, incoming drifts in from the left
val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith
slideOutHorizontally(tween(300)) { it }
NavDisplay( NavDisplay(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
backStack = rootBackStack, backStack = rootBackStack,
onBack = navigateBack, onBack = navigateBack,
transitionSpec = { slideInTransition }, transitionSpec = { pushTransition },
popTransitionSpec = { slideOutTransition }, popTransitionSpec = { popTransition },
predictivePopTransitionSpec = { slideOutTransition }, predictivePopTransitionSpec = { popTransition },
entryDecorators = listOf( entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(), // Required for saving Compose state per entry rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator() // Required for ViewModel scoping per entry rememberViewModelStoreNavEntryDecorator()
), ),
entryProvider = entryProvider { entryProvider = entryProvider {
entry<Screen.Passes> { MainScreen(navigateToRadar = { rootBackStack.add(RadarDestination) }) } entry<Screen.Passes> { MainScreen(navigateToRadar = navigateToRadar) }
entry<RadarDestination> { entry<RadarDestination> {
Scaffold { innerPadding -> Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
RadarDestination(navigateUp = navigateBack) RadarDestination(navigateUp = navigateBack)
innerPadding.calculateTopPadding()
} }
} }
} }
@@ -120,18 +124,20 @@ fun NavRoot(deeplink: String? = null) {
} }
@Composable @Composable
fun MainScreen(navigateToRadar: () -> Unit = {}) { private fun MainScreen(navigateToRadar: () -> Unit = {}) {
val backStack = rememberNavBackStack(Screen.Passes)
val currentKey = backStack.lastOrNull()
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings)
val context = LocalContext.current val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle() val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle() val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
val backStack = rememberNavBackStack(Screen.Passes)
val currentKey = backStack.lastOrNull()
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
val fadeTransition = fadeIn(animationSpec = tween(300)) togetherWith
fadeOut(animationSpec = tween(300))
val navItems =
listOf(Screen.Satellites, Screen.Passes, Screen.Status, Screen.Map, Screen.Settings)
CompositionLocalProvider( CompositionLocalProvider(
LocalElevationThresholds provides ElevationThresholds( LocalElevationThresholds provides ElevationThresholds(
low = otherSettings.lowElevation, low = otherSettings.lowElevation,
@@ -144,13 +150,18 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
val isSelected = when (currentKey) { val isSelected = when (currentKey) {
is Screen.Satellites -> screen is Screen.Satellites is Screen.Satellites -> screen is Screen.Satellites
is Screen.Passes -> screen is Screen.Passes is Screen.Passes -> screen is Screen.Passes
is Screen.Radar -> screen is Screen.Radar is Screen.Status -> screen is Screen.Status
is Screen.Map -> screen is Screen.Map is Screen.Map -> screen is Screen.Map
is Screen.Settings -> screen is Screen.Settings is Screen.Settings -> screen is Screen.Settings
else -> false else -> false
} }
item( item(
icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) }, icon = {
Icon(
painter = painterResource(screen.iconResId),
contentDescription = stringResource(screen.titleResId)
)
},
label = { Text(stringResource(screen.titleResId)) }, label = { Text(stringResource(screen.titleResId)) },
selected = isSelected, selected = isSelected,
onClick = { onClick = {
@@ -179,9 +190,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
popTransitionSpec = { fadeTransition }, popTransitionSpec = { fadeTransition },
predictivePopTransitionSpec = { fadeTransition }, predictivePopTransitionSpec = { fadeTransition },
entryDecorators = listOf( entryDecorators = listOf(
// Required for saving Compose state per entry
rememberSaveableStateHolderNavEntryDecorator(), rememberSaveableStateHolderNavEntryDecorator(),
// Required for ViewModel scoping per entry
rememberViewModelStoreNavEntryDecorator() rememberViewModelStoreNavEntryDecorator()
), ),
entryProvider = entryProvider { entryProvider = entryProvider {
@@ -191,69 +200,71 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
entry<Screen.Passes> { entry<Screen.Passes> {
PassesDestination { catNum, aosTime -> PassesDestination { catNum, aosTime ->
container.satelliteRepo.selectPass(catNum, aosTime) container.satelliteRepo.selectPass(catNum, aosTime)
backStack.add(Screen.Radar) navigateToRadar()
// navigateToRadar()
} }
} }
entry<Screen.Radar> { entry<Screen.Status> { SatStatusDestination() }
RadarDestination(navigateUp = navigateBack) entry<Screen.Map> { MapDestination() }
} entry<Screen.Settings> { SettingsDestination() }
entry<Screen.Map> {
MapDestination()
}
entry<Screen.Settings> {
SettingsDestination()
}
} }
) )
// Radio tracking status banner
if (trackingState.isActive) { if (trackingState.isActive) {
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse") TrackingBanner(
val alpha by infiniteTransition.animateFloat( state = trackingState,
initialValue = 1f, targetValue = 0.4f, onClick = {
animationSpec = infiniteRepeatable( val pass = trackingState.currentPass
animation = tween(1000, easing = LinearEasing), if (pass != null) {
repeatMode = RepeatMode.Reverse container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
), label = "pulseAlpha" navigateToRadar()
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.primaryContainer)
.clickable {
val pass = trackingState.currentPass
if (pass != null) {
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
backStack.add(Screen.Radar)
}
} }
.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
)
}
} }
} }
} }
} }
} }
@Composable
private fun TrackingBanner(state: RadioTrackingState, onClick: () -> Unit) {
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(onClick = onClick)
.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: ${state.currentPass?.name ?: ""}",
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.weight(1f)
)
val connections = listOfNotNull(
"TX".takeIf { state.txConnected }, "RX".takeIf { state.rxConnected }
)
Text(
text = connections.joinToString("/"),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
@@ -36,6 +36,7 @@ internal class ApplicationPlugin : Plugin<Project> {
implementation(project(":feature:radar")) implementation(project(":feature:radar"))
implementation(project(":feature:satellites")) implementation(project(":feature:satellites"))
implementation(project(":feature:settings")) implementation(project(":feature:settings"))
implementation(project(":feature:status"))
implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.core.splashscreen)
implementation(libs.compose.material3.adaptive) implementation(libs.compose.material3.adaptive)
implementation(libs.compose.navigation3) implementation(libs.compose.navigation3)
@@ -17,8 +17,10 @@
*/ */
package com.rtbishop.look4sat.core.data.framework package com.rtbishop.look4sat.core.data.framework
import com.rtbishop.look4sat.core.domain.model.Constants
import com.rtbishop.look4sat.core.domain.repository.IReporter import com.rtbishop.look4sat.core.domain.repository.IReporter
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
@@ -31,18 +33,30 @@ class NetworkReporter(
private val rotatorServer: String, private val rotatorServer: String,
private val rotatorPort: Int, private val rotatorPort: Int,
private val frequencyServer: String, private val frequencyServer: String,
private val frequencyPort: Int private val frequencyPort: Int,
private val frequencyOffsetHz: Long = 0L
) : IReporter { ) : IReporter {
private val writeMutex = Mutex() private val writeMutex = Mutex()
private val connectionMutex = Mutex()
private val frequencyCommands = Channel<String>(Channel.CONFLATED)
private var rotatorSocket: SocketChannel? = null private var rotatorSocket: SocketChannel? = null
private var rotatorConnected = false private var rotatorConnected = false
private var rotatorConnecting = false
private var frequencySocket: SocketChannel? = null private var frequencySocket: SocketChannel? = null
private var frequencyConnected = false private var frequencyConnected = false
private var frequencyConnecting = false
init {
// Keep only the latest frequency command to avoid stale backlog and effective lag.
reporterScope.launch {
for (command in frequencyCommands) {
ensureFrequencyConnected()
if (!frequencyConnected) continue
write(frequencySocket, command) { resetFrequencyConnection() }
}
}
}
override fun reportRotation(format: String, azimuth: Double, elevation: Double) { override fun reportRotation(format: String, azimuth: Double, elevation: Double) {
reporterScope.launch { reporterScope.launch {
@@ -58,46 +72,43 @@ class NetworkReporter(
} }
override fun reportFrequency(format: String, frequency: Long) { override fun reportFrequency(format: String, frequency: Long) {
reporterScope.launch { val clampedOffset = frequencyOffsetHz.coerceIn(
ensureFrequencyConnected() Constants.FREQ_OFFSET_MIN_HZ,
if (!frequencyConnected) return@launch Constants.FREQ_OFFSET_MAX_HZ
val command = format )
.replace($$"$FREQ", frequency.toString()) val correctedFreq = frequency.coerceAtLeast(0L).safeAdd(clampedOffset).coerceAtLeast(0L)
.unescapeControlChars() val command = format
write(frequencySocket, command) { frequencyConnected = false } .replace($$"$FREQ", correctedFreq.toString())
} .unescapeControlChars()
frequencyCommands.trySend(command)
} }
private fun ensureRotatorConnected() { private suspend fun ensureRotatorConnected() {
if (rotatorConnected || rotatorConnecting || rotatorServer.isBlank()) return connectionMutex.withLock {
reporterScope.launch { if (rotatorConnected || rotatorServer.isBlank()) return
try { try {
rotatorConnecting = true resetRotatorConnection()
rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort)) rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
rotatorConnected = true rotatorConnected = true
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort") println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
} catch (e: Exception) { } catch (e: Exception) {
println("NetworkReporter rotator connect error: ${e.message}") println("NetworkReporter rotator connect error: ${e.message}")
rotatorConnected = false resetRotatorConnection()
} finally {
rotatorConnecting = false
} }
} }
} }
private fun ensureFrequencyConnected() { private suspend fun ensureFrequencyConnected() {
if (frequencyConnected || frequencyConnecting || frequencyServer.isBlank()) return connectionMutex.withLock {
reporterScope.launch { if (frequencyConnected || frequencyServer.isBlank()) return
try { try {
frequencyConnecting = true resetFrequencyConnection()
frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort)) frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
frequencyConnected = true frequencyConnected = true
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort") println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
} catch (e: Exception) { } catch (e: Exception) {
println("NetworkReporter frequency connect error: ${e.message}") println("NetworkReporter frequency connect error: ${e.message}")
frequencyConnected = false resetFrequencyConnection()
} finally {
frequencyConnecting = false
} }
} }
} }
@@ -106,7 +117,9 @@ class NetworkReporter(
try { try {
writeMutex.withLock { writeMutex.withLock {
val buffer = ByteBuffer.wrap("$command\n".toByteArray()) val buffer = ByteBuffer.wrap("$command\n".toByteArray())
socket?.write(buffer) while (buffer.hasRemaining()) {
socket?.write(buffer)
}
} }
} catch (e: Exception) { } catch (e: Exception) {
println("NetworkReporter write error: ${e.message}") println("NetworkReporter write error: ${e.message}")
@@ -114,6 +127,32 @@ class NetworkReporter(
} }
} }
private fun resetRotatorConnection() {
rotatorConnected = false
closeQuietly(rotatorSocket)
rotatorSocket = null
}
private fun resetFrequencyConnection() {
frequencyConnected = false
closeQuietly(frequencySocket)
frequencySocket = null
}
private fun closeQuietly(socket: SocketChannel?) {
try {
socket?.close()
} catch (_: Exception) {}
}
private fun Long.safeAdd(delta: Long): Long {
return when {
delta > 0 && this > Long.MAX_VALUE - delta -> Long.MAX_VALUE
delta < 0 && this < Long.MIN_VALUE - delta -> Long.MIN_VALUE
else -> this + delta
}
}
private fun String.unescapeControlChars(): String = private fun String.unescapeControlChars(): String =
replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t") replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
} }
@@ -29,6 +29,7 @@ import com.rtbishop.look4sat.core.data.framework.Ft817Controller
import com.rtbishop.look4sat.core.data.framework.Ic705Controller import com.rtbishop.look4sat.core.data.framework.Ic705Controller
import com.rtbishop.look4sat.core.data.framework.NetworkReporter import com.rtbishop.look4sat.core.data.framework.NetworkReporter
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
import com.rtbishop.look4sat.core.data.repository.AmSatRepository
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
import com.rtbishop.look4sat.core.data.repository.SelectionRepo import com.rtbishop.look4sat.core.data.repository.SelectionRepo
@@ -66,12 +67,14 @@ import okhttp3.OkHttpClient
class MainContainer(private val context: Context) : IMainContainer { class MainContainer(private val context: Context) : IMainContainer {
private val localSource = provideLocalSource() private val localSource = provideLocalSource()
private val remoteSource = provideRemoteSource()
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") } private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler) override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
override val settingsRepo = provideSettingsRepo() override val settingsRepo = provideSettingsRepo()
override val selectionRepo = provideSelectionRepo() override val selectionRepo = provideSelectionRepo()
override val satelliteRepo = provideSatelliteRepo() override val satelliteRepo = provideSatelliteRepo()
override val databaseRepo = provideDatabaseRepo() override val databaseRepo = provideDatabaseRepo()
override val amSatRepo by lazy { AmSatRepository(remoteSource) }
override val radioTrackingService: IRadioTrackingService by lazy { override val radioTrackingService: IRadioTrackingService by lazy {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo) RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
@@ -103,7 +106,8 @@ class MainContainer(private val context: Context) : IMainContainer {
rc.rotatorAddress, rc.rotatorAddress,
rc.rotatorPort.toIntOrNull() ?: 0, rc.rotatorPort.toIntOrNull() ?: 0,
rc.frequencyAddress, rc.frequencyAddress,
rc.frequencyPort.toIntOrNull() ?: 0 rc.frequencyPort.toIntOrNull() ?: 0,
rc.frequencyOffsetHz
) )
} }
@@ -0,0 +1,158 @@
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.SatDay
import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatSlot
import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import java.util.TimeZone
/** One report from the AMSAT API (data layer model). */
private data class ApiReport(
val id: String,
val name: String,
val callsign: String,
val report: String,
val gridSquare: String,
val reportedTimeUtcSec: Long
)
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepository {
private val isoUtcFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) {
val nowSec = System.currentTimeMillis() / 1000
val catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext null
// 72h = 3 days; API hard cap is limit=500 regardless of what we send.
// 500 records across ~100 catalog satellites ≈ ~1-5 reports/satellite/day — enough for 3 days.
// Upgrade path: paginate or request AMSAT to raise the cap if catalog grows beyond ~200 sats.
val reportsJson = remoteSource.getAmSatReports(hours = 72, limit = 500) ?: return@withContext null
val names = parseCatalog(catalogJson)
val reports = parseReports(reportsJson)
if (names.isEmpty() && reports.isEmpty()) return@withContext null
val statuses = buildStatuses(names, reports, nowSec)
val reportMap = reports.associate { it.id to toSatReport(it) }
SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
}
/** Parse catalog JSON to list of satellite names */
private fun parseCatalog(json: String): List<String> {
return try {
val arr = JSONObject(json).getJSONArray("data")
(0 until arr.length()).map { arr.getJSONObject(it).getString("name") }
} catch (_: Exception) {
emptyList()
}
}
/** Parse reports JSON to list of ApiReport domain objects */
private fun parseReports(json: String): List<ApiReport> {
return try {
val arr = JSONObject(json).getJSONArray("data")
(0 until arr.length()).mapNotNull { i ->
val o = arr.getJSONObject(i)
val iso = o.optString("reported_time", "")
if (iso.isEmpty()) null else ApiReport(
id = o.optString("id", ""),
name = o.optString("name", ""),
callsign = o.optString("callsign", ""),
report = o.optString("report", ""),
gridSquare = o.optString("grid_square", ""),
reportedTimeUtcSec = parseIsoUtcSec(iso)
)
}
} catch (_: Exception) {
emptyList()
}
}
/** Parse ISO 8601 UTC timestamp to epoch seconds (e.g., "2026-08-05T07:30:00Z") */
private fun parseIsoUtcSec(iso: String): Long {
return try {
(isoUtcFormat.parse(iso)?.time ?: 0L) / 1000
} catch (_: Exception) {
0L
}
}
/** Build one SatStatus (5 days x 12 slots) per catalog satellite, slotting reports by age. */
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
val byName = reports.groupBy { it.name }
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
val labels = (0 until 3).map { d ->
utc.timeInMillis = (nowSec - d * 86400L) * 1000
"${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}"
}
return names.map { name ->
val slots = (0 until 36).map { slotIdx ->
val slotStart = nowSec - (slotIdx + 1) * 7200L
val slotEnd = nowSec - slotIdx * 7200L
val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd }
if (inSlot.isEmpty()) {
SatSlot(statusColor = NO_REPORT_GRAY, count = 0)
} else {
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
SatSlot(
statusColor = statusColorOf(newest.report),
count = inSlot.size,
reportIds = inSlot.map { it.id }
)
}
}
val days = (0 until 3).map { d ->
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
}
SatStatus(name = name, days = days)
}
}
private fun toSatReport(r: ApiReport): SatReport {
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
cal.timeInMillis = r.reportedTimeUtcSec * 1000
val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
val y = cal.get(Calendar.YEAR)
val mo = (cal.get(Calendar.MONTH) + 1).toString().padStart(2, '0')
val d = cal.get(Calendar.DAY_OF_MONTH).toString().padStart(2, '0')
return SatReport(
id = r.id,
statusText = r.report,
call = r.callsign,
grid = r.gridSquare,
dateUtc = "$y-$mo-$d",
timeUtc = "$hh:$mm UTC"
)
}
/** Map status text to color value (for UI rendering). */
private fun statusColorOf(report: String): Long = when (report.lowercase()) {
"heard", "crew active" -> ACTIVE_BLUE
"telemetry only" -> TLM_ORANGE
"not heard" -> NOT_HEARD_PINK
else -> CONFLICT_DEEP_ORANGE
}
companion object {
// AMSAT official status colors (from amsat.org/status)
private const val ACTIVE_BLUE = 0xFF648FFF
private const val TLM_ORANGE = 0xFFFFB000
private const val NOT_HEARD_PINK = 0xFFDC267F
private const val CONFLICT_DEEP_ORANGE = 0xFFFE6100
private const val NO_REPORT_GRAY = 0xFFC0C0C0
}
}
@@ -23,7 +23,6 @@ import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.source.ILocalSource import com.rtbishop.look4sat.core.domain.source.ILocalSource
import com.rtbishop.look4sat.core.domain.source.IRemoteSource import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.domain.utility.DataParser import com.rtbishop.look4sat.core.domain.utility.DataParser
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.async import kotlinx.coroutines.async
@@ -40,7 +39,6 @@ class DatabaseRepo(
private val settingsRepo: ISettingsRepo private val settingsRepo: ISettingsRepo
) : IDatabaseRepo { ) : IDatabaseRepo {
private val customSourceType = "Other"
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) { override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
var importedCount = 0 var importedCount = 0
@@ -65,18 +63,13 @@ class DatabaseRepo(
} }
override suspend fun updateFromRemote() = withContext(dispatcher) { override suspend fun updateFromRemote() = withContext(dispatcher) {
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value val settings = settingsRepo.dataSourcesSettings.value
val tleUrls = buildMap { fun normalizeUrl(url: String) = if (url.startsWith("http")) url else "https://$url"
putAll(Sources.satelliteDataUrls) val tleUrls = settings.satelliteUrls.filter { it.isNotBlank() }.map(::normalizeUrl)
if (dataSourcesSettings.useCustomTLE) put(customSourceType, dataSourcesSettings.tleUrl) val radioUrls = settings.transceiversUrls.filter { it.isNotBlank() }.map(::normalizeUrl)
}.filterValues { it.isNotBlank() }
val radioUrls = buildMap {
putAll(Sources.transceiversDataUrls)
if (dataSourcesSettings.useCustomTransceivers) put(customSourceType, dataSourcesSettings.transceiversUrl)
}.filterValues { it.isNotBlank() }
// launch all network requests concurrently // launch all network requests concurrently
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } } val tleJobs = tleUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } }
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } } val radioJobs = radioUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } }
// parse fetched data concurrently // parse fetched data concurrently
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) -> val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty() stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty()
@@ -34,6 +34,7 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.TimeZone import java.util.TimeZone
@@ -64,19 +65,23 @@ class SatelliteRepo(
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id) override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
override suspend fun initRepository() = withContext(dispatcher) { override suspend fun initRepository() = withContext(dispatcher) {
settingsRepo.selectedIds.collect { selectedIds -> combine(
_satellites.update { localStorage.getEntriesWithIds(selectedIds) } settingsRepo.selectedIds,
val settings = settingsRepo.passesSettings.value settingsRepo.stationPosition
calculatePasses( ) { selectedIds, _ -> selectedIds }
time = System.currentTimeMillis(), .collect { selectedIds ->
hoursAhead = settings.hoursAhead, _satellites.update { localStorage.getEntriesWithIds(selectedIds) }
minElevation = settings.minElevation, val settings = settingsRepo.passesSettings.value
aosStartMinute = settings.aosStartMinute, calculatePasses(
aosEndMinute = settings.aosEndMinute, time = System.currentTimeMillis(),
invertAosTimeWindow = settings.invertAosTimeWindow, hoursAhead = settings.hoursAhead,
modes = settingsRepo.selectedSatModes.value minElevation = settings.minElevation,
) aosStartMinute = settings.aosStartMinute,
} aosEndMinute = settings.aosEndMinute,
invertAosTimeWindow = settings.invertAosTimeWindow,
modes = settingsRepo.selectedSatModes.value
)
}
} }
override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos { override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos {
@@ -29,14 +29,17 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.Constants
import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.domain.utility.positionToQth import com.rtbishop.look4sat.core.domain.utility.positionToQth
import com.rtbishop.look4sat.core.domain.utility.qthToPosition import com.rtbishop.look4sat.core.domain.utility.qthToPosition
import com.rtbishop.look4sat.core.domain.utility.round import com.rtbishop.look4sat.core.domain.utility.round
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import org.json.JSONObject
class SettingsRepo( class SettingsRepo(
private val locationManager: LocationManager, private val locationManager: LocationManager,
@@ -67,6 +70,7 @@ class SettingsRepo(
private val keyFrequencyAddress = "frequencyAddress" private val keyFrequencyAddress = "frequencyAddress"
private val keyFrequencyPort = "frequencyPort" private val keyFrequencyPort = "frequencyPort"
private val keyFrequencyFormat = "frequencyFormat" private val keyFrequencyFormat = "frequencyFormat"
private val keyFrequencyOffsetHz = "frequencyOffsetHz"
private val keySelectedIds = "selectedIds" private val keySelectedIds = "selectedIds"
private val keySelectedSatModes = "selectedSatModes" private val keySelectedSatModes = "selectedSatModes"
private val keyStateOfAutoUpdate = "stateOfAutoUpdate" private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
@@ -88,11 +92,10 @@ class SettingsRepo(
private val keyHighElevation = "highElevation" private val keyHighElevation = "highElevation"
private val keyRadarCompassOffset = "radarCompassOffset" private val keyRadarCompassOffset = "radarCompassOffset"
private val keyRadarCompassOffsetElev = "radarCompassOffsetElev" private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
private val keyUseCustomTle = "useCustomTle" private val keySatelliteUrls = "satelliteUrls"
private val keyUseCustomTransceivers = "useCustomTransceivers" private val keyTransceiversUrls = "transceiversUrls"
private val keyTleUrl = "tleUrl"
private val keyTransceiversUrl = "transceiversUrl"
private val separatorComma = "," private val separatorComma = ","
private val separatorUrl = "\n"
//region # Satellites selection settings //region # Satellites selection settings
private val _satelliteSelection = MutableStateFlow(getSelectedIds()) private val _satelliteSelection = MutableStateFlow(getSelectedIds())
@@ -276,6 +279,10 @@ class SettingsRepo(
override val rcSettings: StateFlow<RCSettings> = _rcSettings override val rcSettings: StateFlow<RCSettings> = _rcSettings
override fun updateRCSettings(settings: RCSettings) { override fun updateRCSettings(settings: RCSettings) {
val clampedFreqOffsetHz = settings.frequencyOffsetHz.coerceIn(
Constants.FREQ_OFFSET_MIN_HZ,
Constants.FREQ_OFFSET_MAX_HZ
)
preferences.edit { preferences.edit {
putBoolean(keyRotatorState, settings.rotatorState) putBoolean(keyRotatorState, settings.rotatorState)
putString(keyRotatorAddress, settings.rotatorAddress) putString(keyRotatorAddress, settings.rotatorAddress)
@@ -285,6 +292,7 @@ class SettingsRepo(
putString(keyFrequencyAddress, settings.frequencyAddress) putString(keyFrequencyAddress, settings.frequencyAddress)
putString(keyFrequencyPort, settings.frequencyPort) putString(keyFrequencyPort, settings.frequencyPort)
putString(keyFrequencyFormat, settings.frequencyFormat) putString(keyFrequencyFormat, settings.frequencyFormat)
putLong(keyFrequencyOffsetHz, clampedFreqOffsetHz)
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState) putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat) putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName) putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
@@ -293,7 +301,7 @@ class SettingsRepo(
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat) putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress) putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
} }
_rcSettings.value = settings _rcSettings.value = settings.copy(frequencyOffsetHz = clampedFreqOffsetHz)
} }
private fun getRCSettings(): RCSettings = RCSettings( private fun getRCSettings(): RCSettings = RCSettings(
@@ -305,6 +313,8 @@ class SettingsRepo(
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1", frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532", frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ", frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ",
frequencyOffsetHz = preferences.getLong(keyFrequencyOffsetHz, 0L)
.coerceIn(Constants.FREQ_OFFSET_MIN_HZ, Constants.FREQ_OFFSET_MAX_HZ),
bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false), bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL", bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default", bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
@@ -364,19 +374,19 @@ class SettingsRepo(
override fun updateDataSourcesSettings(settings: DataSourcesSettings) { override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
preferences.edit { preferences.edit {
putBoolean(keyUseCustomTle, settings.useCustomTLE) putString(keySatelliteUrls, settings.satelliteUrls.joinToString(separatorUrl))
putBoolean(keyUseCustomTransceivers, settings.useCustomTransceivers) putString(keyTransceiversUrls, settings.transceiversUrls.joinToString(separatorUrl))
putString(keyTleUrl, settings.tleUrl)
putString(keyTransceiversUrl, settings.transceiversUrl)
} }
_dataSourcesSettings.value = settings _dataSourcesSettings.value = settings
} }
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings( private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false), satelliteUrls = preferences.getString(keySatelliteUrls, null)
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false), ?.split(separatorUrl)?.filter { it.isNotBlank() }
tleUrl = preferences.getString(keyTleUrl, "https://example.com/tle.txt") ?: "", ?: Sources.satelliteDataUrls,
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: "" transceiversUrls = preferences.getString(keyTransceiversUrls, null)
?.split(separatorUrl)?.filter { it.isNotBlank() }
?: Sources.transceiversDataUrls
) )
//endregion //endregion
@@ -417,5 +427,27 @@ class SettingsRepo(
baudRate = preferences.getInt(keyRadioBaudRate, 4800), baudRate = preferences.getInt(keyRadioBaudRate, 4800),
splitMode = preferences.getBoolean(keyRadioSplitMode, false) splitMode = preferences.getBoolean(keyRadioSplitMode, false)
) )
//endregion
private val keySatelliteOffsets = "satelliteOffsets"
override fun getSatelliteOffset(catnum: Int): String {
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
return try {
JSONObject(json).optString(catnum.toString(), "")
} catch (_: Exception) {
""
}
}
override fun setSatelliteOffset(catnum: Int, offset: String) {
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
val updated = try {
val obj = JSONObject(json)
if (offset.isEmpty()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), offset)
obj.toString()
} catch (_: Exception) {
"""{"$catnum": "$offset"}"""
}
preferences.edit { putString(keySatelliteOffsets, updated) }
}
} }
@@ -58,4 +58,36 @@ class RemoteSource(
null null
} }
} }
override suspend fun getAmSatCatalog(): String? = withContext(dispatcher) {
try {
val request = Request.Builder()
.url("https://www.amsat.org/status/api/v1/catalog.php")
.header("User-Agent", "Look4Sat")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
response.body.string()
}
} catch (exception: Exception) {
println("RemoteSource getAmSatCatalog exception: $exception")
null
}
}
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = withContext(dispatcher) {
try {
val request = Request.Builder()
.url("https://www.amsat.org/status/api/v1/reports.php?hours=$hours&limit=$limit")
.header("User-Agent", "Look4Sat")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
response.body.string()
}
} catch (exception: Exception) {
println("RemoteSource getAmSatReports exception: $exception")
null
}
}
} }
@@ -90,10 +90,8 @@ class DatabaseRepoTest {
} }
val settingsRepo = FakeSettingsRepo( val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings( dataSources = DataSourcesSettings(
useCustomTLE = true, satelliteUrls = listOf(customCsvUrl),
useCustomTransceivers = false, transceiversUrls = emptyList()
tleUrl = customCsvUrl,
transceiversUrl = ""
) )
) )
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo) val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
@@ -122,6 +120,10 @@ private class FakeRemoteSource : IRemoteSource {
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke() override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke() override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke()
override suspend fun getAmSatCatalog(): String? = null
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
} }
private class FakeLocalSource : ILocalSource { private class FakeLocalSource : ILocalSource {
@@ -174,7 +176,7 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L)) override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow( override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
RCSettings(false, "", "", "", false, "", "", "", false, "", "", "", false, "", "") RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
) )
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow( override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
@@ -213,13 +215,15 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
} }
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
} }
private fun defaultDataSourcesSettings(): DataSourcesSettings { private fun defaultDataSourcesSettings(): DataSourcesSettings {
return DataSourcesSettings( return DataSourcesSettings(
useCustomTLE = false, satelliteUrls = emptyList(),
useCustomTransceivers = false, transceiversUrls = emptyList()
tleUrl = "",
transceiversUrl = ""
) )
} }
@@ -127,7 +127,7 @@ class SelectionRepoTest {
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L)) override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow( override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
RCSettings(false, "", "", "", false, "", "", "", false, "", "", "", false, "", "") RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
) )
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow( override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
@@ -135,7 +135,7 @@ class SelectionRepoTest {
) )
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow( override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
DataSourcesSettings(false, false, "", "") DataSourcesSettings(emptyList(), emptyList())
) )
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow( override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
@@ -167,6 +167,10 @@ class SelectionRepoTest {
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
} }
} }
@@ -0,0 +1,23 @@
/*
* 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.model
object Constants {
const val FREQ_OFFSET_MIN_HZ = -50_000L
const val FREQ_OFFSET_MAX_HZ = 50_000L
}
@@ -0,0 +1,37 @@
package com.rtbishop.look4sat.core.domain.model
/** One satellite status report (AMSAT site tooltip data) */
data class SatReport(
val id: String, // 报告 ID(a885153)
val statusText: String, // Heard / Telemetry Only / Not Heard ...
val call: String, // 呼号
val grid: String, // 网格坐标(可为空)
val dateUtc: String, // 2026-08-04
val timeUtc: String // 2:46-:59 UTC
)
/** State of one 2-hour slot */
data class SatSlot(
val statusColor: Long, // ARGB 状态色(-1 = 无报告)
val count: Int, // 报告数量(0 = 无)
val reportIds: List<String> = emptyList() // 该槽报告 ID 列表
)
/** One satellite day (12 two-hour slots) */
data class SatDay(
val dateLabel: String, // "Aug 4"
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
)
/** One satellite, 5 days of state */
data class SatStatus(
val name: String, // "AO-123_[FM]"
val days: List<SatDay> // 5 天(新→旧)
)
/** Overall page parse result */
data class SatStatusPage(
val fetchedAtUtcMs: Long,
val statuses: List<SatStatus>,
val reports: Map<String, SatReport> // id → 报告
)
@@ -41,6 +41,7 @@ data class RCSettings(
val frequencyAddress: String, val frequencyAddress: String,
val frequencyPort: String, val frequencyPort: String,
val frequencyFormat: String, val frequencyFormat: String,
val frequencyOffsetHz: Long = 0L,
val bluetoothRotatorState: Boolean, val bluetoothRotatorState: Boolean,
val bluetoothRotatorFormat: String, val bluetoothRotatorFormat: String,
val bluetoothRotatorName: String, val bluetoothRotatorName: String,
@@ -67,10 +68,8 @@ data class OtherSettings(
) )
data class DataSourcesSettings( data class DataSourcesSettings(
val useCustomTLE: Boolean, val satelliteUrls: List<String>,
val useCustomTransceivers: Boolean, val transceiversUrls: List<String>
val tleUrl: String,
val transceiversUrl: String
) )
data class RadioControlSettings( data class RadioControlSettings(
@@ -0,0 +1,9 @@
package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
/** AMSAT satellite status data source */
interface IAmSatRepository {
/** Fetch and parse the AMSAT status page; null on failure */
suspend fun fetchStatus(): SatStatusPage?
}
@@ -29,6 +29,7 @@ interface IMainContainer {
val selectionRepo: ISelectionRepo val selectionRepo: ISelectionRepo
val satelliteRepo: ISatelliteRepo val satelliteRepo: ISatelliteRepo
val databaseRepo: IDatabaseRepo val databaseRepo: IDatabaseRepo
val amSatRepo: IAmSatRepository
val radioTrackingService: IRadioTrackingService val radioTrackingService: IRadioTrackingService
fun provideAddToCalendar(): IAddToCalendar fun provideAddToCalendar(): IAddToCalendar
fun provideShowToast(): IShowToast fun provideShowToast(): IShowToast
@@ -75,4 +75,9 @@ interface ISettingsRepo {
val radioControlSettings: StateFlow<RadioControlSettings> val radioControlSettings: StateFlow<RadioControlSettings>
fun updateRadioControlSettings(settings: RadioControlSettings) fun updateRadioControlSettings(settings: RadioControlSettings)
//endregion //endregion
//region # Per-satellite calculator offset settings
fun getSatelliteOffset(catnum: Int): String
fun setSatelliteOffset(catnum: Int, offset: String)
//endregion
} }
@@ -22,4 +22,6 @@ import java.io.InputStream
interface IRemoteSource { interface IRemoteSource {
suspend fun getFileStream(uri: String): InputStream? suspend fun getFileStream(uri: String): InputStream?
suspend fun getNetworkStream(url: String): InputStream? suspend fun getNetworkStream(url: String): InputStream?
suspend fun getAmSatCatalog(): String?
suspend fun getAmSatReports(hours: Int, limit: Int): String?
} }
@@ -18,18 +18,17 @@
package com.rtbishop.look4sat.core.domain.source package com.rtbishop.look4sat.core.domain.source
object Sources { object Sources {
val satelliteDataUrls = mapOf( val satelliteDataUrls = listOf(
"CelesTrak" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv", "celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"AMSAT" to "https://amsat.org/tle/current/nasabare.txt", "db.satnogs.org/api/tle/?format=3le",
"McCants Classified" to "https://www.mmccants.org/tles/classfd.zip", "amsat.org/tle/current/nasabare.txt",
"McCants Integrated" to "https://www.mmccants.org/tles/inttles.zip", "mmccants.org/tles/classfd.zip",
"R4UAB" to "https://r4uab.ru/satonline.txt", "r4uab.ru/satonline.txt",
"SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le", "live.ariss.org/iss.txt"
"ARISS" to "https://live.ariss.org/iss.txt",
"Other" to "" // key for sats filter
) )
val transceiversDataUrls = mapOf( val transceiversDataUrls = listOf(
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active" "db.satnogs.org/api/transmitters/?format=json&status=active",
"r4uab.ru/transmitters.json"
) )
val satelliteModes = listOf( val satelliteModes = listOf(
"4FSK", "64-QAM", "AFSK", "AFSK TUBiX10", "AHRPT", "AM", "APT", "ASK", "BPSK", "4FSK", "64-QAM", "AFSK", "AFSK TUBiX10", "AHRPT", "AM", "APT", "ASK", "BPSK",
@@ -112,11 +112,30 @@ object DopplerFrequencyCalculator {
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode) val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
.joinToString(separator = " ") .joinToString(separator = " ")
.lowercase(Locale.ENGLISH) .lowercase(Locale.ENGLISH)
val hasLinearName = info.contains("linear") val hasLinearName = info.contains("linear") || info.contains(" lin") || info.startsWith("lin")
val hasTransponderName = info.contains("transponder") || info.contains("transp") || val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
info.contains("xponder") || info.contains("xpdr") info.contains("xponder") || info.contains("xpdr")
val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) } val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) }
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode) return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode) ||
(hasLinearName && hasLinearMode)
}
/**
* Removes duplicate transponder entries that describe the same physical
* transponder with different mode labels (e.g. SatNOGS lists AO-7's Mode A
* as both "Lin SSB" and "Lin CW", and JO-97's U/V transponder as both
* "CW Transponder" and "SSB Transponder").
*
* Entries sharing the same uplink/downlink frequency range are considered
* the same transponder. The non-CW entry is preferred because its invert
* flag is more reliable (e.g. JO-97's CW entry wrongly has invert=false).
*/
fun deduplicateTransponders(radios: List<SatRadio>): List<SatRadio> {
return radios.groupBy { radio ->
listOf(radio.uplinkLow, radio.uplinkHigh, radio.downlinkLow, radio.downlinkHigh)
}.values.map { group ->
group.firstOrNull { it.downlinkMode?.equals("CW", ignoreCase = true) != true } ?: group.first()
}
} }
} }
@@ -41,6 +41,21 @@ fun Double.round(decimals: Int): Double {
return kotlin.math.round(this * multiplier) / multiplier return kotlin.math.round(this * multiplier) / multiplier
} }
fun String.aprsPasscode(): Int {
val callsign = this.trim().uppercase().substringBefore('-') // commonly strip SSID
var hash = 0x73E2
var i = 0
while (i < callsign.length) {
hash = hash xor (callsign[i].code shl 8)
i++
if (i < callsign.length) {
hash = hash xor callsign[i].code
i++
}
}
return hash and 0x7FFF
}
//fun String.getHash(type: String = "SHA-256"): String { //fun String.getHash(type: String = "SHA-256"): String {
// val hexChars = "0123456789ABCDEF" // val hexChars = "0123456789ABCDEF"
// val bytes = MessageDigest.getInstance(type).digest(this.toByteArray()) // val bytes = MessageDigest.getInstance(type).digest(this.toByteArray())
@@ -18,6 +18,7 @@
package com.rtbishop.look4sat.core.domain package com.rtbishop.look4sat.core.domain
import com.rtbishop.look4sat.core.domain.utility.DataParser import com.rtbishop.look4sat.core.domain.utility.DataParser
import com.rtbishop.look4sat.core.domain.utility.aprsPasscode
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -239,4 +240,10 @@ class DataParserTest {
// Matches the CSV test data epoch: 2021-11-16 → day 320 // Matches the CSV test data epoch: 2021-11-16 → day 320
assert(dataParser.getDayOfYear(2021, 11, 16) == 320) assert(dataParser.getDayOfYear(2021, 11, 16) == 320)
} }
@Test
fun `check APRS passcode calculation`() {
assert("M7LNB".aprsPasscode() == 12443)
assert("N0CALL".aprsPasscode() == 13023)
}
} }
@@ -13,6 +13,7 @@ import org.junit.Test
class DopplerFrequencyCalculatorTest { class DopplerFrequencyCalculatorTest {
private fun linearTransponder( private fun linearTransponder(
uuid: String = "linear",
upLow: Long = 145_000_000L, upLow: Long = 145_000_000L,
upHigh: Long = 145_500_000L, upHigh: Long = 145_500_000L,
downLow: Long = 435_000_000L, downLow: Long = 435_000_000L,
@@ -22,7 +23,7 @@ class DopplerFrequencyCalculatorTest {
downlinkMode: String? = "USB", downlinkMode: String? = "USB",
uplinkMode: String? = "LSB" uplinkMode: String? = "LSB"
) = SatRadio( ) = SatRadio(
uuid = "linear", info = info, isAlive = true, uuid = uuid, info = info, isAlive = true,
downlinkLow = downLow, downlinkHigh = downHigh, downlinkLow = downLow, downlinkHigh = downHigh,
downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh, downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345 uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
@@ -72,11 +73,93 @@ class DopplerFrequencyCalculatorTest {
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry)) assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry))
} }
@Test
fun isNamedLinearTransponder_returnsTrueForAbbreviatedLinName() {
// AO-7 style: "Mode V/A (A) Lin SSB" — "Lin" abbreviation, no "transponder" word
val ao7Entry = linearTransponder(info = "Mode V/A (A) Lin SSB", downlinkMode = "USB", uplinkMode = "USB")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7Entry))
val ao7CwEntry = linearTransponder(info = "Mode V/A (A) Lin CW", downlinkMode = "CW", uplinkMode = "CW")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7CwEntry))
val ao7ModeBEntry = linearTransponder(info = "Mode U/V (B) Lin", downlinkMode = "USB", uplinkMode = "LSB")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7ModeBEntry))
}
@Test
fun isNamedLinearTransponder_returnsTrueForLinearWithoutTransponderWord() {
// AO-73 style: "Mode U/V Linear" — has "Linear" but no "transponder"
val ao73Entry = linearTransponder(info = "Mode U/V Linear", downlinkMode = "USB", uplinkMode = "LSB")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao73Entry))
}
@Test
fun isNamedLinearTransponder_returnsFalseForDownlinkContainingLinInsideWord() {
// "Downlink" contains "lin" but is not a linear-transponder name
val downlinkEntry = linearTransponder(info = "Mode U Downlink", downlinkMode = "FM", uplinkMode = "FM")
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(downlinkEntry))
}
@Test @Test
fun isNamedLinearTransponder_returnsFalseForFmRepeater() { fun isNamedLinearTransponder_returnsFalseForFmRepeater() {
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder())) assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder()))
} }
@Test
fun deduplicateTransponders_mergesSameFrequencyRange() {
// AO-7's Mode A: same range, SSB and CW entries
val ssb = linearTransponder(
uuid = "ssb-uuid", info = "Mode V/A (A) Lin SSB",
downlinkMode = "USB", uplinkMode = "USB"
)
val cw = linearTransponder(
uuid = "cw-uuid", info = "Mode V/A (A) Lin CW",
downlinkMode = "CW", uplinkMode = "CW"
)
val modeB = linearTransponder(
uuid = "modeb-uuid", info = "Mode U/V (B) Lin",
upLow = 432_125_000L, upHigh = 432_175_000L,
downLow = 145_925_000L, downHigh = 145_975_000L,
downlinkMode = "USB", uplinkMode = "LSB"
)
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(ssb, cw, modeB))
assertEquals(2, result.size)
// SSB entry should be preferred over CW (same range)
assertEquals("ssb-uuid", result[0].uuid)
assertEquals("modeb-uuid", result[1].uuid)
}
@Test
fun deduplicateTransponders_prefersNonCwEntry() {
// JO-97: CW entry has invert=false (wrong), SSB has invert=true (correct)
val cw = linearTransponder(
uuid = "cw-uuid", info = "U/V CW Transponder",
downlinkMode = "CW", uplinkMode = "CW",
upLow = 435_100_000L, upHigh = 435_120_000L,
downLow = 145_855_000L, downHigh = 145_875_000L
)
val ssb = linearTransponder(
uuid = "ssb-uuid", info = "U/V SSB Transponder",
downlinkMode = "USB", uplinkMode = "LSB",
upLow = 435_100_000L, upHigh = 435_120_000L,
downLow = 145_855_000L, downHigh = 145_875_000L,
inverted = true
)
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(cw, ssb))
assertEquals(1, result.size)
assertEquals("ssb-uuid", result[0].uuid)
// Verify the correct invert flag is preserved
assertTrue(result[0].isInverted)
}
@Test
fun deduplicateTransponders_preservesUniqueEntries() {
val t1 = linearTransponder(uuid = "t1", upLow = 145_000_000L, upHigh = 145_500_000L,
downLow = 435_000_000L, downHigh = 435_500_000L)
val t2 = linearTransponder(uuid = "t2", upLow = 435_000_000L, upHigh = 435_500_000L,
downLow = 145_000_000L, downHigh = 145_500_000L)
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(t1, t2))
assertEquals(2, result.size)
}
@Test @Test
fun computeUplinkFromDownlink_linear_noDoppler() { fun computeUplinkFromDownlink_linear_noDoppler() {
val xpdr = linearTransponder() val xpdr = linearTransponder()
@@ -27,11 +27,15 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -47,7 +51,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -73,6 +76,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
@@ -234,8 +239,13 @@ fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier)
} }
@Composable @Composable
fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier, enabled: Boolean = true) { fun IconCard(
ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action) { action: () -> Unit, resId: Int, modifier: Modifier = Modifier,
enabled: Boolean = true, containerColor: Color = Color.Unspecified
) {
val colors = if (containerColor == Color.Unspecified) CardDefaults.elevatedCardColors()
else CardDefaults.elevatedCardColors(containerColor = containerColor)
ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action, colors = colors) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier) Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier)
} }
@@ -291,52 +301,81 @@ fun getDefaultPass(): OrbitalPass = OrbitalPass(
) )
@Composable @Composable
fun SharedDialog( fun InfoDialog(
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
) {
SharedDialog(title = title, onDismissRequest = onCancel, onCancel = onCancel, onAccept = onAccept) { _ ->
content()
}
}
@Composable
fun SharedDialog(
title: String, title: String,
onDismissRequest: () -> Unit, onDismiss: () -> Unit,
onCancel: (() -> Unit)? = null, onAccept: () -> Unit,
onAccept: (() -> Unit)? = null, content: @Composable () -> Unit
titleFontSize: Int = 16,
titleTextAlign: TextAlign = if (onCancel != null && onAccept != null) TextAlign.Center else TextAlign.Start,
content: @Composable (padding: Dp) -> Unit
) { ) {
val dismissible = onCancel != null DialogShell(onDismissRequest = onDismiss) { padding ->
DialogShell(onDismissRequest = onDismissRequest, dismissible = dismissible) { padding ->
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = padding, top = padding, end = padding) .padding(start = padding, top = padding, end = padding)
) { ) {
if (onCancel != null) {
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
}
Text( Text(
text = title, text = title,
fontSize = titleFontSize.sp, fontSize = 16.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
textAlign = titleTextAlign, maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
}
content()
}
}
@Composable
fun ConfirmDialog(
title: String,
onCancel: () -> Unit,
onAccept: () -> Unit,
content: @Composable () -> Unit
) {
DialogShell(onDismissRequest = onCancel) { padding ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(start = padding, top = padding, end = padding)
) {
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
Text(
text = title,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.padding(horizontal = if (onCancel != null && onAccept != null) padding else 0.dp) .padding(horizontal = padding)
) )
if (onAccept != null) { CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
}
} }
content(padding) content()
}
}
@Composable
fun WhatsNewDialog(onDismiss: () -> Unit) {
InfoDialog(
title = stringResource(R.string.pass_whatsnew_title),
onDismiss = onDismiss,
onAccept = onDismiss
) {
Text(
text = stringResource(R.string.pass_whatsnew_message),
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
Spacer(modifier = Modifier.height(0.dp))
} }
} }
@@ -344,14 +383,13 @@ fun SharedDialog(
@Composable @Composable
private fun DialogShell( private fun DialogShell(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
dismissible: Boolean = true,
content: @Composable (padding: Dp) -> Unit content: @Composable (padding: Dp) -> Unit
) { ) {
val padding = LocalSpacing.current.large val padding = LocalSpacing.current.large
val sheetState = rememberModalBottomSheetState( val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
skipPartiallyExpanded = true, val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
confirmValueChange = { if (dismissible) true else it != SheetValue.Hidden } val containerHeight = with(LocalDensity.current) { LocalWindowInfo.current.containerSize.height.toDp() }
) val maxSheetHeight = containerHeight - statusBarHeight
val stopSheetFling = remember { val stopSheetFling = remember {
object : NestedScrollConnection { object : NestedScrollConnection {
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
@@ -366,10 +404,10 @@ private fun DialogShell(
} }
} }
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = if (dismissible) onDismissRequest else { {} }, onDismissRequest = onDismissRequest,
sheetState = sheetState, sheetState = sheetState,
dragHandle = null, dragHandle = null,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
scrimColor = Color.Black.copy(alpha = 0.64f) scrimColor = Color.Black.copy(alpha = 0.64f)
) { ) {
Column( Column(
@@ -377,6 +415,7 @@ private fun DialogShell(
verticalArrangement = Arrangement.spacedBy(padding), verticalArrangement = Arrangement.spacedBy(padding),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(max = maxSheetHeight)
.nestedScroll(stopSheetFling) .nestedScroll(stopSheetFling)
) { ) {
content(padding) content(padding)
@@ -79,6 +79,10 @@ private val lightScheme = lightColorScheme(
onSecondary = Color(0xFFFFFFFF), onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFF1E1BB), secondaryContainer = Color(0xFFF1E1BB),
onSecondaryContainer = Color(0xFF221B04), onSecondaryContainer = Color(0xFF221B04),
tertiary = Color(0xFF3C6FE0), // AMSAT Active (darker for light theme)
onTertiary = Color(0xFFFFFFFF),
tertiaryContainer = Color(0xFFE09800), // AMSAT Telemetry (darker)
onTertiaryContainer = Color(0xFF000000),
background = Color(0xFFFFF8F0), background = Color(0xFFFFF8F0),
onBackground = Color(0xFF1E1B13), onBackground = Color(0xFF1E1B13),
surface = Color(0xFFFFF8F0), surface = Color(0xFFFFF8F0),
@@ -101,10 +105,10 @@ private val darkScheme = darkColorScheme(
onSecondary = Color(0xFF000000), onSecondary = Color(0xFF000000),
secondaryContainer = Color(0xFF404040), // navBar indicator, secondaryContainer = Color(0xFF404040), // navBar indicator,
onSecondaryContainer = Color(0xFFE0E0E0), // navBar active icon onSecondaryContainer = Color(0xFFE0E0E0), // navBar active icon
// tertiary = Color(0xFF121212), tertiary = Color(0xFF648FFF), // AMSAT Active (from amsat.org/status)
// onTertiary = Color(0xFF121212), onTertiary = Color(0xFF000000),
// tertiaryContainer = Color(0xFF121212), tertiaryContainer = Color(0xFFFFB000), // AMSAT Telemetry
// onTertiaryContainer = Color(0xFF121212), onTertiaryContainer = Color(0xFF000000),
background = Color(0xFF121212), background = Color(0xFF121212),
onBackground = Color(0xFFE0E0E0), onBackground = Color(0xFFE0E0E0),
surface = Color(0xFF202020), // card background surface = Color(0xFF202020), // card background
@@ -24,13 +24,13 @@ import kotlinx.serialization.Serializable
sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey { sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
@Serializable @Serializable
data object Satellites : Screen(R.drawable.ic_satellites, R.string.nav_sat) data object Satellites : Screen(R.drawable.ic_sputnik, R.string.nav_sat)
@Serializable @Serializable
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass) data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
@Serializable @Serializable
data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar) data object Status : Screen(R.drawable.ic_satellite, R.string.nav_status)
@Serializable @Serializable
data object Map : Screen(R.drawable.ic_map, R.string.nav_map) data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z" />
</vector>
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80L520,80L520,411Q538,422 549,439.5Q560,457 560,480Q560,513 536.5,536.5Q513,560 480,560Q447,560 423.5,536.5Q400,513 400,480Q400,457 411,439Q422,421 440,411L440,325Q388,339 354,381.5Q320,424 320,480Q320,546 367,593Q414,640 480,640Q546,640 593,593Q640,546 640,480Q640,444 625.5,413.5Q611,383 586,360L643,303Q678,336 699,381.5Q720,427 720,480Q720,580 650,650Q580,720 480,720Q380,720 310,650Q240,580 240,480Q240,390 297,323.5Q354,257 440,243L440,162Q321,177 240.5,267Q160,357 160,480Q160,614 253,707Q346,800 480,800Q614,800 707,707Q800,614 800,480Q800,411 773,351Q746,291 699,247L756,190Q813,245 846.5,319.5Q880,394 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880Z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z" />
</vector>
@@ -0,0 +1,31 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="128"
android:viewportHeight="128">
<path
android:pathData="m105.14,114.12a2.86,2.86 90,0 0,1.7 -0.85l19.33,-19.33a2.86,2.86 90,0 0,-0 -4.03l-26.58,-26.62a2.86,2.86 90,0 0,-1.84 -0.81l-7.92,-0.63 5.77,-5.77a2.86,2.86 90,0 0,-0 -4.03l-19.6,-19.64a2.86,2.86 90,0 0,-4.07 -0l-5.77,5.77 -0.63,-7.92a2.86,2.86 90,0 0,-0.81 -1.83l-26.63,-26.58a2.86,2.86 90,0 0,-4.07 -0l-19.29,19.29a2.86,2.86 90,0 0,-0 4.07l26.58,26.62a2.86,2.86 90,0 0,1.83 0.81l7.92,0.63 -5.77,5.77a2.86,2.86 90,0 0,-0 4.07l19.6,19.6a2.86,2.86 90,0 0,4.07 -0l5.77,-5.77 0.63,7.92a2.86,2.86 90,0 0,0.81 1.83l26.63,26.58a2.86,2.86 90,0 0,2.06 0.85,2.86 2.86,90 0,0 0.27,-0zM104.82,107.23 L80.97,83.38 80.16,72.69 85.58,67.27 96.28,68.12 120.13,91.93zM55.33,47.85 L44.63,47 20.78,23.19 36.08,7.89 59.89,31.74 60.74,42.44z"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="evenOdd"/>
<path
android:pathData="m62.65,115.81a2.86,2.86 90,1 0,-0 -5.73c-11.46,-0 -22.88,-4.35 -31.63,-13.11 -8.74,-8.74 -13.1,-20.17 -13.1,-31.61a2.86,2.86 90,1 0,-5.73 -0c-0,12.9 4.94,25.83 14.77,35.66 9.85,9.85 22.77,14.78 35.68,14.78z"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="evenOdd"/>
<path
android:pathData="m62.65,127a2.86,2.86 90,1 0,-0 -5.73c-14.32,-0 -28.61,-5.44 -39.55,-16.37 -10.93,-10.93 -16.38,-25.23 -16.38,-39.54a2.86,2.86 90,1 0,-5.73 -0c-0,15.76 6.03,31.56 18.06,43.59 12.03,12.03 27.82,18.05 43.6,18.05z"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="evenOdd"/>
<path
android:pathData="m62.65,104.7a2.86,2.86 90,1 0,-0 -5.73c-8.62,-0 -17.2,-3.26 -23.78,-9.84 -6.58,-6.58 -9.86,-15.17 -9.86,-23.77a2.86,2.86 90,1 0,-5.73 -0c-0,10.06 3.86,20.15 11.53,27.82 7.67,7.67 17.76,11.52 27.83,11.52z"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="evenOdd"/>
<path
android:pathData="m62.65,93.48a2.86,2.86 90,1 0,-0 -5.73c-5.74,-0 -11.45,-2.17 -15.84,-6.56 -4.38,-4.38 -6.56,-10.1 -6.56,-15.83a2.86,2.86 90,1 0,-5.73 -0c-0,7.19 2.76,14.41 8.24,19.88 5.48,5.48 12.69,8.23 19.89,8.23z"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="evenOdd"/>
</vector>
@@ -87,7 +87,7 @@
<string name="prefs_updated_title">更新:%s</string> <string name="prefs_updated_title">更新:%s</string>
<string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string> <string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string>
<string name="prefs_loc_title">站位</string> <string name="prefs_loc_title">站点位置</string>
<string name="prefs_loc_gps_title">GPS 定位</string> <string name="prefs_loc_gps_title">GPS 定位</string>
<string name="prefs_loc_gps_error">检查您的位置权限</string> <string name="prefs_loc_gps_error">检查您的位置权限</string>
<string name="prefs_loc_input_title">输入位置</string> <string name="prefs_loc_input_title">输入位置</string>
@@ -95,11 +95,11 @@
<string name="prefs_loc_qth_title">QTH 网格</string> <string name="prefs_loc_qth_title">QTH 网格</string>
<string name="prefs_loc_qth_error">无效的 QTH 网格</string> <string name="prefs_loc_qth_error">无效的 QTH 网格</string>
<string name="prefs_loc_success">位置更新成功</string> <string name="prefs_loc_success">位置更新成功</string>
<string name="prefs_station_title">站位</string> <string name="prefs_station_title">站点位置</string>
<string name="prefs_station_lat_text">站位纬度</string> <string name="prefs_station_lat_text">站点纬度</string>
<string name="prefs_station_lon_text">站位经度</string> <string name="prefs_station_lon_text">站点经度</string>
<string name="prefs_locator_title">QTH 定位</string> <string name="prefs_locator_title">QTH 定位</string>
<string name="prefs_locator_text">通过 QTH 设置站位</string> <string name="prefs_locator_text">通过 QTH 设置站点位置</string>
<string name="prefs_data_title">卫星数据更新</string> <string name="prefs_data_title">卫星数据更新</string>
<string name="prefs_data_entries">卫星:%s</string> <string name="prefs_data_entries">卫星:%s</string>
@@ -155,6 +155,8 @@
<string name="prefs_other_switch_sweep">雷达扫描动画</string> <string name="prefs_other_switch_sweep">雷达扫描动画</string>
<string name="prefs_other_switch_sensors">传感器控制雷达</string> <string name="prefs_other_switch_sensors">传感器控制雷达</string>
<string name="prefs_other_switch_night_mode">红色夜间模式</string> <string name="prefs_other_switch_night_mode">红色夜间模式</string>
<string name="prefs_other_compass_offset">雷达罗盘水平偏移量</string>
<string name="prefs_other_compass_offset_elev">雷达罗盘俯仰偏移量</string>
<string name="prefs_highlight_title">仰角高亮</string> <string name="prefs_highlight_title">仰角高亮</string>
<string name="prefs_highlight_low">低 &lt; %1$d°</string> <string name="prefs_highlight_low">低 &lt; %1$d°</string>
@@ -12,6 +12,7 @@
<string name="nav_radar">Radar</string> <string name="nav_radar">Radar</string>
<string name="nav_map">Map</string> <string name="nav_map">Map</string>
<string name="nav_prefs">Settings</string> <string name="nav_prefs">Settings</string>
<string name="nav_status" translatable="false">AMSAT</string>
<!-- Satellites screen --> <!-- Satellites screen -->
<string name="sat_type_hint">Modes: %s</string> <string name="sat_type_hint">Modes: %s</string>
@@ -55,18 +56,30 @@
\n\nPlease update the database at least weekly to get accurate predictions.</string> \n\nPlease update the database at least weekly to get accurate predictions.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string> <string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false"> <string name="pass_whatsnew_message" translatable="false">
* Added small tweaks to Sources and strings. Added official ISS source (#225) * Added AMSAT status tracking page by MCKero6423 (#234)
\n* Implemented AOS window and elevation highlight filters (#226) \n* Tweaked Radar track on reposition by atsunatsu (#235)
\n* Added cleartext traffic support for custom TLE URLs (#227) \n* Tweaked linear transponder calc by atsunatsu (#236)
\n* Fixed manual OMM (.csv) data import, tweaked error messaging (#228) \n* Added saving per-sat doppler offset by atsunatsu (#237)
\n* Added support for Icom IC-705 CAT by ruilvo (#229) \n* Added custom freq offset setting to network reporting
\n* Added configurable Radar offset to the sensors output by qwqtoday (#230) \n* Added ability to customize data sources via import
\n* Localized pass date and time formats for Chinese by atsunatsu (#231) \n* Fixed star chart in README by PingouinFerreux (#240)
\n* Added linear transponder Doppler calculator by atsunatsu (#232)
\n* Added a few tweaks to SSTV sensitivity and reception, similar to Robot36
\n* Replaced the types selection dialog with modes selection to avoid confusion
</string> </string>
<!-- AMSAT Status screen -->
<string name="nav_amsat">AMSAT</string>
<string name="amsat_title">AMSAT Satellite Status</string>
<string name="amsat_refresh">Refresh</string>
<string name="amsat_retry">Retry</string>
<string name="amsat_updated">Updated:</string>
<string name="amsat_active">Active</string>
<string name="amsat_tlm">TLM/Beacon</string>
<string name="amsat_not_heard">Not Heard</string>
<string name="amsat_conflict">Conflicting</string>
<string name="amsat_name">Name</string>
<string name="amsat_load_failed">Load failed - check network and retry</string>
<string name="amsat_no_reports">No reports for this period</string>
<string name="amsat_close">Close</string>
<!-- Radar screen --> <!-- Radar screen -->
<string name="radar_back">Back</string> <string name="radar_back">Back</string>
<string name="radar_notify">Notify</string> <string name="radar_notify">Notify</string>
@@ -156,10 +169,7 @@
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string> <string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string> <string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
<string name="prefs_data_sources_title">Custom sources</string> <string name="prefs_data_sources_title">Data sources</string>
<string name="prefs_data_sources_tle_switch">TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Transceivers URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_output_title">Data output</string> <string name="prefs_data_output_title">Data output</string>
<string name="prefs_net_output">Network</string> <string name="prefs_net_output">Network</string>
@@ -183,6 +193,8 @@
<string name="prefs_net_frequency_switch">Enable frequency output</string> <string name="prefs_net_frequency_switch">Enable frequency output</string>
<string name="prefs_net_frequency_address_hint">IP:Port</string> <string name="prefs_net_frequency_address_hint">IP:Port</string>
<string name="prefs_net_frequency_format_hint">Format</string> <string name="prefs_net_frequency_format_hint">Format</string>
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string>
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
<string name="prefs_bt_title">Bluetooth output</string> <string name="prefs_bt_title">Bluetooth output</string>
<string name="prefs_bt_rotator_switch">Enable rotation output</string> <string name="prefs_bt_rotator_switch">Enable rotation output</string>
@@ -1,8 +1,7 @@
* Added ARISS source, tweaked SSTV sensitivity #225 * Added AMSAT status tracking page by MCKero6423 (#234)
* Implemented AOS window and elev filter #226 * Tweaked Radar track on reposition by atsunatsu (#235)
* Added cleartext traffic support for TLEs #227 * Tweaked linear transponder calc by atsunatsu (#236)
* Fixed OMM import, tweaked error messages #228 * Added saving per-sat doppler offset by atsunatsu (#237)
* Added support for Icom CAT by ruilvo #229 * Added custom freq offset setting to network reporting
* Added tweakable Radar offset by qwqtoday #230 * Added ability to customize data sources via import
* Localized date/time formats by atsunatsu #231 * Fixed star chart in README by PingouinFerreux (#240)
* Added linear radio Doppler calc by atsunatsu #232
@@ -58,7 +58,7 @@ import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.presentation.LocalSpacing import com.rtbishop.look4sat.core.presentation.LocalSpacing
import com.rtbishop.look4sat.core.presentation.MainTheme import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog import com.rtbishop.look4sat.core.presentation.ConfirmDialog
import com.rtbishop.look4sat.core.presentation.ElevationHighColor import com.rtbishop.look4sat.core.presentation.ElevationHighColor
import com.rtbishop.look4sat.core.presentation.ElevationLowColor import com.rtbishop.look4sat.core.presentation.ElevationLowColor
import com.rtbishop.look4sat.core.presentation.elevationColor import com.rtbishop.look4sat.core.presentation.elevationColor
@@ -140,7 +140,7 @@ internal fun PassesFilterDialog(
) )
cancel() cancel()
} }
SharedDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) { ConfirmDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) {
SliderRow( SliderRow(
title = stringResource(R.string.pass_filter_elev), title = stringResource(R.string.pass_filter_elev),
value = elevationValueNew.doubleValue, value = elevationValueNew.doubleValue,
@@ -365,7 +365,7 @@ internal fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List
selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode
} }
val onAccept = { accept(selected.value.toList()).also { cancel() } } val onAccept = { accept(selected.value.toList()).also { cancel() } }
SharedDialog(title = stringResource(R.string.pass_modes_title), onCancel = cancel, onAccept = onAccept) { ConfirmDialog(title = stringResource(R.string.pass_modes_title), onCancel = cancel, onAccept = onAccept) {
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Adaptive(240.dp), columns = GridCells.Adaptive(240.dp),
modifier = Modifier modifier = Modifier
@@ -30,7 +30,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@@ -72,10 +71,10 @@ import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.NextPassRow import com.rtbishop.look4sat.core.presentation.NextPassRow
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.ScreenColumn import com.rtbishop.look4sat.core.presentation.ScreenColumn
import com.rtbishop.look4sat.core.presentation.SharedDialog
import com.rtbishop.look4sat.core.presentation.SwipeableItem import com.rtbishop.look4sat.core.presentation.SwipeableItem
import com.rtbishop.look4sat.core.presentation.TimerRow import com.rtbishop.look4sat.core.presentation.TimerRow
import com.rtbishop.look4sat.core.presentation.TopBar import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.WhatsNewDialog
import com.rtbishop.look4sat.core.presentation.elevationColor import com.rtbishop.look4sat.core.presentation.elevationColor
import com.rtbishop.look4sat.core.presentation.infiniteMarquee import com.rtbishop.look4sat.core.presentation.infiniteMarquee
import com.rtbishop.look4sat.core.presentation.isVerticalLayout import com.rtbishop.look4sat.core.presentation.isVerticalLayout
@@ -136,27 +135,14 @@ private fun PassesScreen(
} }
if (uiState.shouldSeeWhatsNew) { if (uiState.shouldSeeWhatsNew) {
val dismiss = { onAction(PassesAction.DismissWhatsNew) } val dismiss = { onAction(PassesAction.DismissWhatsNew) }
SharedDialog( WhatsNewDialog(onDismiss = dismiss)
title = stringResource(R.string.pass_whatsnew_title),
onDismissRequest = dismiss,
onAccept = dismiss,
titleFontSize = 18
) { padding ->
Text(
text = stringResource(R.string.pass_whatsnew_message),
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = padding)
)
Spacer(modifier = Modifier.height(0.dp))
}
} }
ScreenColumn( ScreenColumn(
topBar = { isVerticalLayout -> topBar = { isVerticalLayout ->
TopBar( TopBar(
isVerticalLayout = isVerticalLayout, isVerticalLayout = isVerticalLayout,
startAction = { startAction = {
IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter) IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
}, },
topInfo = { topInfo = {
TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos) TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos)
@@ -165,7 +151,7 @@ private fun PassesScreen(
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc) NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
}, },
endAction = { endAction = {
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios) IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter)
} }
) )
} }
@@ -106,17 +106,18 @@ class PassesViewModel(
.collectLatest { (allPasses, isUtc, showDeepSpace) -> .collectLatest { (allPasses, isUtc, showDeepSpace) ->
val filtered = if (showDeepSpace) allPasses val filtered = if (showDeepSpace) allPasses
else allPasses.filter { !it.isDeepSpace } else allPasses.filter { !it.isDeepSpace }
// Expensive: recompute once per pass-list/UTC change, not every second // Expensive: recompute sun times once per items/UTC change, not every second
val sunTimes = computeSunTimes(filtered, isUtc) val sunTimes = computeSunTimes(filtered, isUtc)
val grouped = groupPasses(filtered, isUtc) _uiState.update { it.copy(sunTimes = sunTimes) }
_uiState.update { it.copy(sunTimes = sunTimes, groupedPasses = grouped) }
while (isActive) { while (isActive) {
val timeNow = System.currentTimeMillis() val timeNow = System.currentTimeMillis()
val processed = computePassProgress(filtered, timeNow) val processed = computePassProgress(filtered, timeNow)
val grouped = groupPasses(processed, isUtc)
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow) val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
_uiState.update { _uiState.update {
it.copy( it.copy(
itemsList = processed, itemsList = processed,
groupedPasses = grouped,
nextPass = nextPass, nextPass = nextPass,
nextTime = nextTime, nextTime = nextTime,
isNextTimeAos = isAos isNextTimeAos = isAos
@@ -198,7 +198,8 @@ private fun PagerCard(
transceivers = uiState.transceivers.transmitters, transceivers = uiState.transceivers.transmitters,
selectedUuid = uiState.transceivers.selectedUuid, selectedUuid = uiState.transceivers.selectedUuid,
orbitalPos = uiState.orbitalPos, orbitalPos = uiState.orbitalPos,
onAction = onAction onAction = onAction,
calculatorOffsetKHz = uiState.calculatorOffsetKHz
) )
RadarPage.Sstv -> SstvPage( RadarPage.Sstv -> SstvPage(
sstv = uiState.sstv, sstv = uiState.sstv,
@@ -63,7 +63,8 @@ data class RadarState(
val moonPosition: CelestialComputer.MoonPosition? = null, val moonPosition: CelestialComputer.MoonPosition? = null,
val transceivers: TransceiverSubState = TransceiverSubState(), val transceivers: TransceiverSubState = TransceiverSubState(),
val radioControl: RadioControlSubState = RadioControlSubState(), val radioControl: RadioControlSubState = RadioControlSubState(),
val sstv: SstvSubState = SstvSubState() val sstv: SstvSubState = SstvSubState(),
val calculatorOffsetKHz: String = ""
) )
enum class SstvStatus { Idle, Recording } enum class SstvStatus { Idle, Recording }
@@ -97,4 +98,7 @@ sealed interface RadarAction {
data object SstvReset : RadarAction data object SstvReset : RadarAction
data class SstvSelectMode(val modeName: String) : RadarAction data class SstvSelectMode(val modeName: String) : RadarAction
data class SstvPermissionResult(val granted: Boolean) : RadarAction data class SstvPermissionResult(val granted: Boolean) : RadarAction
// Calculator actions
data class ChangeCalculatorOffset(val offsetKHz: String) : RadarAction
} }
@@ -65,8 +65,8 @@ class RadarViewModel(
private val showToast: IShowToast private val showToast: IShowToast
) : ViewModel() { ) : ViewModel() {
private val stationPos = settingsRepo.stationPosition.value private var stationPos = settingsRepo.stationPosition.value
private val magDeclination = sensorsRepo.getMagDeclination(stationPos) private var magDeclination = sensorsRepo.getMagDeclination(stationPos)
private var compassOffset = settingsRepo.otherSettings.value.radarCompassOffset private var compassOffset = settingsRepo.otherSettings.value.radarCompassOffset
private var compassOffsetElev = settingsRepo.otherSettings.value.radarCompassOffsetElev private var compassOffsetElev = settingsRepo.otherSettings.value.radarCompassOffsetElev
private var transponders: List<SatRadio> = emptyList() private var transponders: List<SatRadio> = emptyList()
@@ -92,6 +92,7 @@ class RadarViewModel(
init { init {
collectSettingsChanges() // also handles initial sensor subscription collectSettingsChanges() // also handles initial sensor subscription
collectStationPositionChanges()
collectPassAndStartTickLoop() collectPassAndStartTickLoop()
collectRadioTrackingState() collectRadioTrackingState()
} }
@@ -138,6 +139,23 @@ class RadarViewModel(
} }
} }
private fun collectStationPositionChanges() {
viewModelScope.launch {
settingsRepo.stationPosition.collect { newPos ->
if (stationPos != newPos) {
stationPos = newPos
magDeclination = sensorsRepo.getMagDeclination(stationPos)
lastCelestialUpdateMs = 0L // force celestial recompute on next tick
val pass = _uiState.value.currentPass ?: return@collect
if (!pass.isDeepSpace) {
val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime)
_uiState.update { it.copy(satTrack = track) }
}
}
}
}
}
// --- Pass loading split into focused functions --- // --- Pass loading split into focused functions ---
private fun collectPassAndStartTickLoop() { private fun collectPassAndStartTickLoop() {
@@ -242,7 +260,17 @@ class RadarViewModel(
// Compute toggle state before the update so we don't read post-update value // Compute toggle state before the update so we don't read post-update value
val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid
val newUuid = if (isTogglingOff) null else action.uuid val newUuid = if (isTogglingOff) null else action.uuid
_uiState.update { it.copy(transceivers = it.transceivers.copy(selectedUuid = newUuid)) } // Load the saved offset for the newly selected satellite
val offsetKHz = if (!isTogglingOff) {
transponders.find { it.uuid == action.uuid }
?.catnum?.let { settingsRepo.getSatelliteOffset(it) } ?: ""
} else ""
_uiState.update {
it.copy(
transceivers = it.transceivers.copy(selectedUuid = newUuid),
calculatorOffsetKHz = offsetKHz
)
}
// Only update the tracking service when selecting a different transponder to // Only update the tracking service when selecting a different transponder to
// avoid resetting a user-adjusted TX base on re-expand // avoid resetting a user-adjusted TX base on re-expand
if (!isTogglingOff) { if (!isTogglingOff) {
@@ -291,6 +319,15 @@ class RadarViewModel(
sstvDecoder?.clearPixels() sstvDecoder?.clearPixels()
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) } _uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) }
} }
is RadarAction.ChangeCalculatorOffset -> {
val catnum = _uiState.value.transceivers.selectedUuid?.let { uuid ->
transponders.find { it.uuid == uuid }?.catnum
}
if (catnum != null) {
settingsRepo.setSatelliteOffset(catnum, action.offsetKHz)
}
_uiState.update { it.copy(calculatorOffsetKHz = action.offsetKHz) }
}
} }
} }
@@ -53,10 +53,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.presentation.ConfirmDialog
import com.rtbishop.look4sat.core.presentation.IconCard import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.OutlinedText import com.rtbishop.look4sat.core.presentation.OutlinedText
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog
import com.rtbishop.look4sat.core.presentation.infiniteMarquee import com.rtbishop.look4sat.core.presentation.infiniteMarquee
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -96,14 +96,14 @@ internal fun SstvPage(
if (showModeDialog.value) { if (showModeDialog.value) {
val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes } val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes }
val dismiss = { showModeDialog.value = false } val dismiss = { showModeDialog.value = false }
SharedDialog( ConfirmDialog(
title = "SSTV Mode", title = "SSTV Mode",
onDismissRequest = dismiss, onAccept = dismiss,
onAccept = dismiss onCancel = dismiss
) { _ -> ) {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxHeight(0.7f) .fillMaxHeight(0.69f)
.background(MaterialTheme.colorScheme.background), .background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.spacedBy(1.dp) verticalArrangement = Arrangement.spacedBy(1.dp)
) { ) {
@@ -21,14 +21,18 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -39,16 +43,20 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -59,6 +67,7 @@ import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -68,6 +77,7 @@ import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.model.SatRadio import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
import com.rtbishop.look4sat.core.presentation.CardButton import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.formatFrequency import com.rtbishop.look4sat.core.presentation.formatFrequency
@@ -118,10 +128,12 @@ fun CalculatorPage(
selectedUuid: String?, selectedUuid: String?,
orbitalPos: OrbitalPos?, orbitalPos: OrbitalPos?,
onAction: (RadarAction) -> Unit, onAction: (RadarAction) -> Unit,
calculatorOffsetKHz: String = "",
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val calculatorTransceivers = remember(transceivers) { val calculatorTransceivers = remember(transceivers) {
transceivers.filter(DopplerFrequencyCalculator::isNamedLinearTransponder) transceivers.filter(DopplerFrequencyCalculator::isNamedLinearTransponder)
.let(DopplerFrequencyCalculator::deduplicateTransponders)
} }
val selectedTransceiver = remember(calculatorTransceivers, selectedUuid) { val selectedTransceiver = remember(calculatorTransceivers, selectedUuid) {
calculatorTransceivers.firstOrNull { it.uuid == selectedUuid } calculatorTransceivers.firstOrNull { it.uuid == selectedUuid }
@@ -166,9 +178,11 @@ fun CalculatorPage(
} }
item { item {
DopplerCalculatorPanel( DopplerFrequencyCalculator(
transponder = selectedTransceiver, transponder = selectedTransceiver,
orbitalPos = orbitalPos, orbitalPos = orbitalPos,
offsetKHz = calculatorOffsetKHz,
onOffsetChange = { onAction(RadarAction.ChangeCalculatorOffset(it)) },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
} }
@@ -504,108 +518,362 @@ private fun ExpandedRadioControl(
} }
} }
private enum class EditedField { TX, PASSBAND, RX }
@Composable @Composable
private fun DopplerCalculatorPanel( private fun DopplerFrequencyCalculator(
transponder: SatRadio, transponder: SatRadio,
orbitalPos: OrbitalPos?, orbitalPos: OrbitalPos?,
offsetKHz: String = "",
onOffsetChange: (String) -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return
var txInputMHz by remember(transponder.uuid) { mutableStateOf("") } var lastEditedField by remember { mutableStateOf(EditedField.TX) }
var rxInputMHz by remember(transponder.uuid) { mutableStateOf("") } var txFrequencyHz by remember { mutableStateOf(0L) }
var offsetKHz by remember(transponder.uuid) { mutableStateOf("") } var rxFrequencyHz by remember { mutableStateOf(0L) }
var lastEditedBy by remember(transponder.uuid) { mutableStateOf(EditedField.TX) } var passbandPosition by remember { mutableStateOf(0.5f) }
var stepSizeKHz by remember { mutableIntStateOf(1) }
fun offsetHz(text: String): Long = text.toDoubleOrNull()?.let { (it * 1000).toLong() } ?: 0L val offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L
fun formatHz(hz: Long): String = String.format(Locale.ENGLISH, "%.6f", hz / 1_000_000.0)
fun mhzToHz(text: String): Long? = text.toDoubleOrNull()
?.takeIf { it > 0.0 }
?.let { (it * 1_000_000).toLong() }
fun calculateDownlink(txText: String, offsetText: String): String? { val txLow = transponder.uplinkLow ?: return
val txHz = mhzToHz(txText) ?: return null val txHigh = transponder.uplinkHigh ?: return
return DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( val rxLow = transponder.downlinkLow ?: return
uplinkHz = txHz, val rxHigh = transponder.downlinkHigh ?: return
transponder = transponder, val txRange = txHigh - txLow
orbitalPos = orbitalPos, val rxRange = rxHigh - rxLow
offsetHz = offsetHz(offsetText) if (txRange <= 0L || rxRange <= 0L) return
)?.let(::formatHz)
// 原始转发器值(逆转多普勒),用于 passband 映射计算
val rawTxLow = orbitalPos.getDownlinkFreq(txLow)
val rawTxHigh = orbitalPos.getDownlinkFreq(txHigh)
val rawRxLow = orbitalPos.getUplinkFreq(rxLow)
val rawRxHigh = orbitalPos.getUplinkFreq(rxHigh)
val rawTxRange = rawTxHigh - rawTxLow
val rawRxRange = rawRxHigh - rawRxLow
val rawTransponder = transponder.copy(
uplinkLow = rawTxLow, uplinkHigh = rawTxHigh,
downlinkLow = rawRxLow, downlinkHigh = rawRxHigh
)
// 初始化
LaunchedEffect(Unit) {
if (txFrequencyHz == 0L) {
txFrequencyHz = txLow + txRange / 2
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: (rxLow + rxRange / 2)
}
} }
fun calculateUplink(rxText: String, offsetText: String): String? { // 实时重算:卫星移动时,锚点侧不变,重算另一侧
val rxHz = mhzToHz(rxText) ?: return null LaunchedEffect(orbitalPos) {
return DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset( if (lastEditedField == EditedField.TX) {
downlinkHz = rxHz, // TX 是锚点,不变;重算 RX
transponder = transponder, rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
orbitalPos = orbitalPos, txFrequencyHz, rawTransponder, orbitalPos, offsetHz
offsetHz = offsetHz(offsetText) ) ?: rxFrequencyHz
)?.let(::formatHz) } else if (lastEditedField == EditedField.RX) {
// RX 是锚点,不变;重算 TX
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
} else if (lastEditedField == EditedField.PASSBAND) {
// 相对位置不变(passbandPosition),TX 用地面频率,RX 经 Transceiver 完整路径计算
txFrequencyHz = txLow + (passbandPosition * txRange).toLong()
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
} }
LaunchedEffect(orbitalPos, transponder.uuid) { val txSliderValue = if (lastEditedField == EditedField.PASSBAND) {
if (lastEditedBy == EditedField.TX) { passbandPosition
calculateDownlink(txInputMHz, offsetKHz)?.let { rxInputMHz = it } } else {
} else { ((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
calculateUplink(rxInputMHz, offsetKHz)?.let { txInputMHz = it } }
val rxSliderValue = if (lastEditedField == EditedField.PASSBAND) {
((rxFrequencyHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
} else {
((rxFrequencyHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
}
fun updateTx(newTxHz: Long) {
when (lastEditedField) {
EditedField.PASSBAND -> {
// Passband 模式:滑块位置 → passbandPosition → 地面频率,RX 经 Transceiver 计算
val pos = ((newTxHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
passbandPosition = pos
txFrequencyHz = txLow + (pos * txRange).toLong()
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
else -> {
txFrequencyHz = newTxHz.coerceIn(txLow, txHigh)
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
}
}
fun updateRx(newRxHz: Long) {
when (lastEditedField) {
EditedField.PASSBAND -> {
// Passband 模式:从 RX 反推 TX 位置,TX 用地面频率
val pos = ((newRxHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
val txFromRx = TransponderMapper.mapDownlinkToUplink(newRxHz, rawTransponder)
passbandPosition = if (txFromRx != null) {
((txFromRx - rawTxLow).toFloat() / rawTxRange).coerceIn(0f, 1f)
} else pos
txFrequencyHz = txLow + (passbandPosition * txRange).toLong()
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
else -> {
rxFrequencyHz = newRxHz.coerceIn(rxLow, rxHigh)
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
}
} }
} }
Column( Column(
modifier = modifier, modifier = modifier.fillMaxWidth().padding(top = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
OutlinedTextField( Row(
value = offsetKHz, modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
onValueChange = { newValue -> verticalAlignment = Alignment.CenterVertically,
offsetKHz = newValue horizontalArrangement = Arrangement.spacedBy(6.dp)
if (lastEditedBy == EditedField.TX) { ) {
calculateDownlink(txInputMHz, newValue)?.let { rxInputMHz = it } // 左半边: TX + RX 按钮
} else { Row(
calculateUplink(rxInputMHz, newValue)?.let { txInputMHz = it } modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
FilterChip(
selected = lastEditedField == EditedField.TX,
onClick = {
when (lastEditedField) {
EditedField.TX -> {
passbandPosition = ((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
lastEditedField = EditedField.PASSBAND
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
EditedField.PASSBAND -> { lastEditedField = EditedField.TX }
else -> { lastEditedField = EditedField.TX }
}
},
label = {
Text("TX", fontSize = 13.sp, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth())
},
modifier = Modifier.weight(1f).fillMaxHeight()
)
FilterChip(
selected = lastEditedField == EditedField.RX,
onClick = {
when (lastEditedField) {
EditedField.RX -> {
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
passbandPosition = ((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
lastEditedField = EditedField.PASSBAND
}
EditedField.PASSBAND -> {
lastEditedField = EditedField.RX
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
}
else -> { lastEditedField = EditedField.RX }
}
},
label = {
Text("RX", fontSize = 13.sp, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth())
},
modifier = Modifier.weight(1f).fillMaxHeight()
)
} }
}, // 右半边: Offset 输入框(带细线边框)
label = { Text(stringResource(R.string.radar_doppler_offset_hint)) }, Box(
singleLine = true, modifier = Modifier
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), .weight(1f)
modifier = Modifier.fillMaxWidth() .height(48.dp)
) .border(1.dp, MaterialTheme.colorScheme.outline, MaterialTheme.shapes.small),
contentAlignment = Alignment.Center
) {
BasicTextField(
value = offsetKHz,
onValueChange = onOffsetChange,
singleLine = true,
textStyle = TextStyle(
fontSize = 16.sp,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
),
decorationBox = { innerTextField ->
if (offsetKHz.isEmpty()) {
Text(
text = "Offset (kHz)",
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
innerTextField()
}
)
}
}
Row( // TX 行
horizontalArrangement = Arrangement.spacedBy(8.dp), Column(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.fillMaxWidth() Row(verticalAlignment = Alignment.CenterVertically) {
) { OutlinedButton(
OutlinedTextField( onClick = { updateTx(txFrequencyHz - stepSizeKHz * 1000L) },
value = txInputMHz, modifier = Modifier.width(32.dp).height(28.dp),
onValueChange = { newValue -> contentPadding = PaddingValues(0.dp),
txInputMHz = newValue shape = MaterialTheme.shapes.extraSmall
lastEditedBy = EditedField.TX ) { Text("−", fontSize = 14.sp) }
rxInputMHz = calculateDownlink(newValue, offsetKHz) ?: if (newValue.isEmpty()) "" else rxInputMHz
}, Slider(
label = { Text(stringResource(R.string.radar_doppler_tx_hint)) }, value = txSliderValue,
singleLine = true, onValueChange = { norm ->
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), val rawFreq = txLow + (txRange * norm).toLong()
modifier = Modifier.weight(1f) val snapped = ((rawFreq + 500) / 1000L) * 1000L
updateTx(snapped.coerceIn(txLow, txHigh))
},
valueRange = 0f..1f,
modifier = Modifier.weight(1f).height(20.dp).padding(horizontal = 4.dp)
)
OutlinedButton(
onClick = { updateTx(txFrequencyHz + stepSizeKHz * 1000L) },
modifier = Modifier.width(32.dp).height(28.dp),
contentPadding = PaddingValues(0.dp),
shape = MaterialTheme.shapes.extraSmall
) { Text("+", fontSize = 14.sp) }
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = String.format(Locale.ENGLISH, "%.4f", txLow / 1_000_000.0),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = String.format(Locale.ENGLISH, "%.6f", txFrequencyHz / 1_000_000.0),
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = " MHz",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
Text(
text = String.format(Locale.ENGLISH, "%.4f", txHigh / 1_000_000.0),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
textAlign = TextAlign.End
)
}
}
HorizontalDivider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
) )
OutlinedTextField( // RX 行
value = rxInputMHz, Column(modifier = Modifier.fillMaxWidth()) {
onValueChange = { newValue -> Row(verticalAlignment = Alignment.CenterVertically) {
rxInputMHz = newValue OutlinedButton(
lastEditedBy = EditedField.RX onClick = { updateRx(rxFrequencyHz - stepSizeKHz * 1000L) },
txInputMHz = calculateUplink(newValue, offsetKHz) ?: if (newValue.isEmpty()) "" else txInputMHz modifier = Modifier.width(32.dp).height(28.dp),
}, contentPadding = PaddingValues(0.dp),
label = { Text(stringResource(R.string.radar_doppler_rx_hint)) }, shape = MaterialTheme.shapes.extraSmall
singleLine = true, ) { Text("−", fontSize = 14.sp) }
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.weight(1f) Slider(
) value = rxSliderValue,
onValueChange = { norm ->
val rawFreq = rxLow + (rxRange * norm).toLong()
val snapped = ((rawFreq + 500) / 1000L) * 1000L
updateRx(snapped.coerceIn(rxLow, rxHigh))
},
valueRange = 0f..1f,
modifier = Modifier.weight(1f).height(20.dp).padding(horizontal = 4.dp)
)
OutlinedButton(
onClick = { updateRx(rxFrequencyHz + stepSizeKHz * 1000L) },
modifier = Modifier.width(32.dp).height(28.dp),
contentPadding = PaddingValues(0.dp),
shape = MaterialTheme.shapes.extraSmall
) { Text("+", fontSize = 14.sp) }
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = String.format(Locale.ENGLISH, "%.4f", rxLow / 1_000_000.0),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = String.format(Locale.ENGLISH, "%.6f", rxFrequencyHz / 1_000_000.0),
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = " MHz",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
Text(
text = String.format(Locale.ENGLISH, "%.4f", rxHigh / 1_000_000.0),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
textAlign = TextAlign.End
)
}
}
} }
}
} }
private enum class EditedField { TX, RX }
@Composable @Composable
private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) { private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
val text = frequency?.let { val text = frequency?.let {
@@ -629,3 +897,4 @@ private fun transceiverTitle(radio: SatRadio): String {
private val FREQ_ADJUSTMENTS = 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") listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
@@ -39,9 +39,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.rtbishop.look4sat.core.presentation.ConfirmDialog
import com.rtbishop.look4sat.core.presentation.MainTheme import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
@@ -59,7 +59,7 @@ internal fun MultiModesDialog(
selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode
} }
val onAccept = { accept(selected.value.toList()) } val onAccept = { accept(selected.value.toList()) }
SharedDialog(title = stringResource(R.string.sat_type_title), onCancel = cancel, onAccept = onAccept) { ConfirmDialog(title = stringResource(R.string.sat_type_title), onCancel = cancel, onAccept = onAccept) {
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Adaptive(240.dp), columns = GridCells.Adaptive(240.dp),
modifier = Modifier modifier = Modifier
@@ -62,10 +62,11 @@ import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.CardLoadingIndicator import com.rtbishop.look4sat.core.presentation.CardLoadingIndicator
import com.rtbishop.look4sat.core.presentation.EmptyListCard import com.rtbishop.look4sat.core.presentation.EmptyListCard
import com.rtbishop.look4sat.core.presentation.IconCard import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.InfoDialog
import com.rtbishop.look4sat.core.presentation.LocalSpacing
import com.rtbishop.look4sat.core.presentation.MainTheme import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.PrimaryIconCard import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog
import com.rtbishop.look4sat.core.presentation.TopBar import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.infiniteMarquee import com.rtbishop.look4sat.core.presentation.infiniteMarquee
import com.rtbishop.look4sat.core.presentation.isVerticalLayout import com.rtbishop.look4sat.core.presentation.isVerticalLayout
@@ -96,17 +97,16 @@ private fun SatellitesScreen(
} }
if (uiState.shouldSeeWarning) { if (uiState.shouldSeeWarning) {
val dismiss = { onAction(SatellitesAction.DismissWarning) } val dismiss = { onAction(SatellitesAction.DismissWarning) }
SharedDialog( InfoDialog(
title = stringResource(R.string.sat_warning_title), title = stringResource(R.string.sat_warning_title),
onDismissRequest = dismiss, onDismiss = dismiss,
onAccept = dismiss, onAccept = dismiss
titleFontSize = 18 ) {
) { padding ->
Text( Text(
text = stringResource(R.string.sat_warning_message), text = stringResource(R.string.sat_warning_message),
fontSize = 16.sp, fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = padding) modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
) )
Spacer(modifier = Modifier.height(0.dp)) Spacer(modifier = Modifier.height(0.dp))
} }
@@ -17,26 +17,40 @@
*/ */
package com.rtbishop.look4sat.feature.settings package com.rtbishop.look4sat.feature.settings
import androidx.compose.animation.core.spring
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -45,11 +59,13 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.model.RCSettings import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.Constants
import com.rtbishop.look4sat.core.presentation.CardButton import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.LocalSpacing import com.rtbishop.look4sat.core.presentation.LocalSpacing
import com.rtbishop.look4sat.core.presentation.MainTheme import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog import com.rtbishop.look4sat.core.presentation.ConfirmDialog
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
@@ -63,7 +79,7 @@ fun PositionDialog(lat: Double, lon: Double, dismiss: () -> Unit, save: (Double,
val lonValue = rememberSaveable { mutableStateOf(lon.toString()) } val lonValue = rememberSaveable { mutableStateOf(lon.toString()) }
val titleText = stringResource(id = R.string.prefs_station_title) val titleText = stringResource(id = R.string.prefs_station_title)
val onAccept = { saveValues(latValue.value, lonValue.value, save).also { dismiss() } } val onAccept = { saveValues(latValue.value, lonValue.value, save).also { dismiss() } }
SharedDialog(title = titleText, onCancel = dismiss, onAccept = onAccept) { ConfirmDialog(title = titleText, onCancel = dismiss, onAccept = onAccept) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier modifier = Modifier
@@ -107,7 +123,7 @@ private fun LocatorDialogPreview() {
fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Unit) { fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Unit) {
val locator = rememberSaveable { mutableStateOf(qthLocator) } val locator = rememberSaveable { mutableStateOf(qthLocator) }
val onAccept = { save(locator.value).also { dismiss() } } val onAccept = { save(locator.value).also { dismiss() } }
SharedDialog( ConfirmDialog(
title = stringResource(R.string.prefs_locator_title), title = stringResource(R.string.prefs_locator_title),
onCancel = dismiss, onCancel = dismiss,
onAccept = onAccept onAccept = onAccept
@@ -129,133 +145,144 @@ fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Uni
private fun TransceiversDialogPreview() { private fun TransceiversDialogPreview() {
MainTheme { MainTheme {
DataSourcesDialog( DataSourcesDialog(
useCustomTle = true, satelliteUrls = listOf(
useCustomTransceivers = true, "celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
tleUrl = "https://example.com/tle.txt", "amsat.org/tle/current/nasabare.txt"
transceiversUrl = "https://example.com/tx.json", ),
requestCustomSourcesPermission = { onGranted, _ -> onGranted() }, transceiversUrls = listOf(
"db.satnogs.org/api/transmitters/?format=json&status=active"
),
onImportTle = {}, onImportTle = {},
onImportTransceivers = {}, onImportTransceivers = {},
onDismiss = {}, onDismiss = {},
onSave = { _, _, _, _ -> } onSave = { _, _ -> }
) )
} }
} }
@Composable @Composable
fun DataSourcesDialog( fun DataSourcesDialog(
useCustomTle: Boolean, satelliteUrls: List<String>,
useCustomTransceivers: Boolean, transceiversUrls: List<String>,
tleUrl: String,
transceiversUrl: String,
requestCustomSourcesPermission: (onGranted: () -> Unit, onDenied: () -> Unit) -> Unit,
onImportTle: () -> Unit, onImportTle: () -> Unit,
onImportTransceivers: () -> Unit, onImportTransceivers: () -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSave: (Boolean, Boolean, String, String) -> Unit onSave: (List<String>, List<String>) -> Unit
) { ) {
val padding = LocalSpacing.current.large val padding = LocalSpacing.current.large
val isEnabledCustomTle = rememberSaveable { mutableStateOf(useCustomTle) } // Use stable Long IDs to avoid key collisions (e.g. multiple empty "" entries).
val isEnabledCustomTransceivers = rememberSaveable { mutableStateOf(useCustomTransceivers) } val nextId = remember { mutableLongStateOf((satelliteUrls.size + transceiversUrls.size).toLong()) }
val urlTle = rememberSaveable { mutableStateOf(tleUrl) } val satUrls = remember {
val urlTransceivers = rememberSaveable { mutableStateOf(transceiversUrl) } satelliteUrls.mapIndexed { i, url -> i.toLong() to url }.toMutableStateList()
val onAccept = {
onSave(
isEnabledCustomTle.value,
isEnabledCustomTransceivers.value,
urlTle.value,
urlTransceivers.value
)
onDismiss()
} }
val onCancel = { onDismiss() } val txUrls = remember {
SharedDialog( transceiversUrls.mapIndexed { i, url -> (satelliteUrls.size + i).toLong() to url }.toMutableStateList()
}
val onAccept = { onSave(satUrls.map { it.second }, txUrls.map { it.second }); onDismiss() }
ConfirmDialog(
title = stringResource(id = R.string.prefs_data_sources_title), title = stringResource(id = R.string.prefs_data_sources_title),
onCancel = onCancel, onCancel = onDismiss,
onAccept = onAccept, onAccept = onAccept,
) { ) {
Column(modifier = Modifier.padding(horizontal = padding)) { LazyColumn(
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { modifier = Modifier
CardButton( .fillMaxHeight(0.84f)
onClick = { .padding(horizontal = padding),
onImportTle() verticalArrangement = Arrangement.spacedBy(4.dp),
onDismiss() contentPadding = PaddingValues(vertical = 6.dp)
}, ) {
text = "TLE/3LE (.txt)\nOMM (.csv)", item {
modifier = Modifier.weight(1f) Row(
) horizontalArrangement = Arrangement.spacedBy(6.dp),
CardButton( modifier = Modifier.fillMaxWidth()
onClick = { ) {
onImportTransceivers() CardButton(
onDismiss() onClick = { onImportTle(); onDismiss() },
}, text = "TLE/3LE (.txt)\nOMM (.csv)",
text = "Transceivers\nSatNOGS (.json)", modifier = Modifier.weight(1f)
modifier = Modifier.weight(1f) )
) CardButton(
onClick = { onImportTransceivers(); onDismiss() },
text = "Transceivers\nSatNOGS (.json)",
modifier = Modifier.weight(1f)
)
}
} }
Spacer(modifier = Modifier.height(6.dp)) sourceSection(
Row( sectionKey = "sat",
horizontalArrangement = Arrangement.SpaceBetween, label = "Satellites data",
verticalAlignment = Alignment.CenterVertically, urls = satUrls,
modifier = Modifier.fillMaxWidth() onAdd = { satUrls.add(nextId.longValue++ to "") },
) { onMoveUp = { i -> if (i > 0) satUrls.add(i - 1, satUrls.removeAt(i)) },
Text(text = stringResource(id = R.string.prefs_data_sources_tle_switch)) onRemove = { i -> satUrls.removeAt(i) },
Switch( onUrlChange = { i, v -> satUrls[i] = satUrls[i].first to v }
checked = isEnabledCustomTle.value,
onCheckedChange = { enabled ->
if (!enabled) {
isEnabledCustomTle.value = false
} else {
requestCustomSourcesPermission(
{ isEnabledCustomTle.value = true },
{ isEnabledCustomTle.value = false }
)
}
}
)
}
OutlinedTextField(
value = urlTle.value,
onValueChange = { urlTle.value = it },
label = { Text(text = stringResource(id = R.string.prefs_data_sources_url_title)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
enabled = isEnabledCustomTle.value,
) )
Spacer(modifier = Modifier.height(6.dp)) sourceSection(
Row( sectionKey = "tx",
horizontalArrangement = Arrangement.SpaceBetween, label = "Transceivers data",
verticalAlignment = Alignment.CenterVertically, urls = txUrls,
modifier = Modifier.fillMaxWidth() onAdd = { txUrls.add(nextId.longValue++ to "") },
) { onMoveUp = { i -> if (i > 0) txUrls.add(i - 1, txUrls.removeAt(i)) },
Text(text = stringResource(id = R.string.prefs_data_sources_transceivers_switch)) onRemove = { i -> txUrls.removeAt(i) },
Switch( onUrlChange = { i, v -> txUrls[i] = txUrls[i].first to v }
checked = isEnabledCustomTransceivers.value,
onCheckedChange = { enabled ->
if (!enabled) {
isEnabledCustomTransceivers.value = false
} else {
requestCustomSourcesPermission(
{ isEnabledCustomTransceivers.value = true },
{ isEnabledCustomTransceivers.value = false }
)
}
}
)
}
OutlinedTextField(
value = urlTransceivers.value,
onValueChange = { urlTransceivers.value = it },
label = { Text(text = stringResource(id = R.string.prefs_data_sources_url_title)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
enabled = isEnabledCustomTransceivers.value,
) )
Spacer(modifier = Modifier.height(12.dp))
} }
} }
} }
private fun LazyListScope.sourceSection(
sectionKey: String,
label: String,
urls: List<Pair<Long, String>>,
onAdd: () -> Unit,
onMoveUp: (Int) -> Unit,
onRemove: (Int) -> Unit,
onUrlChange: (Int, String) -> Unit
) {
item {
Spacer(modifier = Modifier.height(6.dp))
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
text = label,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f)
)
IconCard(action = onAdd, resId = R.drawable.ic_add, containerColor = MaterialTheme.colorScheme.surfaceVariant)
}
}
itemsIndexed(urls, key = { _, entry -> "$sectionKey-${entry.first}" }) { index, (_, url) ->
val enabledTint = MaterialTheme.colorScheme.onSurfaceVariant
OutlinedTextField(
value = url,
onValueChange = { onUrlChange(index, it) },
label = { Text("Source URL") },
leadingIcon = {
IconButton(onClick = { onMoveUp(index) }) {
Icon(
painter = painterResource(R.drawable.ic_arrow),
contentDescription = null,
tint = if (index > 0) enabledTint else enabledTint.copy(alpha = 0.32f),
modifier = Modifier.rotate(270f)
)
}
},
trailingIcon = {
IconButton(onClick = { onRemove(index) }) {
Icon(
painter = painterResource(R.drawable.ic_delete),
contentDescription = null
)
}
},
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.animateItem(fadeInSpec = spring(), fadeOutSpec = spring())
)
}
}
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
fun PreviewNetworkOutputDialog() { fun PreviewNetworkOutputDialog() {
@@ -270,6 +297,7 @@ fun PreviewNetworkOutputDialog() {
frequencyAddress = "127.0.0.1", frequencyAddress = "127.0.0.1",
frequencyPort = "4532", frequencyPort = "4532",
frequencyFormat = $$"F $FREQ", frequencyFormat = $$"F $FREQ",
frequencyOffsetHz = 0L,
bluetoothRotatorState = false, bluetoothRotatorState = false,
bluetoothRotatorFormat = $$"P $AZ $EL", bluetoothRotatorFormat = $$"P $AZ $EL",
bluetoothRotatorName = "Default", bluetoothRotatorName = "Default",
@@ -279,7 +307,7 @@ fun PreviewNetworkOutputDialog() {
bluetoothFrequencyFormat = $$"F $FREQ" bluetoothFrequencyFormat = $$"F $FREQ"
), ),
onDismiss = {}, onDismiss = {},
onSave = { _, _, _, _, _, _, _, _ -> } onSave = { _, _, _, _, _, _, _, _, _ -> }
) )
} }
} }
@@ -290,7 +318,7 @@ fun NetworkOutputDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSave: ( onSave: (
Boolean, String, String, String, Boolean, String, String, String,
Boolean, String, String, String Boolean, String, String, String, Long
) -> Unit ) -> Unit
) { ) {
val padding = LocalSpacing.current.large val padding = LocalSpacing.current.large
@@ -304,16 +332,19 @@ fun NetworkOutputDialog(
mutableStateOf("${initialSettings.frequencyAddress}:${initialSettings.frequencyPort}") mutableStateOf("${initialSettings.frequencyAddress}:${initialSettings.frequencyPort}")
} }
val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.frequencyFormat) } val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.frequencyFormat) }
val frequencyOffsetHz = rememberSaveable { mutableStateOf(initialSettings.frequencyOffsetHz.toString()) }
val onAccept = { val onAccept = {
val (rotIp, rotPort) = splitAddress(rotatorAddress.value) val (rotIp, rotPort) = splitAddress(rotatorAddress.value)
val (freqIp, freqPort) = splitAddress(frequencyAddress.value) val (freqIp, freqPort) = splitAddress(frequencyAddress.value)
val offsetHz = (frequencyOffsetHz.value.trim().toLongOrNull() ?: 0L)
.coerceIn(Constants.FREQ_OFFSET_MIN_HZ, Constants.FREQ_OFFSET_MAX_HZ)
onSave( onSave(
rotatorState.value, rotIp, rotPort, rotatorFormat.value, rotatorState.value, rotIp, rotPort, rotatorFormat.value,
frequencyState.value, freqIp, freqPort, frequencyFormat.value frequencyState.value, freqIp, freqPort, frequencyFormat.value, offsetHz
) )
onDismiss() onDismiss()
} }
SharedDialog( ConfirmDialog(
title = stringResource(R.string.prefs_net_title), title = stringResource(R.string.prefs_net_title),
onCancel = onDismiss, onCancel = onDismiss,
onAccept = onAccept onAccept = onAccept
@@ -342,6 +373,27 @@ fun NetworkOutputDialog(
onFormatChange = { frequencyFormat.value = it }, onFormatChange = { frequencyFormat.value = it },
formatLabel = stringResource(R.string.prefs_net_frequency_format_hint) formatLabel = stringResource(R.string.prefs_net_frequency_format_hint)
) )
Spacer(modifier = Modifier.height(6.dp))
OutlinedTextField(
value = frequencyOffsetHz.value,
onValueChange = { frequencyOffsetHz.value = it },
singleLine = true,
label = { Text(stringResource(R.string.prefs_net_frequency_offset_hint)) },
supportingText = { Text(stringResource(R.string.prefs_net_frequency_offset_help)) },
trailingIcon = {
IconButton(
onClick = { frequencyOffsetHz.value = "0" },
enabled = frequencyState.value && frequencyOffsetHz.value != "0"
) {
Icon(
painter = painterResource(R.drawable.ic_close),
contentDescription = null
)
}
},
modifier = Modifier.fillMaxWidth(),
enabled = frequencyState.value
)
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
} }
} }
@@ -370,6 +422,7 @@ fun PreviewBluetoothOutputDialog() {
frequencyAddress = "127.0.0.1", frequencyAddress = "127.0.0.1",
frequencyPort = "4532", frequencyPort = "4532",
frequencyFormat = $$"F $FREQ", frequencyFormat = $$"F $FREQ",
frequencyOffsetHz = 0L,
bluetoothRotatorState = false, bluetoothRotatorState = false,
bluetoothRotatorFormat = $$"P $AZ $EL", bluetoothRotatorFormat = $$"P $AZ $EL",
bluetoothRotatorName = "Default", bluetoothRotatorName = "Default",
@@ -411,7 +464,7 @@ fun BluetoothOutputDialog(
) )
onDismiss() onDismiss()
} }
SharedDialog( ConfirmDialog(
title = stringResource(R.string.prefs_bt_title), title = stringResource(R.string.prefs_bt_title),
onCancel = onDismiss, onCancel = onDismiss,
onAccept = onAccept onAccept = onAccept
@@ -538,7 +591,7 @@ fun RadioControlDialog(
onDismiss() onDismiss()
} }
SharedDialog( ConfirmDialog(
title = stringResource(R.string.rc_settings_title), title = stringResource(R.string.rc_settings_title),
onCancel = onDismiss, onCancel = onDismiss,
onAccept = onAccept onAccept = onAccept
@@ -64,6 +64,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.OtherSettings import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
@@ -74,6 +75,7 @@ import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.ScreenColumn import com.rtbishop.look4sat.core.presentation.ScreenColumn
import com.rtbishop.look4sat.core.presentation.TopBar import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.WhatsNewDialog
import com.rtbishop.look4sat.core.presentation.infiniteMarquee import com.rtbishop.look4sat.core.presentation.infiniteMarquee
import com.rtbishop.look4sat.core.presentation.isVerticalLayout import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -92,22 +94,10 @@ fun SettingsDestination() {
@Composable @Composable
private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) { private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) {
val dialogs = rememberDialogVisibility() val dialogs = rememberDialogVisibility()
val pendingCustomSourcesGrant = remember { mutableStateOf<(() -> Unit)?>(null) }
val pendingCustomSourcesDeny = remember { mutableStateOf<(() -> Unit)?>(null) }
val permissions = rememberSettingsPermissions( val permissions = rememberSettingsPermissions(
sendAction = onAction, sendAction = onAction,
onBluetoothGranted = { dialogs.bluetooth = true }, onBluetoothGranted = { dialogs.bluetooth = true },
onNetworkGranted = { dialogs.network = true }, onNetworkGranted = { dialogs.network = true }
onCustomSourcesPermissionGranted = {
pendingCustomSourcesGrant.value?.invoke()
pendingCustomSourcesGrant.value = null
pendingCustomSourcesDeny.value = null
},
onCustomSourcesPermissionDenied = {
pendingCustomSourcesDeny.value?.invoke()
pendingCustomSourcesGrant.value = null
pendingCustomSourcesDeny.value = null
}
) )
// Dialogs // Dialogs
@@ -128,30 +118,15 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
} }
if (dialogs.dataSources) { if (dialogs.dataSources) {
DataSourcesDialog( DataSourcesDialog(
useCustomTle = uiState.dataSourcesSettings.useCustomTLE, satelliteUrls = uiState.dataSourcesSettings.satelliteUrls,
useCustomTransceivers = uiState.dataSourcesSettings.useCustomTransceivers, transceiversUrls = uiState.dataSourcesSettings.transceiversUrls,
tleUrl = uiState.dataSourcesSettings.tleUrl,
transceiversUrl = uiState.dataSourcesSettings.transceiversUrl,
requestCustomSourcesPermission = { onGranted, onDenied ->
pendingCustomSourcesGrant.value = onGranted
pendingCustomSourcesDeny.value = onDenied
permissions.launchCustomSourcesPermission()
},
onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false }, onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false },
onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false }, onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false },
onDismiss = { dialogs.dataSources = false }, onDismiss = { dialogs.dataSources = false },
onSave = { useCustomTle, useCustomTransceivers, tleUrl, transceiversUrl -> onSave = { satUrls, txUrls ->
val current = uiState.dataSourcesSettings val newSettings = DataSourcesSettings(satelliteUrls = satUrls, transceiversUrls = txUrls)
val newSettings = current.copy( if (newSettings != uiState.dataSourcesSettings) onAction(SettingsAction.UpdateDataSources(newSettings))
useCustomTLE = if (!useCustomTle || tleUrl.isNotBlank()) useCustomTle else current.useCustomTLE, onAction(SettingsAction.UpdateFromWeb)
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 (newSettings.useCustomTLE || newSettings.useCustomTransceivers) {
onAction(SettingsAction.UpdateFromWeb)
}
} }
) )
} }
@@ -159,14 +134,15 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
NetworkOutputDialog( NetworkOutputDialog(
initialSettings = uiState.rcSettings, initialSettings = uiState.rcSettings,
onDismiss = { dialogs.network = false }, onDismiss = { dialogs.network = false },
onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt -> onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt, freqOffsetHz ->
onAction( onAction(
SettingsAction.UpdateRC( SettingsAction.UpdateRC(
uiState.rcSettings.copy( uiState.rcSettings.copy(
rotatorState = rotState, rotatorAddress = rotAddr, rotatorState = rotState, rotatorAddress = rotAddr,
rotatorPort = rotPort, rotatorFormat = rotFmt, rotatorPort = rotPort, rotatorFormat = rotFmt,
frequencyState = freqState, frequencyAddress = freqAddr, frequencyState = freqState, frequencyAddress = freqAddr,
frequencyPort = freqPort, frequencyFormat = freqFmt frequencyPort = freqPort, frequencyFormat = freqFmt,
frequencyOffsetHz = freqOffsetHz
) )
) )
) )
@@ -198,6 +174,9 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
onSave = { onAction(SettingsAction.UpdateRadioControl(it)) } onSave = { onAction(SettingsAction.UpdateRadioControl(it)) }
) )
} }
if (dialogs.whatsNew) {
WhatsNewDialog(onDismiss = { dialogs.whatsNew = false })
}
// URLs for top bar // URLs for top bar
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
@@ -218,7 +197,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
if (isVerticalLayout) { if (isVerticalLayout) {
TopBar { TopBar {
TopCard( TopCard(
onClick = { safeOpenUri(appUrl) }, onClick = { dialogs.whatsNew = true },
version = uiState.appVersionName, version = uiState.appVersionName,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
@@ -246,7 +225,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
TopBar { TopBar {
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like) PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like)
TopCard( TopCard(
onClick = { safeOpenUri(appUrl) }, onClick = { dialogs.whatsNew = true },
version = uiState.appVersionName, version = uiState.appVersionName,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
@@ -667,7 +646,7 @@ private fun TopCard(onClick: () -> Unit, modifier: Modifier = Modifier, version:
.clickable { onClick() }) { .clickable { onClick() }) {
Spacer(Modifier) Spacer(Modifier)
Icon( Icon(
painter = painterResource(id = R.drawable.ic_satellites), painter = painterResource(id = R.drawable.ic_sputnik),
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.primary
) )
@@ -718,6 +697,7 @@ private class DialogVisibility {
var network by mutableStateOf(false) var network by mutableStateOf(false)
var bluetooth by mutableStateOf(false) var bluetooth by mutableStateOf(false)
var radioControl by mutableStateOf(false) var radioControl by mutableStateOf(false)
var whatsNew by mutableStateOf(false)
} }
@Composable @Composable
@@ -725,12 +705,12 @@ private fun rememberDialogVisibility(): DialogVisibility {
return rememberSaveable(saver = run { return rememberSaveable(saver = run {
androidx.compose.runtime.saveable.Saver( androidx.compose.runtime.saveable.Saver(
save = { save = {
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl) listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.whatsNew)
}, },
restore = { restore = {
DialogVisibility().apply { DialogVisibility().apply {
position = it[0]; locator = it[1]; dataSources = it[2] position = it[0]; locator = it[1]; dataSources = it[2]
network = it[3]; bluetooth = it[4]; radioControl = it[5] network = it[3]; bluetooth = it[4]; radioControl = it[5]; whatsNew = it[6]
} }
} }
) )
@@ -747,17 +727,14 @@ private class SettingsPermissions(
val launchTleImport: () -> Unit, val launchTleImport: () -> Unit,
val launchTransceiverImport: () -> Unit, val launchTransceiverImport: () -> Unit,
val launchBluetooth: () -> Unit, val launchBluetooth: () -> Unit,
val launchNetwork: () -> Unit, val launchNetwork: () -> Unit
val launchCustomSourcesPermission: () -> Unit
) )
@Composable @Composable
private fun rememberSettingsPermissions( private fun rememberSettingsPermissions(
sendAction: (SettingsAction) -> Unit, sendAction: (SettingsAction) -> Unit,
onBluetoothGranted: () -> Unit, onBluetoothGranted: () -> Unit,
onNetworkGranted: () -> Unit, onNetworkGranted: () -> Unit
onCustomSourcesPermissionGranted: () -> Unit,
onCustomSourcesPermissionDenied: () -> Unit
): SettingsPermissions { ): SettingsPermissions {
val locationError = stringResource(R.string.prefs_loc_gps_error) val locationError = stringResource(R.string.prefs_loc_gps_error)
val locationRequest = rememberLauncherForActivityResult( val locationRequest = rememberLauncherForActivityResult(
@@ -794,15 +771,6 @@ private fun rememberSettingsPermissions(
else sendAction(SettingsAction.ShowToast(networkError)) else sendAction(SettingsAction.ShowToast(networkError))
} }
val customSourcesRequest = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) {
onCustomSourcesPermissionGranted()
} else {
onCustomSourcesPermissionDenied()
sendAction(SettingsAction.ShowToast(networkError))
}
}
return remember { return remember {
SettingsPermissions( SettingsPermissions(
launchLocation = { launchLocation = {
@@ -819,13 +787,6 @@ private fun rememberSettingsPermissions(
} else { } else {
onNetworkGranted() onNetworkGranted()
} }
},
launchCustomSourcesPermission = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
customSourcesRequest.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
} else {
onCustomSourcesPermissionGranted()
}
} }
) )
} }
+17
View File
@@ -0,0 +1,17 @@
import com.android.build.api.dsl.LibraryExtension
plugins {
alias(libs.plugins.convention.featurePlugin)
}
android {
namespace = "com.rtbishop.look4sat.feature.status"
compileOptions {
encoding = "UTF-8"
}
}
dependencies {
implementation(project(":core:domain"))
implementation(project(":core:presentation"))
}
@@ -0,0 +1,327 @@
package com.rtbishop.look4sat.feature.status
import androidx.compose.foundation.Image
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.FlowRow
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.heightIn
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.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.model.SatDay
import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatSlot
import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.InfoDialog
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.layoutPadding
import java.util.Calendar
/** Fixed width per day tile — tablet-safe; name column absorbs remaining space. */
private val TILE_WIDTH: Dp = 64.dp
/**
* Map AMSAT status text to Material3 colorScheme colors.
* Addresses PR #233 review: use colorScheme instead of hardcoded Color() constants.
*/
@Composable
private fun statusColorOf(statusText: String): Color {
return when {
statusText.contains("Heard", ignoreCase = true) && !statusText.contains("Not", ignoreCase = true) ->
MaterialTheme.colorScheme.tertiary
statusText.contains("Telemetry", ignoreCase = true) || statusText.contains("Beacon", ignoreCase = true) ->
MaterialTheme.colorScheme.tertiaryContainer
statusText.contains("Not Heard", ignoreCase = true) ->
Color(0xFFDC267F)
else ->
MaterialTheme.colorScheme.error
}
}
@Composable
fun SatStatusDestination() {
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SatStatusScreen(uiState) { viewModel.refresh() }
}
@Composable
private fun SatStatusScreen(uiState: SatStatusUiState, refresh: () -> Unit) {
var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) }
Column(modifier = Modifier.fillMaxSize().layoutPadding()) {
StatusHeader(fetchedAtUtcMs = uiState.fetchedAtUtcMs, isRefreshing = uiState.isRefreshing, onRefresh = refresh)
LegendRow()
when {
uiState.isLoading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
uiState.error != null && uiState.statuses.isEmpty() -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = stringResource(id = R.string.amsat_load_failed), color = MaterialTheme.colorScheme.error)
TextButton(onClick = refresh) { Text(text = stringResource(id = R.string.amsat_retry)) }
}
}
else -> {
HeaderRow(statuses = uiState.statuses)
HorizontalDivider(thickness = 1.dp)
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(uiState.statuses, key = { it.name }) { status ->
StatusRow(status = status, onClickDay = { day -> selectedDay = status to day })
}
}
}
}
}
selectedDay?.let { (status, day) ->
ReportDialog(statusName = status.name, day = day, reports = uiState.reports, onDismiss = { selectedDay = null })
}
}
/** Top: update time + refresh button (spinner while loading) */
@Composable
private fun StatusHeader(fetchedAtUtcMs: Long, isRefreshing: Boolean, onRefresh: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (fetchedAtUtcMs > 0)
stringResource(id = R.string.amsat_updated) + " " + formatFetchedAt(fetchedAtUtcMs)
else
stringResource(id = R.string.amsat_title),
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onRefresh),
contentAlignment = Alignment.Center
) {
if (isRefreshing) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Image(
painter = painterResource(id = R.drawable.ic_refresh),
contentDescription = stringResource(id = R.string.amsat_refresh),
modifier = Modifier.size(16.dp),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary)
)
}
}
}
}
/** Legend: FlowRow of colored chips — wraps to two lines on narrow screens, stays one line when wide. */
@Composable
private fun LegendRow() {
val legend = listOf(
stringResource(id = R.string.amsat_active) to Color(0xFF648FFF),
stringResource(id = R.string.amsat_tlm) to Color(0xFFFFB000),
stringResource(id = R.string.amsat_not_heard) to Color(0xFFDC267F),
stringResource(id = R.string.amsat_conflict) to Color(0xFFFE6100)
)
FlowRow(
modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
legend.forEach { (label, color) ->
val alphaColor = color.copy(alpha = 0.25f)
Row(
modifier = Modifier
.clip(RoundedCornerShape(32.dp))
.background(alphaColor)
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(RoundedCornerShape(4.dp))
.background(color)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = label,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
/** Header: satellite name column + fixed-width date labels aligned to tiles. */
@Composable
private fun HeaderRow(statuses: List<SatStatus>) {
val dates = statuses.firstOrNull()?.days?.map { it.dateLabel } ?: emptyList()
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(id = R.string.amsat_name),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f).padding(start = 4.dp)
)
dates.forEach { date ->
Text(
text = date,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.width(TILE_WIDTH)
)
}
}
}
/** Satellite row: name takes remaining width; day tiles are fixed-width (tablet-safe). */
@Composable
private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
val noReportGray = 0xFFC0C0C0L
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = status.name,
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(end = 4.dp)
)
status.days.forEach { day ->
val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first()
DayCell(
slot = slot,
modifier = Modifier.width(TILE_WIDTH).padding(horizontal = 2.dp),
onClick = { onClickDay(day) }
)
}
}
}
/** Day block: newest reported status among the day's 12 slots; gray when none. */
@Composable
private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
val color = Color(slot.statusColor)
Box(
modifier = modifier
.height(28.dp)
.clip(RoundedCornerShape(4.dp))
.background(color)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
if (slot.count > 0) {
Text(text = slot.count.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
}
}
}
/** Report detail dialog (callsign / date / time / grid) */
@Composable
private fun ReportDialog(statusName: String, day: SatDay, reports: Map<String, SatReport>, onDismiss: () -> Unit) {
val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] }
InfoDialog(title = "$statusName · ${day.dateLabel}", onDismiss = onDismiss, onAccept = onDismiss) {
if (dayReports.isEmpty()) {
Text(stringResource(id = R.string.amsat_no_reports))
Spacer(modifier = Modifier.height(8.dp))
} else {
LazyColumn(modifier = Modifier.heightIn(max = 480.dp)) {
items(dayReports) { report ->
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(statusColorOf(report.statusText))
)
Spacer(modifier = Modifier.width(6.dp))
Text(text = report.statusText, fontSize = 14.sp, fontWeight = FontWeight.Bold)
}
Text(
text = "${report.call} ${report.dateUtc} ${report.timeUtc}" +
if (report.grid.isNotBlank() && report.grid != "-") " ${report.grid}" else "",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
HorizontalDivider(thickness = 0.5.dp)
}
}
}
}
}
private val MONTH_ABBR = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
private fun formatFetchedAt(utcMs: Long): String {
val cal = Calendar.getInstance()
cal.timeInMillis = utcMs
val day = cal.get(Calendar.DAY_OF_MONTH)
val month = MONTH_ABBR[cal.get(Calendar.MONTH)]
val year = cal.get(Calendar.YEAR)
val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
val ss = cal.get(Calendar.SECOND).toString().padStart(2, '0')
return "$day$month $year - $hh:$mm:$ss"
}
@@ -0,0 +1,95 @@
package com.rtbishop.look4sat.feature.status
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class SatStatusUiState(
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val statuses: List<SatStatus> = emptyList(),
val reports: Map<String, SatReport> = emptyMap(),
val fetchedAtUtcMs: Long = 0L,
val error: String? = null
)
class SatStatusViewModel(
private val amSatRepo: IAmSatRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true))
val uiState: StateFlow<SatStatusUiState> = _uiState
init {
fetch()
}
fun fetch() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
try {
val page = amSatRepo.fetchStatus()
if (page != null && page.statuses.isNotEmpty()) {
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
statuses = page.statuses,
reports = page.reports,
fetchedAtUtcMs = page.fetchedAtUtcMs
)
}
} else {
_uiState.update {
it.copy(isLoading = false, isRefreshing = false, error = "fetch_failed")
}
}
} catch (exception: Exception) {
_uiState.update {
it.copy(isLoading = false, isRefreshing = false, error = exception.message ?: "fetch_failed")
}
}
}
}
fun refresh() {
if (_uiState.value.isRefreshing) return
_uiState.update { it.copy(isRefreshing = true, error = null) }
viewModelScope.launch {
try {
val page = amSatRepo.fetchStatus()
if (page != null && page.statuses.isNotEmpty()) {
_uiState.update {
it.copy(
isRefreshing = false,
statuses = page.statuses,
reports = page.reports,
fetchedAtUtcMs = page.fetchedAtUtcMs
)
}
} else {
_uiState.update { it.copy(isRefreshing = false, error = "fetch_failed") }
}
} catch (exception: Exception) {
_uiState.update { it.copy(isRefreshing = false, error = exception.message ?: "fetch_failed") }
}
}
}
companion object {
fun factory(container: IMainContainer) = viewModelFactory {
initializer {
SatStatusViewModel(container.amSatRepo)
}
}
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
[versions] [versions]
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionCode = "444" appVersionCode = "446"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.4" appVersionName = "4.4.6"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
compileSdk = "37" compileSdk = "37"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
+2 -2
View File
@@ -1,7 +1,7 @@
#Sat Jun 27 12:51:12 BST 2026 #Tue Aug 11 14:16:53 CEST 2026
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
+2 -1
View File
@@ -25,5 +25,6 @@ include(
":feature:passes", ":feature:passes",
":feature:radar", ":feature:radar",
":feature:satellites", ":feature:satellites",
":feature:settings" ":feature:settings",
":feature:status"
) )