Compare commits

..
Author SHA1 Message Date
atsunatsu 4aada90b76 feat: remember per-satellite doppler offset in calculator
Store the last entered offset (kHz) per satellite in SharedPreferences
via SettingsRepo, and restore it when the linear transponder calculator
is shown for that satellite.

Follows the project's MVI/clean architecture:
- ISettingsRepo: getSatelliteOffset / setSatelliteOffset
- SettingsRepo: backed by SharedPreferences
- RadarState: calculatorOffsetKHz field
- RadarAction: ChangeCalculatorOffset
- RadarViewModel: loads offset on SelectTransmitter, saves on change
- CalculatorPage/DopplerFrequencyCalculator: receive offset via params
instead of accessing SharedPreferences directly from the UI layer.
2026-08-10 17:03:45 +08:00
51 changed files with 640 additions and 1377 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://star-history.dera.page/#rt-bishop/Look4Sat&type=timeline&legend=top-left"> <a href="https://www.star-history.com/?repos=rt-bishop%2FLook4Sat&type=timeline&legend=top-left">
<picture> <picture>
<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: dark)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&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" /> <source media="(prefers-color-scheme: light)" srcset="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" /> <img alt="Star History Chart" src="https://api.star-history.com/chart?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) { NavRoot() } MainTheme(isDarkTheme = true) { MainScreen() }
} }
} }
@@ -25,6 +25,8 @@ 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
@@ -42,7 +44,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.Surface import androidx.compose.material3.Scaffold
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
@@ -68,7 +70,6 @@ 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
@@ -81,42 +82,37 @@ 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 { rootBackStack.add(deeplinkResolver.resolve(it)) } deeplink?.let {
val destination = deeplinkResolver.resolve(it) // rootBackStack.clear()
rootBackStack.add(destination)
}
} }
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() } val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) } val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style) val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
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 = { pushTransition }, transitionSpec = { slideInTransition },
popTransitionSpec = { popTransition }, popTransitionSpec = { slideOutTransition },
predictivePopTransitionSpec = { popTransition }, predictivePopTransitionSpec = { slideOutTransition },
entryDecorators = listOf( entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(), rememberSaveableStateHolderNavEntryDecorator(), // Required for saving Compose state per entry
rememberViewModelStoreNavEntryDecorator() rememberViewModelStoreNavEntryDecorator() // Required for ViewModel scoping per entry
), ),
entryProvider = entryProvider { entryProvider = entryProvider {
entry<Screen.Passes> { MainScreen(navigateToRadar = navigateToRadar) } entry<Screen.Passes> { MainScreen(navigateToRadar = { rootBackStack.add(RadarDestination) }) }
entry<RadarDestination> { entry<RadarDestination> {
Surface( Scaffold { innerPadding ->
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
RadarDestination(navigateUp = navigateBack) RadarDestination(navigateUp = navigateBack)
innerPadding.calculateTopPadding()
} }
} }
} }
@@ -124,20 +120,18 @@ fun NavRoot(deeplink: String? = null) {
} }
@Composable @Composable
private fun MainScreen(navigateToRadar: () -> Unit = {}) { 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,
@@ -150,18 +144,13 @@ private 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.Status -> screen is Screen.Status is Screen.Radar -> screen is Screen.Radar
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 = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) },
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 = {
@@ -190,7 +179,9 @@ private 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 {
@@ -200,71 +191,69 @@ private 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)
navigateToRadar() backStack.add(Screen.Radar)
// navigateToRadar()
} }
} }
entry<Screen.Status> { SatStatusDestination() } entry<Screen.Radar> {
entry<Screen.Map> { MapDestination() } RadarDestination(navigateUp = navigateBack)
entry<Screen.Settings> { SettingsDestination() } }
entry<Screen.Map> {
MapDestination()
}
entry<Screen.Settings> {
SettingsDestination()
}
} }
) )
// Radio tracking status banner
if (trackingState.isActive) { if (trackingState.isActive) {
TrackingBanner( val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
state = trackingState, val alpha by infiniteTransition.animateFloat(
onClick = { initialValue = 1f, targetValue = 0.4f,
val pass = trackingState.currentPass animationSpec = infiniteRepeatable(
if (pass != null) { animation = tween(1000, easing = LinearEasing),
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime) repeatMode = RepeatMode.Reverse
navigateToRadar() ), label = "pulseAlpha"
}
}
) )
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,7 +36,6 @@ 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,10 +17,8 @@
*/ */
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
@@ -33,30 +31,18 @@ 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 {
@@ -72,43 +58,46 @@ class NetworkReporter(
} }
override fun reportFrequency(format: String, frequency: Long) { override fun reportFrequency(format: String, frequency: Long) {
val clampedOffset = frequencyOffsetHz.coerceIn( reporterScope.launch {
Constants.FREQ_OFFSET_MIN_HZ, ensureFrequencyConnected()
Constants.FREQ_OFFSET_MAX_HZ if (!frequencyConnected) return@launch
) val command = format
val correctedFreq = frequency.coerceAtLeast(0L).safeAdd(clampedOffset).coerceAtLeast(0L) .replace($$"$FREQ", frequency.toString())
val command = format .unescapeControlChars()
.replace($$"$FREQ", correctedFreq.toString()) write(frequencySocket, command) { frequencyConnected = false }
.unescapeControlChars() }
frequencyCommands.trySend(command)
} }
private suspend fun ensureRotatorConnected() { private fun ensureRotatorConnected() {
connectionMutex.withLock { if (rotatorConnected || rotatorConnecting || rotatorServer.isBlank()) return
if (rotatorConnected || rotatorServer.isBlank()) return reporterScope.launch {
try { try {
resetRotatorConnection() rotatorConnecting = true
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}")
resetRotatorConnection() rotatorConnected = false
} finally {
rotatorConnecting = false
} }
} }
} }
private suspend fun ensureFrequencyConnected() { private fun ensureFrequencyConnected() {
connectionMutex.withLock { if (frequencyConnected || frequencyConnecting || frequencyServer.isBlank()) return
if (frequencyConnected || frequencyServer.isBlank()) return reporterScope.launch {
try { try {
resetFrequencyConnection() frequencyConnecting = true
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}")
resetFrequencyConnection() frequencyConnected = false
} finally {
frequencyConnecting = false
} }
} }
} }
@@ -117,9 +106,7 @@ class NetworkReporter(
try { try {
writeMutex.withLock { writeMutex.withLock {
val buffer = ByteBuffer.wrap("$command\n".toByteArray()) val buffer = ByteBuffer.wrap("$command\n".toByteArray())
while (buffer.hasRemaining()) { socket?.write(buffer)
socket?.write(buffer)
}
} }
} catch (e: Exception) { } catch (e: Exception) {
println("NetworkReporter write error: ${e.message}") println("NetworkReporter write error: ${e.message}")
@@ -127,32 +114,6 @@ 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,8 +106,7 @@ 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,15 +35,13 @@ 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
// 72h = 3 days; API hard cap is limit=500 regardless of what we send. val reportsJson = remoteSource.getAmSatReports(hours = 168, limit = 500) ?: return@withContext null
// 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)
@@ -54,7 +52,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 (_: Exception) { } catch (e: Exception) {
emptyList() emptyList()
} }
} }
@@ -75,7 +73,7 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
reportedTimeUtcSec = parseIsoUtcSec(iso) reportedTimeUtcSec = parseIsoUtcSec(iso)
) )
} }
} catch (_: Exception) { } catch (e: Exception) {
emptyList() emptyList()
} }
} }
@@ -84,21 +82,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 (_: Exception) { } catch (e: Exception) {
0L 0L
} }
} }
/** Build one SatStatus (5 days x 12 slots) per catalog satellite, slotting reports by age. */ /** Build one SatStatus (6 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 utc = Calendar.getInstance(TimeZone.getTimeZone("UTC")) val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
val labels = (0 until 3).map { d -> val labels = (0 until 6).map { d ->
utc.timeInMillis = (nowSec - d * 86400L) * 1000 utc.timeInMillis = (nowSec - d * 86400L) * 1000
formatDayLabel(utc) "${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}"
} }
return names.map { name -> return names.map { name ->
val slots = (0 until 36).map { slotIdx -> val slots = (0 until 72).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 }
@@ -113,22 +112,13 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
) )
} }
} }
val days = (0 until 3).map { d -> val days = (0 until 6).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)
} }
} }
private fun formatDayLabel(calendar: Calendar): String {
return if (Locale.getDefault().language == Locale.CHINESE.language) {
"${calendar.get(Calendar.MONTH) + 1}月${calendar.get(Calendar.DAY_OF_MONTH)}日"
} else {
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
"${monthAbbr[calendar.get(Calendar.MONTH)]} ${calendar.get(Calendar.DAY_OF_MONTH)}"
}
}
private fun toSatReport(r: ApiReport): SatReport { private fun toSatReport(r: ApiReport): SatReport {
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")) val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
cal.timeInMillis = r.reportedTimeUtcSec * 1000 cal.timeInMillis = r.reportedTimeUtcSec * 1000
@@ -23,6 +23,7 @@ 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
@@ -39,6 +40,7 @@ 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
@@ -63,27 +65,25 @@ class DatabaseRepo(
} }
override suspend fun updateFromRemote() = withContext(dispatcher) { override suspend fun updateFromRemote() = withContext(dispatcher) {
val settings = settingsRepo.dataSourcesSettings.value val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
fun normalizeUrl(url: String) = if (url.startsWith("http")) url else "https://$url" val tleUrls = buildMap {
val tleUrls = settings.satelliteUrls.filterIndexed { i, url -> url.isNotBlank() && settings.isSatelliteEnabled(i) } putAll(Sources.satelliteDataUrls)
val radioUrls = settings.transceiversUrls.filterIndexed { i, url -> url.isNotBlank() && settings.isTransceiverEnabled(i) } if (dataSourcesSettings.useCustomTLE) put(customSourceType, dataSourcesSettings.tleUrl)
// launch all network requests concurrently, keeping the raw url as key for status reporting }.filterValues { it.isNotBlank() }
val tleJobs = tleUrls.map { url -> async { url to remoteSource.getNetworkStream(normalizeUrl(url)) } } val radioUrls = buildMap {
val radioJobs = radioUrls.map { url -> async { url to remoteSource.getNetworkStream(normalizeUrl(url)) } } putAll(Sources.transceiversDataUrls)
val tleResults = tleJobs.awaitAll() if (dataSourcesSettings.useCustomTransceivers) put(customSourceType, dataSourcesSettings.transceiversUrl)
val radioResults = radioJobs.awaitAll() }.filterValues { it.isNotBlank() }
// report the HTTP status code of every source (200, 404, ...) // launch all network requests concurrently
settingsRepo.updateDataSourcesStatus( val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
(tleResults + radioResults).associate { (url, result) -> url to result.code } val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
) // parse fetched data concurrently
// parse fetched data concurrently, keeping the first occurrence per primary key val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
// so sources listed higher in the dialog take priority over lower ones stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty()
val importedEntries = tleResults.flatMap { (url, result) -> }
result.stream?.let { val nUrl = normalizeUrl(url); parseSatelliteStream(nUrl, unwrapIfZipped(nUrl, it)) }.orEmpty() val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
}.distinctBy { it.catnum } stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
val importedRadios = radioResults.flatMap { (url, result) -> }
result.stream?.let { val nUrl = normalizeUrl(url); dataParser.parseJSONStream(unwrapIfZipped(nUrl, it)) }.orEmpty()
}.filter { it.uuid.isNotBlank() }.distinctBy { it.uuid }
// insert parsed data into the database // insert parsed data into the database
localSource.insertEntries(importedEntries) localSource.insertEntries(importedEntries)
localSource.insertRadios(importedRadios) localSource.insertRadios(importedRadios)
@@ -29,10 +29,8 @@ 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
@@ -70,7 +68,6 @@ 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"
@@ -92,12 +89,11 @@ 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 keySatelliteUrls = "satelliteUrls" private val keyUseCustomTle = "useCustomTle"
private val keyTransceiversUrls = "transceiversUrls" private val keyUseCustomTransceivers = "useCustomTransceivers"
private val keySatelliteEnabled = "satelliteEnabled" private val keyTleUrl = "tleUrl"
private val keyTransceiversEnabled = "transceiversEnabled" 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())
@@ -281,10 +277,6 @@ 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)
@@ -294,7 +286,6 @@ 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)
@@ -303,7 +294,7 @@ class SettingsRepo(
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat) putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress) putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
} }
_rcSettings.value = settings.copy(frequencyOffsetHz = clampedFreqOffsetHz) _rcSettings.value = settings
} }
private fun getRCSettings(): RCSettings = RCSettings( private fun getRCSettings(): RCSettings = RCSettings(
@@ -315,8 +306,6 @@ 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",
@@ -375,73 +364,21 @@ class SettingsRepo(
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings
override fun updateDataSourcesSettings(settings: DataSourcesSettings) { override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
// Normalize the enabled lists so they are positionally aligned with the URL lists.
// Missing entries default to enabled (true), keeping the persisted "one flag per URL"
// invariant intact even when a default empty list is used to construct the model.
val normalized = settings.copy(
satelliteEnabled = alignFlags(settings.satelliteUrls, settings.satelliteEnabled),
transceiversEnabled = alignFlags(settings.transceiversUrls, settings.transceiversEnabled)
)
preferences.edit { preferences.edit {
putString(keySatelliteUrls, normalized.satelliteUrls.joinToString(separatorUrl)) putBoolean(keyUseCustomTle, settings.useCustomTLE)
putString(keyTransceiversUrls, normalized.transceiversUrls.joinToString(separatorUrl)) putBoolean(keyUseCustomTransceivers, settings.useCustomTransceivers)
putString(keySatelliteEnabled, normalized.satelliteEnabled.joinToString(separatorComma)) putString(keyTleUrl, settings.tleUrl)
putString(keyTransceiversEnabled, normalized.transceiversEnabled.joinToString(separatorComma)) putString(keyTransceiversUrl, settings.transceiversUrl)
} }
_dataSourcesSettings.value = normalized _dataSourcesSettings.value = settings
} }
private fun getDataSourcesSettings(): DataSourcesSettings { private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
val (satUrls, satEnabled) = parseSources( useCustomTLE = preferences.getBoolean(keyUseCustomTle, false),
preferences.getString(keySatelliteUrls, null), useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false),
preferences.getString(keySatelliteEnabled, null), tleUrl = preferences.getString(keyTleUrl, "https://example.com/tle.txt") ?: "",
Sources.satelliteDataUrls transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: ""
) )
val (txUrls, txEnabled) = parseSources(
preferences.getString(keyTransceiversUrls, null),
preferences.getString(keyTransceiversEnabled, null),
Sources.transceiversDataUrls
)
return DataSourcesSettings(
satelliteUrls = satUrls,
transceiversUrls = txUrls,
satelliteEnabled = satEnabled,
transceiversEnabled = txEnabled
)
}
private fun parseSources(
storedUrls: String?,
storedEnabled: String?,
defaults: List<String>
): Pair<List<String>, List<Boolean>> {
if (storedUrls == null) return defaults to defaults.map { true }
val urls = storedUrls.split(separatorUrl)
val flags = if (storedEnabled.isNullOrEmpty()) emptyList() else storedEnabled.split(separatorComma)
val filteredUrls = mutableListOf<String>()
val filteredFlags = mutableListOf<Boolean>()
urls.forEachIndexed { index, url ->
if (url.isNotBlank()) {
filteredUrls.add(url)
filteredFlags.add(flags.getOrNull(index)?.toBoolean() ?: true)
}
}
return filteredUrls to filteredFlags
}
private fun alignFlags(urls: List<String>, flags: List<Boolean>): List<Boolean> {
if (flags.size >= urls.size) return flags.take(urls.size)
return flags + List(urls.size - flags.size) { true }
}
//endregion
//region # Data sources status
private val _dataSourcesStatus = MutableStateFlow<Map<String, Int>>(emptyMap())
override val dataSourcesStatus: StateFlow<Map<String, Int>> = _dataSourcesStatus
override fun updateDataSourcesStatus(status: Map<String, Int>) {
_dataSourcesStatus.value = status
}
//endregion //endregion
//region # Radio control settings //region # Radio control settings
@@ -488,7 +425,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 (_: Exception) { } catch (e: Exception) {
"" ""
} }
} }
@@ -499,7 +436,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 (_: Exception) { } catch (e: Exception) {
"""{"$catnum": "$offset"}""" """{"$catnum": "$offset"}"""
} }
preferences.edit { putString(keySatelliteOffsets, updated) } preferences.edit { putString(keySatelliteOffsets, updated) }
@@ -20,7 +20,6 @@ package com.rtbishop.look4sat.core.data.source
import android.content.ContentResolver import android.content.ContentResolver
import androidx.core.net.toUri import androidx.core.net.toUri
import com.rtbishop.look4sat.core.domain.source.IRemoteSource import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import com.rtbishop.look4sat.core.domain.source.NetworkResult
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -43,26 +42,20 @@ class RemoteSource(
} }
} }
override suspend fun getNetworkStream(url: String): NetworkResult = withContext(dispatcher) { override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
try { try {
val networkRequest = Request.Builder().url(url).build() val networkRequest = Request.Builder().url(url).build()
val response = httpClient.newCall(networkRequest).execute() val response = httpClient.newCall(networkRequest).execute()
if (!response.isSuccessful) { if (!response.isSuccessful) {
val code = response.code
response.close() response.close()
return@withContext NetworkResult(code, null) return@withContext null
} }
// Return the body stream directly as the caller is responsible for closing it // Return the body stream directly as the caller is responsible for closing it
// That returns the connection to OkHttp's pool // That returns the connection to OkHttp's pool
val body = response.body response.body.byteStream().buffered()
if (body == null) {
response.close()
return@withContext NetworkResult(response.code, null)
}
NetworkResult(response.code, body.byteStream().buffered())
} catch (exception: Exception) { } catch (exception: Exception) {
println("RemoteSource network stream exception: $exception") println("RemoteSource network stream exception: $exception")
NetworkResult(NetworkResult.CONNECTION_ERROR, null) null
} }
} }
@@ -31,7 +31,6 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
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.NetworkResult
import com.rtbishop.look4sat.core.domain.utility.DataParser import com.rtbishop.look4sat.core.domain.utility.DataParser
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -91,8 +90,10 @@ class DatabaseRepoTest {
} }
val settingsRepo = FakeSettingsRepo( val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings( dataSources = DataSourcesSettings(
satelliteUrls = listOf(customCsvUrl), useCustomTLE = true,
transceiversUrls = emptyList() useCustomTransceivers = false,
tleUrl = customCsvUrl,
transceiversUrl = ""
) )
) )
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo) val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
@@ -120,14 +121,7 @@ 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): NetworkResult { override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke()
val stream = networkStreams[url]?.invoke()
return if (stream != null) NetworkResult(200, stream) else NetworkResult(404, null)
}
override suspend fun getAmSatCatalog(): String? = null
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
} }
private class FakeLocalSource : ILocalSource { private class FakeLocalSource : ILocalSource {
@@ -180,7 +174,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, "", "", "", 0L, false, "", "", "", false, "", "") RCSettings(false, "", "", "", false, "", "", "", false, "", "", "", false, "", "")
) )
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow( override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
@@ -189,8 +183,6 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> = MutableStateFlow(dataSources) override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> = MutableStateFlow(dataSources)
override val dataSourcesStatus: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow( override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600) RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
) )
@@ -220,10 +212,6 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
dataSourcesSettings.value = settings dataSourcesSettings.value = settings
} }
override fun updateDataSourcesStatus(status: Map<String, Int>) {
dataSourcesStatus.value = status
}
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = "" override fun getSatelliteOffset(catnum: Int): String = ""
@@ -233,7 +221,9 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
private fun defaultDataSourcesSettings(): DataSourcesSettings { private fun defaultDataSourcesSettings(): DataSourcesSettings {
return DataSourcesSettings( return DataSourcesSettings(
satelliteUrls = emptyList(), useCustomTLE = false,
transceiversUrls = emptyList() useCustomTransceivers = false,
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, "", "", "", 0L, false, "", "", "", false, "", "") RCSettings(false, "", "", "", false, "", "", "", false, "", "", "", false, "", "")
) )
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow( override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
@@ -135,11 +135,9 @@ class SelectionRepoTest {
) )
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow( override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
DataSourcesSettings(emptyList(), emptyList()) DataSourcesSettings(false, false, "", "")
) )
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow( override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600) RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
) )
@@ -168,8 +166,6 @@ class SelectionRepoTest {
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
override fun updateDataSourcesStatus(status: Map<String, Int>) = Unit
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = "" override fun getSatelliteOffset(catnum: Int): String = ""
@@ -1,23 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.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, 5 days of state */ /** One satellite, 6 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> // 5 天(新→旧) val days: List<SatDay> // 6 天(新→旧)
) )
/** Overall page parse result */ /** Overall page parse result */
@@ -41,7 +41,6 @@ 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,21 +66,12 @@ data class OtherSettings(
val radarCompassOffsetElev: Float = 0f val radarCompassOffsetElev: Float = 0f
) )
/**
* Data source URLs (TLE / transceivers) and their per-source enabled flags.
* [satelliteEnabled] and [transceiversEnabled] must be positionally aligned with the
* corresponding URL list: the flag at index [i] applies to the URL at index [i].
* Missing flags are treated as enabled (see [isSatelliteEnabled]/[isTransceiverEnabled]).
*/
data class DataSourcesSettings( data class DataSourcesSettings(
val satelliteUrls: List<String>, val useCustomTLE: Boolean,
val transceiversUrls: List<String>, val useCustomTransceivers: Boolean,
val satelliteEnabled: List<Boolean> = emptyList(), val tleUrl: String,
val transceiversEnabled: List<Boolean> = emptyList() val transceiversUrl: String
) { )
fun isSatelliteEnabled(index: Int): Boolean = satelliteEnabled.getOrElse(index) { true }
fun isTransceiverEnabled(index: Int): Boolean = transceiversEnabled.getOrElse(index) { true }
}
data class RadioControlSettings( data class RadioControlSettings(
val enabled: Boolean, val enabled: Boolean,
@@ -424,12 +424,8 @@ object CelestialComputer {
} }
if (sunrise == 0.0) sunrise = daynum if (sunrise == 0.0) sunrise = daynum
// Phase 4: fast-forward through the day until sun drops back below threshold. // Phase 4: fast-forward through the day until sun drops back below threshold
// Start from just after sunrise (small offset) so the sun is clearly above daynum = sunrise
// the threshold. This prevents a bug where Phase 3 converges to a point
// slightly below -threshold, causing Phase 4 to skip and Phase 5 to converge
// to the same time as sunrise, producing identical sunrise/sunset times.
daynum = sunrise + 0.001
sunPos = getSunPosition(observer, daynumToMillis(daynum)) sunPos = getSunPosition(observer, daynumToMillis(daynum))
guard = 0 guard = 0
while (sunPos.elevation > -threshold && guard++ < 500) { while (sunPos.elevation > -threshold && guard++ < 500) {
@@ -71,11 +71,6 @@ interface ISettingsRepo {
fun updateDataSourcesSettings(settings: DataSourcesSettings) fun updateDataSourcesSettings(settings: DataSourcesSettings)
//endregion //endregion
//region # Data sources status
val dataSourcesStatus: StateFlow<Map<String, Int>>
fun updateDataSourcesStatus(status: Map<String, Int>)
//endregion
//region # Radio control settings //region # Radio control settings
val radioControlSettings: StateFlow<RadioControlSettings> val radioControlSettings: StateFlow<RadioControlSettings>
fun updateRadioControlSettings(settings: RadioControlSettings) fun updateRadioControlSettings(settings: RadioControlSettings)
@@ -19,17 +19,9 @@ package com.rtbishop.look4sat.core.domain.source
import java.io.InputStream import java.io.InputStream
/** Result of a network download: the HTTP status code and the response body stream. */
data class NetworkResult(val code: Int, val stream: InputStream?) {
companion object {
/** Code used when a request fails before any HTTP response is received. */
const val CONNECTION_ERROR = -1
}
}
interface IRemoteSource { interface IRemoteSource {
suspend fun getFileStream(uri: String): InputStream? suspend fun getFileStream(uri: String): InputStream?
suspend fun getNetworkStream(url: String): NetworkResult suspend fun getNetworkStream(url: String): InputStream?
suspend fun getAmSatCatalog(): String? suspend fun getAmSatCatalog(): String?
suspend fun getAmSatReports(hours: Int, limit: Int): String? suspend fun getAmSatReports(hours: Int, limit: Int): String?
} }
@@ -18,17 +18,18 @@
package com.rtbishop.look4sat.core.domain.source package com.rtbishop.look4sat.core.domain.source
object Sources { object Sources {
val satelliteDataUrls = listOf( val satelliteDataUrls = mapOf(
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv", "CelesTrak" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"db.satnogs.org/api/tle/?format=3le", "AMSAT" to "https://amsat.org/tle/current/nasabare.txt",
"amsat.org/tle/current/nasabare.txt", "McCants Classified" to "https://www.mmccants.org/tles/classfd.zip",
"mmccants.org/tles/classfd.zip", "McCants Integrated" to "https://www.mmccants.org/tles/inttles.zip",
"r4uab.ru/satonline.txt", "R4UAB" to "https://r4uab.ru/satonline.txt",
"live.ariss.org/iss.txt" "SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le",
"ARISS" to "https://live.ariss.org/iss.txt",
"Other" to "" // key for sats filter
) )
val transceiversDataUrls = listOf( val transceiversDataUrls = mapOf(
"db.satnogs.org/api/transmitters/?format=json&status=active", "SatNOGS" to "https://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,21 +41,6 @@ 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,7 +18,6 @@
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
@@ -240,10 +239,4 @@ 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)
}
} }
@@ -1,79 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.predict
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.Instant
import kotlin.math.abs
class CelestialComputerTest {
private data class RiseSetCase(
val name: String,
val observer: GeoPos,
val startIso: String
)
@Test
fun `findSunRiseSet returns distinct sunrise and sunset for representative locations`() {
val cases = listOf(
RiseSetCase("Equator at March equinox", GeoPos(0.0, 0.0), "2026-03-20T00:00:00Z"),
RiseSetCase("Equator at September equinox", GeoPos(0.0, 0.0), "2026-09-23T00:00:00Z"),
RiseSetCase("Sydney winter", GeoPos(-33.8688, 151.2093), "2026-06-21T00:00:00Z"),
RiseSetCase("Buenos Aires winter", GeoPos(-34.6037, -58.3816), "2026-06-21T00:00:00Z"),
RiseSetCase("Cape Town winter", GeoPos(-33.9249, 18.4241), "2026-06-21T00:00:00Z"),
RiseSetCase("London summer", GeoPos(51.5074, -0.1278), "2026-06-21T00:00:00Z")
)
cases.forEach { testCase ->
val result = CelestialComputer.findSunRiseSet(testCase.observer, testCase.startIso.toMillis())
val daylightDuration = result.setTimeMillis - result.riseTimeMillis
assertTrue("${testCase.name}: sunrise should be non-zero", result.riseTimeMillis > 0L)
assertTrue("${testCase.name}: sunset should be non-zero", result.setTimeMillis > 0L)
assertTrue("${testCase.name}: sunset should be after sunrise", result.setTimeMillis > result.riseTimeMillis)
assertTrue("${testCase.name}: daylight duration should be longer than 1 hour", daylightDuration > HOUR_MILLIS)
assertTrue("${testCase.name}: daylight duration should be shorter than 24 hours", daylightDuration < DAY_MILLIS)
val riseElevation = CelestialComputer.getSunPosition(testCase.observer, result.riseTimeMillis).elevation
val setElevation = CelestialComputer.getSunPosition(testCase.observer, result.setTimeMillis).elevation
assertEquals("${testCase.name}: sunrise should converge near the standard threshold", SUNRISE_SET_THRESHOLD, riseElevation, 0.02)
assertEquals("${testCase.name}: sunset should converge near the standard threshold", SUNRISE_SET_THRESHOLD, setElevation, 0.02)
}
}
@Test
fun `findSunRiseSet does not return the same instant for equinox regression cases`() {
listOf("2026-03-20T00:00:00Z", "2026-09-23T00:00:00Z").forEach { startIso ->
val result = CelestialComputer.findSunRiseSet(GeoPos(0.0, 0.0), startIso.toMillis())
val separationMillis = abs(result.setTimeMillis - result.riseTimeMillis)
assertTrue("$startIso: sunrise and sunset should be separated", separationMillis > HOUR_MILLIS)
}
}
private fun String.toMillis(): Long = Instant.parse(this).toEpochMilli()
private companion object {
private const val SUNRISE_SET_THRESHOLD = -0.8333
private const val HOUR_MILLIS = 60L * 60L * 1000L
private const val DAY_MILLIS = 24L * HOUR_MILLIS
}
}
@@ -27,15 +27,11 @@ 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
@@ -51,6 +47,7 @@ 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
@@ -76,8 +73,6 @@ 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
@@ -239,13 +234,8 @@ fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier)
} }
@Composable @Composable
fun IconCard( fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier, enabled: Boolean = true) {
action: () -> Unit, resId: Int, modifier: Modifier = Modifier, ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action) {
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)
} }
@@ -301,81 +291,52 @@ fun getDefaultPass(): OrbitalPass = OrbitalPass(
) )
@Composable @Composable
fun InfoDialog( fun SharedDialog(
title: String, title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
onDismiss: () -> Unit,
onAccept: () -> Unit,
content: @Composable () -> Unit
) { ) {
DialogShell(onDismissRequest = onDismiss) { padding -> SharedDialog(title = title, onDismissRequest = onCancel, onCancel = onCancel, onAccept = onAccept) { _ ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(start = padding, top = padding, end = padding)
) {
Text(
text = title,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
}
content() content()
} }
} }
@Composable @Composable
fun ConfirmDialog( fun SharedDialog(
title: String, title: String,
onCancel: () -> Unit, onDismissRequest: () -> Unit,
onAccept: () -> Unit, onCancel: (() -> Unit)? = null,
content: @Composable () -> Unit onAccept: (() -> Unit)? = null,
titleFontSize: Int = 16,
titleTextAlign: TextAlign = if (onCancel != null && onAccept != null) TextAlign.Center else TextAlign.Start,
content: @Composable (padding: Dp) -> Unit
) { ) {
DialogShell(onDismissRequest = onCancel) { padding -> val dismissible = onCancel != null
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)
) { ) {
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel)) if (onCancel != null) {
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
}
Text( Text(
text = title, text = title,
fontSize = 16.sp, fontSize = titleFontSize.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center, textAlign = titleTextAlign,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.padding(horizontal = padding) .padding(horizontal = if (onCancel != null && onAccept != null) padding else 0.dp)
) )
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept)) if (onAccept != null) {
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
}
} }
content() content(padding)
}
}
@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))
} }
} }
@@ -383,13 +344,14 @@ fun WhatsNewDialog(onDismiss: () -> Unit) {
@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(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() skipPartiallyExpanded = true,
val containerHeight = with(LocalDensity.current) { LocalWindowInfo.current.containerSize.height.toDp() } confirmValueChange = { if (dismissible) true else it != SheetValue.Hidden }
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 {
@@ -404,10 +366,10 @@ private fun DialogShell(
} }
} }
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = onDismissRequest, onDismissRequest = if (dismissible) onDismissRequest else { {} },
sheetState = sheetState, sheetState = sheetState,
dragHandle = null, dragHandle = null,
shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp), shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
scrimColor = Color.Black.copy(alpha = 0.64f) scrimColor = Color.Black.copy(alpha = 0.64f)
) { ) {
Column( Column(
@@ -415,7 +377,6 @@ 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_sputnik, R.string.nav_sat) data object Satellites : Screen(R.drawable.ic_satellites, 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 Status : Screen(R.drawable.ic_satellite, R.string.nav_status) data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar)
@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)
@@ -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="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z" />
</vector>
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M240,320q-33,0 -56.5,-23.5T160,240q0,-33 23.5,-56.5T240,160q33,0 56.5,23.5T320,240q0,33 -23.5,56.5T240,320zM240,560q-33,0 -56.5,-23.5T160,480q0,-33 23.5,-56.5T240,400q33,0 56.5,23.5T320,480q0,33 -23.5,56.5T240,560zM240,800q-33,0 -56.5,-23.5T160,720q0,-33 23.5,-56.5T240,640q33,0 56.5,23.5T320,720q0,33 -23.5,56.5T240,800zM720,320q-33,0 -56.5,-23.5T640,240q0,-33 23.5,-56.5T720,160q33,0 56.5,23.5T800,240q0,33 -23.5,56.5T720,320zM720,560q-33,0 -56.5,-23.5T640,480q0,-33 23.5,-56.5T720,400q33,0 56.5,23.5T800,480q0,33 -23.5,56.5T720,560zM720,800q-33,0 -56.5,-23.5T640,720q0,-33 23.5,-56.5T720,640q33,0 56.5,23.5T800,720q0,33 -23.5,56.5T720,800z" />
</vector>
@@ -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="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,31 +0,0 @@
<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>
@@ -118,9 +118,6 @@
<string name="prefs_data_import_transceivers_error">No se importaron transceptores. Usa un SatNOGS (.json) válido.</string> <string name="prefs_data_import_transceivers_error">No se importaron transceptores. Usa un SatNOGS (.json) válido.</string>
<string name="prefs_data_sources_title">Fuentes personalizadas</string> <string name="prefs_data_sources_title">Fuentes personalizadas</string>
<string name="prefs_data_sources_restore">Restaurar fuentes predeterminadas</string>
<string name="prefs_data_sources_sat_title">Datos de satélites</string>
<string name="prefs_data_sources_transceivers_title">Datos de transceptores</string>
<string name="prefs_data_sources_tle_switch">URL TLE</string> <string name="prefs_data_sources_tle_switch">URL TLE</string>
<string name="prefs_data_sources_transceivers_switch">URL transceptores</string> <string name="prefs_data_sources_transceivers_switch">URL transceptores</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string> <string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
@@ -117,9 +117,6 @@
<string name="prefs_data_import_transceivers_error">Трансиверы не импортированы. Нужен SatNOGS (.json).</string> <string name="prefs_data_import_transceivers_error">Трансиверы не импортированы. Нужен SatNOGS (.json).</string>
<string name="prefs_data_sources_title">Свои источники</string> <string name="prefs_data_sources_title">Свои источники</string>
<string name="prefs_data_sources_restore">Восстановить источники по умолчанию</string>
<string name="prefs_data_sources_sat_title">Данные спутников</string>
<string name="prefs_data_sources_transceivers_title">Данные трансиверов</string>
<string name="prefs_data_sources_tle_switch">URL TLE</string> <string name="prefs_data_sources_tle_switch">URL TLE</string>
<string name="prefs_data_sources_transceivers_switch">URL трансиверов</string> <string name="prefs_data_sources_transceivers_switch">URL трансиверов</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string> <string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
@@ -118,9 +118,6 @@
<string name="prefs_data_import_transceivers_error">සම්ප්‍රේෂක ආයාත නොවීය. වලංගු SatNOGS (.json) ගොනුවක් තෝරන්න.</string> <string name="prefs_data_import_transceivers_error">සම්ප්‍රේෂක ආයාත නොවීය. වලංගු SatNOGS (.json) ගොනුවක් තෝරන්න.</string>
<string name="prefs_data_sources_title">අභිරුචි මූලාශ්‍ර</string> <string name="prefs_data_sources_title">අභිරුචි මූලාශ්‍ර</string>
<string name="prefs_data_sources_restore">පෙරනිමි මූලාශ්‍ර ප්‍රතිස්ථාපනය කරන්න</string>
<string name="prefs_data_sources_sat_title">චන්ද්‍රිකා දත්ත</string>
<string name="prefs_data_sources_transceivers_title">සම්ප්‍රේෂක දත්ත</string>
<string name="prefs_data_sources_tle_switch">TLE URL</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_transceivers_switch">Transceivers URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string> <string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
@@ -149,9 +149,6 @@
<string name="prefs_data_import_transceivers_error">Transceiver içe aktarılmadı. Geçerli bir SatNOGS (.json) dosyası seçin.</string> <string name="prefs_data_import_transceivers_error">Transceiver içe aktarılmadı. Geçerli bir SatNOGS (.json) dosyası seçin.</string>
<string name="prefs_data_sources_title">Özel kaynaklar</string> <string name="prefs_data_sources_title">Özel kaynaklar</string>
<string name="prefs_data_sources_restore">Varsayılan kaynakları geri yükle</string>
<string name="prefs_data_sources_sat_title">Uydu verileri</string>
<string name="prefs_data_sources_transceivers_title">Transceiver verileri</string>
<string name="prefs_data_sources_tle_switch">TLE URL</string> <string name="prefs_data_sources_tle_switch">TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Transceiver URL</string> <string name="prefs_data_sources_transceivers_switch">Transceiver URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string> <string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
@@ -118,9 +118,6 @@
<string name="prefs_data_import_transceivers_error">Трансивери не імпортовано. Потрібен SatNOGS (.json).</string> <string name="prefs_data_import_transceivers_error">Трансивери не імпортовано. Потрібен SatNOGS (.json).</string>
<string name="prefs_data_sources_title">Власні джерела</string> <string name="prefs_data_sources_title">Власні джерела</string>
<string name="prefs_data_sources_restore">Відновити джерела за замовчуванням</string>
<string name="prefs_data_sources_sat_title">Дані супутників</string>
<string name="prefs_data_sources_transceivers_title">Дані трансиверів</string>
<string name="prefs_data_sources_tle_switch">URL TLE</string> <string name="prefs_data_sources_tle_switch">URL TLE</string>
<string name="prefs_data_sources_transceivers_switch">URL трансиверів</string> <string name="prefs_data_sources_transceivers_switch">URL трансиверів</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string> <string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
@@ -12,25 +12,6 @@
<string name="nav_map">地图</string> <string name="nav_map">地图</string>
<string name="nav_prefs">设置</string> <string name="nav_prefs">设置</string>
<!-- AMSAT Status screen -->
<string name="nav_amsat">AMSAT</string>
<string name="amsat_title">AMSAT 卫星状态</string>
<string name="amsat_refresh">刷新</string>
<string name="amsat_retry">重试</string>
<string name="amsat_updated">更新于:</string>
<string name="amsat_active">活跃</string>
<string name="amsat_tlm">遥测/信标</string>
<string name="amsat_not_heard">未听到</string>
<string name="amsat_conflict">冲突</string>
<string name="amsat_name">名称</string>
<string name="amsat_load_failed">加载失败 - 检查网络后重试</string>
<string name="amsat_no_reports">此时间段无报告</string>
<string name="amsat_close">关闭</string>
<string name="amsat_status_heard">已听到</string>
<string name="amsat_status_tlm_only">仅遥测</string>
<string name="amsat_status_not_heard">未听到</string>
<string name="amsat_status_crew_active">乘组活跃</string>
<!-- Satellites screen --> <!-- Satellites screen -->
<string name="sat_type_hint">模式:%s</string> <string name="sat_type_hint">模式:%s</string>
<string name="sat_type_title">选择模式</string> <string name="sat_type_title">选择模式</string>
@@ -132,9 +113,6 @@
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string> <string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
<string name="prefs_data_sources_title">自定义源</string> <string name="prefs_data_sources_title">自定义源</string>
<string name="prefs_data_sources_restore">恢复默认源</string>
<string name="prefs_data_sources_sat_title">卫星数据</string>
<string name="prefs_data_sources_transceivers_title">收发器数据</string>
<string name="prefs_data_sources_tle_switch">TLE URL</string> <string name="prefs_data_sources_tle_switch">TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">收发器 URL</string> <string name="prefs_data_sources_transceivers_switch">收发器 URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string> <string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
@@ -12,7 +12,6 @@
<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>
@@ -56,13 +55,16 @@
\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 AMSAT status tracking page by MCKero6423 (#234) * Added small tweaks to Sources and strings. Added official ISS source (#225)
\n* Tweaked Radar track on reposition by atsunatsu (#235) \n* Implemented AOS window and elevation highlight filters (#226)
\n* Tweaked linear transponder calc by atsunatsu (#236) \n* Added cleartext traffic support for custom TLE URLs (#227)
\n* Added saving per-sat doppler offset by atsunatsu (#237) \n* Fixed manual OMM (.csv) data import, tweaked error messaging (#228)
\n* Added custom freq offset setting to network reporting \n* Added support for Icom IC-705 CAT by ruilvo (#229)
\n* Added ability to customize data sources via import \n* Added configurable Radar offset to the sensors output by qwqtoday (#230)
\n* Fixed star chart in README by PingouinFerreux (#240) \n* Localized pass date and time formats for Chinese by atsunatsu (#231)
\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 -->
@@ -79,10 +81,6 @@
<string name="amsat_load_failed">Load failed - check network and retry</string> <string name="amsat_load_failed">Load failed - check network and retry</string>
<string name="amsat_no_reports">No reports for this period</string> <string name="amsat_no_reports">No reports for this period</string>
<string name="amsat_close">Close</string> <string name="amsat_close">Close</string>
<string name="amsat_status_heard">Heard</string>
<string name="amsat_status_tlm_only">Telemetry Only</string>
<string name="amsat_status_not_heard">Not Heard</string>
<string name="amsat_status_crew_active">Crew Active</string>
<!-- Radar screen --> <!-- Radar screen -->
<string name="radar_back">Back</string> <string name="radar_back">Back</string>
@@ -173,10 +171,10 @@
<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">Data sources</string> <string name="prefs_data_sources_title">Custom sources</string>
<string name="prefs_data_sources_restore">Restore default sources</string> <string name="prefs_data_sources_tle_switch">TLE URL</string>
<string name="prefs_data_sources_sat_title">Satellites data</string> <string name="prefs_data_sources_transceivers_switch">Transceivers URL</string>
<string name="prefs_data_sources_transceivers_title">Transceivers data</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>
@@ -200,8 +198,6 @@
<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,7 +1,8 @@
* Added AMSAT status tracking page by MCKero6423 (#234) * Added ARISS source, tweaked SSTV sensitivity #225
* Tweaked Radar track on reposition by atsunatsu (#235) * Implemented AOS window and elev filter #226
* Tweaked linear transponder calc by atsunatsu (#236) * Added cleartext traffic support for TLEs #227
* Added saving per-sat doppler offset by atsunatsu (#237) * Fixed OMM import, tweaked error messages #228
* Added custom freq offset setting to network reporting * Added support for Icom CAT by ruilvo #229
* Added ability to customize data sources via import * Added tweakable Radar offset by qwqtoday #230
* Fixed star chart in README by PingouinFerreux (#240) * Localized date/time formats by atsunatsu #231
* 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.ConfirmDialog import com.rtbishop.look4sat.core.presentation.SharedDialog
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()
} }
ConfirmDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) { SharedDialog(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() } }
ConfirmDialog(title = stringResource(R.string.pass_modes_title), onCancel = cancel, onAccept = onAccept) { SharedDialog(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,6 +30,7 @@ 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
@@ -71,10 +72,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
@@ -135,7 +136,20 @@ private fun PassesScreen(
} }
if (uiState.shouldSeeWhatsNew) { if (uiState.shouldSeeWhatsNew) {
val dismiss = { onAction(PassesAction.DismissWhatsNew) } val dismiss = { onAction(PassesAction.DismissWhatsNew) }
WhatsNewDialog(onDismiss = dismiss) SharedDialog(
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 }
ConfirmDialog( SharedDialog(
title = "SSTV Mode", title = "SSTV Mode",
onAccept = dismiss, onDismissRequest = dismiss,
onCancel = dismiss onAccept = dismiss
) { ) { _ ->
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxHeight(0.69f) .fillMaxHeight(0.7f)
.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()) }
ConfirmDialog(title = stringResource(R.string.sat_type_title), onCancel = cancel, onAccept = onAccept) { SharedDialog(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,11 +62,10 @@ 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
@@ -97,16 +96,17 @@ private fun SatellitesScreen(
} }
if (uiState.shouldSeeWarning) { if (uiState.shouldSeeWarning) {
val dismiss = { onAction(SatellitesAction.DismissWarning) } val dismiss = { onAction(SatellitesAction.DismissWarning) }
InfoDialog( SharedDialog(
title = stringResource(R.string.sat_warning_title), title = stringResource(R.string.sat_warning_title),
onDismiss = dismiss, onDismissRequest = 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 = LocalSpacing.current.large) modifier = Modifier.padding(horizontal = padding)
) )
Spacer(modifier = Modifier.height(0.dp)) Spacer(modifier = Modifier.height(0.dp))
} }
@@ -17,84 +17,39 @@
*/ */
package com.rtbishop.look4sat.feature.settings package com.rtbishop.look4sat.feature.settings
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.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.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Checkbox
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.LaunchedEffect
import androidx.compose.runtime.MutableFloatState
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
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.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
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.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
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.compose.ui.zIndex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
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.source.NetworkResult
import com.rtbishop.look4sat.core.domain.source.Sources
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.ConfirmDialog import com.rtbishop.look4sat.core.presentation.SharedDialog
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
@@ -108,7 +63,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() } }
ConfirmDialog(title = titleText, onCancel = dismiss, onAccept = onAccept) { SharedDialog(title = titleText, onCancel = dismiss, onAccept = onAccept) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier modifier = Modifier
@@ -152,7 +107,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() } }
ConfirmDialog( SharedDialog(
title = stringResource(R.string.prefs_locator_title), title = stringResource(R.string.prefs_locator_title),
onCancel = dismiss, onCancel = dismiss,
onAccept = onAccept onAccept = onAccept
@@ -174,19 +129,11 @@ fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Uni
private fun TransceiversDialogPreview() { private fun TransceiversDialogPreview() {
MainTheme { MainTheme {
DataSourcesDialog( DataSourcesDialog(
satelliteUrls = listOf( useCustomTle = true,
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv", useCustomTransceivers = true,
"amsat.org/tle/current/nasabare.txt" tleUrl = "https://example.com/tle.txt",
), transceiversUrl = "https://example.com/tx.json",
transceiversUrls = listOf( requestCustomSourcesPermission = { onGranted, _ -> onGranted() },
"db.satnogs.org/api/transmitters/?format=json&status=active"
),
satelliteEnabled = listOf(true, false),
transceiversEnabled = listOf(true),
statusCodes = mapOf(
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv" to 200,
"amsat.org/tle/current/nasabare.txt" to 404
),
onImportTle = {}, onImportTle = {},
onImportTransceivers = {}, onImportTransceivers = {},
onDismiss = {}, onDismiss = {},
@@ -197,424 +144,118 @@ private fun TransceiversDialogPreview() {
@Composable @Composable
fun DataSourcesDialog( fun DataSourcesDialog(
satelliteUrls: List<String>, useCustomTle: Boolean,
transceiversUrls: List<String>, useCustomTransceivers: Boolean,
satelliteEnabled: List<Boolean>, tleUrl: String,
transceiversEnabled: List<Boolean>, transceiversUrl: String,
statusCodes: Map<String, Int>, requestCustomSourcesPermission: (onGranted: () -> Unit, onDenied: () -> Unit) -> Unit,
onImportTle: () -> Unit, onImportTle: () -> Unit,
onImportTransceivers: () -> Unit, onImportTransceivers: () -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSave: (List<String>, List<String>, List<Boolean>, List<Boolean>) -> Unit onSave: (Boolean, Boolean, String, String) -> Unit
) { ) {
val padding = LocalSpacing.current.large val padding = LocalSpacing.current.large
// Use stable Long IDs to avoid key collisions (e.g. multiple empty "" entries). val isEnabledCustomTle = rememberSaveable { mutableStateOf(useCustomTle) }
val nextId = remember { mutableLongStateOf((satelliteUrls.size + transceiversUrls.size).toLong()) } val isEnabledCustomTransceivers = rememberSaveable { mutableStateOf(useCustomTransceivers) }
val satUrls = remember { val urlTle = rememberSaveable { mutableStateOf(tleUrl) }
satelliteUrls.mapIndexed { i, url -> i.toLong() to url }.toMutableStateList() val urlTransceivers = rememberSaveable { mutableStateOf(transceiversUrl) }
}
val txUrls = remember {
transceiversUrls.mapIndexed { i, url -> (satelliteUrls.size + i).toLong() to url }.toMutableStateList()
}
val satEnabled = remember {
mutableStateMapOf<Long, Boolean>().apply {
satUrls.forEachIndexed { i, (id, _) -> this[id] = satelliteEnabled.getOrElse(i) { true } }
}
}
val txEnabled = remember {
mutableStateMapOf<Long, Boolean>().apply {
txUrls.forEachIndexed { i, (id, _) -> this[id] = transceiversEnabled.getOrElse(i) { true } }
}
}
val listState = rememberLazyListState()
val satDraggedId = remember { mutableStateOf(-1L) }
val txDraggedId = remember { mutableStateOf(-1L) }
val onRestoreDefaults = {
nextId.longValue = (Sources.satelliteDataUrls.size + Sources.transceiversDataUrls.size).toLong()
satUrls.clear()
satUrls.addAll(Sources.satelliteDataUrls.mapIndexed { i, url -> i.toLong() to url })
txUrls.clear()
txUrls.addAll(
Sources.transceiversDataUrls.mapIndexed { i, url ->
(Sources.satelliteDataUrls.size + i).toLong() to url
}
)
satEnabled.clear()
txEnabled.clear()
Unit
}
val onAccept = { val onAccept = {
val satFiltered = satUrls.filter { it.second.isNotBlank() } onSave(
val txFiltered = txUrls.filter { it.second.isNotBlank() } isEnabledCustomTle.value,
try { isEnabledCustomTransceivers.value,
onSave( urlTle.value,
satFiltered.map { it.second }, urlTransceivers.value
txFiltered.map { it.second }, )
satFiltered.map { satEnabled[it.first] ?: true }, onDismiss()
txFiltered.map { txEnabled[it.first] ?: true }
)
} finally {
onDismiss()
}
} }
val satTitle = stringResource(R.string.prefs_data_sources_sat_title) val onCancel = { onDismiss() }
val transceiversTitle = stringResource(R.string.prefs_data_sources_transceivers_title) SharedDialog(
ConfirmDialog(
title = stringResource(id = R.string.prefs_data_sources_title), title = stringResource(id = R.string.prefs_data_sources_title),
onCancel = onDismiss, onCancel = onCancel,
onAccept = onAccept, onAccept = onAccept,
) { ) {
LazyColumn( Column(modifier = Modifier.padding(horizontal = padding)) {
state = listState, Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
modifier = Modifier
.fillMaxHeight(0.84f)
.padding(horizontal = padding),
verticalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = PaddingValues(vertical = 6.dp)
) {
item {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
) {
CardButton(
onClick = { onImportTle(); onDismiss() },
text = "TLE/3LE (.txt)\nOMM (.csv)",
modifier = Modifier.weight(1f)
)
CardButton(
onClick = { onImportTransceivers(); onDismiss() },
text = "Transceivers\nSatNOGS (.json)",
modifier = Modifier.weight(1f)
)
}
}
item {
CardButton( CardButton(
onClick = onRestoreDefaults, onClick = {
text = stringResource(R.string.prefs_data_sources_restore), onImportTle()
modifier = Modifier.fillMaxWidth() onDismiss()
},
text = "TLE/3LE (.txt)\nOMM (.csv)",
modifier = Modifier.weight(1f)
)
CardButton(
onClick = {
onImportTransceivers()
onDismiss()
},
text = "Transceivers\nSatNOGS (.json)",
modifier = Modifier.weight(1f)
) )
} }
sourceSection( Spacer(modifier = Modifier.height(6.dp))
sectionKey = "sat", Row(
label = satTitle, horizontalArrangement = Arrangement.SpaceBetween,
urls = satUrls, verticalAlignment = Alignment.CenterVertically,
listState = listState, modifier = Modifier.fillMaxWidth()
draggedId = satDraggedId,
statusCodes = statusCodes,
enabledMap = satEnabled,
onToggle = { id -> satEnabled[id] = !(satEnabled[id] ?: true) },
onAdd = { satUrls.add(nextId.longValue++ to "") },
onMove = { from, to -> satUrls.add(to, satUrls.removeAt(from)) },
onRemove = { i -> satUrls.removeAt(i) },
onUrlChange = { i, v -> satUrls[i] = satUrls[i].first to v }
)
sourceSection(
sectionKey = "tx",
label = transceiversTitle,
urls = txUrls,
listState = listState,
draggedId = txDraggedId,
statusCodes = statusCodes,
enabledMap = txEnabled,
onToggle = { id -> txEnabled[id] = !(txEnabled[id] ?: true) },
onAdd = { txUrls.add(nextId.longValue++ to "") },
onMove = { from, to -> txUrls.add(to, txUrls.removeAt(from)) },
onRemove = { i -> txUrls.removeAt(i) },
onUrlChange = { i, v -> txUrls[i] = txUrls[i].first to v }
)
}
}
}
private fun LazyListScope.sourceSection(
sectionKey: String,
label: String,
urls: List<Pair<Long, String>>,
listState: LazyListState,
draggedId: MutableState<Long>,
statusCodes: Map<String, Int>,
enabledMap: Map<Long, Boolean>,
onToggle: (Long) -> Unit,
onAdd: () -> Unit,
onMove: (Int, 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, (id, url) ->
val enabledTint = MaterialTheme.colorScheme.onSurfaceVariant
val enabled = enabledMap[id] ?: true
val rowState = remember { DragRowState() }
val scope = rememberCoroutineScope()
val isDragging = draggedId.value == id
val isLifted = isDragging || rowState.isSettling.value
LaunchedEffect(isDragging) {
if (!isDragging) return@LaunchedEffect
autoScroll(listState, rowState.startCenterY, rowState.fingerOffset, rowState.scrollComp)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.draggedVisual(
isLifted = isLifted,
translationY = if (rowState.isSettling.value) {
rowState.settleAnim.value
} else {
rowState.offsetY.floatValue + rowState.scrollComp.floatValue
}
)
.animateItem(
fadeInSpec = spring(),
// The dragged row repositions instantly, while its neighbours spring
// out of the way (the "squeeze" effect).
placementSpec = if (isDragging) {
tween<IntOffset>(durationMillis = 0)
} else {
spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMediumLow
)
},
fadeOutSpec = spring()
)
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(40.dp)
.dragHandle(listState, sectionKey, id, urls, draggedId, rowState, scope, onMove)
) { ) {
Icon( Text(text = stringResource(id = R.string.prefs_data_sources_tle_switch))
painter = painterResource(R.drawable.ic_drag_handle), Switch(
contentDescription = null, checked = isEnabledCustomTle.value,
tint = enabledTint onCheckedChange = { enabled ->
if (!enabled) {
isEnabledCustomTle.value = false
} else {
requestCustomSourcesPermission(
{ isEnabledCustomTle.value = true },
{ isEnabledCustomTle.value = false }
)
}
}
) )
} }
OutlinedTextField( OutlinedTextField(
value = url, value = urlTle.value,
onValueChange = { onUrlChange(index, it) }, onValueChange = { urlTle.value = it },
label = { Text("Source URL") }, label = { Text(text = stringResource(id = R.string.prefs_data_sources_url_title)) },
supportingText = statusCodes[url]?.let { code ->
{ Text(statusLabel(code), color = statusColor(code), fontSize = 12.sp) }
},
trailingIcon = {
IconButton(onClick = { onRemove(index) }) {
Icon(
painter = painterResource(R.drawable.ic_delete),
contentDescription = null
)
}
},
singleLine = true, singleLine = true,
enabled = enabled, modifier = Modifier.fillMaxWidth(),
modifier = Modifier.weight(1f) enabled = isEnabledCustomTle.value,
) )
Checkbox( Spacer(modifier = Modifier.height(6.dp))
checked = enabled, Row(
onCheckedChange = { onToggle(id) } horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = R.string.prefs_data_sources_transceivers_switch))
Switch(
checked = isEnabledCustomTransceivers.value,
onCheckedChange = { enabled ->
if (!enabled) {
isEnabledCustomTransceivers.value = false
} else {
requestCustomSourcesPermission(
{ isEnabledCustomTransceivers.value = true },
{ isEnabledCustomTransceivers.value = false }
)
}
}
)
}
OutlinedTextField(
value = urlTransceivers.value,
onValueChange = { urlTransceivers.value = it },
label = { Text(text = stringResource(id = R.string.prefs_data_sources_url_title)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
enabled = isEnabledCustomTransceivers.value,
) )
Spacer(modifier = Modifier.height(12.dp))
} }
} }
} }
/**
* Per-row drag state, kept in one object to keep the drag-handle modifier signature small.
*
* [offsetY] is the compensated visual displacement during a drag (finger travel minus the
* heights of already-swapped neighbours), so the row stays glued to the finger. [fingerOffset]
* tracks the raw finger travel for edge auto-scroll and swap detection. [settleAnim] smoothly
* flies the lifted row back into its slot once the finger is released.
*/
private class DragRowState {
val offsetY = mutableFloatStateOf(0f)
val fingerOffset = mutableFloatStateOf(0f)
val scrollComp = mutableFloatStateOf(0f)
val startCenterY = mutableFloatStateOf(0f)
val settleAnim = Animatable(0f)
val isSettling = mutableStateOf(false)
}
/** Spring shared by neighbour "squeeze" and the settle-back animation: soft and slightly bouncy. */
private val reorderSpring = spring<Float>(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMediumLow
)
/**
* Drag handle gesture that performs live reordering while dragging.
*
* [fingerOffset] tracks the raw finger travel, used for edge auto-scroll and for
* deciding when the dragged row's centre crosses a neighbour's midpoint. [offsetY]
* additionally subtracts the heights of already-swapped neighbours so the visual
* translation (see [draggedVisual]) keeps the row glued to the finger even as the
* layout slot moves. Rendering of the whole field is applied on the field itself so
* the entire row follows the finger, not just the handle icon.
*/
@Composable
private fun Modifier.dragHandle(
listState: LazyListState,
sectionKey: String,
entryId: Long,
urls: List<Pair<Long, String>>,
draggedId: MutableState<Long>,
rowState: DragRowState,
scope: CoroutineScope,
onMove: (from: Int, to: Int) -> Unit
): Modifier = pointerInput(entryId, sectionKey) {
fun reorderLive() {
val myIndex = urls.indexOfFirst { it.first == entryId }
if (myIndex !in urls.indices) return
val myCenter = rowState.startCenterY.floatValue + rowState.fingerOffset.floatValue
val visible = listState.layoutInfo.visibleItemsInfo
// Dragging down: swap when the dragged centre passes the next row's midpoint.
if (myIndex < urls.lastIndex) {
val next = visible.firstOrNull { it.key == "$sectionKey-${urls[myIndex + 1].first}" }
if (next != null && myCenter > next.offset + next.size / 2f) {
onMove(myIndex, myIndex + 1)
rowState.offsetY.floatValue -= next.size.toFloat()
return
}
}
// Dragging up: swap when the dragged centre passes the previous row's midpoint.
if (myIndex > 0) {
val prev = visible.firstOrNull { it.key == "$sectionKey-${urls[myIndex - 1].first}" }
if (prev != null && myCenter < prev.offset + prev.size / 2f) {
onMove(myIndex, myIndex - 1)
rowState.offsetY.floatValue += prev.size.toFloat()
}
}
}
// Reset the drag bookkeeping and fly the lifted row back into its slot.
// All state resets happen inside the launched block so the settle animation takes
// over from the current visual position without a one-frame jump: isSettling is
// flipped to true (switching rendering to settleAnim, already snapped to the last
// offset) before the drag flags are cleared.
fun finishDrag() {
val lastOffset = rowState.offsetY.floatValue + rowState.scrollComp.floatValue
if (kotlin.math.abs(lastOffset) < 1f) {
rowState.fingerOffset.floatValue = 0f
rowState.offsetY.floatValue = 0f
rowState.scrollComp.floatValue = 0f
draggedId.value = -1L
return
}
scope.launch {
rowState.settleAnim.snapTo(lastOffset)
rowState.isSettling.value = true
rowState.fingerOffset.floatValue = 0f
rowState.offsetY.floatValue = 0f
rowState.scrollComp.floatValue = 0f
draggedId.value = -1L
rowState.settleAnim.animateTo(0f, reorderSpring)
rowState.isSettling.value = false
}
}
detectDragGesturesAfterLongPress(
onDragStart = {
rowState.isSettling.value = false
val layout = listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == "$sectionKey-$entryId" }
rowState.startCenterY.floatValue = (layout?.offset ?: 0) + (layout?.size ?: 0) / 2f
rowState.fingerOffset.floatValue = 0f
rowState.offsetY.floatValue = 0f
rowState.scrollComp.floatValue = 0f
draggedId.value = entryId
},
onDragEnd = ::finishDrag,
onDragCancel = ::finishDrag
) { change, dragAmount ->
change.consume()
if (draggedId.value != entryId) return@detectDragGesturesAfterLongPress
rowState.fingerOffset.floatValue += dragAmount.y
rowState.offsetY.floatValue += dragAmount.y
reorderLive()
}
}
@Composable
private fun Modifier.draggedVisual(isLifted: Boolean, translationY: Float): Modifier {
val shape = MaterialTheme.shapes.small
// Smoothly scale the row up/down as the lifted card appears and disappears.
val scale by animateFloatAsState(
targetValue = if (isLifted) 1.02f else 1f,
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
label = "dragScale"
)
return this
.graphicsLayer {
if (isLifted) {
this.translationY = translationY
scaleX = scale
scaleY = scale
}
}
.then(
if (isLifted) {
// Solid card on top so the lifted row fully covers the row beneath it
// instead of showing a translucent overlap of both rows.
Modifier
.zIndex(1f)
.shadow(8.dp, shape, clip = false)
.background(MaterialTheme.colorScheme.surface, shape)
} else {
Modifier
}
)
}
/**
* Scrolls the list while dragging so the entry follows the finger past the viewport edges.
* The visual centre is tracked independently of the entry's layout slot (which can scroll out
* of [LazyListState.layoutInfo.visibleItemsInfo] during a long drag); [startCenterY] is the
* entry's viewport centre captured at drag start and [fingerOffset] is the raw finger delta.
*/
private suspend fun autoScroll(
listState: LazyListState,
startCenterY: MutableFloatState,
fingerOffset: MutableFloatState,
scrollComp: MutableFloatState
) {
val threshold = 48f
val maxSpeed = 24f
while (true) {
val info = listState.layoutInfo
val center = startCenterY.floatValue + fingerOffset.floatValue
val top = info.viewportStartOffset + threshold
val bottom = info.viewportEndOffset - threshold
val delta = when {
center < top -> -(top - center).coerceAtMost(maxSpeed)
center > bottom -> (center - bottom).coerceAtMost(maxSpeed)
else -> 0f
}
if (delta != 0f) scrollComp.floatValue += listState.scrollBy(delta)
delay(16L)
}
}
private fun statusLabel(code: Int): String = if (code == NetworkResult.CONNECTION_ERROR) "ERR" else code.toString()
@Composable
private fun statusColor(code: Int): Color = when {
code == NetworkResult.CONNECTION_ERROR -> MaterialTheme.colorScheme.error
code in 200..299 -> Color(0xFF66BB6A)
else -> MaterialTheme.colorScheme.error
}
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
fun PreviewNetworkOutputDialog() { fun PreviewNetworkOutputDialog() {
@@ -629,7 +270,6 @@ 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",
@@ -639,7 +279,7 @@ fun PreviewNetworkOutputDialog() {
bluetoothFrequencyFormat = $$"F $FREQ" bluetoothFrequencyFormat = $$"F $FREQ"
), ),
onDismiss = {}, onDismiss = {},
onSave = { _, _, _, _, _, _, _, _, _ -> } onSave = { _, _, _, _, _, _, _, _ -> }
) )
} }
} }
@@ -650,7 +290,7 @@ fun NetworkOutputDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSave: ( onSave: (
Boolean, String, String, String, Boolean, String, String, String,
Boolean, String, String, String, Long Boolean, String, String, String
) -> Unit ) -> Unit
) { ) {
val padding = LocalSpacing.current.large val padding = LocalSpacing.current.large
@@ -664,19 +304,16 @@ 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, offsetHz frequencyState.value, freqIp, freqPort, frequencyFormat.value
) )
onDismiss() onDismiss()
} }
ConfirmDialog( SharedDialog(
title = stringResource(R.string.prefs_net_title), title = stringResource(R.string.prefs_net_title),
onCancel = onDismiss, onCancel = onDismiss,
onAccept = onAccept onAccept = onAccept
@@ -705,27 +342,6 @@ 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))
} }
} }
@@ -754,7 +370,6 @@ 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",
@@ -796,7 +411,7 @@ fun BluetoothOutputDialog(
) )
onDismiss() onDismiss()
} }
ConfirmDialog( SharedDialog(
title = stringResource(R.string.prefs_bt_title), title = stringResource(R.string.prefs_bt_title),
onCancel = onDismiss, onCancel = onDismiss,
onAccept = onAccept onAccept = onAccept
@@ -923,7 +538,7 @@ fun RadioControlDialog(
onDismiss() onDismiss()
} }
ConfirmDialog( SharedDialog(
title = stringResource(R.string.rc_settings_title), title = stringResource(R.string.rc_settings_title),
onCancel = onDismiss, onCancel = onDismiss,
onAccept = onAccept onAccept = onAccept
@@ -64,7 +64,6 @@ 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
@@ -75,7 +74,6 @@ 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
@@ -94,10 +92,22 @@ 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
@@ -118,23 +128,30 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
} }
if (dialogs.dataSources) { if (dialogs.dataSources) {
DataSourcesDialog( DataSourcesDialog(
satelliteUrls = uiState.dataSourcesSettings.satelliteUrls, useCustomTle = uiState.dataSourcesSettings.useCustomTLE,
transceiversUrls = uiState.dataSourcesSettings.transceiversUrls, useCustomTransceivers = uiState.dataSourcesSettings.useCustomTransceivers,
satelliteEnabled = uiState.dataSourcesSettings.satelliteEnabled, tleUrl = uiState.dataSourcesSettings.tleUrl,
transceiversEnabled = uiState.dataSourcesSettings.transceiversEnabled, transceiversUrl = uiState.dataSourcesSettings.transceiversUrl,
statusCodes = uiState.dataSourcesStatus, 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 = { satUrls, txUrls, satEnabled, txEnabled -> onSave = { useCustomTle, useCustomTransceivers, tleUrl, transceiversUrl ->
val newSettings = DataSourcesSettings( val current = uiState.dataSourcesSettings
satelliteUrls = satUrls, val newSettings = current.copy(
transceiversUrls = txUrls, useCustomTLE = if (!useCustomTle || tleUrl.isNotBlank()) useCustomTle else current.useCustomTLE,
satelliteEnabled = satEnabled, tleUrl = if (!useCustomTle || tleUrl.isNotBlank()) tleUrl else current.tleUrl,
transceiversEnabled = txEnabled useCustomTransceivers = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) useCustomTransceivers else current.useCustomTransceivers,
transceiversUrl = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) transceiversUrl else current.transceiversUrl
) )
if (newSettings != uiState.dataSourcesSettings) onAction(SettingsAction.UpdateDataSources(newSettings)) if (newSettings != current) onAction(SettingsAction.UpdateDataSources(newSettings))
onAction(SettingsAction.UpdateFromWeb) if (newSettings.useCustomTLE || newSettings.useCustomTransceivers) {
onAction(SettingsAction.UpdateFromWeb)
}
} }
) )
} }
@@ -142,15 +159,14 @@ 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, freqOffsetHz -> onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt ->
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
) )
) )
) )
@@ -182,9 +198,6 @@ 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
@@ -205,7 +218,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
if (isVerticalLayout) { if (isVerticalLayout) {
TopBar { TopBar {
TopCard( TopCard(
onClick = { dialogs.whatsNew = true }, onClick = { safeOpenUri(appUrl) },
version = uiState.appVersionName, version = uiState.appVersionName,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
@@ -233,7 +246,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 = { dialogs.whatsNew = true }, onClick = { safeOpenUri(appUrl) },
version = uiState.appVersionName, version = uiState.appVersionName,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
@@ -654,7 +667,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_sputnik), painter = painterResource(id = R.drawable.ic_satellites),
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.primary
) )
@@ -705,7 +718,6 @@ 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
@@ -713,12 +725,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, it.whatsNew) listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl)
}, },
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]; whatsNew = it[6] network = it[3]; bluetooth = it[4]; radioControl = it[5]
} }
} }
) )
@@ -735,14 +747,17 @@ 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(
@@ -779,6 +794,15 @@ 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 = {
@@ -795,6 +819,13 @@ 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()
}
} }
) )
} }
@@ -42,7 +42,6 @@ data class SettingsState(
val rcSettings: RCSettings, val rcSettings: RCSettings,
val radioControlSettings: RadioControlSettings, val radioControlSettings: RadioControlSettings,
val dataSourcesSettings: DataSourcesSettings, val dataSourcesSettings: DataSourcesSettings,
val dataSourcesStatus: Map<String, Int> = emptyMap(),
val pairedBluetoothDevices: List<Pair<String, String>> = emptyList() val pairedBluetoothDevices: List<Pair<String, String>> = emptyList()
) )
@@ -49,7 +49,6 @@ class SettingsViewModel(
rcSettings = settingsRepo.rcSettings.value, rcSettings = settingsRepo.rcSettings.value,
radioControlSettings = settingsRepo.radioControlSettings.value, radioControlSettings = settingsRepo.radioControlSettings.value,
dataSourcesSettings = settingsRepo.dataSourcesSettings.value, dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
dataSourcesStatus = settingsRepo.dataSourcesStatus.value,
pairedBluetoothDevices = container.providePairedBluetoothDevices() pairedBluetoothDevices = container.providePairedBluetoothDevices()
) )
) )
@@ -93,11 +92,6 @@ class SettingsViewModel(
_uiState.update { it.copy(dataSourcesSettings = settings) } _uiState.update { it.copy(dataSourcesSettings = settings) }
} }
} }
viewModelScope.launch {
settingsRepo.dataSourcesStatus.collect { status ->
_uiState.update { it.copy(dataSourcesStatus = status) }
}
}
viewModelScope.launch { viewModelScope.launch {
settingsRepo.radioControlSettings.collect { settings -> settingsRepo.radioControlSettings.collect { settings ->
_uiState.update { it.copy(radioControlSettings = settings) } _uiState.update { it.copy(radioControlSettings = settings) }
@@ -1,25 +1,34 @@
package com.rtbishop.look4sat.feature.status package com.rtbishop.look4sat.feature.status
import androidx.compose.foundation.Image import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.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.heightIn import androidx.compose.foundation.layout.PaddingValues
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
@@ -33,15 +42,11 @@ 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
@@ -50,15 +55,10 @@ 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.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IMainContainer
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.core.presentation.layoutPadding import com.rtbishop.look4sat.feature.status.R as StatusR
import java.util.Calendar import java.util.Calendar
import java.util.Locale
/** 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.
@@ -68,43 +68,37 @@ private val TILE_WIDTH: Dp = 64.dp
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 MaterialTheme.colorScheme.tertiary // Active
statusText.contains("Telemetry", ignoreCase = true) || statusText.contains("Beacon", ignoreCase = true) -> statusText.contains("Telemetry", ignoreCase = true) || statusText.contains("Beacon", ignoreCase = true) ->
MaterialTheme.colorScheme.tertiaryContainer MaterialTheme.colorScheme.tertiaryContainer // Telemetry
statusText.contains("Not Heard", ignoreCase = true) -> statusText.contains("Not Heard", ignoreCase = true) ->
Color(0xFFDC267F) Color(0xFFDC267F) // NotHeard pink (no semantic slot)
else -> else ->
MaterialTheme.colorScheme.error MaterialTheme.colorScheme.error // Conflict
} }
} }
/** Localize AMSAT API status text for the current locale; falls back to the raw text. */ private val NoReportGray = Color(0xFFC0C0C0) // Neutral state
@Composable
private fun statusTextOf(statusText: String): String {
if (Locale.getDefault().language != Locale.CHINESE.language) return statusText
return when (statusText.lowercase()) {
"heard" -> stringResource(R.string.amsat_status_heard)
"telemetry only" -> stringResource(R.string.amsat_status_tlm_only)
"not heard" -> stringResource(R.string.amsat_status_not_heard)
"crew active" -> stringResource(R.string.amsat_status_crew_active)
else -> statusText
}
}
@Composable @Composable
fun SatStatusDestination() { fun SatStatusScreen(container: IMainContainer) {
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()
SatStatusScreen(uiState) { viewModel.refresh() }
}
@Composable
private fun SatStatusScreen(uiState: SatStatusUiState, refresh: () -> Unit) {
var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) } var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) }
Column(modifier = Modifier.fillMaxSize().layoutPadding()) {
StatusHeader(fetchedAtUtcMs = uiState.fetchedAtUtcMs, isRefreshing = uiState.isRefreshing, onRefresh = refresh) Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.navigationBarsPadding()
.padding(horizontal = 8.dp)
) {
// Top: legend + refresh
StatusHeader(
fetchedAtUtcMs = uiState.fetchedAtUtcMs,
isRefreshing = uiState.isRefreshing,
onRefresh = { viewModel.refresh() }
)
LegendRow() LegendRow()
when { when {
@@ -119,16 +113,29 @@ private fun SatStatusScreen(uiState: SatStatusUiState, refresh: () -> Unit) {
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text(text = stringResource(id = R.string.amsat_load_failed), color = MaterialTheme.colorScheme.error) Text(
TextButton(onClick = refresh) { Text(text = stringResource(id = R.string.amsat_retry)) } text = stringResource(id = R.string.amsat_load_failed),
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)
LazyColumn(modifier = Modifier.fillMaxSize()) { // Row
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 16.dp)
) {
items(uiState.statuses, key = { it.name }) { status -> items(uiState.statuses, key = { it.name }) { status ->
StatusRow(status = status, onClickDay = { day -> selectedDay = status to day }) StatusRow(
status = status,
onClickDay = { day -> selectedDay = status to day }
)
} }
} }
} }
@@ -136,41 +143,56 @@ private fun SatStatusScreen(uiState: SatStatusUiState, refresh: () -> Unit) {
} }
selectedDay?.let { (status, day) -> selectedDay?.let { (status, day) ->
ReportDialog(statusName = status.name, day = day, reports = uiState.reports, onDismiss = { selectedDay = null }) ReportDialog(
statusName = status.name,
day = day,
reports = uiState.reports,
onDismiss = { selectedDay = null }
)
} }
} }
/** Top: update time + refresh button (spinner while loading) */ /** Top: update time + refresh button (spinner while loading) */
@Composable @Composable
private fun StatusHeader(fetchedAtUtcMs: Long, isRefreshing: Boolean, onRefresh: () -> Unit) { private fun StatusHeader(
fetchedAtUtcMs: Long,
isRefreshing: Boolean,
onRefresh: () -> Unit
) {
val infiniteTransition = rememberInfiniteTransition(label = "refresh")
val angle by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(tween(800), RepeatMode.Restart),
label = "angle"
)
Row( Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = if (fetchedAtUtcMs > 0) text = if (fetchedAtUtcMs > 0) stringResource(id = R.string.amsat_updated) + " " + formatFetchedAt(fetchedAtUtcMs) else stringResource(id = R.string.amsat_title),
stringResource(id = R.string.amsat_updated) + " " + formatFetchedAt(fetchedAtUtcMs) fontSize = 13.sp,
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(32.dp) .size(34.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(16.dp), strokeWidth = 2.dp) CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else { } else {
Image( Image(
painter = painterResource(id = R.drawable.ic_refresh), painter = painterResource(id = StatusR.drawable.ic_refresh),
contentDescription = stringResource(id = R.string.amsat_refresh), contentDescription = stringResource(id = R.string.amsat_refresh),
modifier = Modifier.size(16.dp), modifier = Modifier
.size(18.dp)
.rotate(angle),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary) colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary)
) )
} }
@@ -178,168 +200,171 @@ private fun StatusHeader(fetchedAtUtcMs: Long, isRefreshing: Boolean, onRefresh:
} }
} }
/** Legend: FlowRow of colored chips — wraps to two lines on narrow screens, stays one line when wide. */ /** Legend (official four colors) */
@Composable @Composable
private fun LegendRow() { private fun LegendRow() {
val legend = listOf( val legend = listOf(
stringResource(id = R.string.amsat_active) to Color(0xFF648FFF), stringResource(id = R.string.amsat_active) to MaterialTheme.colorScheme.tertiary,
stringResource(id = R.string.amsat_tlm) to Color(0xFFFFB000), stringResource(id = R.string.amsat_tlm) to MaterialTheme.colorScheme.tertiaryContainer,
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 Color(0xFFFE6100) stringResource(id = R.string.amsat_conflict) to MaterialTheme.colorScheme.error
) )
FlowRow( Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)
verticalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
legend.forEach { (label, color) -> legend.forEach { (label, color) ->
val alphaColor = color.copy(alpha = 0.25f) Row(verticalAlignment = Alignment.CenterVertically) {
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(8.dp) .size(10.dp)
.clip(RoundedCornerShape(4.dp)) .clip(RoundedCornerShape(2.dp))
.background(color) .background(color)
) )
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(3.dp))
Text( Text(text = label, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
text = label,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} }
} }
} }
} }
/** Header: satellite name column + fixed-width date labels aligned to tiles. */ /** Header: satellite name + 6 day dates */
@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 = 2.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = stringResource(id = R.string.amsat_name), text = stringResource(id = R.string.amsat_name),
fontSize = 12.sp, fontSize = 11.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f).padding(start = 4.dp) modifier = Modifier.weight(2f).padding(start = 4.dp)
) )
dates.forEach { date -> dates.forEach { date ->
Text( Text(
text = date, text = date,
fontSize = 12.sp, fontSize = 10.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
modifier = Modifier.width(TILE_WIDTH) modifier = Modifier.weight(0.8f)
) )
} }
} }
} }
/** Satellite row: name takes remaining width; day tiles are fixed-width (tablet-safe). */ /** Satellite row: name + 6 day color blocks (displays the newest non-gray status) */
@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.fillMaxWidth().padding(vertical = 2.dp), modifier = Modifier
.fillMaxWidth()
.padding(vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = status.name, text = status.name,
fontSize = 14.sp, fontSize = 11.sp,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(end = 4.dp) modifier = Modifier.weight(2f).padding(start = 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.width(TILE_WIDTH).padding(horizontal = 2.dp), modifier = Modifier.weight(0.8f).padding(horizontal = 1.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(28.dp) .height(24.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 = slot.count.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White) Text(
text = slot.count.toString(),
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
} }
} }
} }
/** Report detail dialog (callsign / date / time / grid) */ /** Report detail dialog (3 levels: callsign/date/time/grid) */
@Composable @Composable
private fun ReportDialog(statusName: String, day: SatDay, reports: Map<String, SatReport>, onDismiss: () -> Unit) { private fun ReportDialog(
val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] } statusName: String,
InfoDialog(title = "$statusName · ${day.dateLabel}", onDismiss = onDismiss, onAccept = onDismiss) { day: SatDay,
if (dayReports.isEmpty()) { reports: Map<String, SatReport>,
Text(stringResource(id = R.string.amsat_no_reports)) onDismiss: () -> Unit
Spacer(modifier = Modifier.height(8.dp)) ) {
} else { val dayReports = day.slots.flatMap { it.reportIds }
LazyColumn(modifier = Modifier.heightIn(max = 480.dp)) { .mapNotNull { reports[it] }
items(dayReports) { report -> AlertDialog(
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { onDismissRequest = onDismiss,
Row(verticalAlignment = Alignment.CenterVertically) { title = { Text(text = "$statusName · ${day.dateLabel}") },
Box( text = {
modifier = Modifier if (dayReports.isEmpty()) {
.size(8.dp) Text(stringResource(id = R.string.amsat_no_reports))
.clip(CircleShape) } else {
.background(statusColorOf(report.statusText)) LazyColumn(modifier = Modifier.height(320.dp)) {
items(dayReports) { report ->
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(statusColorOf(report.statusText))
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = report.statusText,
fontSize = 13.sp,
fontWeight = FontWeight.Bold
)
}
Text(
text = "${report.call} ${report.dateUtc} ${report.timeUtc}" +
if (report.grid.isNotBlank() && report.grid != "-") " ${report.grid}" else "",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Spacer(modifier = Modifier.width(6.dp))
Text(text = statusTextOf(report.statusText), fontSize = 14.sp, fontWeight = FontWeight.Bold)
} }
Text( HorizontalDivider(thickness = 0.5.dp)
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
val day = cal.get(Calendar.DAY_OF_MONTH) return "%02d-%02d %02d:%02d".format(
val monthIndex = cal.get(Calendar.MONTH) cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH),
val year = cal.get(Calendar.YEAR) cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE)
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 if (Locale.getDefault().language == Locale.CHINESE.language) {
"${year}年${monthIndex + 1}月${day}日 - $hh:$mm:$ss"
} else {
"$day${MONTH_ABBR[monthIndex]} $year - $hh:$mm:$ss"
}
} }
@@ -6,7 +6,6 @@ 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
@@ -23,7 +22,7 @@ data class SatStatusUiState(
) )
class SatStatusViewModel( class SatStatusViewModel(
private val amSatRepo: IAmSatRepository private val container: IMainContainer
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true)) private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true))
@@ -37,7 +36,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 = amSatRepo.fetchStatus() val page = container.amSatRepo.fetchStatus()
if (page != null && page.statuses.isNotEmpty()) { if (page != null && page.statuses.isNotEmpty()) {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -66,7 +65,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 = amSatRepo.fetchStatus() val page = container.amSatRepo.fetchStatus()
if (page != null && page.statuses.isNotEmpty()) { if (page != null && page.statuses.isNotEmpty()) {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -88,7 +87,7 @@ class SatStatusViewModel(
companion object { companion object {
fun factory(container: IMainContainer) = viewModelFactory { fun factory(container: IMainContainer) = viewModelFactory {
initializer { initializer {
SatStatusViewModel(container.amSatRepo) SatStatusViewModel(container)
} }
} }
} }
@@ -1,10 +1,12 @@
<?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="@android:color/white" android:fillColor="#FF000000"
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>
+2 -2
View File
@@ -1,8 +1,8 @@
[versions] [versions]
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionCode = "446" appVersionCode = "445"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.6" appVersionName = "4.4.5"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
compileSdk = "37" compileSdk = "37"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
+2 -2
View File
@@ -1,7 +1,7 @@
#Tue Aug 11 14:16:53 CEST 2026 #Sat Jun 27 12:51:12 BST 2026
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME