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