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