Compare commits

..
37 changed files with 756 additions and 635 deletions

No files matched your search

+4 -4
View File
@@ -36,10 +36,10 @@ It is now and always will be completely ad-free and open-source.
## Star History ## Star History
<a href="https://www.star-history.com/?repos=rt-bishop%2FLook4Sat&type=timeline&legend=top-left"> <a href="https://star-history.dera.page/#rt-bishop/Look4Sat&type=timeline&legend=top-left">
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" /> <source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" /> <source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" /> <img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
</picture> </picture>
</a> </a>
@@ -49,7 +49,7 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
observeNightFilterState() observeNightFilterState()
setContent { setContent {
MainTheme(isDarkTheme = true) { MainScreen() } MainTheme(isDarkTheme = true) { NavRoot() }
} }
} }
@@ -25,8 +25,6 @@ import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
@@ -44,7 +42,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
@@ -70,6 +68,7 @@ import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
import com.rtbishop.look4sat.core.presentation.ElevationThresholds import com.rtbishop.look4sat.core.presentation.ElevationThresholds
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
@@ -82,37 +81,42 @@ import com.rtbishop.look4sat.feature.passes.PassesDestination
import com.rtbishop.look4sat.feature.radar.RadarDestination import com.rtbishop.look4sat.feature.radar.RadarDestination
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
import com.rtbishop.look4sat.feature.settings.SettingsDestination import com.rtbishop.look4sat.feature.settings.SettingsDestination
import com.rtbishop.look4sat.feature.status.SatStatusDestination
@Composable @Composable
fun NavRoot(deeplink: String? = null) { fun NavRoot(deeplink: String? = null) {
val rootBackStack = rememberNavBackStack(Screen.Passes) val rootBackStack = rememberNavBackStack(Screen.Passes)
val deeplinkResolver = DeeplinkResolver() val deeplinkResolver = DeeplinkResolver()
LaunchedEffect(deeplink) { LaunchedEffect(deeplink) {
deeplink?.let { deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
val destination = deeplinkResolver.resolve(it) // rootBackStack.clear()
rootBackStack.add(destination)
}
} }
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() } val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f) val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it }) // Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
slideOutHorizontally(tween(300)) { -it / 3 }
// Reverse: outgoing slides out to the right, incoming drifts in from the left
val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith
slideOutHorizontally(tween(300)) { it }
NavDisplay( NavDisplay(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
backStack = rootBackStack, backStack = rootBackStack,
onBack = navigateBack, onBack = navigateBack,
transitionSpec = { slideInTransition }, transitionSpec = { pushTransition },
popTransitionSpec = { slideOutTransition }, popTransitionSpec = { popTransition },
predictivePopTransitionSpec = { slideOutTransition }, predictivePopTransitionSpec = { popTransition },
entryDecorators = listOf( entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(), // Required for saving Compose state per entry rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator() // Required for ViewModel scoping per entry rememberViewModelStoreNavEntryDecorator()
), ),
entryProvider = entryProvider { entryProvider = entryProvider {
entry<Screen.Passes> { MainScreen(navigateToRadar = { rootBackStack.add(RadarDestination) }) } entry<Screen.Passes> { MainScreen(navigateToRadar = navigateToRadar) }
entry<RadarDestination> { entry<RadarDestination> {
Scaffold { innerPadding -> Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
RadarDestination(navigateUp = navigateBack) RadarDestination(navigateUp = navigateBack)
innerPadding.calculateTopPadding()
} }
} }
} }
@@ -120,18 +124,20 @@ fun NavRoot(deeplink: String? = null) {
} }
@Composable @Composable
fun MainScreen(navigateToRadar: () -> Unit = {}) { private fun MainScreen(navigateToRadar: () -> Unit = {}) {
val backStack = rememberNavBackStack(Screen.Passes)
val currentKey = backStack.lastOrNull()
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings)
val context = LocalContext.current val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle() val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle() val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
val backStack = rememberNavBackStack(Screen.Passes)
val currentKey = backStack.lastOrNull()
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
val fadeTransition = fadeIn(animationSpec = tween(300)) togetherWith
fadeOut(animationSpec = tween(300))
val navItems =
listOf(Screen.Satellites, Screen.Passes, Screen.Status, Screen.Map, Screen.Settings)
CompositionLocalProvider( CompositionLocalProvider(
LocalElevationThresholds provides ElevationThresholds( LocalElevationThresholds provides ElevationThresholds(
low = otherSettings.lowElevation, low = otherSettings.lowElevation,
@@ -144,13 +150,18 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
val isSelected = when (currentKey) { val isSelected = when (currentKey) {
is Screen.Satellites -> screen is Screen.Satellites is Screen.Satellites -> screen is Screen.Satellites
is Screen.Passes -> screen is Screen.Passes is Screen.Passes -> screen is Screen.Passes
is Screen.Radar -> screen is Screen.Radar is Screen.Status -> screen is Screen.Status
is Screen.Map -> screen is Screen.Map is Screen.Map -> screen is Screen.Map
is Screen.Settings -> screen is Screen.Settings is Screen.Settings -> screen is Screen.Settings
else -> false else -> false
} }
item( item(
icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) }, icon = {
Icon(
painter = painterResource(screen.iconResId),
contentDescription = stringResource(screen.titleResId)
)
},
label = { Text(stringResource(screen.titleResId)) }, label = { Text(stringResource(screen.titleResId)) },
selected = isSelected, selected = isSelected,
onClick = { onClick = {
@@ -179,9 +190,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
popTransitionSpec = { fadeTransition }, popTransitionSpec = { fadeTransition },
predictivePopTransitionSpec = { fadeTransition }, predictivePopTransitionSpec = { fadeTransition },
entryDecorators = listOf( entryDecorators = listOf(
// Required for saving Compose state per entry
rememberSaveableStateHolderNavEntryDecorator(), rememberSaveableStateHolderNavEntryDecorator(),
// Required for ViewModel scoping per entry
rememberViewModelStoreNavEntryDecorator() rememberViewModelStoreNavEntryDecorator()
), ),
entryProvider = entryProvider { entryProvider = entryProvider {
@@ -191,69 +200,71 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
entry<Screen.Passes> { entry<Screen.Passes> {
PassesDestination { catNum, aosTime -> PassesDestination { catNum, aosTime ->
container.satelliteRepo.selectPass(catNum, aosTime) container.satelliteRepo.selectPass(catNum, aosTime)
backStack.add(Screen.Radar) navigateToRadar()
// navigateToRadar()
} }
} }
entry<Screen.Radar> { entry<Screen.Status> { SatStatusDestination() }
RadarDestination(navigateUp = navigateBack) entry<Screen.Map> { MapDestination() }
} entry<Screen.Settings> { SettingsDestination() }
entry<Screen.Map> {
MapDestination()
}
entry<Screen.Settings> {
SettingsDestination()
}
} }
) )
// Radio tracking status banner
if (trackingState.isActive) { if (trackingState.isActive) {
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse") TrackingBanner(
val alpha by infiniteTransition.animateFloat( state = trackingState,
initialValue = 1f, targetValue = 0.4f, onClick = {
animationSpec = infiniteRepeatable( val pass = trackingState.currentPass
animation = tween(1000, easing = LinearEasing), if (pass != null) {
repeatMode = RepeatMode.Reverse container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
), label = "pulseAlpha" navigateToRadar()
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.primaryContainer)
.clickable {
val pass = trackingState.currentPass
if (pass != null) {
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
backStack.add(Screen.Radar)
}
} }
.padding(horizontal = 12.dp, vertical = 6.dp) }
) { )
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(Color(0xFF4CAF50).copy(alpha = alpha))
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Tracking: ${trackingState.currentPass?.name ?: ""}",
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.weight(1f)
)
val txOk = if (trackingState.txConnected) "TX" else ""
val rxOk = if (trackingState.rxConnected) "RX" else ""
Text(
text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
} }
} }
} }
} }
} }
@Composable
private fun TrackingBanner(state: RadioTrackingState, onClick: () -> Unit) {
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
val alpha by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 0.4f,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing), repeatMode = RepeatMode.Reverse
),
label = "pulseAlpha"
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.primaryContainer)
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 6.dp)
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(Color(0xFF4CAF50).copy(alpha = alpha))
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Tracking: ${state.currentPass?.name ?: ""}",
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.weight(1f)
)
val connections = listOfNotNull(
"TX".takeIf { state.txConnected }, "RX".takeIf { state.rxConnected }
)
Text(
text = connections.joinToString("/"),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
@@ -36,6 +36,7 @@ internal class ApplicationPlugin : Plugin<Project> {
implementation(project(":feature:radar")) implementation(project(":feature:radar"))
implementation(project(":feature:satellites")) implementation(project(":feature:satellites"))
implementation(project(":feature:settings")) implementation(project(":feature:settings"))
implementation(project(":feature:status"))
implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.core.splashscreen)
implementation(libs.compose.material3.adaptive) implementation(libs.compose.material3.adaptive)
implementation(libs.compose.navigation3) implementation(libs.compose.navigation3)
@@ -17,8 +17,10 @@
*/ */
package com.rtbishop.look4sat.core.data.framework package com.rtbishop.look4sat.core.data.framework
import com.rtbishop.look4sat.core.domain.model.Constants
import com.rtbishop.look4sat.core.domain.repository.IReporter import com.rtbishop.look4sat.core.domain.repository.IReporter
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
@@ -31,18 +33,30 @@ class NetworkReporter(
private val rotatorServer: String, private val rotatorServer: String,
private val rotatorPort: Int, private val rotatorPort: Int,
private val frequencyServer: String, private val frequencyServer: String,
private val frequencyPort: Int private val frequencyPort: Int,
private val frequencyOffsetHz: Long = 0L
) : IReporter { ) : IReporter {
private val writeMutex = Mutex() private val writeMutex = Mutex()
private val connectionMutex = Mutex()
private val frequencyCommands = Channel<String>(Channel.CONFLATED)
private var rotatorSocket: SocketChannel? = null private var rotatorSocket: SocketChannel? = null
private var rotatorConnected = false private var rotatorConnected = false
private var rotatorConnecting = false
private var frequencySocket: SocketChannel? = null private var frequencySocket: SocketChannel? = null
private var frequencyConnected = false private var frequencyConnected = false
private var frequencyConnecting = false
init {
// Keep only the latest frequency command to avoid stale backlog and effective lag.
reporterScope.launch {
for (command in frequencyCommands) {
ensureFrequencyConnected()
if (!frequencyConnected) continue
write(frequencySocket, command) { resetFrequencyConnection() }
}
}
}
override fun reportRotation(format: String, azimuth: Double, elevation: Double) { override fun reportRotation(format: String, azimuth: Double, elevation: Double) {
reporterScope.launch { reporterScope.launch {
@@ -58,46 +72,43 @@ class NetworkReporter(
} }
override fun reportFrequency(format: String, frequency: Long) { override fun reportFrequency(format: String, frequency: Long) {
reporterScope.launch { val clampedOffset = frequencyOffsetHz.coerceIn(
ensureFrequencyConnected() Constants.FREQ_OFFSET_MIN_HZ,
if (!frequencyConnected) return@launch Constants.FREQ_OFFSET_MAX_HZ
val command = format )
.replace($$"$FREQ", frequency.toString()) val correctedFreq = frequency.coerceAtLeast(0L).safeAdd(clampedOffset).coerceAtLeast(0L)
.unescapeControlChars() val command = format
write(frequencySocket, command) { frequencyConnected = false } .replace($$"$FREQ", correctedFreq.toString())
} .unescapeControlChars()
frequencyCommands.trySend(command)
} }
private fun ensureRotatorConnected() { private suspend fun ensureRotatorConnected() {
if (rotatorConnected || rotatorConnecting || rotatorServer.isBlank()) return connectionMutex.withLock {
reporterScope.launch { if (rotatorConnected || rotatorServer.isBlank()) return
try { try {
rotatorConnecting = true resetRotatorConnection()
rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort)) rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
rotatorConnected = true rotatorConnected = true
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort") println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
} catch (e: Exception) { } catch (e: Exception) {
println("NetworkReporter rotator connect error: ${e.message}") println("NetworkReporter rotator connect error: ${e.message}")
rotatorConnected = false resetRotatorConnection()
} finally {
rotatorConnecting = false
} }
} }
} }
private fun ensureFrequencyConnected() { private suspend fun ensureFrequencyConnected() {
if (frequencyConnected || frequencyConnecting || frequencyServer.isBlank()) return connectionMutex.withLock {
reporterScope.launch { if (frequencyConnected || frequencyServer.isBlank()) return
try { try {
frequencyConnecting = true resetFrequencyConnection()
frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort)) frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
frequencyConnected = true frequencyConnected = true
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort") println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
} catch (e: Exception) { } catch (e: Exception) {
println("NetworkReporter frequency connect error: ${e.message}") println("NetworkReporter frequency connect error: ${e.message}")
frequencyConnected = false resetFrequencyConnection()
} finally {
frequencyConnecting = false
} }
} }
} }
@@ -106,7 +117,9 @@ class NetworkReporter(
try { try {
writeMutex.withLock { writeMutex.withLock {
val buffer = ByteBuffer.wrap("$command\n".toByteArray()) val buffer = ByteBuffer.wrap("$command\n".toByteArray())
socket?.write(buffer) while (buffer.hasRemaining()) {
socket?.write(buffer)
}
} }
} catch (e: Exception) { } catch (e: Exception) {
println("NetworkReporter write error: ${e.message}") println("NetworkReporter write error: ${e.message}")
@@ -114,6 +127,32 @@ class NetworkReporter(
} }
} }
private fun resetRotatorConnection() {
rotatorConnected = false
closeQuietly(rotatorSocket)
rotatorSocket = null
}
private fun resetFrequencyConnection() {
frequencyConnected = false
closeQuietly(frequencySocket)
frequencySocket = null
}
private fun closeQuietly(socket: SocketChannel?) {
try {
socket?.close()
} catch (_: Exception) {}
}
private fun Long.safeAdd(delta: Long): Long {
return when {
delta > 0 && this > Long.MAX_VALUE - delta -> Long.MAX_VALUE
delta < 0 && this < Long.MIN_VALUE - delta -> Long.MIN_VALUE
else -> this + delta
}
}
private fun String.unescapeControlChars(): String = private fun String.unescapeControlChars(): String =
replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t") replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
} }
@@ -106,7 +106,8 @@ class MainContainer(private val context: Context) : IMainContainer {
rc.rotatorAddress, rc.rotatorAddress,
rc.rotatorPort.toIntOrNull() ?: 0, rc.rotatorPort.toIntOrNull() ?: 0,
rc.frequencyAddress, rc.frequencyAddress,
rc.frequencyPort.toIntOrNull() ?: 0 rc.frequencyPort.toIntOrNull() ?: 0,
rc.frequencyOffsetHz
) )
} }
@@ -35,13 +35,15 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) { override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) {
val nowSec = System.currentTimeMillis() / 1000 val nowSec = System.currentTimeMillis() / 1000
val catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext null 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 names = parseCatalog(catalogJson)
val reports = parseReports(reportsJson) val reports = parseReports(reportsJson)
if (names.isEmpty() && reports.isEmpty()) return@withContext null if (names.isEmpty() && reports.isEmpty()) return@withContext null
val statuses = buildStatuses(names, reports, nowSec) val statuses = buildStatuses(names, reports, nowSec)
val reportMap = reports.associate { it.id to toSatReport(it) } val reportMap = reports.associate { it.id to toSatReport(it) }
SatStatusPage(System.currentTimeMillis(), statuses, reportMap) SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
@@ -52,7 +54,7 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
return try { return try {
val arr = JSONObject(json).getJSONArray("data") val arr = JSONObject(json).getJSONArray("data")
(0 until arr.length()).map { arr.getJSONObject(it).getString("name") } (0 until arr.length()).map { arr.getJSONObject(it).getString("name") }
} catch (e: Exception) { } catch (_: Exception) {
emptyList() emptyList()
} }
} }
@@ -73,7 +75,7 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
reportedTimeUtcSec = parseIsoUtcSec(iso) reportedTimeUtcSec = parseIsoUtcSec(iso)
) )
} }
} catch (e: Exception) { } catch (_: Exception) {
emptyList() emptyList()
} }
} }
@@ -82,22 +84,22 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
private fun parseIsoUtcSec(iso: String): Long { private fun parseIsoUtcSec(iso: String): Long {
return try { return try {
(isoUtcFormat.parse(iso)?.time ?: 0L) / 1000 (isoUtcFormat.parse(iso)?.time ?: 0L) / 1000
} catch (e: Exception) { } catch (_: Exception) {
0L 0L
} }
} }
/** Build one SatStatus (6 days x 12 slots) per catalog satellite, slotting reports by age. */ /** Build one SatStatus (5 days x 12 slots) per catalog satellite, slotting reports by age. */
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> { private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
val byName = reports.groupBy { it.name } val byName = reports.groupBy { it.name }
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC")) 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 utc.timeInMillis = (nowSec - d * 86400L) * 1000
"${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}" "${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}"
} }
return names.map { name -> 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 slotStart = nowSec - (slotIdx + 1) * 7200L
val slotEnd = nowSec - slotIdx * 7200L val slotEnd = nowSec - slotIdx * 7200L
val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd } val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd }
@@ -112,7 +114,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)) SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
} }
SatStatus(name = name, days = days) SatStatus(name = name, days = days)
@@ -23,7 +23,6 @@ import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.source.ILocalSource import com.rtbishop.look4sat.core.domain.source.ILocalSource
import com.rtbishop.look4sat.core.domain.source.IRemoteSource import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.domain.utility.DataParser import com.rtbishop.look4sat.core.domain.utility.DataParser
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.async import kotlinx.coroutines.async
@@ -40,7 +39,6 @@ class DatabaseRepo(
private val settingsRepo: ISettingsRepo private val settingsRepo: ISettingsRepo
) : IDatabaseRepo { ) : IDatabaseRepo {
private val customSourceType = "Other"
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) { override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
var importedCount = 0 var importedCount = 0
@@ -65,18 +63,13 @@ class DatabaseRepo(
} }
override suspend fun updateFromRemote() = withContext(dispatcher) { override suspend fun updateFromRemote() = withContext(dispatcher) {
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value val settings = settingsRepo.dataSourcesSettings.value
val tleUrls = buildMap { fun normalizeUrl(url: String) = if (url.startsWith("http")) url else "https://$url"
putAll(Sources.satelliteDataUrls) val tleUrls = settings.satelliteUrls.filter { it.isNotBlank() }.map(::normalizeUrl)
if (dataSourcesSettings.useCustomTLE) put(customSourceType, dataSourcesSettings.tleUrl) val radioUrls = settings.transceiversUrls.filter { it.isNotBlank() }.map(::normalizeUrl)
}.filterValues { it.isNotBlank() }
val radioUrls = buildMap {
putAll(Sources.transceiversDataUrls)
if (dataSourcesSettings.useCustomTransceivers) put(customSourceType, dataSourcesSettings.transceiversUrl)
}.filterValues { it.isNotBlank() }
// launch all network requests concurrently // launch all network requests concurrently
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } } val tleJobs = tleUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } }
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } } val radioJobs = radioUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } }
// parse fetched data concurrently // parse fetched data concurrently
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) -> val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty() stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty()
@@ -29,8 +29,10 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.Constants
import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.domain.utility.positionToQth import com.rtbishop.look4sat.core.domain.utility.positionToQth
import com.rtbishop.look4sat.core.domain.utility.qthToPosition import com.rtbishop.look4sat.core.domain.utility.qthToPosition
import com.rtbishop.look4sat.core.domain.utility.round import com.rtbishop.look4sat.core.domain.utility.round
@@ -68,6 +70,7 @@ class SettingsRepo(
private val keyFrequencyAddress = "frequencyAddress" private val keyFrequencyAddress = "frequencyAddress"
private val keyFrequencyPort = "frequencyPort" private val keyFrequencyPort = "frequencyPort"
private val keyFrequencyFormat = "frequencyFormat" private val keyFrequencyFormat = "frequencyFormat"
private val keyFrequencyOffsetHz = "frequencyOffsetHz"
private val keySelectedIds = "selectedIds" private val keySelectedIds = "selectedIds"
private val keySelectedSatModes = "selectedSatModes" private val keySelectedSatModes = "selectedSatModes"
private val keyStateOfAutoUpdate = "stateOfAutoUpdate" private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
@@ -89,11 +92,10 @@ class SettingsRepo(
private val keyHighElevation = "highElevation" private val keyHighElevation = "highElevation"
private val keyRadarCompassOffset = "radarCompassOffset" private val keyRadarCompassOffset = "radarCompassOffset"
private val keyRadarCompassOffsetElev = "radarCompassOffsetElev" private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
private val keyUseCustomTle = "useCustomTle" private val keySatelliteUrls = "satelliteUrls"
private val keyUseCustomTransceivers = "useCustomTransceivers" private val keyTransceiversUrls = "transceiversUrls"
private val keyTleUrl = "tleUrl"
private val keyTransceiversUrl = "transceiversUrl"
private val separatorComma = "," private val separatorComma = ","
private val separatorUrl = "\n"
//region # Satellites selection settings //region # Satellites selection settings
private val _satelliteSelection = MutableStateFlow(getSelectedIds()) private val _satelliteSelection = MutableStateFlow(getSelectedIds())
@@ -277,6 +279,10 @@ class SettingsRepo(
override val rcSettings: StateFlow<RCSettings> = _rcSettings override val rcSettings: StateFlow<RCSettings> = _rcSettings
override fun updateRCSettings(settings: RCSettings) { override fun updateRCSettings(settings: RCSettings) {
val clampedFreqOffsetHz = settings.frequencyOffsetHz.coerceIn(
Constants.FREQ_OFFSET_MIN_HZ,
Constants.FREQ_OFFSET_MAX_HZ
)
preferences.edit { preferences.edit {
putBoolean(keyRotatorState, settings.rotatorState) putBoolean(keyRotatorState, settings.rotatorState)
putString(keyRotatorAddress, settings.rotatorAddress) putString(keyRotatorAddress, settings.rotatorAddress)
@@ -286,6 +292,7 @@ class SettingsRepo(
putString(keyFrequencyAddress, settings.frequencyAddress) putString(keyFrequencyAddress, settings.frequencyAddress)
putString(keyFrequencyPort, settings.frequencyPort) putString(keyFrequencyPort, settings.frequencyPort)
putString(keyFrequencyFormat, settings.frequencyFormat) putString(keyFrequencyFormat, settings.frequencyFormat)
putLong(keyFrequencyOffsetHz, clampedFreqOffsetHz)
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState) putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat) putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName) putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
@@ -294,7 +301,7 @@ class SettingsRepo(
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat) putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress) putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
} }
_rcSettings.value = settings _rcSettings.value = settings.copy(frequencyOffsetHz = clampedFreqOffsetHz)
} }
private fun getRCSettings(): RCSettings = RCSettings( private fun getRCSettings(): RCSettings = RCSettings(
@@ -306,6 +313,8 @@ class SettingsRepo(
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1", frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532", frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ", frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ",
frequencyOffsetHz = preferences.getLong(keyFrequencyOffsetHz, 0L)
.coerceIn(Constants.FREQ_OFFSET_MIN_HZ, Constants.FREQ_OFFSET_MAX_HZ),
bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false), bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL", bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default", bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
@@ -365,19 +374,19 @@ class SettingsRepo(
override fun updateDataSourcesSettings(settings: DataSourcesSettings) { override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
preferences.edit { preferences.edit {
putBoolean(keyUseCustomTle, settings.useCustomTLE) putString(keySatelliteUrls, settings.satelliteUrls.joinToString(separatorUrl))
putBoolean(keyUseCustomTransceivers, settings.useCustomTransceivers) putString(keyTransceiversUrls, settings.transceiversUrls.joinToString(separatorUrl))
putString(keyTleUrl, settings.tleUrl)
putString(keyTransceiversUrl, settings.transceiversUrl)
} }
_dataSourcesSettings.value = settings _dataSourcesSettings.value = settings
} }
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings( private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false), satelliteUrls = preferences.getString(keySatelliteUrls, null)
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false), ?.split(separatorUrl)?.filter { it.isNotBlank() }
tleUrl = preferences.getString(keyTleUrl, "https://example.com/tle.txt") ?: "", ?: Sources.satelliteDataUrls,
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: "" transceiversUrls = preferences.getString(keyTransceiversUrls, null)
?.split(separatorUrl)?.filter { it.isNotBlank() }
?: Sources.transceiversDataUrls
) )
//endregion //endregion
@@ -425,7 +434,7 @@ class SettingsRepo(
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}" val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
return try { return try {
JSONObject(json).optString(catnum.toString(), "") JSONObject(json).optString(catnum.toString(), "")
} catch (e: Exception) { } catch (_: Exception) {
"" ""
} }
} }
@@ -436,7 +445,7 @@ class SettingsRepo(
val obj = JSONObject(json) val obj = JSONObject(json)
if (offset.isEmpty()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), offset) if (offset.isEmpty()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), offset)
obj.toString() obj.toString()
} catch (e: Exception) { } catch (_: Exception) {
"""{"$catnum": "$offset"}""" """{"$catnum": "$offset"}"""
} }
preferences.edit { putString(keySatelliteOffsets, updated) } preferences.edit { putString(keySatelliteOffsets, updated) }
@@ -90,10 +90,8 @@ class DatabaseRepoTest {
} }
val settingsRepo = FakeSettingsRepo( val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings( dataSources = DataSourcesSettings(
useCustomTLE = true, satelliteUrls = listOf(customCsvUrl),
useCustomTransceivers = false, transceiversUrls = emptyList()
tleUrl = customCsvUrl,
transceiversUrl = ""
) )
) )
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo) val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
@@ -122,6 +120,10 @@ private class FakeRemoteSource : IRemoteSource {
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke() override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke() override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke()
override suspend fun getAmSatCatalog(): String? = null
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
} }
private class FakeLocalSource : ILocalSource { private class FakeLocalSource : ILocalSource {
@@ -174,7 +176,7 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L)) override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow( override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
RCSettings(false, "", "", "", false, "", "", "", false, "", "", "", false, "", "") RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
) )
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow( override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
@@ -221,9 +223,7 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
private fun defaultDataSourcesSettings(): DataSourcesSettings { private fun defaultDataSourcesSettings(): DataSourcesSettings {
return DataSourcesSettings( return DataSourcesSettings(
useCustomTLE = false, satelliteUrls = emptyList(),
useCustomTransceivers = false, transceiversUrls = emptyList()
tleUrl = "",
transceiversUrl = ""
) )
} }
@@ -127,7 +127,7 @@ class SelectionRepoTest {
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L)) override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow( override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
RCSettings(false, "", "", "", false, "", "", "", false, "", "", "", false, "", "") RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
) )
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow( override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
@@ -135,7 +135,7 @@ class SelectionRepoTest {
) )
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow( override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
DataSourcesSettings(false, false, "", "") DataSourcesSettings(emptyList(), emptyList())
) )
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow( override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
@@ -0,0 +1,23 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.model
object Constants {
const val FREQ_OFFSET_MIN_HZ = -50_000L
const val FREQ_OFFSET_MAX_HZ = 50_000L
}
@@ -23,10 +23,10 @@ data class SatDay(
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24) val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
) )
/** One satellite, 6 days of state */ /** One satellite, 5 days of state */
data class SatStatus( data class SatStatus(
val name: String, // "AO-123_[FM]" val name: String, // "AO-123_[FM]"
val days: List<SatDay> // 6 天(新→旧) val days: List<SatDay> // 5 天(新→旧)
) )
/** Overall page parse result */ /** Overall page parse result */
@@ -41,6 +41,7 @@ data class RCSettings(
val frequencyAddress: String, val frequencyAddress: String,
val frequencyPort: String, val frequencyPort: String,
val frequencyFormat: String, val frequencyFormat: String,
val frequencyOffsetHz: Long = 0L,
val bluetoothRotatorState: Boolean, val bluetoothRotatorState: Boolean,
val bluetoothRotatorFormat: String, val bluetoothRotatorFormat: String,
val bluetoothRotatorName: String, val bluetoothRotatorName: String,
@@ -67,10 +68,8 @@ data class OtherSettings(
) )
data class DataSourcesSettings( data class DataSourcesSettings(
val useCustomTLE: Boolean, val satelliteUrls: List<String>,
val useCustomTransceivers: Boolean, val transceiversUrls: List<String>
val tleUrl: String,
val transceiversUrl: String
) )
data class RadioControlSettings( data class RadioControlSettings(
@@ -18,18 +18,17 @@
package com.rtbishop.look4sat.core.domain.source package com.rtbishop.look4sat.core.domain.source
object Sources { object Sources {
val satelliteDataUrls = mapOf( val satelliteDataUrls = listOf(
"CelesTrak" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv", "celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"AMSAT" to "https://amsat.org/tle/current/nasabare.txt", "db.satnogs.org/api/tle/?format=3le",
"McCants Classified" to "https://www.mmccants.org/tles/classfd.zip", "amsat.org/tle/current/nasabare.txt",
"McCants Integrated" to "https://www.mmccants.org/tles/inttles.zip", "mmccants.org/tles/classfd.zip",
"R4UAB" to "https://r4uab.ru/satonline.txt", "r4uab.ru/satonline.txt",
"SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le", "live.ariss.org/iss.txt"
"ARISS" to "https://live.ariss.org/iss.txt",
"Other" to "" // key for sats filter
) )
val transceiversDataUrls = mapOf( val transceiversDataUrls = listOf(
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active" "db.satnogs.org/api/transmitters/?format=json&status=active",
"r4uab.ru/transmitters.json"
) )
val satelliteModes = listOf( val satelliteModes = listOf(
"4FSK", "64-QAM", "AFSK", "AFSK TUBiX10", "AHRPT", "AM", "APT", "ASK", "BPSK", "4FSK", "64-QAM", "AFSK", "AFSK TUBiX10", "AHRPT", "AM", "APT", "ASK", "BPSK",
@@ -41,6 +41,21 @@ fun Double.round(decimals: Int): Double {
return kotlin.math.round(this * multiplier) / multiplier return kotlin.math.round(this * multiplier) / multiplier
} }
fun String.aprsPasscode(): Int {
val callsign = this.trim().uppercase().substringBefore('-') // commonly strip SSID
var hash = 0x73E2
var i = 0
while (i < callsign.length) {
hash = hash xor (callsign[i].code shl 8)
i++
if (i < callsign.length) {
hash = hash xor callsign[i].code
i++
}
}
return hash and 0x7FFF
}
//fun String.getHash(type: String = "SHA-256"): String { //fun String.getHash(type: String = "SHA-256"): String {
// val hexChars = "0123456789ABCDEF" // val hexChars = "0123456789ABCDEF"
// val bytes = MessageDigest.getInstance(type).digest(this.toByteArray()) // val bytes = MessageDigest.getInstance(type).digest(this.toByteArray())
@@ -18,6 +18,7 @@
package com.rtbishop.look4sat.core.domain package com.rtbishop.look4sat.core.domain
import com.rtbishop.look4sat.core.domain.utility.DataParser import com.rtbishop.look4sat.core.domain.utility.DataParser
import com.rtbishop.look4sat.core.domain.utility.aprsPasscode
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -239,4 +240,10 @@ class DataParserTest {
// Matches the CSV test data epoch: 2021-11-16 → day 320 // Matches the CSV test data epoch: 2021-11-16 → day 320
assert(dataParser.getDayOfYear(2021, 11, 16) == 320) assert(dataParser.getDayOfYear(2021, 11, 16) == 320)
} }
@Test
fun `check APRS passcode calculation`() {
assert("M7LNB".aprsPasscode() == 12443)
assert("N0CALL".aprsPasscode() == 13023)
}
} }
@@ -27,11 +27,15 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -47,7 +51,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -73,6 +76,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
@@ -234,8 +239,13 @@ fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier)
} }
@Composable @Composable
fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier, enabled: Boolean = true) { fun IconCard(
ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action) { action: () -> Unit, resId: Int, modifier: Modifier = Modifier,
enabled: Boolean = true, containerColor: Color = Color.Unspecified
) {
val colors = if (containerColor == Color.Unspecified) CardDefaults.elevatedCardColors()
else CardDefaults.elevatedCardColors(containerColor = containerColor)
ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action, colors = colors) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier) Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier)
} }
@@ -291,52 +301,81 @@ fun getDefaultPass(): OrbitalPass = OrbitalPass(
) )
@Composable @Composable
fun SharedDialog( fun InfoDialog(
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
) {
SharedDialog(title = title, onDismissRequest = onCancel, onCancel = onCancel, onAccept = onAccept) { _ ->
content()
}
}
@Composable
fun SharedDialog(
title: String, title: String,
onDismissRequest: () -> Unit, onDismiss: () -> Unit,
onCancel: (() -> Unit)? = null, onAccept: () -> Unit,
onAccept: (() -> Unit)? = null, content: @Composable () -> Unit
titleFontSize: Int = 16,
titleTextAlign: TextAlign = if (onCancel != null && onAccept != null) TextAlign.Center else TextAlign.Start,
content: @Composable (padding: Dp) -> Unit
) { ) {
val dismissible = onCancel != null DialogShell(onDismissRequest = onDismiss) { padding ->
DialogShell(onDismissRequest = onDismissRequest, dismissible = dismissible) { padding ->
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = padding, top = padding, end = padding) .padding(start = padding, top = padding, end = padding)
) { ) {
if (onCancel != null) {
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
}
Text( Text(
text = title, text = title,
fontSize = titleFontSize.sp, fontSize = 16.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
textAlign = titleTextAlign, maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
}
content()
}
}
@Composable
fun ConfirmDialog(
title: String,
onCancel: () -> Unit,
onAccept: () -> Unit,
content: @Composable () -> Unit
) {
DialogShell(onDismissRequest = onCancel) { padding ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(start = padding, top = padding, end = padding)
) {
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
Text(
text = title,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.padding(horizontal = if (onCancel != null && onAccept != null) padding else 0.dp) .padding(horizontal = padding)
) )
if (onAccept != null) { CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
}
} }
content(padding) content()
}
}
@Composable
fun WhatsNewDialog(onDismiss: () -> Unit) {
InfoDialog(
title = stringResource(R.string.pass_whatsnew_title),
onDismiss = onDismiss,
onAccept = onDismiss
) {
Text(
text = stringResource(R.string.pass_whatsnew_message),
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
Spacer(modifier = Modifier.height(0.dp))
} }
} }
@@ -344,14 +383,13 @@ fun SharedDialog(
@Composable @Composable
private fun DialogShell( private fun DialogShell(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
dismissible: Boolean = true,
content: @Composable (padding: Dp) -> Unit content: @Composable (padding: Dp) -> Unit
) { ) {
val padding = LocalSpacing.current.large val padding = LocalSpacing.current.large
val sheetState = rememberModalBottomSheetState( val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
skipPartiallyExpanded = true, val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
confirmValueChange = { if (dismissible) true else it != SheetValue.Hidden } val containerHeight = with(LocalDensity.current) { LocalWindowInfo.current.containerSize.height.toDp() }
) val maxSheetHeight = containerHeight - statusBarHeight
val stopSheetFling = remember { val stopSheetFling = remember {
object : NestedScrollConnection { object : NestedScrollConnection {
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
@@ -366,10 +404,10 @@ private fun DialogShell(
} }
} }
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = if (dismissible) onDismissRequest else { {} }, onDismissRequest = onDismissRequest,
sheetState = sheetState, sheetState = sheetState,
dragHandle = null, dragHandle = null,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
scrimColor = Color.Black.copy(alpha = 0.64f) scrimColor = Color.Black.copy(alpha = 0.64f)
) { ) {
Column( Column(
@@ -377,6 +415,7 @@ private fun DialogShell(
verticalArrangement = Arrangement.spacedBy(padding), verticalArrangement = Arrangement.spacedBy(padding),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(max = maxSheetHeight)
.nestedScroll(stopSheetFling) .nestedScroll(stopSheetFling)
) { ) {
content(padding) content(padding)
@@ -24,13 +24,13 @@ import kotlinx.serialization.Serializable
sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey { sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
@Serializable @Serializable
data object Satellites : Screen(R.drawable.ic_satellites, R.string.nav_sat) data object Satellites : Screen(R.drawable.ic_sputnik, R.string.nav_sat)
@Serializable @Serializable
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass) data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
@Serializable @Serializable
data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar) data object Status : Screen(R.drawable.ic_satellite, R.string.nav_status)
@Serializable @Serializable
data object Map : Screen(R.drawable.ic_map, R.string.nav_map) data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z" />
</vector>
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80L520,80L520,411Q538,422 549,439.5Q560,457 560,480Q560,513 536.5,536.5Q513,560 480,560Q447,560 423.5,536.5Q400,513 400,480Q400,457 411,439Q422,421 440,411L440,325Q388,339 354,381.5Q320,424 320,480Q320,546 367,593Q414,640 480,640Q546,640 593,593Q640,546 640,480Q640,444 625.5,413.5Q611,383 586,360L643,303Q678,336 699,381.5Q720,427 720,480Q720,580 650,650Q580,720 480,720Q380,720 310,650Q240,580 240,480Q240,390 297,323.5Q354,257 440,243L440,162Q321,177 240.5,267Q160,357 160,480Q160,614 253,707Q346,800 480,800Q614,800 707,707Q800,614 800,480Q800,411 773,351Q746,291 699,247L756,190Q813,245 846.5,319.5Q880,394 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880Z" />
</vector>
@@ -1,12 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Standard Material refresh icon (24dp viewport), centered geometry -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24" android:viewportHeight="24">
>
<path <path
android:fillColor="#FF000000" android:fillColor="@android:color/white"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z" /> android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z" />
</vector> </vector>
@@ -0,0 +1,31 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="128"
android:viewportHeight="128">
<path
android:pathData="m105.14,114.12a2.86,2.86 90,0 0,1.7 -0.85l19.33,-19.33a2.86,2.86 90,0 0,-0 -4.03l-26.58,-26.62a2.86,2.86 90,0 0,-1.84 -0.81l-7.92,-0.63 5.77,-5.77a2.86,2.86 90,0 0,-0 -4.03l-19.6,-19.64a2.86,2.86 90,0 0,-4.07 -0l-5.77,5.77 -0.63,-7.92a2.86,2.86 90,0 0,-0.81 -1.83l-26.63,-26.58a2.86,2.86 90,0 0,-4.07 -0l-19.29,19.29a2.86,2.86 90,0 0,-0 4.07l26.58,26.62a2.86,2.86 90,0 0,1.83 0.81l7.92,0.63 -5.77,5.77a2.86,2.86 90,0 0,-0 4.07l19.6,19.6a2.86,2.86 90,0 0,4.07 -0l5.77,-5.77 0.63,7.92a2.86,2.86 90,0 0,0.81 1.83l26.63,26.58a2.86,2.86 90,0 0,2.06 0.85,2.86 2.86,90 0,0 0.27,-0zM104.82,107.23 L80.97,83.38 80.16,72.69 85.58,67.27 96.28,68.12 120.13,91.93zM55.33,47.85 L44.63,47 20.78,23.19 36.08,7.89 59.89,31.74 60.74,42.44z"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="evenOdd"/>
<path
android:pathData="m62.65,115.81a2.86,2.86 90,1 0,-0 -5.73c-11.46,-0 -22.88,-4.35 -31.63,-13.11 -8.74,-8.74 -13.1,-20.17 -13.1,-31.61a2.86,2.86 90,1 0,-5.73 -0c-0,12.9 4.94,25.83 14.77,35.66 9.85,9.85 22.77,14.78 35.68,14.78z"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="evenOdd"/>
<path
android:pathData="m62.65,127a2.86,2.86 90,1 0,-0 -5.73c-14.32,-0 -28.61,-5.44 -39.55,-16.37 -10.93,-10.93 -16.38,-25.23 -16.38,-39.54a2.86,2.86 90,1 0,-5.73 -0c-0,15.76 6.03,31.56 18.06,43.59 12.03,12.03 27.82,18.05 43.6,18.05z"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="evenOdd"/>
<path
android:pathData="m62.65,104.7a2.86,2.86 90,1 0,-0 -5.73c-8.62,-0 -17.2,-3.26 -23.78,-9.84 -6.58,-6.58 -9.86,-15.17 -9.86,-23.77a2.86,2.86 90,1 0,-5.73 -0c-0,10.06 3.86,20.15 11.53,27.82 7.67,7.67 17.76,11.52 27.83,11.52z"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="evenOdd"/>
<path
android:pathData="m62.65,93.48a2.86,2.86 90,1 0,-0 -5.73c-5.74,-0 -11.45,-2.17 -15.84,-6.56 -4.38,-4.38 -6.56,-10.1 -6.56,-15.83a2.86,2.86 90,1 0,-5.73 -0c-0,7.19 2.76,14.41 8.24,19.88 5.48,5.48 12.69,8.23 19.89,8.23z"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="evenOdd"/>
</vector>
@@ -12,6 +12,7 @@
<string name="nav_radar">Radar</string> <string name="nav_radar">Radar</string>
<string name="nav_map">Map</string> <string name="nav_map">Map</string>
<string name="nav_prefs">Settings</string> <string name="nav_prefs">Settings</string>
<string name="nav_status" translatable="false">AMSAT</string>
<!-- Satellites screen --> <!-- Satellites screen -->
<string name="sat_type_hint">Modes: %s</string> <string name="sat_type_hint">Modes: %s</string>
@@ -55,16 +56,13 @@
\n\nPlease update the database at least weekly to get accurate predictions.</string> \n\nPlease update the database at least weekly to get accurate predictions.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string> <string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false"> <string name="pass_whatsnew_message" translatable="false">
* Added small tweaks to Sources and strings. Added official ISS source (#225) * Added AMSAT status tracking page by MCKero6423 (#234)
\n* Implemented AOS window and elevation highlight filters (#226) \n* Tweaked Radar track on reposition by atsunatsu (#235)
\n* Added cleartext traffic support for custom TLE URLs (#227) \n* Tweaked linear transponder calc by atsunatsu (#236)
\n* Fixed manual OMM (.csv) data import, tweaked error messaging (#228) \n* Added saving per-sat doppler offset by atsunatsu (#237)
\n* Added support for Icom IC-705 CAT by ruilvo (#229) \n* Added custom freq offset setting to network reporting
\n* Added configurable Radar offset to the sensors output by qwqtoday (#230) \n* Added ability to customize data sources via import
\n* Localized pass date and time formats for Chinese by atsunatsu (#231) \n* Fixed star chart in README by PingouinFerreux (#240)
\n* Added linear transponder Doppler calculator by atsunatsu (#232)
\n* Added a few tweaks to SSTV sensitivity and reception, similar to Robot36
\n* Replaced the types selection dialog with modes selection to avoid confusion
</string> </string>
<!-- AMSAT Status screen --> <!-- AMSAT Status screen -->
@@ -171,10 +169,7 @@
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string> <string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string> <string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
<string name="prefs_data_sources_title">Custom sources</string> <string name="prefs_data_sources_title">Data sources</string>
<string name="prefs_data_sources_tle_switch">TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Transceivers URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_output_title">Data output</string> <string name="prefs_data_output_title">Data output</string>
<string name="prefs_net_output">Network</string> <string name="prefs_net_output">Network</string>
@@ -198,6 +193,8 @@
<string name="prefs_net_frequency_switch">Enable frequency output</string> <string name="prefs_net_frequency_switch">Enable frequency output</string>
<string name="prefs_net_frequency_address_hint">IP:Port</string> <string name="prefs_net_frequency_address_hint">IP:Port</string>
<string name="prefs_net_frequency_format_hint">Format</string> <string name="prefs_net_frequency_format_hint">Format</string>
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string>
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
<string name="prefs_bt_title">Bluetooth output</string> <string name="prefs_bt_title">Bluetooth output</string>
<string name="prefs_bt_rotator_switch">Enable rotation output</string> <string name="prefs_bt_rotator_switch">Enable rotation output</string>
@@ -1,8 +1,7 @@
* Added ARISS source, tweaked SSTV sensitivity #225 * Added AMSAT status tracking page by MCKero6423 (#234)
* Implemented AOS window and elev filter #226 * Tweaked Radar track on reposition by atsunatsu (#235)
* Added cleartext traffic support for TLEs #227 * Tweaked linear transponder calc by atsunatsu (#236)
* Fixed OMM import, tweaked error messages #228 * Added saving per-sat doppler offset by atsunatsu (#237)
* Added support for Icom CAT by ruilvo #229 * Added custom freq offset setting to network reporting
* Added tweakable Radar offset by qwqtoday #230 * Added ability to customize data sources via import
* Localized date/time formats by atsunatsu #231 * Fixed star chart in README by PingouinFerreux (#240)
* Added linear radio Doppler calc by atsunatsu #232
@@ -58,7 +58,7 @@ import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.presentation.LocalSpacing import com.rtbishop.look4sat.core.presentation.LocalSpacing
import com.rtbishop.look4sat.core.presentation.MainTheme import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog import com.rtbishop.look4sat.core.presentation.ConfirmDialog
import com.rtbishop.look4sat.core.presentation.ElevationHighColor import com.rtbishop.look4sat.core.presentation.ElevationHighColor
import com.rtbishop.look4sat.core.presentation.ElevationLowColor import com.rtbishop.look4sat.core.presentation.ElevationLowColor
import com.rtbishop.look4sat.core.presentation.elevationColor import com.rtbishop.look4sat.core.presentation.elevationColor
@@ -140,7 +140,7 @@ internal fun PassesFilterDialog(
) )
cancel() cancel()
} }
SharedDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) { ConfirmDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) {
SliderRow( SliderRow(
title = stringResource(R.string.pass_filter_elev), title = stringResource(R.string.pass_filter_elev),
value = elevationValueNew.doubleValue, value = elevationValueNew.doubleValue,
@@ -365,7 +365,7 @@ internal fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List
selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode
} }
val onAccept = { accept(selected.value.toList()).also { cancel() } } val onAccept = { accept(selected.value.toList()).also { cancel() } }
SharedDialog(title = stringResource(R.string.pass_modes_title), onCancel = cancel, onAccept = onAccept) { ConfirmDialog(title = stringResource(R.string.pass_modes_title), onCancel = cancel, onAccept = onAccept) {
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Adaptive(240.dp), columns = GridCells.Adaptive(240.dp),
modifier = Modifier modifier = Modifier
@@ -30,7 +30,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@@ -72,10 +71,10 @@ import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.NextPassRow import com.rtbishop.look4sat.core.presentation.NextPassRow
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.ScreenColumn import com.rtbishop.look4sat.core.presentation.ScreenColumn
import com.rtbishop.look4sat.core.presentation.SharedDialog
import com.rtbishop.look4sat.core.presentation.SwipeableItem import com.rtbishop.look4sat.core.presentation.SwipeableItem
import com.rtbishop.look4sat.core.presentation.TimerRow import com.rtbishop.look4sat.core.presentation.TimerRow
import com.rtbishop.look4sat.core.presentation.TopBar import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.WhatsNewDialog
import com.rtbishop.look4sat.core.presentation.elevationColor import com.rtbishop.look4sat.core.presentation.elevationColor
import com.rtbishop.look4sat.core.presentation.infiniteMarquee import com.rtbishop.look4sat.core.presentation.infiniteMarquee
import com.rtbishop.look4sat.core.presentation.isVerticalLayout import com.rtbishop.look4sat.core.presentation.isVerticalLayout
@@ -136,20 +135,7 @@ private fun PassesScreen(
} }
if (uiState.shouldSeeWhatsNew) { if (uiState.shouldSeeWhatsNew) {
val dismiss = { onAction(PassesAction.DismissWhatsNew) } val dismiss = { onAction(PassesAction.DismissWhatsNew) }
SharedDialog( WhatsNewDialog(onDismiss = dismiss)
title = stringResource(R.string.pass_whatsnew_title),
onDismissRequest = dismiss,
onAccept = dismiss,
titleFontSize = 18
) { padding ->
Text(
text = stringResource(R.string.pass_whatsnew_message),
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = padding)
)
Spacer(modifier = Modifier.height(0.dp))
}
} }
ScreenColumn( ScreenColumn(
topBar = { isVerticalLayout -> topBar = { isVerticalLayout ->
@@ -53,10 +53,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.presentation.ConfirmDialog
import com.rtbishop.look4sat.core.presentation.IconCard import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.OutlinedText import com.rtbishop.look4sat.core.presentation.OutlinedText
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog
import com.rtbishop.look4sat.core.presentation.infiniteMarquee import com.rtbishop.look4sat.core.presentation.infiniteMarquee
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -96,14 +96,14 @@ internal fun SstvPage(
if (showModeDialog.value) { if (showModeDialog.value) {
val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes } val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes }
val dismiss = { showModeDialog.value = false } val dismiss = { showModeDialog.value = false }
SharedDialog( ConfirmDialog(
title = "SSTV Mode", title = "SSTV Mode",
onDismissRequest = dismiss, onAccept = dismiss,
onAccept = dismiss onCancel = dismiss
) { _ -> ) {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxHeight(0.7f) .fillMaxHeight(0.69f)
.background(MaterialTheme.colorScheme.background), .background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.spacedBy(1.dp) verticalArrangement = Arrangement.spacedBy(1.dp)
) { ) {
@@ -39,9 +39,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.rtbishop.look4sat.core.presentation.ConfirmDialog
import com.rtbishop.look4sat.core.presentation.MainTheme import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
@@ -59,7 +59,7 @@ internal fun MultiModesDialog(
selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode
} }
val onAccept = { accept(selected.value.toList()) } val onAccept = { accept(selected.value.toList()) }
SharedDialog(title = stringResource(R.string.sat_type_title), onCancel = cancel, onAccept = onAccept) { ConfirmDialog(title = stringResource(R.string.sat_type_title), onCancel = cancel, onAccept = onAccept) {
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Adaptive(240.dp), columns = GridCells.Adaptive(240.dp),
modifier = Modifier modifier = Modifier
@@ -62,10 +62,11 @@ import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.CardLoadingIndicator import com.rtbishop.look4sat.core.presentation.CardLoadingIndicator
import com.rtbishop.look4sat.core.presentation.EmptyListCard import com.rtbishop.look4sat.core.presentation.EmptyListCard
import com.rtbishop.look4sat.core.presentation.IconCard import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.InfoDialog
import com.rtbishop.look4sat.core.presentation.LocalSpacing
import com.rtbishop.look4sat.core.presentation.MainTheme import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.PrimaryIconCard import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog
import com.rtbishop.look4sat.core.presentation.TopBar import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.infiniteMarquee import com.rtbishop.look4sat.core.presentation.infiniteMarquee
import com.rtbishop.look4sat.core.presentation.isVerticalLayout import com.rtbishop.look4sat.core.presentation.isVerticalLayout
@@ -96,17 +97,16 @@ private fun SatellitesScreen(
} }
if (uiState.shouldSeeWarning) { if (uiState.shouldSeeWarning) {
val dismiss = { onAction(SatellitesAction.DismissWarning) } val dismiss = { onAction(SatellitesAction.DismissWarning) }
SharedDialog( InfoDialog(
title = stringResource(R.string.sat_warning_title), title = stringResource(R.string.sat_warning_title),
onDismissRequest = dismiss, onDismiss = dismiss,
onAccept = dismiss, onAccept = dismiss
titleFontSize = 18 ) {
) { padding ->
Text( Text(
text = stringResource(R.string.sat_warning_message), text = stringResource(R.string.sat_warning_message),
fontSize = 16.sp, fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = padding) modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
) )
Spacer(modifier = Modifier.height(0.dp)) Spacer(modifier = Modifier.height(0.dp))
} }
@@ -17,26 +17,40 @@
*/ */
package com.rtbishop.look4sat.feature.settings package com.rtbishop.look4sat.feature.settings
import androidx.compose.animation.core.spring
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -45,11 +59,13 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.model.RCSettings import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.Constants
import com.rtbishop.look4sat.core.presentation.CardButton import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.LocalSpacing import com.rtbishop.look4sat.core.presentation.LocalSpacing
import com.rtbishop.look4sat.core.presentation.MainTheme import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog import com.rtbishop.look4sat.core.presentation.ConfirmDialog
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
@@ -63,7 +79,7 @@ fun PositionDialog(lat: Double, lon: Double, dismiss: () -> Unit, save: (Double,
val lonValue = rememberSaveable { mutableStateOf(lon.toString()) } val lonValue = rememberSaveable { mutableStateOf(lon.toString()) }
val titleText = stringResource(id = R.string.prefs_station_title) val titleText = stringResource(id = R.string.prefs_station_title)
val onAccept = { saveValues(latValue.value, lonValue.value, save).also { dismiss() } } val onAccept = { saveValues(latValue.value, lonValue.value, save).also { dismiss() } }
SharedDialog(title = titleText, onCancel = dismiss, onAccept = onAccept) { ConfirmDialog(title = titleText, onCancel = dismiss, onAccept = onAccept) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier modifier = Modifier
@@ -107,7 +123,7 @@ private fun LocatorDialogPreview() {
fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Unit) { fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Unit) {
val locator = rememberSaveable { mutableStateOf(qthLocator) } val locator = rememberSaveable { mutableStateOf(qthLocator) }
val onAccept = { save(locator.value).also { dismiss() } } val onAccept = { save(locator.value).also { dismiss() } }
SharedDialog( ConfirmDialog(
title = stringResource(R.string.prefs_locator_title), title = stringResource(R.string.prefs_locator_title),
onCancel = dismiss, onCancel = dismiss,
onAccept = onAccept onAccept = onAccept
@@ -129,133 +145,144 @@ fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Uni
private fun TransceiversDialogPreview() { private fun TransceiversDialogPreview() {
MainTheme { MainTheme {
DataSourcesDialog( DataSourcesDialog(
useCustomTle = true, satelliteUrls = listOf(
useCustomTransceivers = true, "celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
tleUrl = "https://example.com/tle.txt", "amsat.org/tle/current/nasabare.txt"
transceiversUrl = "https://example.com/tx.json", ),
requestCustomSourcesPermission = { onGranted, _ -> onGranted() }, transceiversUrls = listOf(
"db.satnogs.org/api/transmitters/?format=json&status=active"
),
onImportTle = {}, onImportTle = {},
onImportTransceivers = {}, onImportTransceivers = {},
onDismiss = {}, onDismiss = {},
onSave = { _, _, _, _ -> } onSave = { _, _ -> }
) )
} }
} }
@Composable @Composable
fun DataSourcesDialog( fun DataSourcesDialog(
useCustomTle: Boolean, satelliteUrls: List<String>,
useCustomTransceivers: Boolean, transceiversUrls: List<String>,
tleUrl: String,
transceiversUrl: String,
requestCustomSourcesPermission: (onGranted: () -> Unit, onDenied: () -> Unit) -> Unit,
onImportTle: () -> Unit, onImportTle: () -> Unit,
onImportTransceivers: () -> Unit, onImportTransceivers: () -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSave: (Boolean, Boolean, String, String) -> Unit onSave: (List<String>, List<String>) -> Unit
) { ) {
val padding = LocalSpacing.current.large val padding = LocalSpacing.current.large
val isEnabledCustomTle = rememberSaveable { mutableStateOf(useCustomTle) } // Use stable Long IDs to avoid key collisions (e.g. multiple empty "" entries).
val isEnabledCustomTransceivers = rememberSaveable { mutableStateOf(useCustomTransceivers) } val nextId = remember { mutableLongStateOf((satelliteUrls.size + transceiversUrls.size).toLong()) }
val urlTle = rememberSaveable { mutableStateOf(tleUrl) } val satUrls = remember {
val urlTransceivers = rememberSaveable { mutableStateOf(transceiversUrl) } satelliteUrls.mapIndexed { i, url -> i.toLong() to url }.toMutableStateList()
val onAccept = {
onSave(
isEnabledCustomTle.value,
isEnabledCustomTransceivers.value,
urlTle.value,
urlTransceivers.value
)
onDismiss()
} }
val onCancel = { onDismiss() } val txUrls = remember {
SharedDialog( transceiversUrls.mapIndexed { i, url -> (satelliteUrls.size + i).toLong() to url }.toMutableStateList()
}
val onAccept = { onSave(satUrls.map { it.second }, txUrls.map { it.second }); onDismiss() }
ConfirmDialog(
title = stringResource(id = R.string.prefs_data_sources_title), title = stringResource(id = R.string.prefs_data_sources_title),
onCancel = onCancel, onCancel = onDismiss,
onAccept = onAccept, onAccept = onAccept,
) { ) {
Column(modifier = Modifier.padding(horizontal = padding)) { LazyColumn(
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { modifier = Modifier
CardButton( .fillMaxHeight(0.84f)
onClick = { .padding(horizontal = padding),
onImportTle() verticalArrangement = Arrangement.spacedBy(4.dp),
onDismiss() contentPadding = PaddingValues(vertical = 6.dp)
}, ) {
text = "TLE/3LE (.txt)\nOMM (.csv)", item {
modifier = Modifier.weight(1f) Row(
) horizontalArrangement = Arrangement.spacedBy(6.dp),
CardButton( modifier = Modifier.fillMaxWidth()
onClick = { ) {
onImportTransceivers() CardButton(
onDismiss() onClick = { onImportTle(); onDismiss() },
}, text = "TLE/3LE (.txt)\nOMM (.csv)",
text = "Transceivers\nSatNOGS (.json)", modifier = Modifier.weight(1f)
modifier = Modifier.weight(1f) )
) CardButton(
onClick = { onImportTransceivers(); onDismiss() },
text = "Transceivers\nSatNOGS (.json)",
modifier = Modifier.weight(1f)
)
}
} }
Spacer(modifier = Modifier.height(6.dp)) sourceSection(
Row( sectionKey = "sat",
horizontalArrangement = Arrangement.SpaceBetween, label = "Satellites data",
verticalAlignment = Alignment.CenterVertically, urls = satUrls,
modifier = Modifier.fillMaxWidth() onAdd = { satUrls.add(nextId.longValue++ to "") },
) { onMoveUp = { i -> if (i > 0) satUrls.add(i - 1, satUrls.removeAt(i)) },
Text(text = stringResource(id = R.string.prefs_data_sources_tle_switch)) onRemove = { i -> satUrls.removeAt(i) },
Switch( onUrlChange = { i, v -> satUrls[i] = satUrls[i].first to v }
checked = isEnabledCustomTle.value,
onCheckedChange = { enabled ->
if (!enabled) {
isEnabledCustomTle.value = false
} else {
requestCustomSourcesPermission(
{ isEnabledCustomTle.value = true },
{ isEnabledCustomTle.value = false }
)
}
}
)
}
OutlinedTextField(
value = urlTle.value,
onValueChange = { urlTle.value = it },
label = { Text(text = stringResource(id = R.string.prefs_data_sources_url_title)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
enabled = isEnabledCustomTle.value,
) )
Spacer(modifier = Modifier.height(6.dp)) sourceSection(
Row( sectionKey = "tx",
horizontalArrangement = Arrangement.SpaceBetween, label = "Transceivers data",
verticalAlignment = Alignment.CenterVertically, urls = txUrls,
modifier = Modifier.fillMaxWidth() onAdd = { txUrls.add(nextId.longValue++ to "") },
) { onMoveUp = { i -> if (i > 0) txUrls.add(i - 1, txUrls.removeAt(i)) },
Text(text = stringResource(id = R.string.prefs_data_sources_transceivers_switch)) onRemove = { i -> txUrls.removeAt(i) },
Switch( onUrlChange = { i, v -> txUrls[i] = txUrls[i].first to v }
checked = isEnabledCustomTransceivers.value,
onCheckedChange = { enabled ->
if (!enabled) {
isEnabledCustomTransceivers.value = false
} else {
requestCustomSourcesPermission(
{ isEnabledCustomTransceivers.value = true },
{ isEnabledCustomTransceivers.value = false }
)
}
}
)
}
OutlinedTextField(
value = urlTransceivers.value,
onValueChange = { urlTransceivers.value = it },
label = { Text(text = stringResource(id = R.string.prefs_data_sources_url_title)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
enabled = isEnabledCustomTransceivers.value,
) )
Spacer(modifier = Modifier.height(12.dp))
} }
} }
} }
private fun LazyListScope.sourceSection(
sectionKey: String,
label: String,
urls: List<Pair<Long, String>>,
onAdd: () -> Unit,
onMoveUp: (Int) -> Unit,
onRemove: (Int) -> Unit,
onUrlChange: (Int, String) -> Unit
) {
item {
Spacer(modifier = Modifier.height(6.dp))
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
text = label,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f)
)
IconCard(action = onAdd, resId = R.drawable.ic_add, containerColor = MaterialTheme.colorScheme.surfaceVariant)
}
}
itemsIndexed(urls, key = { _, entry -> "$sectionKey-${entry.first}" }) { index, (_, url) ->
val enabledTint = MaterialTheme.colorScheme.onSurfaceVariant
OutlinedTextField(
value = url,
onValueChange = { onUrlChange(index, it) },
label = { Text("Source URL") },
leadingIcon = {
IconButton(onClick = { onMoveUp(index) }) {
Icon(
painter = painterResource(R.drawable.ic_arrow),
contentDescription = null,
tint = if (index > 0) enabledTint else enabledTint.copy(alpha = 0.32f),
modifier = Modifier.rotate(270f)
)
}
},
trailingIcon = {
IconButton(onClick = { onRemove(index) }) {
Icon(
painter = painterResource(R.drawable.ic_delete),
contentDescription = null
)
}
},
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.animateItem(fadeInSpec = spring(), fadeOutSpec = spring())
)
}
}
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
fun PreviewNetworkOutputDialog() { fun PreviewNetworkOutputDialog() {
@@ -270,6 +297,7 @@ fun PreviewNetworkOutputDialog() {
frequencyAddress = "127.0.0.1", frequencyAddress = "127.0.0.1",
frequencyPort = "4532", frequencyPort = "4532",
frequencyFormat = $$"F $FREQ", frequencyFormat = $$"F $FREQ",
frequencyOffsetHz = 0L,
bluetoothRotatorState = false, bluetoothRotatorState = false,
bluetoothRotatorFormat = $$"P $AZ $EL", bluetoothRotatorFormat = $$"P $AZ $EL",
bluetoothRotatorName = "Default", bluetoothRotatorName = "Default",
@@ -279,7 +307,7 @@ fun PreviewNetworkOutputDialog() {
bluetoothFrequencyFormat = $$"F $FREQ" bluetoothFrequencyFormat = $$"F $FREQ"
), ),
onDismiss = {}, onDismiss = {},
onSave = { _, _, _, _, _, _, _, _ -> } onSave = { _, _, _, _, _, _, _, _, _ -> }
) )
} }
} }
@@ -290,7 +318,7 @@ fun NetworkOutputDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSave: ( onSave: (
Boolean, String, String, String, Boolean, String, String, String,
Boolean, String, String, String Boolean, String, String, String, Long
) -> Unit ) -> Unit
) { ) {
val padding = LocalSpacing.current.large val padding = LocalSpacing.current.large
@@ -304,16 +332,19 @@ fun NetworkOutputDialog(
mutableStateOf("${initialSettings.frequencyAddress}:${initialSettings.frequencyPort}") mutableStateOf("${initialSettings.frequencyAddress}:${initialSettings.frequencyPort}")
} }
val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.frequencyFormat) } val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.frequencyFormat) }
val frequencyOffsetHz = rememberSaveable { mutableStateOf(initialSettings.frequencyOffsetHz.toString()) }
val onAccept = { val onAccept = {
val (rotIp, rotPort) = splitAddress(rotatorAddress.value) val (rotIp, rotPort) = splitAddress(rotatorAddress.value)
val (freqIp, freqPort) = splitAddress(frequencyAddress.value) val (freqIp, freqPort) = splitAddress(frequencyAddress.value)
val offsetHz = (frequencyOffsetHz.value.trim().toLongOrNull() ?: 0L)
.coerceIn(Constants.FREQ_OFFSET_MIN_HZ, Constants.FREQ_OFFSET_MAX_HZ)
onSave( onSave(
rotatorState.value, rotIp, rotPort, rotatorFormat.value, rotatorState.value, rotIp, rotPort, rotatorFormat.value,
frequencyState.value, freqIp, freqPort, frequencyFormat.value frequencyState.value, freqIp, freqPort, frequencyFormat.value, offsetHz
) )
onDismiss() onDismiss()
} }
SharedDialog( ConfirmDialog(
title = stringResource(R.string.prefs_net_title), title = stringResource(R.string.prefs_net_title),
onCancel = onDismiss, onCancel = onDismiss,
onAccept = onAccept onAccept = onAccept
@@ -342,6 +373,27 @@ fun NetworkOutputDialog(
onFormatChange = { frequencyFormat.value = it }, onFormatChange = { frequencyFormat.value = it },
formatLabel = stringResource(R.string.prefs_net_frequency_format_hint) formatLabel = stringResource(R.string.prefs_net_frequency_format_hint)
) )
Spacer(modifier = Modifier.height(6.dp))
OutlinedTextField(
value = frequencyOffsetHz.value,
onValueChange = { frequencyOffsetHz.value = it },
singleLine = true,
label = { Text(stringResource(R.string.prefs_net_frequency_offset_hint)) },
supportingText = { Text(stringResource(R.string.prefs_net_frequency_offset_help)) },
trailingIcon = {
IconButton(
onClick = { frequencyOffsetHz.value = "0" },
enabled = frequencyState.value && frequencyOffsetHz.value != "0"
) {
Icon(
painter = painterResource(R.drawable.ic_close),
contentDescription = null
)
}
},
modifier = Modifier.fillMaxWidth(),
enabled = frequencyState.value
)
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
} }
} }
@@ -370,6 +422,7 @@ fun PreviewBluetoothOutputDialog() {
frequencyAddress = "127.0.0.1", frequencyAddress = "127.0.0.1",
frequencyPort = "4532", frequencyPort = "4532",
frequencyFormat = $$"F $FREQ", frequencyFormat = $$"F $FREQ",
frequencyOffsetHz = 0L,
bluetoothRotatorState = false, bluetoothRotatorState = false,
bluetoothRotatorFormat = $$"P $AZ $EL", bluetoothRotatorFormat = $$"P $AZ $EL",
bluetoothRotatorName = "Default", bluetoothRotatorName = "Default",
@@ -411,7 +464,7 @@ fun BluetoothOutputDialog(
) )
onDismiss() onDismiss()
} }
SharedDialog( ConfirmDialog(
title = stringResource(R.string.prefs_bt_title), title = stringResource(R.string.prefs_bt_title),
onCancel = onDismiss, onCancel = onDismiss,
onAccept = onAccept onAccept = onAccept
@@ -538,7 +591,7 @@ fun RadioControlDialog(
onDismiss() onDismiss()
} }
SharedDialog( ConfirmDialog(
title = stringResource(R.string.rc_settings_title), title = stringResource(R.string.rc_settings_title),
onCancel = onDismiss, onCancel = onDismiss,
onAccept = onAccept onAccept = onAccept
@@ -64,6 +64,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.OtherSettings import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
@@ -74,6 +75,7 @@ import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.ScreenColumn import com.rtbishop.look4sat.core.presentation.ScreenColumn
import com.rtbishop.look4sat.core.presentation.TopBar import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.WhatsNewDialog
import com.rtbishop.look4sat.core.presentation.infiniteMarquee import com.rtbishop.look4sat.core.presentation.infiniteMarquee
import com.rtbishop.look4sat.core.presentation.isVerticalLayout import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -92,22 +94,10 @@ fun SettingsDestination() {
@Composable @Composable
private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) { private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) {
val dialogs = rememberDialogVisibility() val dialogs = rememberDialogVisibility()
val pendingCustomSourcesGrant = remember { mutableStateOf<(() -> Unit)?>(null) }
val pendingCustomSourcesDeny = remember { mutableStateOf<(() -> Unit)?>(null) }
val permissions = rememberSettingsPermissions( val permissions = rememberSettingsPermissions(
sendAction = onAction, sendAction = onAction,
onBluetoothGranted = { dialogs.bluetooth = true }, onBluetoothGranted = { dialogs.bluetooth = true },
onNetworkGranted = { dialogs.network = true }, onNetworkGranted = { dialogs.network = true }
onCustomSourcesPermissionGranted = {
pendingCustomSourcesGrant.value?.invoke()
pendingCustomSourcesGrant.value = null
pendingCustomSourcesDeny.value = null
},
onCustomSourcesPermissionDenied = {
pendingCustomSourcesDeny.value?.invoke()
pendingCustomSourcesGrant.value = null
pendingCustomSourcesDeny.value = null
}
) )
// Dialogs // Dialogs
@@ -128,30 +118,15 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
} }
if (dialogs.dataSources) { if (dialogs.dataSources) {
DataSourcesDialog( DataSourcesDialog(
useCustomTle = uiState.dataSourcesSettings.useCustomTLE, satelliteUrls = uiState.dataSourcesSettings.satelliteUrls,
useCustomTransceivers = uiState.dataSourcesSettings.useCustomTransceivers, transceiversUrls = uiState.dataSourcesSettings.transceiversUrls,
tleUrl = uiState.dataSourcesSettings.tleUrl,
transceiversUrl = uiState.dataSourcesSettings.transceiversUrl,
requestCustomSourcesPermission = { onGranted, onDenied ->
pendingCustomSourcesGrant.value = onGranted
pendingCustomSourcesDeny.value = onDenied
permissions.launchCustomSourcesPermission()
},
onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false }, onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false },
onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false }, onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false },
onDismiss = { dialogs.dataSources = false }, onDismiss = { dialogs.dataSources = false },
onSave = { useCustomTle, useCustomTransceivers, tleUrl, transceiversUrl -> onSave = { satUrls, txUrls ->
val current = uiState.dataSourcesSettings val newSettings = DataSourcesSettings(satelliteUrls = satUrls, transceiversUrls = txUrls)
val newSettings = current.copy( if (newSettings != uiState.dataSourcesSettings) onAction(SettingsAction.UpdateDataSources(newSettings))
useCustomTLE = if (!useCustomTle || tleUrl.isNotBlank()) useCustomTle else current.useCustomTLE, onAction(SettingsAction.UpdateFromWeb)
tleUrl = if (!useCustomTle || tleUrl.isNotBlank()) tleUrl else current.tleUrl,
useCustomTransceivers = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) useCustomTransceivers else current.useCustomTransceivers,
transceiversUrl = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) transceiversUrl else current.transceiversUrl
)
if (newSettings != current) onAction(SettingsAction.UpdateDataSources(newSettings))
if (newSettings.useCustomTLE || newSettings.useCustomTransceivers) {
onAction(SettingsAction.UpdateFromWeb)
}
} }
) )
} }
@@ -159,14 +134,15 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
NetworkOutputDialog( NetworkOutputDialog(
initialSettings = uiState.rcSettings, initialSettings = uiState.rcSettings,
onDismiss = { dialogs.network = false }, onDismiss = { dialogs.network = false },
onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt -> onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt, freqOffsetHz ->
onAction( onAction(
SettingsAction.UpdateRC( SettingsAction.UpdateRC(
uiState.rcSettings.copy( uiState.rcSettings.copy(
rotatorState = rotState, rotatorAddress = rotAddr, rotatorState = rotState, rotatorAddress = rotAddr,
rotatorPort = rotPort, rotatorFormat = rotFmt, rotatorPort = rotPort, rotatorFormat = rotFmt,
frequencyState = freqState, frequencyAddress = freqAddr, frequencyState = freqState, frequencyAddress = freqAddr,
frequencyPort = freqPort, frequencyFormat = freqFmt frequencyPort = freqPort, frequencyFormat = freqFmt,
frequencyOffsetHz = freqOffsetHz
) )
) )
) )
@@ -198,6 +174,9 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
onSave = { onAction(SettingsAction.UpdateRadioControl(it)) } onSave = { onAction(SettingsAction.UpdateRadioControl(it)) }
) )
} }
if (dialogs.whatsNew) {
WhatsNewDialog(onDismiss = { dialogs.whatsNew = false })
}
// URLs for top bar // URLs for top bar
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
@@ -218,7 +197,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
if (isVerticalLayout) { if (isVerticalLayout) {
TopBar { TopBar {
TopCard( TopCard(
onClick = { safeOpenUri(appUrl) }, onClick = { dialogs.whatsNew = true },
version = uiState.appVersionName, version = uiState.appVersionName,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
@@ -246,7 +225,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
TopBar { TopBar {
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like) PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like)
TopCard( TopCard(
onClick = { safeOpenUri(appUrl) }, onClick = { dialogs.whatsNew = true },
version = uiState.appVersionName, version = uiState.appVersionName,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
@@ -667,7 +646,7 @@ private fun TopCard(onClick: () -> Unit, modifier: Modifier = Modifier, version:
.clickable { onClick() }) { .clickable { onClick() }) {
Spacer(Modifier) Spacer(Modifier)
Icon( Icon(
painter = painterResource(id = R.drawable.ic_satellites), painter = painterResource(id = R.drawable.ic_sputnik),
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.primary
) )
@@ -718,6 +697,7 @@ private class DialogVisibility {
var network by mutableStateOf(false) var network by mutableStateOf(false)
var bluetooth by mutableStateOf(false) var bluetooth by mutableStateOf(false)
var radioControl by mutableStateOf(false) var radioControl by mutableStateOf(false)
var whatsNew by mutableStateOf(false)
} }
@Composable @Composable
@@ -725,12 +705,12 @@ private fun rememberDialogVisibility(): DialogVisibility {
return rememberSaveable(saver = run { return rememberSaveable(saver = run {
androidx.compose.runtime.saveable.Saver( androidx.compose.runtime.saveable.Saver(
save = { save = {
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl) listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.whatsNew)
}, },
restore = { restore = {
DialogVisibility().apply { DialogVisibility().apply {
position = it[0]; locator = it[1]; dataSources = it[2] position = it[0]; locator = it[1]; dataSources = it[2]
network = it[3]; bluetooth = it[4]; radioControl = it[5] network = it[3]; bluetooth = it[4]; radioControl = it[5]; whatsNew = it[6]
} }
} }
) )
@@ -747,17 +727,14 @@ private class SettingsPermissions(
val launchTleImport: () -> Unit, val launchTleImport: () -> Unit,
val launchTransceiverImport: () -> Unit, val launchTransceiverImport: () -> Unit,
val launchBluetooth: () -> Unit, val launchBluetooth: () -> Unit,
val launchNetwork: () -> Unit, val launchNetwork: () -> Unit
val launchCustomSourcesPermission: () -> Unit
) )
@Composable @Composable
private fun rememberSettingsPermissions( private fun rememberSettingsPermissions(
sendAction: (SettingsAction) -> Unit, sendAction: (SettingsAction) -> Unit,
onBluetoothGranted: () -> Unit, onBluetoothGranted: () -> Unit,
onNetworkGranted: () -> Unit, onNetworkGranted: () -> Unit
onCustomSourcesPermissionGranted: () -> Unit,
onCustomSourcesPermissionDenied: () -> Unit
): SettingsPermissions { ): SettingsPermissions {
val locationError = stringResource(R.string.prefs_loc_gps_error) val locationError = stringResource(R.string.prefs_loc_gps_error)
val locationRequest = rememberLauncherForActivityResult( val locationRequest = rememberLauncherForActivityResult(
@@ -794,15 +771,6 @@ private fun rememberSettingsPermissions(
else sendAction(SettingsAction.ShowToast(networkError)) else sendAction(SettingsAction.ShowToast(networkError))
} }
val customSourcesRequest = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) {
onCustomSourcesPermissionGranted()
} else {
onCustomSourcesPermissionDenied()
sendAction(SettingsAction.ShowToast(networkError))
}
}
return remember { return remember {
SettingsPermissions( SettingsPermissions(
launchLocation = { launchLocation = {
@@ -819,13 +787,6 @@ private fun rememberSettingsPermissions(
} else { } else {
onNetworkGranted() onNetworkGranted()
} }
},
launchCustomSourcesPermission = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
customSourcesRequest.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
} else {
onCustomSourcesPermissionGranted()
}
} }
) )
} }
@@ -1,34 +1,25 @@
package com.rtbishop.look4sat.feature.status package com.rtbishop.look4sat.feature.status
import androidx.compose.animation.core.RepeatMode import androidx.compose.foundation.Image
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.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -42,11 +33,15 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color 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.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.SatReport
import com.rtbishop.look4sat.core.domain.model.SatSlot import com.rtbishop.look4sat.core.domain.model.SatSlot
import com.rtbishop.look4sat.core.domain.model.SatStatus 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.core.presentation.R
import com.rtbishop.look4sat.feature.status.R as StatusR import com.rtbishop.look4sat.core.presentation.layoutPadding
import java.util.Calendar 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. * Map AMSAT status text to Material3 colorScheme colors.
* Addresses PR #233 review: use colorScheme instead of hardcoded Color() constants. * 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 { private fun statusColorOf(statusText: String): Color {
return when { return when {
statusText.contains("Heard", ignoreCase = true) && !statusText.contains("Not", ignoreCase = true) -> 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) -> statusText.contains("Telemetry", ignoreCase = true) || statusText.contains("Beacon", ignoreCase = true) ->
MaterialTheme.colorScheme.tertiaryContainer // Telemetry MaterialTheme.colorScheme.tertiaryContainer
statusText.contains("Not Heard", ignoreCase = true) -> statusText.contains("Not Heard", ignoreCase = true) ->
Color(0xFFDC267F) // NotHeard pink (no semantic slot) Color(0xFFDC267F)
else -> else ->
MaterialTheme.colorScheme.error // Conflict MaterialTheme.colorScheme.error
} }
} }
private val NoReportGray = Color(0xFFC0C0C0) // Neutral state
@Composable @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 viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) } SatStatusScreen(uiState) { viewModel.refresh() }
}
Column( @Composable
modifier = Modifier private fun SatStatusScreen(uiState: SatStatusUiState, refresh: () -> Unit) {
.fillMaxSize() var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) }
.statusBarsPadding() Column(modifier = Modifier.fillMaxSize().layoutPadding()) {
.navigationBarsPadding() StatusHeader(fetchedAtUtcMs = uiState.fetchedAtUtcMs, isRefreshing = uiState.isRefreshing, onRefresh = refresh)
.padding(horizontal = 8.dp)
) {
// Top: legend + refresh
StatusHeader(
fetchedAtUtcMs = uiState.fetchedAtUtcMs,
isRefreshing = uiState.isRefreshing,
onRefresh = { viewModel.refresh() }
)
LegendRow() LegendRow()
when { when {
@@ -113,29 +105,16 @@ fun SatStatusScreen(container: IMainContainer) {
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text( Text(text = stringResource(id = R.string.amsat_load_failed), color = MaterialTheme.colorScheme.error)
text = stringResource(id = R.string.amsat_load_failed), TextButton(onClick = refresh) { Text(text = stringResource(id = R.string.amsat_retry)) }
color = MaterialTheme.colorScheme.error
)
TextButton(onClick = { viewModel.refresh() }) {
Text(text = stringResource(id = R.string.amsat_retry))
}
} }
} }
else -> { else -> {
// Header
HeaderRow(statuses = uiState.statuses) HeaderRow(statuses = uiState.statuses)
HorizontalDivider(thickness = 1.dp) HorizontalDivider(thickness = 1.dp)
// Row LazyColumn(modifier = Modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 16.dp)
) {
items(uiState.statuses, key = { it.name }) { status -> items(uiState.statuses, key = { it.name }) { status ->
StatusRow( StatusRow(status = status, onClickDay = { day -> selectedDay = status to day })
status = status,
onClickDay = { day -> selectedDay = status to day }
)
} }
} }
} }
@@ -143,56 +122,41 @@ fun SatStatusScreen(container: IMainContainer) {
} }
selectedDay?.let { (status, day) -> selectedDay?.let { (status, day) ->
ReportDialog( ReportDialog(statusName = status.name, day = day, reports = uiState.reports, onDismiss = { selectedDay = null })
statusName = status.name,
day = day,
reports = uiState.reports,
onDismiss = { selectedDay = null }
)
} }
} }
/** Top: update time + refresh button (spinner while loading) */ /** Top: update time + refresh button (spinner while loading) */
@Composable @Composable
private fun StatusHeader( private fun StatusHeader(fetchedAtUtcMs: Long, isRefreshing: Boolean, onRefresh: () -> Unit) {
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"
)
Row( Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = if (fetchedAtUtcMs > 0) stringResource(id = R.string.amsat_updated) + " " + formatFetchedAt(fetchedAtUtcMs) else stringResource(id = R.string.amsat_title), text = if (fetchedAtUtcMs > 0)
fontSize = 13.sp, stringResource(id = R.string.amsat_updated) + " " + formatFetchedAt(fetchedAtUtcMs)
else
stringResource(id = R.string.amsat_title),
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
Box( Box(
modifier = Modifier modifier = Modifier
.size(34.dp) .size(32.dp)
.clip(CircleShape) .clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant) .background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onRefresh), .clickable(onClick = onRefresh),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
if (isRefreshing) { if (isRefreshing) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else { } else {
Image( Image(
painter = painterResource(id = StatusR.drawable.ic_refresh), painter = painterResource(id = R.drawable.ic_refresh),
contentDescription = stringResource(id = R.string.amsat_refresh), contentDescription = stringResource(id = R.string.amsat_refresh),
modifier = Modifier modifier = Modifier.size(16.dp),
.size(18.dp)
.rotate(angle),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary) 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 @Composable
private fun LegendRow() { private fun LegendRow() {
val legend = listOf( val legend = listOf(
stringResource(id = R.string.amsat_active) to MaterialTheme.colorScheme.tertiary, stringResource(id = R.string.amsat_active) to Color(0xFF648FFF),
stringResource(id = R.string.amsat_tlm) to MaterialTheme.colorScheme.tertiaryContainer, stringResource(id = R.string.amsat_tlm) to Color(0xFFFFB000),
stringResource(id = R.string.amsat_not_heard) to Color(0xFFDC267F), 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( FlowRow(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
legend.forEach { (label, color) -> 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( Box(
modifier = Modifier modifier = Modifier
.size(10.dp) .size(8.dp)
.clip(RoundedCornerShape(2.dp)) .clip(RoundedCornerShape(4.dp))
.background(color) .background(color)
) )
Spacer(modifier = Modifier.width(3.dp)) Spacer(modifier = Modifier.width(6.dp))
Text(text = label, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) 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 @Composable
private fun HeaderRow(statuses: List<SatStatus>) { private fun HeaderRow(statuses: List<SatStatus>) {
val dates = statuses.firstOrNull()?.days?.map { it.dateLabel } ?: emptyList() val dates = statuses.firstOrNull()?.days?.map { it.dateLabel } ?: emptyList()
Row( Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = stringResource(id = R.string.amsat_name), text = stringResource(id = R.string.amsat_name),
fontSize = 11.sp, fontSize = 12.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(2f).padding(start = 4.dp) modifier = Modifier.weight(1f).padding(start = 4.dp)
) )
dates.forEach { date -> dates.forEach { date ->
Text( Text(
text = date, text = date,
fontSize = 10.sp, fontSize = 12.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary, 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 @Composable
private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) { private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
val noReportGray = 0xFFC0C0C0L val noReportGray = 0xFFC0C0C0L
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
.fillMaxWidth()
.padding(vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = status.name, text = status.name,
fontSize = 11.sp, fontSize = 14.sp,
maxLines = 1, maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(2f).padding(start = 4.dp) modifier = Modifier.weight(1f).padding(end = 4.dp)
) )
status.days.forEach { day -> status.days.forEach { day ->
val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first() val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first()
DayCell( DayCell(
slot = slot, slot = slot,
modifier = Modifier.weight(0.8f).padding(horizontal = 1.dp), modifier = Modifier.width(TILE_WIDTH).padding(horizontal = 2.dp),
onClick = { onClickDay(day) } 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 @Composable
private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) { private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
val color = Color(slot.statusColor) val color = Color(slot.statusColor)
Box( Box(
modifier = modifier modifier = modifier
.height(24.dp) .height(28.dp)
.clip(RoundedCornerShape(4.dp)) .clip(RoundedCornerShape(4.dp))
.background(color) .background(color)
.clickable(onClick = onClick), .clickable(onClick = onClick),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
if (slot.count > 0) { if (slot.count > 0) {
Text( Text(text = slot.count.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
text = slot.count.toString(),
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
} }
} }
} }
/** Report detail dialog (3 levels: callsign/date/time/grid) */ /** Report detail dialog (callsign / date / time / grid) */
@Composable @Composable
private fun ReportDialog( private fun ReportDialog(statusName: String, day: SatDay, reports: Map<String, SatReport>, onDismiss: () -> Unit) {
statusName: String, val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] }
day: SatDay, InfoDialog(title = "$statusName · ${day.dateLabel}", onDismiss = onDismiss, onAccept = onDismiss) {
reports: Map<String, SatReport>, if (dayReports.isEmpty()) {
onDismiss: () -> Unit Text(stringResource(id = R.string.amsat_no_reports))
) { Spacer(modifier = Modifier.height(8.dp))
val dayReports = day.slots.flatMap { it.reportIds } } else {
.mapNotNull { reports[it] } LazyColumn(modifier = Modifier.heightIn(max = 480.dp)) {
AlertDialog( items(dayReports) { report ->
onDismissRequest = onDismiss, Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
title = { Text(text = "$statusName · ${day.dateLabel}") }, Row(verticalAlignment = Alignment.CenterVertically) {
text = { Box(
if (dayReports.isEmpty()) { modifier = Modifier
Text(stringResource(id = R.string.amsat_no_reports)) .size(8.dp)
} else { .clip(CircleShape)
LazyColumn(modifier = Modifier.height(320.dp)) { .background(statusColorOf(report.statusText))
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
) )
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 { private fun formatFetchedAt(utcMs: Long): String {
val cal = Calendar.getInstance() val cal = Calendar.getInstance()
cal.timeInMillis = utcMs cal.timeInMillis = utcMs
return "%02d-%02d %02d:%02d".format( val day = cal.get(Calendar.DAY_OF_MONTH)
cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH), val month = MONTH_ABBR[cal.get(Calendar.MONTH)]
cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE) 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"
} }
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.model.SatReport import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatStatus 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 com.rtbishop.look4sat.core.domain.repository.IMainContainer
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -22,7 +23,7 @@ data class SatStatusUiState(
) )
class SatStatusViewModel( class SatStatusViewModel(
private val container: IMainContainer private val amSatRepo: IAmSatRepository
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true)) private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true))
@@ -36,7 +37,7 @@ class SatStatusViewModel(
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) } _uiState.update { it.copy(isLoading = true, error = null) }
try { try {
val page = container.amSatRepo.fetchStatus() val page = amSatRepo.fetchStatus()
if (page != null && page.statuses.isNotEmpty()) { if (page != null && page.statuses.isNotEmpty()) {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -65,7 +66,7 @@ class SatStatusViewModel(
_uiState.update { it.copy(isRefreshing = true, error = null) } _uiState.update { it.copy(isRefreshing = true, error = null) }
viewModelScope.launch { viewModelScope.launch {
try { try {
val page = container.amSatRepo.fetchStatus() val page = amSatRepo.fetchStatus()
if (page != null && page.statuses.isNotEmpty()) { if (page != null && page.statuses.isNotEmpty()) {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -87,7 +88,7 @@ class SatStatusViewModel(
companion object { companion object {
fun factory(container: IMainContainer) = viewModelFactory { fun factory(container: IMainContainer) = viewModelFactory {
initializer { initializer {
SatStatusViewModel(container) SatStatusViewModel(container.amSatRepo)
} }
} }
} }
+2 -2
View File
@@ -1,8 +1,8 @@
[versions] [versions]
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionCode = "445" appVersionCode = "446"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.5" appVersionName = "4.4.6"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
compileSdk = "37" compileSdk = "37"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
+2 -2
View File
@@ -1,7 +1,7 @@
#Sat Jun 27 12:51:12 BST 2026 #Tue Aug 11 14:16:53 CEST 2026
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME