diff --git a/README.md b/README.md
index 81ebba63..2638c47c 100644
--- a/README.md
+++ b/README.md
@@ -23,4 +23,14 @@
## 许可证
-GNU General Public License v3.0。详见 [LICENSE](LICENSE)。
\ No newline at end of file
+GNU General Public License v3.0。详见 [LICENSE](LICENSE)。
+
+## Star History
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/rtbishop/look4sat/MainActivity.kt b/app/src/main/java/com/rtbishop/look4sat/MainActivity.kt
index 3a8ded66..80913a63 100644
--- a/app/src/main/java/com/rtbishop/look4sat/MainActivity.kt
+++ b/app/src/main/java/com/rtbishop/look4sat/MainActivity.kt
@@ -49,7 +49,7 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
observeNightFilterState()
setContent {
- MainTheme(isDarkTheme = true) { MainScreen() }
+ MainTheme(isDarkTheme = true) { NavRoot() }
}
}
diff --git a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt
index e3a126af..a7b949d5 100644
--- a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt
+++ b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt
@@ -25,8 +25,6 @@ import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
-import androidx.compose.animation.scaleIn
-import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
@@ -44,7 +42,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
@@ -87,38 +85,42 @@ import com.rtbishop.look4sat.feature.passes.PassesDestination
import com.rtbishop.look4sat.feature.radar.RadarDestination
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
import com.rtbishop.look4sat.feature.settings.SettingsDestination
-import com.rtbishop.look4sat.feature.status.SatStatusScreen
+import com.rtbishop.look4sat.feature.status.SatStatusDestination
@Composable
fun NavRoot(deeplink: String? = null) {
val rootBackStack = rememberNavBackStack(Screen.Passes)
val deeplinkResolver = DeeplinkResolver()
LaunchedEffect(deeplink) {
- deeplink?.let {
- val destination = deeplinkResolver.resolve(it) // rootBackStack.clear()
- rootBackStack.add(destination)
- }
+ deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
}
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
- val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
- val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
+ val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
+ // 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(
modifier = Modifier.fillMaxSize(),
backStack = rootBackStack,
onBack = navigateBack,
- transitionSpec = { slideInTransition },
- popTransitionSpec = { slideOutTransition },
- predictivePopTransitionSpec = { slideOutTransition },
+ transitionSpec = { pushTransition },
+ popTransitionSpec = { popTransition },
+ predictivePopTransitionSpec = { popTransition },
entryDecorators = listOf(
- rememberSaveableStateHolderNavEntryDecorator(), // Required for saving Compose state per entry
- rememberViewModelStoreNavEntryDecorator() // Required for ViewModel scoping per entry
+ rememberSaveableStateHolderNavEntryDecorator(),
+ rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
- entry { MainScreen(navigateToRadar = { rootBackStack.add(RadarDestination) }) }
+ entry { MainScreen(navigateToRadar = navigateToRadar) }
entry {
- Scaffold { innerPadding ->
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
RadarDestination(navigateUp = navigateBack)
- innerPadding.calculateTopPadding()
}
}
}
@@ -131,7 +133,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
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.Mutual, Screen.Map, Screen.AMSAT, Screen.Settings)
+ val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Mutual, Screen.Map, Screen.AMSAT, Screen.Settings)
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
@@ -155,7 +157,6 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
val isSelected = when (currentKey) {
is Screen.Satellites -> screen is Screen.Satellites
is Screen.Passes -> screen is Screen.Passes
- is Screen.Radar -> screen is Screen.Radar
is Screen.Mutual -> screen is Screen.Mutual
is Screen.Map -> screen is Screen.Map
is Screen.AMSAT -> screen is Screen.AMSAT
@@ -205,13 +206,9 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
PassesDestination { catNum, aosTime ->
container.setMutualPassData(MutualPassData())
container.satelliteRepo.selectPass(catNum, aosTime)
- backStack.add(Screen.Radar)
-// navigateToRadar()
+ navigateToRadar()
}
}
- entry {
- RadarDestination(navigateUp = navigateBack)
- }
entry {
MapDestination()
}
@@ -222,13 +219,11 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
navigateToRadar = { catNum, aosTime, pass ->
container.setMutualPassData(pass ?: MutualPassData())
container.satelliteRepo.selectPass(catNum, aosTime)
- backStack.add(Screen.Radar)
+ navigateToRadar()
}
)
}
- entry {
- SatStatusScreen(container = container)
- }
+ entry { SatStatusDestination() }
entry {
SettingsDestination()
}
@@ -254,7 +249,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
if (pass != null) {
container.setMutualPassData(MutualPassData())
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
- backStack.add(Screen.Radar)
+ navigateToRadar()
}
}
.padding(horizontal = 12.dp, vertical = 6.dp)
diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/NetworkReporter.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/NetworkReporter.kt
index b116b190..58af26aa 100644
--- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/NetworkReporter.kt
+++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/NetworkReporter.kt
@@ -17,8 +17,10 @@
*/
package com.rtbishop.look4sat.core.data.framework
+import com.rtbishop.look4sat.core.domain.model.Constants
import com.rtbishop.look4sat.core.domain.repository.IReporter
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -31,18 +33,30 @@ class NetworkReporter(
private val rotatorServer: String,
private val rotatorPort: Int,
private val frequencyServer: String,
- private val frequencyPort: Int
+ private val frequencyPort: Int,
+ private val frequencyOffsetHz: Long = 0L
) : IReporter {
private val writeMutex = Mutex()
+ private val connectionMutex = Mutex()
+ private val frequencyCommands = Channel(Channel.CONFLATED)
private var rotatorSocket: SocketChannel? = null
private var rotatorConnected = false
- private var rotatorConnecting = false
private var frequencySocket: SocketChannel? = null
private var frequencyConnected = false
- private var frequencyConnecting = false
+
+ 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) {
reporterScope.launch {
@@ -53,51 +67,48 @@ class NetworkReporter(
.replace($$"$AZ", azimuth.toString())
.replace($$"$EL", el.toString())
.unescapeControlChars()
- write(rotatorSocket, command) { rotatorConnected = false }
+ write(rotatorSocket, command) { resetRotatorConnection() }
}
}
override fun reportFrequency(format: String, frequency: Long) {
- reporterScope.launch {
- ensureFrequencyConnected()
- if (!frequencyConnected) return@launch
- val command = format
- .replace($$"$FREQ", frequency.toString())
- .unescapeControlChars()
- write(frequencySocket, command) { frequencyConnected = false }
- }
+ val clampedOffset = frequencyOffsetHz.coerceIn(
+ Constants.FREQ_OFFSET_MIN_HZ,
+ Constants.FREQ_OFFSET_MAX_HZ
+ )
+ val correctedFreq = frequency.coerceAtLeast(0L).safeAdd(clampedOffset).coerceAtLeast(0L)
+ val command = format
+ .replace($$"$FREQ", correctedFreq.toString())
+ .unescapeControlChars()
+ frequencyCommands.trySend(command)
}
- private fun ensureRotatorConnected() {
- if (rotatorConnected || rotatorConnecting || rotatorServer.isBlank()) return
- reporterScope.launch {
+ private suspend fun ensureRotatorConnected() {
+ connectionMutex.withLock {
+ if (rotatorConnected || rotatorServer.isBlank()) return
try {
- rotatorConnecting = true
+ resetRotatorConnection()
rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
rotatorConnected = true
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
} catch (e: Exception) {
println("NetworkReporter rotator connect error: ${e.message}")
- rotatorConnected = false
- } finally {
- rotatorConnecting = false
+ resetRotatorConnection()
}
}
}
- private fun ensureFrequencyConnected() {
- if (frequencyConnected || frequencyConnecting || frequencyServer.isBlank()) return
- reporterScope.launch {
+ private suspend fun ensureFrequencyConnected() {
+ connectionMutex.withLock {
+ if (frequencyConnected || frequencyServer.isBlank()) return
try {
- frequencyConnecting = true
+ resetFrequencyConnection()
frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
frequencyConnected = true
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
} catch (e: Exception) {
println("NetworkReporter frequency connect error: ${e.message}")
- frequencyConnected = false
- } finally {
- frequencyConnecting = false
+ resetFrequencyConnection()
}
}
}
@@ -106,7 +117,9 @@ class NetworkReporter(
try {
writeMutex.withLock {
val buffer = ByteBuffer.wrap("$command\n".toByteArray())
- socket?.write(buffer)
+ while (buffer.hasRemaining()) {
+ socket?.write(buffer)
+ }
}
} catch (e: Exception) {
println("NetworkReporter write error: ${e.message}")
@@ -114,6 +127,34 @@ 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 =
- replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
+ replace("""\r""", 13.toChar().toString())
+ .replace("""\n""", 10.toChar().toString())
+ .replace("""\t""", 9.toChar().toString())
}
diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt
index 8aeb937d..2bca8416 100644
--- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt
+++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt
@@ -117,7 +117,8 @@ class MainContainer(private val context: Context) : IMainContainer {
rc.rotatorAddress,
rc.rotatorPort.toIntOrNull() ?: 0,
rc.frequencyAddress,
- rc.frequencyPort.toIntOrNull() ?: 0
+ rc.frequencyPort.toIntOrNull() ?: 0,
+ rc.frequencyOffsetHz
)
}
diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt
index fe0d058a..62c26393 100644
--- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt
+++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt
@@ -35,7 +35,10 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) {
val nowSec = System.currentTimeMillis() / 1000
val catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext null
- val reportsJson = remoteSource.getAmSatReports(hours = 168, limit = 500) ?: 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)
@@ -52,7 +55,7 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
return try {
val arr = JSONObject(json).getJSONArray("data")
(0 until arr.length()).map { arr.getJSONObject(it).getString("name") }
- } catch (e: Exception) {
+ } catch (_: Exception) {
emptyList()
}
}
@@ -73,7 +76,7 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
reportedTimeUtcSec = parseIsoUtcSec(iso)
)
}
- } catch (e: Exception) {
+ } catch (_: Exception) {
emptyList()
}
}
@@ -82,22 +85,22 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
private fun parseIsoUtcSec(iso: String): Long {
return try {
(isoUtcFormat.parse(iso)?.time ?: 0L) / 1000
- } catch (e: Exception) {
+ } catch (_: Exception) {
0L
}
}
- /** Build one SatStatus (6 days x 12 slots) per catalog satellite, slotting reports by age. */
+ /** Build one SatStatus (3 days x 12 slots) per catalog satellite, slotting reports by age. */
private fun buildStatuses(names: List, reports: List, nowSec: Long): List {
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 6).map { d ->
+ 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 72).map { slotIdx ->
+ 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 }
@@ -112,7 +115,7 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
)
}
}
- val days = (0 until 6).map { d ->
+ val days = (0 until 3).map { d ->
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
}
SatStatus(name = name, days = days)
diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt
index 8decc7a6..40dd4843 100644
--- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt
+++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt
@@ -66,25 +66,26 @@ class DatabaseRepo(
}
override suspend fun updateFromRemote() = withContext(dispatcher) {
- val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
- val tleUrls = buildMap {
- putAll(Sources.satelliteDataUrls)
- if (dataSourcesSettings.useCustomTLE) put(customSourceType, dataSourcesSettings.tleUrl)
- }.filterValues { it.isNotBlank() }
- val radioUrls = buildMap {
- putAll(Sources.transceiversDataUrls)
- if (dataSourcesSettings.useCustomTransceivers) put(customSourceType, dataSourcesSettings.transceiversUrl)
- }.filterValues { it.isNotBlank() }
+ val settings = settingsRepo.dataSourcesSettings.value
+ val tleUrls = settings.satelliteUrls.filter { it.isNotBlank() }.map(::normalizeUrl).distinct()
+ val radioUrls = settings.transceiversUrls.filter { it.isNotBlank() }.map(::normalizeUrl).distinct()
+ val builtinTypesByUrl = Sources.satelliteDataUrls
+ .filterValues { it.isNotBlank() }
+ .mapValues { normalizeUrl(it.value) }
+ .entries
+ .associate { (type, url) -> url to type }
+ val importedTypeIds = mutableMapOf>()
// launch all network requests concurrently
- val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
- val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
- // parse fetched data concurrently and associate with types
+ val tleJobs = tleUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } }
+ val radioJobs = radioUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } }
+ // parse fetched data concurrently and associate known built-in URLs with existing type filters.
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
- val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
- stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
- settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
- }
+ val entries = stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty()
+ val type = builtinTypesByUrl[url] ?: customSourceType
+ importedTypeIds.getOrPut(type) { mutableListOf() }.addAll(entries.map { it.catnum })
+ entries
}
+ importedTypeIds.forEach { (type, ids) -> settingsRepo.setSatelliteTypeIds(type, ids.distinct()) }
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
}
@@ -100,6 +101,9 @@ class DatabaseRepo(
setUpdateSuccessful(0L)
}
+ private fun normalizeUrl(url: String): String =
+ if (url.startsWith("http", ignoreCase = true)) url else "https://$url"
+
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List {
val bufferedStream = stream.buffered()
return when {
diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt
index ccc34102..7e0fe38d 100644
--- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt
+++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt
@@ -37,6 +37,8 @@ import com.rtbishop.look4sat.core.domain.utility.round
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
+import com.rtbishop.look4sat.core.domain.model.Constants
+import com.rtbishop.look4sat.core.domain.source.Sources
class SettingsRepo(
private val locationManager: LocationManager,
@@ -67,6 +69,7 @@ class SettingsRepo(
private val keyFrequencyAddress = "frequencyAddress"
private val keyFrequencyPort = "frequencyPort"
private val keyFrequencyFormat = "frequencyFormat"
+ private val keyFrequencyOffsetHz = "frequencyOffsetHz"
private val keySelectedIds = "selectedIds"
private val keySelectedTypes = "selectedTypes"
private val keySelectedModes = "selectedModes"
@@ -91,7 +94,10 @@ class SettingsRepo(
private val keyUseCustomTransceivers = "useCustomTransceivers"
private val keyTleUrl = "tleUrl"
private val keyTransceiversUrl = "transceiversUrl"
+ private val keySatelliteUrls = "satelliteUrls"
+ private val keyTransceiversUrls = "transceiversUrls"
private val separatorComma = ","
+ private val separatorUrl = "\n"
//region # Satellites selection settings
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
@@ -298,6 +304,10 @@ class SettingsRepo(
override val rcSettings: StateFlow = _rcSettings
override fun updateRCSettings(settings: RCSettings) {
+ val clampedFreqOffsetHz = settings.frequencyOffsetHz.coerceIn(
+ Constants.FREQ_OFFSET_MIN_HZ,
+ Constants.FREQ_OFFSET_MAX_HZ
+ )
preferences.edit {
putBoolean(keyRotatorState, settings.rotatorState)
putString(keyRotatorAddress, settings.rotatorAddress)
@@ -307,6 +317,7 @@ class SettingsRepo(
putString(keyFrequencyAddress, settings.frequencyAddress)
putString(keyFrequencyPort, settings.frequencyPort)
putString(keyFrequencyFormat, settings.frequencyFormat)
+ putLong(keyFrequencyOffsetHz, clampedFreqOffsetHz)
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
@@ -315,7 +326,7 @@ class SettingsRepo(
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
}
- _rcSettings.value = settings
+ _rcSettings.value = settings.copy(frequencyOffsetHz = clampedFreqOffsetHz)
}
private fun getRCSettings(): RCSettings = RCSettings(
@@ -327,6 +338,8 @@ class SettingsRepo(
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
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),
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
@@ -382,20 +395,45 @@ class SettingsRepo(
override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
preferences.edit {
- putBoolean(keyUseCustomTle, settings.useCustomTLE)
- putBoolean(keyUseCustomTransceivers, settings.useCustomTransceivers)
- putString(keyTleUrl, settings.tleUrl)
- putString(keyTransceiversUrl, settings.transceiversUrl)
+ putString(keySatelliteUrls, settings.satelliteUrls.joinToString(separatorUrl))
+ putString(keyTransceiversUrls, settings.transceiversUrls.joinToString(separatorUrl))
}
_dataSourcesSettings.value = settings
}
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
- useCustomTLE = preferences.getBoolean(keyUseCustomTle, false),
- useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false),
- tleUrl = preferences.getString(keyTleUrl, "https://example.com/tle.txt") ?: "",
- transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: ""
+ satelliteUrls = getDataSourceUrls(
+ key = keySatelliteUrls,
+ defaultUrls = Sources.satelliteDataUrls.values.filter { it.isNotBlank() },
+ legacyEnabledKey = keyUseCustomTle,
+ legacyUrlKey = keyTleUrl
+ ),
+ transceiversUrls = getDataSourceUrls(
+ key = keyTransceiversUrls,
+ defaultUrls = Sources.transceiversDataUrls.values.filter { it.isNotBlank() },
+ legacyEnabledKey = keyUseCustomTransceivers,
+ legacyUrlKey = keyTransceiversUrl
+ )
)
+
+ private fun getDataSourceUrls(
+ key: String,
+ defaultUrls: List,
+ legacyEnabledKey: String,
+ legacyUrlKey: String
+ ): List {
+ val stored = preferences.getString(key, null)
+ ?.split(separatorUrl)
+ ?.map { it.trim() }
+ ?.filter { it.isNotBlank() }
+ if (stored != null) return stored
+ val legacyCustomUrl = preferences.getString(legacyUrlKey, null)?.trim().orEmpty()
+ return if (preferences.getBoolean(legacyEnabledKey, false) && legacyCustomUrl.isNotBlank()) {
+ (defaultUrls + legacyCustomUrl).distinct()
+ } else {
+ defaultUrls
+ }
+ }
//endregion
//region # Radio control settings
diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt
index 97349b2e..29684304 100644
--- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt
+++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt
@@ -24,7 +24,6 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
-import java.io.ByteArrayInputStream
import java.io.InputStream
class RemoteSource(
@@ -46,10 +45,14 @@ class RemoteSource(
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
try {
val networkRequest = Request.Builder().url(url).build()
- httpClient.newCall(networkRequest).execute().use { response ->
- if (!response.isSuccessful) return@withContext null
- ByteArrayInputStream(response.body.bytes())
+ val response = httpClient.newCall(networkRequest).execute()
+ if (!response.isSuccessful) {
+ response.close()
+ return@withContext null
}
+ // Return the body stream directly as the caller is responsible for closing it.
+ // Closing the stream returns the connection to OkHttp's pool.
+ response.body.byteStream().buffered()
} catch (exception: Exception) {
println("RemoteSource network stream exception: $exception")
null
diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt
index a14ede56..c7cae436 100644
--- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt
+++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt
@@ -91,10 +91,8 @@ class DatabaseRepoTest {
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
- useCustomTLE = true,
- useCustomTransceivers = false,
- tleUrl = customCsvUrl,
- transceiversUrl = ""
+ satelliteUrls = listOf(customCsvUrl),
+ transceiversUrls = emptyList()
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
@@ -124,6 +122,10 @@ private class FakeRemoteSource : IRemoteSource {
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.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 {
@@ -176,7 +178,7 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override val databaseState: MutableStateFlow = MutableStateFlow(DatabaseState(0, 0, 0L))
override val rcSettings: StateFlow = MutableStateFlow(
- RCSettings(false, "", "", "", false, "", "", "", false, "", "", "", false, "", "")
+ RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
)
override val otherSettings: StateFlow = MutableStateFlow(
@@ -226,9 +228,7 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
private fun defaultDataSourcesSettings(): DataSourcesSettings {
return DataSourcesSettings(
- useCustomTLE = false,
- useCustomTransceivers = false,
- tleUrl = "",
- transceiversUrl = ""
+ satelliteUrls = emptyList(),
+ transceiversUrls = emptyList()
)
}
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Constants.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Constants.kt
new file mode 100644
index 00000000..679c1de5
--- /dev/null
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Constants.kt
@@ -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 .
+ */
+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
+}
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatStatus.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatStatus.kt
index 22c9fae8..062ff42e 100644
--- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatStatus.kt
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatStatus.kt
@@ -23,10 +23,10 @@ data class SatDay(
val slots: List // 12 槽(00-02 ... 22-24)
)
-/** One satellite, 6 days of state */
+/** One satellite, 3 days of state */
data class SatStatus(
val name: String, // "AO-123_[FM]"
- val days: List // 6 天(新→旧)
+ val days: List // 3 days (newest first)
)
/** Overall page parse result */
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt
index ef830eb4..e462e343 100644
--- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt
@@ -42,6 +42,7 @@ data class RCSettings(
val frequencyAddress: String,
val frequencyPort: String,
val frequencyFormat: String,
+ val frequencyOffsetHz: Long = 0L,
val bluetoothRotatorState: Boolean,
val bluetoothRotatorFormat: String,
val bluetoothRotatorName: String,
@@ -66,10 +67,8 @@ data class OtherSettings(
)
data class DataSourcesSettings(
- val useCustomTLE: Boolean,
- val useCustomTransceivers: Boolean,
- val tleUrl: String,
- val transceiversUrl: String
+ val satelliteUrls: List,
+ val transceiversUrls: List
)
data class RadioControlSettings(
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/CelestialComputer.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/CelestialComputer.kt
index 6a3cdbd8..de87e7fa 100644
--- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/CelestialComputer.kt
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/CelestialComputer.kt
@@ -424,11 +424,15 @@ object CelestialComputer {
}
if (sunrise == 0.0) sunrise = daynum
- // Phase 4: fast-forward through the day until sun drops back below threshold
- daynum = sunrise
+ // Phase 4: fast-forward through the day until sun drops back below threshold.
+ // Start from just after sunrise (small offset) so the sun is clearly above
+ // the threshold. This prevents a bug where Phase 3 converges to a point
+ // slightly below -threshold, causing Phase 4 to skip and Phase 5 to converge
+ // to the same time as sunrise, producing identical sunrise/sunset times.
+ daynum = sunrise + 0.001
sunPos = getSunPosition(observer, daynumToMillis(daynum))
guard = 0
- while (sunPos.elevation > -threshold && guard++ < 500) {
+ while (sunPos.elevation >= -threshold && guard++ < 500) {
daynum += 0.008
sunPos = getSunPosition(observer, daynumToMillis(daynum))
}
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/Sources.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/Sources.kt
index 17c91e34..122252fc 100644
--- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/Sources.kt
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/Sources.kt
@@ -46,9 +46,11 @@ object Sources {
"Classified" to "https://www.mmccants.org/tles/classfd.zip",
"McCants" to "https://www.mmccants.org/tles/inttles.zip",
"R4UAB" to "https://r4uab.ru/satonline.txt",
+ "ARISS" to "https://live.ariss.org/iss.txt",
"Other" to "" // key for sats filter
)
val transceiversDataUrls = mapOf(
- "SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active"
+ "SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active",
+ "R4UAB" to "https://r4uab.ru/transmitters.json"
)
}
diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt
index c299889e..0a19ee53 100644
--- a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt
+++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt
@@ -280,6 +280,35 @@ fun getDefaultPass(): OrbitalPass = OrbitalPass(
altitude = 0, maxElevation = 0.0, orbitalObject = NearEarthObject(defaultOrbitalData), progress = 0f
)
+@Composable
+fun InfoDialog(
+ title: String,
+ onDismiss: () -> Unit,
+ onAccept: () -> Unit,
+ content: @Composable () -> Unit
+) {
+ DialogShell(onDismissRequest = onDismiss) { padding ->
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = padding, top = padding, end = padding)
+ ) {
+ Text(
+ text = title,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.primary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f)
+ )
+ CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
+ }
+ content()
+ }
+}
+
@Composable
fun SharedDialog(
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt
index 495738fc..3efb850b 100644
--- a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt
+++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt
@@ -29,9 +29,6 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
@Serializable
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
- @Serializable
- data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar)
-
@Serializable
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
diff --git a/core/presentation/src/main/res/drawable/ic_add.xml b/core/presentation/src/main/res/drawable/ic_add.xml
new file mode 100644
index 00000000..9cb98dd6
--- /dev/null
+++ b/core/presentation/src/main/res/drawable/ic_add.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/feature/status/src/main/res/drawable/ic_refresh.xml b/core/presentation/src/main/res/drawable/ic_refresh.xml
similarity index 98%
rename from feature/status/src/main/res/drawable/ic_refresh.xml
rename to core/presentation/src/main/res/drawable/ic_refresh.xml
index 909aa74e..ab59431d 100644
--- a/feature/status/src/main/res/drawable/ic_refresh.xml
+++ b/core/presentation/src/main/res/drawable/ic_refresh.xml
@@ -9,4 +9,4 @@
-
+
\ No newline at end of file
diff --git a/core/presentation/src/main/res/values-es/strings.xml b/core/presentation/src/main/res/values-es/strings.xml
index 1d9bd834..0f7df61f 100644
--- a/core/presentation/src/main/res/values-es/strings.xml
+++ b/core/presentation/src/main/res/values-es/strings.xml
@@ -112,6 +112,13 @@
Actualización completada con éxito
Custom data sources
+ No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.
+ No transceivers imported. Select a valid SatNOGS (.json) file.
+ URL
+ Satellites data
+ Transceivers data
+ Frequency offset (Hz)
+ Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.
Custom TLE URL
Custom transceivers URL
diff --git a/core/presentation/src/main/res/values-ru/strings.xml b/core/presentation/src/main/res/values-ru/strings.xml
index 17031bf9..95a4dd7b 100644
--- a/core/presentation/src/main/res/values-ru/strings.xml
+++ b/core/presentation/src/main/res/values-ru/strings.xml
@@ -111,6 +111,13 @@
Обновление прошло успешно
Свои источники данных
+ No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.
+ No transceivers imported. Select a valid SatNOGS (.json) file.
+ URL
+ Satellites data
+ Transceivers data
+ Frequency offset (Hz)
+ Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.
Custom TLE URL
Custom Transceivers URL
diff --git a/core/presentation/src/main/res/values-si/strings.xml b/core/presentation/src/main/res/values-si/strings.xml
index 44d1433b..441576b5 100644
--- a/core/presentation/src/main/res/values-si/strings.xml
+++ b/core/presentation/src/main/res/values-si/strings.xml
@@ -112,6 +112,13 @@
යාවත්කාලීනය සම්පූර්ණ කිරීම සාර්ථකයි
Custom data sources
+ No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.
+ No transceivers imported. Select a valid SatNOGS (.json) file.
+ URL
+ Satellites data
+ Transceivers data
+ Frequency offset (Hz)
+ Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.
Custom TLE URL
Custom transceivers URL
diff --git a/core/presentation/src/main/res/values-tr/strings.xml b/core/presentation/src/main/res/values-tr/strings.xml
index 03df7f34..14f01cca 100644
--- a/core/presentation/src/main/res/values-tr/strings.xml
+++ b/core/presentation/src/main/res/values-tr/strings.xml
@@ -143,6 +143,12 @@
Güncelleme başarıyla tamamlandı
Özel veri kaynakları
+ No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.
+ No transceivers imported. Select a valid SatNOGS (.json) file.
+ Satellites data
+ Transceivers data
+ Frequency offset (Hz)
+ Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.
Özel TLE URL
Özel transceiver URL
URL (HTTPS)
diff --git a/core/presentation/src/main/res/values-uk/strings.xml b/core/presentation/src/main/res/values-uk/strings.xml
index a6754918..6993c71e 100644
--- a/core/presentation/src/main/res/values-uk/strings.xml
+++ b/core/presentation/src/main/res/values-uk/strings.xml
@@ -112,6 +112,13 @@
Оновлення успішне
Custom data sources
+ No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.
+ No transceivers imported. Select a valid SatNOGS (.json) file.
+ URL
+ Satellites data
+ Transceivers data
+ Frequency offset (Hz)
+ Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.
Custom TLE URL
Custom transceivers URL
diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml
index ee6ccbce..139032e0 100644
--- a/core/presentation/src/main/res/values-zh/strings.xml
+++ b/core/presentation/src/main/res/values-zh/strings.xml
@@ -136,6 +136,9 @@
自定义数据源
自定义TLE URL
自定义收发器URL
+ URL
+ 卫星数据
+ 收发器数据
数据输出
网络
@@ -149,6 +152,8 @@
启用多普勒频率输出
IP:端口
数据格式
+ 频率偏移 (Hz)
+ 范围:-50000 到 50000 Hz。正值提高上报频率,负值降低上报频率。
蓝牙数据输出
启用方位俯仰角输出
diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml
index 6855cf7d..c8615ad6 100644
--- a/core/presentation/src/main/res/values/strings.xml
+++ b/core/presentation/src/main/res/values/strings.xml
@@ -171,6 +171,8 @@
Custom TLE URL
Custom transceivers URL
URL (HTTPS)
+ Satellites data
+ Transceivers data
Data output
Network
@@ -194,6 +196,8 @@
Enable frequency output
IP:Port
Format
+ Frequency offset (Hz)
+ Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.
Bluetooth data output
Enable rotation output
diff --git a/dist/Look4Sat-4.4.6-ba7opf.1-debug.apk b/dist/Look4Sat-4.4.6-ba7opf.1-debug.apk
new file mode 100644
index 00000000..ec4c721d
Binary files /dev/null and b/dist/Look4Sat-4.4.6-ba7opf.1-debug.apk differ
diff --git a/dist/Look4Sat-4.4.6-ba7opf.1-release.apk b/dist/Look4Sat-4.4.6-ba7opf.1-release.apk
new file mode 100644
index 00000000..68aa0cf2
Binary files /dev/null and b/dist/Look4Sat-4.4.6-ba7opf.1-release.apk differ
diff --git a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt
index 5bad711e..e7468801 100644
--- a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt
+++ b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt
@@ -23,6 +23,7 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
+import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
@@ -167,6 +168,7 @@ class PassesViewModel(
// CelestialComputer.findSunRiseSet() returns the next rise/set after the supplied time, so pass
// the calendar day's 00:00 in the same timezone used by the visible date group. Passing a pass's
// AOS time can jump to the following day's sunrise/sunset when that AOS is after the local event.
+ // For white nights / polar day edge cases, we fall back to searching from the previous day.
private fun computeSunTimes(passes: List, isUtc: Boolean): Map> {
val stationPos = settingsRepo.stationPosition.value
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
@@ -175,7 +177,7 @@ class PassesViewModel(
val result = LinkedHashMap>()
// DeepSpace group always shows today's sun times
if (passes.any { it.isDeepSpace }) {
- val riseSet = CelestialComputer.findSunRiseSet(stationPos, startOfDayMillis(System.currentTimeMillis(), tz))
+ val riseSet = findTodaySunRiseSet(stationPos, startOfDayMillis(System.currentTimeMillis(), tz))
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
result["DeepSpace (period >225min)"] = rise to set
@@ -184,7 +186,7 @@ class PassesViewModel(
if (pass.isDeepSpace) continue
val label = sdfDate.format(Date(pass.aosTime))
if (label in result) continue
- val riseSet = CelestialComputer.findSunRiseSet(stationPos, startOfDayMillis(pass.aosTime, tz))
+ val riseSet = findTodaySunRiseSet(stationPos, startOfDayMillis(pass.aosTime, tz))
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
result[label] = rise to set
@@ -192,6 +194,33 @@ class PassesViewModel(
return result
}
+ /**
+ * Find today's sunrise and sunset by starting from local midnight.
+ * Handles the white nights / polar day edge case where the sun is still above
+ * the -0.8333° threshold at midnight: in that case we search from the previous
+ * day's midnight to find a valid sunrise/sunset pair within the target day.
+ */
+ private fun findTodaySunRiseSet(observer: GeoPos, localMidnightMillis: Long): CelestialComputer.RiseSetTimes {
+ val threshold = 0.8333
+ val sunPos = CelestialComputer.getSunPosition(observer, localMidnightMillis)
+ if (sunPos.elevation > -threshold) {
+ // White nights / polar day: the sun is too high at midnight.
+ // Search from earlier days so the algorithm goes through a full
+ // sunset → sunrise → sunset cycle, landing in the target day.
+ val dayEnd = localMidnightMillis + 86400000L
+ for (offset in 1..3) {
+ val result = CelestialComputer.findSunRiseSet(observer, localMidnightMillis - offset * 86400000L)
+ if (result.riseTimeMillis in localMidnightMillis..dayEnd &&
+ result.setTimeMillis in localMidnightMillis..dayEnd) {
+ return result
+ }
+ }
+ // No valid pair found (midnight sun / polar day)
+ return CelestialComputer.RiseSetTimes(0L, 0L)
+ }
+ return CelestialComputer.findSunRiseSet(observer, localMidnightMillis)
+ }
+
private fun startOfDayMillis(timeMillis: Long, tz: TimeZone): Long {
return Calendar.getInstance(tz, Locale.getDefault()).apply {
timeInMillis = timeMillis
diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt
index 69888ebc..4afd0aa8 100644
--- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt
+++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt
@@ -93,7 +93,6 @@ import androidx.constraintlayout.widget.ConstraintLayout
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
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.R
import com.rtbishop.look4sat.core.presentation.formatFrequency
@@ -657,12 +656,15 @@ private fun DopplerFrequencyCalculator(
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)
+ // Passband 模式:从 RX 经完整往返路径(含多普勒补偿)反推 TX 位置
+ val txFromRx = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
+ newRxHz, rawTransponder, orbitalPos, offsetHz
+ )
passbandPosition = if (txFromRx != null) {
- ((txFromRx - rawTxLow).toFloat() / rawTxRange).coerceIn(0f, 1f)
- } else pos
+ ((txFromRx - txLow).toFloat() / txRange).coerceIn(0f, 1f)
+ } else {
+ ((rxFrequencyHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
+ }
txFrequencyHz = txLow + (passbandPosition * txRange).toLong()
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt
index c0897b4d..004ab2e8 100644
--- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt
+++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt
@@ -52,6 +52,21 @@ import com.rtbishop.look4sat.core.presentation.LocalSpacing
import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.toMutableStateList
+import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.res.painterResource
+import com.rtbishop.look4sat.core.domain.model.Constants
+import com.rtbishop.look4sat.core.presentation.IconCard
@Preview(showBackground = true)
@Composable
@@ -116,128 +131,150 @@ fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Uni
private fun TransceiversDialogPreview() {
MainTheme {
DataSourcesDialog(
- useCustomTle = true,
- useCustomTransceivers = true,
- tleUrl = "https://example.com/tle.txt",
- transceiversUrl = "https://example.com/tx.json",
- requestCustomSourcesPermission = { onGranted, _ -> onGranted() },
+ satelliteUrls = listOf(
+ "celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
+ "amsat.org/tle/current/nasabare.txt"
+ ),
+ transceiversUrls = listOf(
+ "db.satnogs.org/api/transmitters/?format=json&status=active"
+ ),
onImportTle = {},
onImportTransceivers = {},
onDismiss = {},
- onSave = { _, _, _, _ -> }
+ onSave = { _, _ -> }
)
}
}
@Composable
fun DataSourcesDialog(
- useCustomTle: Boolean,
- useCustomTransceivers: Boolean,
- tleUrl: String,
- transceiversUrl: String,
- requestCustomSourcesPermission: (onGranted: () -> Unit, onDenied: () -> Unit) -> Unit,
+ satelliteUrls: List,
+ transceiversUrls: List,
onImportTle: () -> Unit,
onImportTransceivers: () -> Unit,
onDismiss: () -> Unit,
- onSave: (Boolean, Boolean, String, String) -> Unit
+ onSave: (List, List) -> Unit
) {
val padding = LocalSpacing.current.large
- val isEnabledCustomTle = rememberSaveable { mutableStateOf(useCustomTle) }
- val isEnabledCustomTransceivers = rememberSaveable { mutableStateOf(useCustomTransceivers) }
- val urlTle = rememberSaveable { mutableStateOf(tleUrl) }
- val urlTransceivers = rememberSaveable { mutableStateOf(transceiversUrl) }
+ // Stable Long IDs avoid key collisions when several entries are empty or duplicated.
+ val nextId = remember { mutableLongStateOf((satelliteUrls.size + transceiversUrls.size).toLong()) }
+ val satUrls = remember {
+ satelliteUrls.mapIndexed { i, url -> i.toLong() to url }.toMutableStateList()
+ }
+ val txUrls = remember {
+ transceiversUrls.mapIndexed { i, url -> (satelliteUrls.size + i).toLong() to url }.toMutableStateList()
+ }
val onAccept = {
- onSave(isEnabledCustomTle.value, isEnabledCustomTransceivers.value, urlTle.value, urlTransceivers.value)
+ onSave(
+ satUrls.map { it.second.trim() }.filter { it.isNotBlank() },
+ txUrls.map { it.second.trim() }.filter { it.isNotBlank() }
+ )
onDismiss()
}
- val onCancel = { onDismiss() }
SharedDialog(
title = stringResource(id = R.string.prefs_data_sources_title),
- onCancel = onCancel,
+ onCancel = onDismiss,
onAccept = onAccept,
) {
- Column(modifier = Modifier.padding(horizontal = padding)) {
- Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
- CardButton(
- onClick = {
- onImportTle()
- onDismiss()
- },
- text = "TLE/3LE (.txt)\nOMM (.csv)",
- modifier = Modifier.weight(1f)
- )
- CardButton(
- onClick = {
- onImportTransceivers()
- onDismiss()
- },
- text = "Transceivers\nSatNOGS (.json)",
- modifier = Modifier.weight(1f)
- )
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxHeight(0.84f)
+ .padding(horizontal = padding),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ contentPadding = PaddingValues(vertical = 6.dp)
+ ) {
+ item {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ CardButton(
+ onClick = { onImportTle(); onDismiss() },
+ text = "TLE/3LE (.txt)\nOMM (.csv)",
+ modifier = Modifier.weight(1f)
+ )
+ CardButton(
+ onClick = { onImportTransceivers(); onDismiss() },
+ text = "Transceivers\nSatNOGS (.json)",
+ modifier = Modifier.weight(1f)
+ )
+ }
}
- Spacer(modifier = Modifier.height(6.dp))
- Row(
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.fillMaxWidth()
- ) {
- Text(text = stringResource(id = R.string.prefs_data_sources_tle_switch))
- Switch(
- 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,
+ sourceSection(
+ sectionKey = "sat",
+ labelResId = R.string.prefs_data_sources_satellites_label,
+ urls = satUrls,
+ onAdd = { satUrls.add(nextId.longValue++ to "") },
+ onMoveUp = { i -> if (i > 0) satUrls.add(i - 1, satUrls.removeAt(i)) },
+ onRemove = { i -> satUrls.removeAt(i) },
+ onUrlChange = { i, v -> satUrls[i] = satUrls[i].first to v }
)
- Spacer(modifier = Modifier.height(6.dp))
- Row(
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.fillMaxWidth()
- ) {
- Text(text = stringResource(id = R.string.prefs_data_sources_transceivers_switch))
- Switch(
- 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,
+ sourceSection(
+ sectionKey = "tx",
+ labelResId = R.string.prefs_data_sources_transceivers_label,
+ urls = txUrls,
+ onAdd = { txUrls.add(nextId.longValue++ to "") },
+ onMoveUp = { i -> if (i > 0) txUrls.add(i - 1, txUrls.removeAt(i)) },
+ onRemove = { i -> txUrls.removeAt(i) },
+ onUrlChange = { i, v -> txUrls[i] = txUrls[i].first to v }
)
- Spacer(modifier = Modifier.height(12.dp))
}
}
}
+private fun LazyListScope.sourceSection(
+ sectionKey: String,
+ labelResId: Int,
+ urls: List>,
+ 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 = stringResource(labelResId),
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.weight(1f)
+ )
+ IconCard(action = onAdd, resId = R.drawable.ic_add)
+ }
+ }
+ itemsIndexed(urls, key = { _, entry -> "$sectionKey-${entry.first}" }) { index, (_, url) ->
+ val enabledTint = MaterialTheme.colorScheme.onSurfaceVariant
+ OutlinedTextField(
+ value = url,
+ onValueChange = { onUrlChange(index, it) },
+ label = { Text(stringResource(R.string.prefs_data_sources_url_title)) },
+ leadingIcon = {
+ IconButton(onClick = { onMoveUp(index) }, enabled = index > 0) {
+ 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)
@Composable
fun PreviewNetworkOutputDialog() {
@@ -252,6 +289,7 @@ fun PreviewNetworkOutputDialog() {
frequencyAddress = "127.0.0.1",
frequencyPort = "4532",
frequencyFormat = $$"F $FREQ",
+ frequencyOffsetHz = 0L,
bluetoothRotatorState = false,
bluetoothRotatorFormat = $$"P $AZ $EL",
bluetoothRotatorName = "Default",
@@ -261,7 +299,7 @@ fun PreviewNetworkOutputDialog() {
bluetoothFrequencyFormat = $$"F $FREQ"
),
onDismiss = {},
- onSave = { _, _, _, _, _, _, _, _ -> }
+ onSave = { _, _, _, _, _, _, _, _, _ -> }
)
}
}
@@ -272,7 +310,7 @@ fun NetworkOutputDialog(
onDismiss: () -> Unit,
onSave: (
Boolean, String, String, String,
- Boolean, String, String, String
+ Boolean, String, String, String, Long
) -> Unit
) {
val padding = LocalSpacing.current.large
@@ -286,12 +324,15 @@ fun NetworkOutputDialog(
mutableStateOf("${initialSettings.frequencyAddress}:${initialSettings.frequencyPort}")
}
val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.frequencyFormat) }
+ val frequencyOffsetHz = rememberSaveable { mutableStateOf(initialSettings.frequencyOffsetHz.toString()) }
val onAccept = {
val (rotIp, rotPort) = splitAddress(rotatorAddress.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(
rotatorState.value, rotIp, rotPort, rotatorFormat.value,
- frequencyState.value, freqIp, freqPort, frequencyFormat.value
+ frequencyState.value, freqIp, freqPort, frequencyFormat.value, offsetHz
)
onDismiss()
}
@@ -324,6 +365,24 @@ fun NetworkOutputDialog(
onFormatChange = { frequencyFormat.value = it },
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))
}
}
@@ -352,6 +411,7 @@ fun PreviewBluetoothOutputDialog() {
frequencyAddress = "127.0.0.1",
frequencyPort = "4532",
frequencyFormat = $$"F $FREQ",
+ frequencyOffsetHz = 0L,
bluetoothRotatorState = false,
bluetoothRotatorFormat = $$"P $AZ $EL",
bluetoothRotatorName = "Default",
diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt
index e4c9e2b0..85934c71 100644
--- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt
+++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt
@@ -61,6 +61,7 @@ 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.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
@@ -125,30 +126,15 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
}
if (dialogs.dataSources) {
DataSourcesDialog(
- useCustomTle = uiState.dataSourcesSettings.useCustomTLE,
- useCustomTransceivers = uiState.dataSourcesSettings.useCustomTransceivers,
- tleUrl = uiState.dataSourcesSettings.tleUrl,
- transceiversUrl = uiState.dataSourcesSettings.transceiversUrl,
- requestCustomSourcesPermission = { onGranted, onDenied ->
- pendingCustomSourcesGrant.value = onGranted
- pendingCustomSourcesDeny.value = onDenied
- permissions.launchCustomSourcesPermission()
- },
+ satelliteUrls = uiState.dataSourcesSettings.satelliteUrls,
+ transceiversUrls = uiState.dataSourcesSettings.transceiversUrls,
onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false },
onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false },
onDismiss = { dialogs.dataSources = false },
- onSave = { useCustomTle, useCustomTransceivers, tleUrl, transceiversUrl ->
- val current = uiState.dataSourcesSettings
- val newSettings = current.copy(
- useCustomTLE = if (!useCustomTle || tleUrl.isNotBlank()) useCustomTle else current.useCustomTLE,
- tleUrl = if (!useCustomTle || tleUrl.isNotBlank()) tleUrl else current.tleUrl,
- useCustomTransceivers = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) useCustomTransceivers else current.useCustomTransceivers,
- transceiversUrl = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) transceiversUrl else current.transceiversUrl
- )
- if (newSettings != current) onAction(SettingsAction.UpdateDataSources(newSettings))
- if (newSettings.useCustomTLE || newSettings.useCustomTransceivers) {
- onAction(SettingsAction.UpdateFromWeb)
- }
+ onSave = { satUrls, txUrls ->
+ val newSettings = DataSourcesSettings(satelliteUrls = satUrls, transceiversUrls = txUrls)
+ if (newSettings != uiState.dataSourcesSettings) onAction(SettingsAction.UpdateDataSources(newSettings))
+ onAction(SettingsAction.UpdateFromWeb)
}
)
}
@@ -156,14 +142,15 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
NetworkOutputDialog(
initialSettings = uiState.rcSettings,
onDismiss = { dialogs.network = false },
- onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt ->
+ onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt, freqOffsetHz ->
onAction(
SettingsAction.UpdateRC(
uiState.rcSettings.copy(
rotatorState = rotState, rotatorAddress = rotAddr,
rotatorPort = rotPort, rotatorFormat = rotFmt,
frequencyState = freqState, frequencyAddress = freqAddr,
- frequencyPort = freqPort, frequencyFormat = freqFmt
+ frequencyPort = freqPort, frequencyFormat = freqFmt,
+ frequencyOffsetHz = freqOffsetHz
)
)
)
diff --git a/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt
index 202cfcde..0f57a1de 100644
--- a/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt
+++ b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt
@@ -1,34 +1,25 @@
package com.rtbishop.look4sat.feature.status
-import androidx.compose.animation.core.RepeatMode
-import androidx.compose.animation.core.animateFloat
-import androidx.compose.animation.core.infiniteRepeatable
-import androidx.compose.animation.core.rememberInfiniteTransition
-import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
-import androidx.compose.foundation.Image
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.PaddingValues
-import androidx.compose.foundation.layout.navigationBarsPadding
-import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.ui.graphics.ColorFilter
-import androidx.compose.ui.res.painterResource
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.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
@@ -42,11 +33,15 @@ 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.draw.rotate
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
@@ -55,11 +50,15 @@ 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.IMainContainer
+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.feature.status.R as StatusR
+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.
@@ -68,37 +67,30 @@ import java.util.Calendar
private fun statusColorOf(statusText: String): Color {
return when {
statusText.contains("Heard", ignoreCase = true) && !statusText.contains("Not", ignoreCase = true) ->
- MaterialTheme.colorScheme.tertiary // Active
+ MaterialTheme.colorScheme.tertiary
statusText.contains("Telemetry", ignoreCase = true) || statusText.contains("Beacon", ignoreCase = true) ->
- MaterialTheme.colorScheme.tertiaryContainer // Telemetry
+ MaterialTheme.colorScheme.tertiaryContainer
statusText.contains("Not Heard", ignoreCase = true) ->
- Color(0xFFDC267F) // NotHeard pink (no semantic slot)
+ Color(0xFFDC267F)
else ->
- MaterialTheme.colorScheme.error // Conflict
+ MaterialTheme.colorScheme.error
}
}
-private val NoReportGray = Color(0xFFC0C0C0) // Neutral state
-
@Composable
-fun SatStatusScreen(container: IMainContainer) {
+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()
- var selectedDay by remember { mutableStateOf?>(null) }
+ SatStatusScreen(uiState) { viewModel.refresh() }
+}
- Column(
- modifier = Modifier
- .fillMaxSize()
- .statusBarsPadding()
- .navigationBarsPadding()
- .padding(horizontal = 8.dp)
- ) {
- // Top: legend + refresh
- StatusHeader(
- fetchedAtUtcMs = uiState.fetchedAtUtcMs,
- isRefreshing = uiState.isRefreshing,
- onRefresh = { viewModel.refresh() }
- )
+@Composable
+private fun SatStatusScreen(uiState: SatStatusUiState, refresh: () -> Unit) {
+ var selectedDay by remember { mutableStateOf?>(null) }
+ Column(modifier = Modifier.fillMaxSize().layoutPadding()) {
+ StatusHeader(fetchedAtUtcMs = uiState.fetchedAtUtcMs, isRefreshing = uiState.isRefreshing, onRefresh = refresh)
LegendRow()
when {
@@ -113,29 +105,16 @@ fun SatStatusScreen(container: IMainContainer) {
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
- Text(
- text = stringResource(id = R.string.amsat_load_failed),
- color = MaterialTheme.colorScheme.error
- )
- TextButton(onClick = { viewModel.refresh() }) {
- Text(text = stringResource(id = R.string.amsat_retry))
- }
+ 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 -> {
- // Header
HeaderRow(statuses = uiState.statuses)
HorizontalDivider(thickness = 1.dp)
- // Row
- LazyColumn(
- modifier = Modifier.fillMaxSize(),
- contentPadding = PaddingValues(bottom = 16.dp)
- ) {
+ LazyColumn(modifier = Modifier.fillMaxSize()) {
items(uiState.statuses, key = { it.name }) { status ->
- StatusRow(
- status = status,
- onClickDay = { day -> selectedDay = status to day }
- )
+ StatusRow(status = status, onClickDay = { day -> selectedDay = status to day })
}
}
}
@@ -143,56 +122,41 @@ fun SatStatusScreen(container: IMainContainer) {
}
selectedDay?.let { (status, day) ->
- ReportDialog(
- statusName = status.name,
- day = day,
- reports = uiState.reports,
- onDismiss = { selectedDay = null }
- )
+ 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
-) {
- val infiniteTransition = rememberInfiniteTransition(label = "refresh")
- val angle by infiniteTransition.animateFloat(
- initialValue = 0f,
- targetValue = 360f,
- animationSpec = infiniteRepeatable(tween(800), RepeatMode.Restart),
- label = "angle"
- )
+private fun StatusHeader(fetchedAtUtcMs: Long, isRefreshing: Boolean, onRefresh: () -> Unit) {
Row(
- modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
+ 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 = 13.sp,
+ 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(34.dp)
+ .size(32.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onRefresh),
contentAlignment = Alignment.Center
) {
if (isRefreshing) {
- CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
+ CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Image(
- painter = painterResource(id = StatusR.drawable.ic_refresh),
+ painter = painterResource(id = R.drawable.ic_refresh),
contentDescription = stringResource(id = R.string.amsat_refresh),
- modifier = Modifier
- .size(18.dp)
- .rotate(angle),
+ modifier = Modifier.size(16.dp),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary)
)
}
@@ -200,171 +164,164 @@ private fun StatusHeader(
}
}
-/** Legend (official four colors) */
+/** 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 MaterialTheme.colorScheme.tertiary,
- stringResource(id = R.string.amsat_tlm) to MaterialTheme.colorScheme.tertiaryContainer,
+ 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 MaterialTheme.colorScheme.error
+ stringResource(id = R.string.amsat_conflict) to Color(0xFFFE6100)
)
- Row(
- modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
- horizontalArrangement = Arrangement.spacedBy(8.dp)
+ FlowRow(
+ modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp),
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ verticalArrangement = Arrangement.spacedBy(6.dp)
) {
legend.forEach { (label, color) ->
- Row(verticalAlignment = Alignment.CenterVertically) {
+ 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(10.dp)
- .clip(RoundedCornerShape(2.dp))
+ .size(8.dp)
+ .clip(RoundedCornerShape(4.dp))
.background(color)
)
- Spacer(modifier = Modifier.width(3.dp))
- Text(text = label, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Spacer(modifier = Modifier.width(6.dp))
+ Text(
+ text = label,
+ fontSize = 14.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
}
}
}
}
-/** Header: satellite name + 6 day dates */
+/** Header: satellite name column + fixed-width date labels aligned to tiles. */
@Composable
private fun HeaderRow(statuses: List) {
val dates = statuses.firstOrNull()?.days?.map { it.dateLabel } ?: emptyList()
Row(
- modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
+ modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(id = R.string.amsat_name),
- fontSize = 11.sp,
- fontWeight = FontWeight.Bold,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
- modifier = Modifier.weight(2f).padding(start = 4.dp)
+ modifier = Modifier.weight(1f).padding(start = 4.dp)
)
dates.forEach { date ->
Text(
text = date,
- fontSize = 10.sp,
- fontWeight = FontWeight.Bold,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary,
- modifier = Modifier.weight(0.8f)
+ modifier = Modifier.width(TILE_WIDTH)
)
}
}
}
-/** Satellite row: name + 6 day color blocks (displays the newest non-gray status) */
+/** 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 = 3.dp),
+ modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = status.name,
- fontSize = 11.sp,
+ fontSize = 14.sp,
maxLines = 1,
- overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
- modifier = Modifier.weight(2f).padding(start = 4.dp)
+ 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.weight(0.8f).padding(horizontal = 1.dp),
+ 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 */
+/** 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(24.dp)
+ .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 = 11.sp,
- fontWeight = FontWeight.Bold,
- color = Color.White
- )
+ Text(text = slot.count.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
}
}
}
-/** Report detail dialog (3 levels: callsign/date/time/grid) */
+/** Report detail dialog (callsign / date / time / grid) */
@Composable
-private fun ReportDialog(
- statusName: String,
- day: SatDay,
- reports: Map,
- onDismiss: () -> Unit
-) {
- val dayReports = day.slots.flatMap { it.reportIds }
- .mapNotNull { reports[it] }
- AlertDialog(
- onDismissRequest = onDismiss,
- title = { Text(text = "$statusName · ${day.dateLabel}") },
- text = {
- if (dayReports.isEmpty()) {
- Text(stringResource(id = R.string.amsat_no_reports))
- } else {
- LazyColumn(modifier = Modifier.height(320.dp)) {
- items(dayReports) { report ->
- Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.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 = 13.sp,
- fontWeight = FontWeight.Bold
- )
- }
- Text(
- text = "${report.call} ${report.dateUtc} ${report.timeUtc}" +
- if (report.grid.isNotBlank() && report.grid != "-") " ${report.grid}" else "",
- fontSize = 12.sp,
- color = MaterialTheme.colorScheme.onSurfaceVariant
+private fun ReportDialog(statusName: String, day: SatDay, reports: Map, 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)
}
- HorizontalDivider(thickness = 0.5.dp)
+ 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)
}
}
- },
- confirmButton = {
- TextButton(onClick = onDismiss) { Text(stringResource(id = R.string.amsat_close)) }
}
- )
+ }
}
+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
- return "%02d-%02d %02d:%02d".format(
- cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH),
- cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE)
- )
-}
+ 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"
+}
\ No newline at end of file
diff --git a/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusViewModel.kt b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusViewModel.kt
index 70788fb3..49371011 100644
--- a/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusViewModel.kt
+++ b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusViewModel.kt
@@ -6,6 +6,7 @@ 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
@@ -22,7 +23,7 @@ data class SatStatusUiState(
)
class SatStatusViewModel(
- private val container: IMainContainer
+ private val amSatRepo: IAmSatRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true))
@@ -36,7 +37,7 @@ class SatStatusViewModel(
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
try {
- val page = container.amSatRepo.fetchStatus()
+ val page = amSatRepo.fetchStatus()
if (page != null && page.statuses.isNotEmpty()) {
_uiState.update {
it.copy(
@@ -65,7 +66,7 @@ class SatStatusViewModel(
_uiState.update { it.copy(isRefreshing = true, error = null) }
viewModelScope.launch {
try {
- val page = container.amSatRepo.fetchStatus()
+ val page = amSatRepo.fetchStatus()
if (page != null && page.statuses.isNotEmpty()) {
_uiState.update {
it.copy(
@@ -87,7 +88,7 @@ class SatStatusViewModel(
companion object {
fun factory(container: IMainContainer) = viewModelFactory {
initializer {
- SatStatusViewModel(container)
+ SatStatusViewModel(container.amSatRepo)
}
}
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 6f817fc0..206319d7 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
-appVersionCode = "443"
+appVersionCode = "446"
#noinspection UnusedVersionCatalogEntry
-appVersionName = "4.4.3"
+appVersionName = "4.4.6-ba7opf.1"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry
@@ -23,7 +23,7 @@ compose-activity = "1.13.0"
compose-lifecycle = "2.11.0"
compose-navigation3 = "1.1.5"
-google-ksp = "2.3.8"
+google-ksp = "2.3.10"
kotlin = "2.4.10"
kotlin-coroutines = "1.11.0"