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 + + + + + + Star History Chart + + 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"