mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Merged RadarScreen and RadioControlScreen functionality
This commit is contained in:
1 parent
22607e7710
commit
18c8ac1822
15 files changed
+1052
-891
No files matched your search
@@ -68,7 +68,6 @@ import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
|||||||
import com.rtbishop.look4sat.feature.map.MapDestination
|
import com.rtbishop.look4sat.feature.map.MapDestination
|
||||||
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
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.radiocontrol.RadioControlDestination
|
|
||||||
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
|
||||||
|
|
||||||
@@ -143,10 +142,7 @@ fun MainScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
entry<Screen.Radar> {
|
entry<Screen.Radar> {
|
||||||
RadarDestination(navigateToRadioControl = { backStack.add(Screen.RadioControl) })
|
RadarDestination(navigateUp = navigateBack)
|
||||||
}
|
|
||||||
entry<Screen.RadioControl> {
|
|
||||||
RadioControlDestination(navigateUp = navigateBack)
|
|
||||||
}
|
}
|
||||||
entry<Screen.Map> {
|
entry<Screen.Map> {
|
||||||
MapDestination()
|
MapDestination()
|
||||||
@@ -175,7 +171,7 @@ fun MainScreen() {
|
|||||||
val pass = trackingState.currentPass
|
val pass = trackingState.currentPass
|
||||||
if (pass != null) {
|
if (pass != null) {
|
||||||
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
||||||
backStack.add(Screen.RadioControl)
|
backStack.add(Screen.Radar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
|
|||||||
-1
@@ -34,7 +34,6 @@ internal class ApplicationPlugin : Plugin<Project> {
|
|||||||
implementation(project(":feature:map"))
|
implementation(project(":feature:map"))
|
||||||
implementation(project(":feature:passes"))
|
implementation(project(":feature:passes"))
|
||||||
implementation(project(":feature:radar"))
|
implementation(project(":feature:radar"))
|
||||||
implementation(project(":feature:radiocontrol"))
|
|
||||||
implementation(project(":feature:satellites"))
|
implementation(project(":feature:satellites"))
|
||||||
implementation(project(":feature:settings"))
|
implementation(project(":feature:settings"))
|
||||||
implementation(libs.androidx.core.splashscreen)
|
implementation(libs.androidx.core.splashscreen)
|
||||||
|
|||||||
+1
-1
@@ -64,7 +64,7 @@ class RadioTrackingService(
|
|||||||
Log.i(tag, "Connecting TX=$txAddr RX=$rxAddr")
|
Log.i(tag, "Connecting TX=$txAddr RX=$rxAddr")
|
||||||
|
|
||||||
if (txAddr.isBlank() && rxAddr.isBlank()) {
|
if (txAddr.isBlank() && rxAddr.isBlank()) {
|
||||||
_state.update { it.copy(errorMessage = "No radio addresses configured. Set them in Settings → FT-817.") }
|
_state.update { it.copy(errorMessage = "No radio addresses configured in Settings") }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,9 +32,6 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
|||||||
@Serializable
|
@Serializable
|
||||||
data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar)
|
data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar)
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data object RadioControl : Screen(0, 0)
|
|
||||||
|
|
||||||
@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)
|
||||||
|
|
||||||
|
|||||||
@@ -23,9 +23,7 @@ import androidx.compose.animation.core.animateFloat
|
|||||||
import androidx.compose.animation.core.infiniteRepeatable
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
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
|
||||||
@@ -33,62 +31,59 @@ import androidx.compose.foundation.layout.Row
|
|||||||
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.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.PrimaryTabRow
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
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.keepScreenOn
|
import androidx.compose.ui.keepScreenOn
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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.semantics.contentDescription
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
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
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||||
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.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.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.getDefaultPass
|
import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
||||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
|
||||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
private enum class RadarPage(val title: String) {
|
||||||
|
Transceivers("Transceivers"),
|
||||||
|
Sstv("SSTV")
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RadarDestination(navigateToRadioControl: () -> Unit = {}) {
|
fun RadarDestination(navigateUp: () -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
|
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
RadarScreen(uiState, viewModel::onAction, navigateToRadioControl)
|
RadarScreen(uiState, viewModel::onAction, navigateUp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RadarScreen(
|
private fun RadarScreen(
|
||||||
uiState: RadarState,
|
uiState: RadarState,
|
||||||
onAction: (RadarAction) -> Unit,
|
onAction: (RadarAction) -> Unit,
|
||||||
navigateToRadioControl: () -> Unit
|
navigateUp: () -> Unit
|
||||||
) {
|
) {
|
||||||
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
||||||
val addToCalendar: () -> Unit = {
|
val addToCalendar: () -> Unit = {
|
||||||
@@ -103,31 +98,95 @@ private fun RadarScreen(
|
|||||||
val isVertical = isVerticalLayout()
|
val isVertical = isVerticalLayout()
|
||||||
if (isVertical) {
|
if (isVertical) {
|
||||||
TopBar {
|
TopBar {
|
||||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
||||||
IconCard(action = navigateToRadioControl, resId = R.drawable.ic_radios)
|
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
||||||
}
|
}
|
||||||
TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
|
TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
|
||||||
} else {
|
} else {
|
||||||
TopBar {
|
TopBar {
|
||||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
||||||
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
|
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
|
||||||
IconCard(action = navigateToRadioControl, resId = R.drawable.ic_radios)
|
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isVertical) {
|
if (isVertical) {
|
||||||
RadarCard(uiState, Modifier.weight(1f))
|
RadarCard(uiState, Modifier.weight(1f))
|
||||||
TransmittersCard(uiState.transmitters, uiState.selectedTransmitterUuid, onAction, Modifier.weight(1f))
|
PagerCard(uiState, onAction, Modifier.weight(1f))
|
||||||
} else {
|
} else {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
RadarCard(uiState, Modifier.weight(1f))
|
RadarCard(uiState, Modifier.weight(1f))
|
||||||
TransmittersCard(uiState.transmitters, uiState.selectedTransmitterUuid, onAction, Modifier.weight(1f))
|
PagerCard(uiState, onAction, Modifier.weight(1f))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PagerCard(
|
||||||
|
uiState: RadarState,
|
||||||
|
onAction: (RadarAction) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val pages = RadarPage.entries
|
||||||
|
val pagerState = rememberPagerState(pageCount = { pages.size })
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
ElevatedCard(modifier = modifier) {
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
PrimaryTabRow(selectedTabIndex = pagerState.currentPage) {
|
||||||
|
pages.forEachIndexed { index, page ->
|
||||||
|
Tab(
|
||||||
|
selected = pagerState.currentPage == index,
|
||||||
|
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
|
||||||
|
text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) { pageIndex ->
|
||||||
|
when (pages[pageIndex]) {
|
||||||
|
RadarPage.Transceivers -> TransceiversPage(
|
||||||
|
transceivers = uiState.transmitters,
|
||||||
|
selectedUuid = uiState.selectedTransmitterUuid,
|
||||||
|
radioControl = uiState.radioControl,
|
||||||
|
onAction = onAction
|
||||||
|
)
|
||||||
|
RadarPage.Sstv -> SstvPlaceholderPage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SstvPlaceholderPage() {
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Text(text = "🚧", fontSize = 48.sp)
|
||||||
|
Text(
|
||||||
|
text = "SSTV Decoder",
|
||||||
|
fontSize = 20.sp,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Coming soon",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
|
private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
|
||||||
val satellitePos = uiState.orbitalPos
|
val satellitePos = uiState.orbitalPos
|
||||||
@@ -237,180 +296,3 @@ private fun RadarLabel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TransmittersCard(
|
|
||||||
transmitters: List<SatRadio>,
|
|
||||||
selectedUuid: String?,
|
|
||||||
onAction: (RadarAction) -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
ElevatedCard(modifier = modifier) {
|
|
||||||
if (transmitters.isEmpty()) {
|
|
||||||
EmptyTransmittersContent()
|
|
||||||
} else {
|
|
||||||
TransmittersList(
|
|
||||||
transmitters = transmitters,
|
|
||||||
selectedUuid = selectedUuid,
|
|
||||||
onSelect = { uuid ->
|
|
||||||
if (selectedUuid != null) {
|
|
||||||
onAction(RadarAction.SelectTransmitter(uuid))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun EmptyTransmittersContent() {
|
|
||||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
|
||||||
modifier = Modifier.padding(32.dp)
|
|
||||||
) {
|
|
||||||
Text(text = """¯\_(ツ)_/¯""", fontSize = 32.sp)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.empty_list_message),
|
|
||||||
fontSize = 21.sp,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.radar_no_data),
|
|
||||||
fontSize = 18.sp,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TransmittersList(
|
|
||||||
transmitters: List<SatRadio>,
|
|
||||||
selectedUuid: String?,
|
|
||||||
onSelect: (String) -> Unit
|
|
||||||
) {
|
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
|
||||||
items(items = transmitters, key = { it.uuid }) { radio ->
|
|
||||||
TransmitterItem(
|
|
||||||
radio = radio,
|
|
||||||
isClickable = selectedUuid != null,
|
|
||||||
isSelected = radio.uuid == selectedUuid,
|
|
||||||
onClick = { onSelect(radio.uuid) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
private fun TransmitterItemPreview() {
|
|
||||||
val transmitter = SatRadio(
|
|
||||||
"", "Extremely powerful transmitter", true, 10000000000L, 10000000000L,
|
|
||||||
null, 10000000000L, 10000000000L, "FSK AX.100 Mode 5", true, 0
|
|
||||||
)
|
|
||||||
MainTheme { TransmitterItem(transmitter, isClickable = true, isSelected = true, onClick = {}) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TransmitterItem(
|
|
||||||
radio: SatRadio,
|
|
||||||
isClickable: Boolean,
|
|
||||||
isSelected: Boolean,
|
|
||||||
onClick: () -> Unit
|
|
||||||
) {
|
|
||||||
val title = if (radio.isInverted) "INVERTED: ${radio.info}" else radio.info
|
|
||||||
val fullTitle = "$title - (${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"})"
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.then(if (isClickable) Modifier.clickable { onClick() } else Modifier)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
|
||||||
.padding(horizontal = 8.dp, vertical = 6.dp)
|
|
||||||
) {
|
|
||||||
Box {
|
|
||||||
Text(
|
|
||||||
text = fullTitle,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 6.dp)
|
|
||||||
.infiniteMarquee()
|
|
||||||
)
|
|
||||||
if (isSelected) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.drawable.ic_radios),
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.background(color = MaterialTheme.colorScheme.surface)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
FrequencyRow(radio = radio, isDownlink = true)
|
|
||||||
FrequencyRow(radio = radio, isDownlink = false)
|
|
||||||
}
|
|
||||||
HorizontalDivider(thickness = 2.dp, color = MaterialTheme.colorScheme.background)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun FrequencyRow(radio: SatRadio, isDownlink: Boolean) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
val desc = if (isDownlink) stringResource(R.string.radar_downlink)
|
|
||||||
else stringResource(R.string.radar_uplink)
|
|
||||||
Text(
|
|
||||||
text = if (isDownlink) "D:" else "U:",
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
fontSize = 18.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(24.dp)
|
|
||||||
.semantics { contentDescription = desc }
|
|
||||||
)
|
|
||||||
FrequencyText(
|
|
||||||
frequency = if (isDownlink) radio.downlinkLow else radio.uplinkLow,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "-",
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
fontSize = 21.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
FrequencyText(
|
|
||||||
frequency = if (isDownlink) radio.downlinkHigh else radio.uplinkHigh,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.drawable.ic_arrow),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier
|
|
||||||
.rotate(if (isDownlink) 90f else -90f)
|
|
||||||
.size(24.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
|
|
||||||
val text = frequency?.let {
|
|
||||||
stringResource(id = R.string.radar_link_low, it / 1000000f)
|
|
||||||
} ?: stringResource(R.string.radar_no_link)
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
fontSize = 21.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = modifier
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -22,6 +22,25 @@ import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
|
|||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
|
|
||||||
|
data class RadioPanelState(
|
||||||
|
val label: String = "",
|
||||||
|
val isConnected: Boolean = false,
|
||||||
|
val frequencyHz: Long? = null,
|
||||||
|
val frequencyDisplay: String = "---",
|
||||||
|
val mode: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RadioControlSubState(
|
||||||
|
val txPanel: RadioPanelState = RadioPanelState("TX (Uplink)"),
|
||||||
|
val rxPanel: RadioPanelState = RadioPanelState("RX (Downlink)"),
|
||||||
|
val transponders: List<SatRadio> = emptyList(),
|
||||||
|
val selectedTransponderUuid: String? = null,
|
||||||
|
val txBaseFrequencyHz: Long? = null,
|
||||||
|
val ctcssTone: Double? = null,
|
||||||
|
val isTracking: Boolean = false,
|
||||||
|
val errorMessage: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
data class RadarState(
|
data class RadarState(
|
||||||
val currentPass: OrbitalPass? = null,
|
val currentPass: OrbitalPass? = null,
|
||||||
val currentTime: String = "00:00:00",
|
val currentTime: String = "00:00:00",
|
||||||
@@ -37,10 +56,20 @@ data class RadarState(
|
|||||||
val moonPosition: CelestialComputer.MoonPosition? = null,
|
val moonPosition: CelestialComputer.MoonPosition? = null,
|
||||||
val transmitters: List<SatRadio> = emptyList(),
|
val transmitters: List<SatRadio> = emptyList(),
|
||||||
val selectedTransmitterUuid: String? = null,
|
val selectedTransmitterUuid: String? = null,
|
||||||
val selectedFrequency: Long? = null
|
val selectedFrequency: Long? = null,
|
||||||
|
val radioControl: RadioControlSubState = RadioControlSubState()
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface RadarAction {
|
sealed interface RadarAction {
|
||||||
data class AddToCalendar(val name: String, val aosTime: Long, val losTime: Long) : RadarAction
|
data class AddToCalendar(val name: String, val aosTime: Long, val losTime: Long) : RadarAction
|
||||||
data class SelectTransmitter(val uuid: String) : RadarAction
|
data class SelectTransmitter(val uuid: String) : RadarAction
|
||||||
|
|
||||||
|
// Radio control actions
|
||||||
|
data class SelectTransponder(val uuid: String) : RadarAction
|
||||||
|
data class SetTxFrequency(val frequencyHz: Long) : RadarAction
|
||||||
|
data class AdjustTxFrequency(val deltaHz: Long) : RadarAction
|
||||||
|
data class SetCtcssTone(val toneHz: Double?) : RadarAction
|
||||||
|
data object ToggleTracking : RadarAction
|
||||||
|
data object ConnectRadios : RadarAction
|
||||||
|
data object DisconnectRadios : RadarAction
|
||||||
}
|
}
|
||||||
+112
-22
@@ -26,6 +26,7 @@ import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
|
|||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||||
@@ -41,6 +42,7 @@ import kotlinx.coroutines.flow.collectLatest
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
class RadarViewModel(
|
class RadarViewModel(
|
||||||
private val bluetoothReporter: IReporter,
|
private val bluetoothReporter: IReporter,
|
||||||
@@ -48,11 +50,14 @@ class RadarViewModel(
|
|||||||
private val satelliteRepo: ISatelliteRepo,
|
private val satelliteRepo: ISatelliteRepo,
|
||||||
private val settingsRepo: ISettingsRepo,
|
private val settingsRepo: ISettingsRepo,
|
||||||
private val sensorsRepo: ISensorsRepo,
|
private val sensorsRepo: ISensorsRepo,
|
||||||
private val addToCalendar: IAddToCalendar
|
private val addToCalendar: IAddToCalendar,
|
||||||
|
private val trackingService: IRadioTrackingService
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val stationPos = settingsRepo.stationPosition.value
|
private val stationPos = settingsRepo.stationPosition.value
|
||||||
private val magDeclination = sensorsRepo.getMagDeclination(stationPos)
|
private val magDeclination = sensorsRepo.getMagDeclination(stationPos)
|
||||||
|
private var transponders: List<SatRadio> = emptyList()
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(
|
private val _uiState = MutableStateFlow(
|
||||||
RadarState(
|
RadarState(
|
||||||
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
|
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
|
||||||
@@ -87,7 +92,20 @@ class RadarViewModel(
|
|||||||
val satPass = passes.find { it.catNum == catNum && it.aosTime == aosTime } ?: passes.firstOrNull()
|
val satPass = passes.find { it.catNum == catNum && it.aosTime == aosTime } ?: passes.firstOrNull()
|
||||||
satPass?.let { pass ->
|
satPass?.let { pass ->
|
||||||
_uiState.update { it.copy(currentPass = pass) }
|
_uiState.update { it.copy(currentPass = pass) }
|
||||||
val transmitters = satelliteRepo.getRadiosWithId(pass.catNum)
|
val transmittersList = satelliteRepo.getRadiosWithId(pass.catNum)
|
||||||
|
// Load transponders for radio control (ones that have downlink)
|
||||||
|
transponders = transmittersList.filter { it.downlinkLow != null }
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.copy(radioControl = state.radioControl.copy(transponders = transponders))
|
||||||
|
}
|
||||||
|
// Auto-expand the first item so users discover the expandable UI
|
||||||
|
if (transmittersList.isNotEmpty()) {
|
||||||
|
val firstUuid = transmittersList.first().uuid
|
||||||
|
_uiState.update { it.copy(selectedTransmitterUuid = firstUuid) }
|
||||||
|
transponders.find { it.uuid == firstUuid }?.let {
|
||||||
|
trackingService.setTransponder(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
// Compute track once (it doesn't change for a given pass)
|
// Compute track once (it doesn't change for a given pass)
|
||||||
if (!pass.isDeepSpace) {
|
if (!pass.isDeepSpace) {
|
||||||
val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime)
|
val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime)
|
||||||
@@ -111,12 +129,42 @@ class RadarViewModel(
|
|||||||
moonPosition = moonPos
|
moonPosition = moonPos
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
processRadios(transmitters, pass.orbitalObject, timeNow)
|
processRadios(transmittersList, pass.orbitalObject, timeNow)
|
||||||
sendPassData(pos)
|
sendPassData(pos)
|
||||||
delay(1000)
|
delay(1000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Observe radio tracking service state
|
||||||
|
viewModelScope.launch {
|
||||||
|
trackingService.state.collect { svc ->
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.copy(
|
||||||
|
radioControl = state.radioControl.copy(
|
||||||
|
txPanel = RadioPanelState(
|
||||||
|
label = "TX (Uplink)",
|
||||||
|
isConnected = svc.txConnected,
|
||||||
|
frequencyHz = svc.txFrequencyHz,
|
||||||
|
frequencyDisplay = svc.txFrequencyHz?.let { formatFrequency(it) } ?: "---",
|
||||||
|
mode = svc.txMode
|
||||||
|
),
|
||||||
|
rxPanel = RadioPanelState(
|
||||||
|
label = "RX (Downlink)",
|
||||||
|
isConnected = svc.rxConnected,
|
||||||
|
frequencyHz = svc.rxFrequencyHz,
|
||||||
|
frequencyDisplay = svc.rxFrequencyHz?.let { formatFrequency(it) } ?: "---",
|
||||||
|
mode = svc.rxMode
|
||||||
|
),
|
||||||
|
txBaseFrequencyHz = svc.txBaseFrequencyHz,
|
||||||
|
ctcssTone = svc.ctcssTone,
|
||||||
|
isTracking = svc.isActive,
|
||||||
|
selectedTransponderUuid = svc.selectedTransponder?.uuid,
|
||||||
|
errorMessage = svc.errorMessage
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
@@ -127,7 +175,41 @@ class RadarViewModel(
|
|||||||
fun onAction(action: RadarAction) {
|
fun onAction(action: RadarAction) {
|
||||||
when (action) {
|
when (action) {
|
||||||
is RadarAction.AddToCalendar -> addToCalendar(action.name, action.aosTime, action.losTime)
|
is RadarAction.AddToCalendar -> addToCalendar(action.name, action.aosTime, action.losTime)
|
||||||
is RadarAction.SelectTransmitter -> _uiState.update { it.copy(selectedTransmitterUuid = action.uuid) }
|
is RadarAction.SelectTransmitter -> {
|
||||||
|
val previousUuid = _uiState.value.selectedTransmitterUuid
|
||||||
|
_uiState.update {
|
||||||
|
val newUuid = if (it.selectedTransmitterUuid == action.uuid) null else action.uuid
|
||||||
|
it.copy(selectedTransmitterUuid = newUuid)
|
||||||
|
}
|
||||||
|
// Also set this as the active transponder for radio tracking.
|
||||||
|
// Always use the original (un-Dopplered) transponders list so that the
|
||||||
|
// TX base frequency is computed from the clean nominal values.
|
||||||
|
// Only update the service when selecting a *different* transponder to
|
||||||
|
// avoid resetting a user-adjusted TX base on re-expand.
|
||||||
|
val selected = transponders.find { it.uuid == action.uuid }
|
||||||
|
if (selected != null && _uiState.value.selectedTransmitterUuid != null && previousUuid != action.uuid) {
|
||||||
|
trackingService.setTransponder(selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is RadarAction.SelectTransponder -> {
|
||||||
|
val transponder = transponders.find { it.uuid == action.uuid } ?: return
|
||||||
|
trackingService.setTransponder(transponder)
|
||||||
|
}
|
||||||
|
is RadarAction.SetTxFrequency -> trackingService.setTxBaseFrequency(action.frequencyHz)
|
||||||
|
is RadarAction.AdjustTxFrequency -> trackingService.adjustTxBaseFrequency(action.deltaHz)
|
||||||
|
is RadarAction.SetCtcssTone -> trackingService.setCtcssTone(action.toneHz)
|
||||||
|
RadarAction.ToggleTracking -> {
|
||||||
|
val svc = trackingService.state.value
|
||||||
|
if (svc.isActive) {
|
||||||
|
trackingService.stopTracking()
|
||||||
|
} else {
|
||||||
|
val pass = _uiState.value.currentPass ?: return
|
||||||
|
val transponder = svc.selectedTransponder ?: return
|
||||||
|
trackingService.startTracking(pass, transponder, svc.txBaseFrequencyHz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RadarAction.ConnectRadios -> viewModelScope.launch { trackingService.connectRadios() }
|
||||||
|
RadarAction.DisconnectRadios -> viewModelScope.launch { trackingService.disconnectRadios() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,20 +260,10 @@ class RadarViewModel(
|
|||||||
val isFreqEnabled =
|
val isFreqEnabled =
|
||||||
settingsRepo.rcSettings.value.frequencyState || settingsRepo.rcSettings.value.bluetoothFrequencyState
|
settingsRepo.rcSettings.value.frequencyState || settingsRepo.rcSettings.value.bluetoothFrequencyState
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
if (!isFreqEnabled) {
|
// Derive the frequency to report from the user's current selection (if any)
|
||||||
// Skip update if nothing changed
|
val freq = if (isFreqEnabled && state.selectedTransmitterUuid != null) {
|
||||||
if (state.transmitters == transmitters && state.selectedTransmitterUuid == null && state.selectedFrequency == null) {
|
val selectedRadio = transmitters.firstOrNull { it.uuid == state.selectedTransmitterUuid }
|
||||||
return@update state
|
selectedRadio?.let { radio ->
|
||||||
}
|
|
||||||
return@update state.copy(
|
|
||||||
transmitters = transmitters,
|
|
||||||
selectedTransmitterUuid = null,
|
|
||||||
selectedFrequency = null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val selectedUuid = state.selectedTransmitterUuid ?: transmitters.firstOrNull()?.uuid
|
|
||||||
val selectedRadio = transmitters.firstOrNull { it.uuid == selectedUuid }
|
|
||||||
val freq = selectedRadio?.let { radio ->
|
|
||||||
val low = radio.downlinkLow
|
val low = radio.downlinkLow
|
||||||
val high = radio.downlinkHigh
|
val high = radio.downlinkHigh
|
||||||
when {
|
when {
|
||||||
@@ -200,15 +272,32 @@ class RadarViewModel(
|
|||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Skip update if nothing changed
|
} else null
|
||||||
if (state.transmitters == transmitters && state.selectedTransmitterUuid == selectedUuid && state.selectedFrequency == freq) {
|
|
||||||
|
if (state.transmitters == transmitters && state.selectedFrequency == freq) {
|
||||||
return@update state
|
return@update state
|
||||||
}
|
}
|
||||||
state.copy(transmitters = transmitters, selectedTransmitterUuid = selectedUuid, selectedFrequency = freq)
|
state.copy(transmitters = transmitters, selectedFrequency = freq)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
|
val CTCSS_TONES = listOf(
|
||||||
|
67.0, 69.3, 71.9, 74.4, 77.0, 79.7, 82.5, 85.4, 88.5, 91.5,
|
||||||
|
94.8, 97.4, 100.0, 103.5, 107.2, 110.9, 114.8, 118.8, 123.0, 127.3, 131.8, 136.5,
|
||||||
|
141.3, 146.2, 151.4, 156.7, 162.2, 167.9, 173.8, 179.9, 186.2, 192.8, 203.5, 210.7,
|
||||||
|
218.1, 225.7, 233.6, 241.8, 250.3
|
||||||
|
)
|
||||||
|
|
||||||
|
fun formatFrequency(frequencyHz: Long): String {
|
||||||
|
if (frequencyHz <= 0) return "---"
|
||||||
|
val mhz = frequencyHz / 1_000_000
|
||||||
|
val khz = (frequencyHz % 1_000_000) / 1_000
|
||||||
|
val hz = frequencyHz % 1_000
|
||||||
|
return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz)
|
||||||
|
}
|
||||||
|
|
||||||
fun factory(container: IMainContainer) = viewModelFactory {
|
fun factory(container: IMainContainer) = viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
RadarViewModel(
|
RadarViewModel(
|
||||||
@@ -217,7 +306,8 @@ class RadarViewModel(
|
|||||||
satelliteRepo = container.satelliteRepo,
|
satelliteRepo = container.satelliteRepo,
|
||||||
settingsRepo = container.settingsRepo,
|
settingsRepo = container.settingsRepo,
|
||||||
sensorsRepo = container.provideSensorsRepo(),
|
sensorsRepo = container.provideSensorsRepo(),
|
||||||
addToCalendar = container.provideAddToCalendar()
|
addToCalendar = container.provideAddToCalendar(),
|
||||||
|
trackingService = container.radioTrackingService
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
/*
|
||||||
|
* 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.feature.radar
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RadioControlPage(
|
||||||
|
radioState: RadioControlSubState,
|
||||||
|
onAction: (RadarAction) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val selectedTransponder = radioState.transponders.find {
|
||||||
|
it.uuid == radioState.selectedTransponderUuid
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
// Transponder selector (dropdown)
|
||||||
|
TransponderDropdown(
|
||||||
|
transponders = radioState.transponders,
|
||||||
|
selectedTransponder = selectedTransponder,
|
||||||
|
onAction = onAction
|
||||||
|
)
|
||||||
|
|
||||||
|
// Active transponder status (TX/RX lines)
|
||||||
|
if (selectedTransponder != null) {
|
||||||
|
ActiveTransponderStatus(
|
||||||
|
transponder = selectedTransponder,
|
||||||
|
txPanel = radioState.txPanel,
|
||||||
|
rxPanel = radioState.rxPanel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frequency tuner + CTCSS
|
||||||
|
if (radioState.txBaseFrequencyHz != null) {
|
||||||
|
FrequencyTunerCard(
|
||||||
|
txBaseFrequencyHz = radioState.txBaseFrequencyHz,
|
||||||
|
selectedTransponder = selectedTransponder,
|
||||||
|
ctcssTone = radioState.ctcssTone,
|
||||||
|
showCtcss = selectedTransponder?.uplinkMode?.uppercase() == "FM",
|
||||||
|
onAction = onAction
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
// Error message
|
||||||
|
radioState.errorMessage?.let { msg ->
|
||||||
|
Text(
|
||||||
|
text = msg,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
modifier = Modifier.padding(horizontal = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control buttons
|
||||||
|
ControlButtons(
|
||||||
|
isConnected = radioState.txPanel.isConnected || radioState.rxPanel.isConnected,
|
||||||
|
isTracking = radioState.isTracking,
|
||||||
|
onAction = onAction
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun TransponderDropdown(
|
||||||
|
transponders: List<SatRadio>,
|
||||||
|
selectedTransponder: SatRadio?,
|
||||||
|
onAction: (RadarAction) -> Unit
|
||||||
|
) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = expanded,
|
||||||
|
onExpandedChange = { expanded = it }
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = selectedTransponder?.info ?: "No transponders available",
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = MaterialTheme.typography.bodyMedium,
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false }
|
||||||
|
) {
|
||||||
|
transponders.forEach { radio ->
|
||||||
|
val invLabel = if (radio.isInverted) " INV" else ""
|
||||||
|
val modeLabel = "${radio.uplinkMode ?: "--"}/${radio.downlinkMode ?: "--"}$invLabel"
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = radio.info,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = modeLabel,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
onAction(RadarAction.SelectTransponder(radio.uuid))
|
||||||
|
expanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ActiveTransponderStatus(
|
||||||
|
transponder: SatRadio,
|
||||||
|
txPanel: RadioPanelState,
|
||||||
|
rxPanel: RadioPanelState
|
||||||
|
) {
|
||||||
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
// TX line (uplink — on top)
|
||||||
|
FrequencyStatusRow(
|
||||||
|
label = "TX",
|
||||||
|
frequency = txPanel.frequencyHz ?: transponder.uplinkLow,
|
||||||
|
isConnected = txPanel.isConnected,
|
||||||
|
mode = txPanel.mode
|
||||||
|
)
|
||||||
|
// RX line (downlink — below)
|
||||||
|
FrequencyStatusRow(
|
||||||
|
label = "RX",
|
||||||
|
frequency = rxPanel.frequencyHz ?: transponder.downlinkLow,
|
||||||
|
isConnected = rxPanel.isConnected,
|
||||||
|
mode = rxPanel.mode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FrequencyStatusRow(
|
||||||
|
label: String,
|
||||||
|
frequency: Long?,
|
||||||
|
isConnected: Boolean,
|
||||||
|
mode: String?
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "$label:",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.width(28.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = frequency?.let { RadarViewModel.formatFrequency(it) } ?: "—",
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
if (mode != null) {
|
||||||
|
Text(
|
||||||
|
text = mode,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(end = 6.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(if (isConnected) Color(0xFF4CAF50) else Color(0xFFE57373))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun FrequencyTunerCard(
|
||||||
|
txBaseFrequencyHz: Long,
|
||||||
|
selectedTransponder: SatRadio?,
|
||||||
|
ctcssTone: Double?,
|
||||||
|
showCtcss: Boolean,
|
||||||
|
onAction: (RadarAction) -> Unit
|
||||||
|
) {
|
||||||
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||||
|
) {
|
||||||
|
// TX Base Frequency label + value on one line
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "TX Base: ",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${RadarViewModel.formatFrequency(txBaseFrequencyHz)} MHz",
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Show band range hint if it's a wide transponder
|
||||||
|
if (selectedTransponder != null) {
|
||||||
|
val upLow = selectedTransponder.uplinkLow
|
||||||
|
val upHigh = selectedTransponder.uplinkHigh
|
||||||
|
if (upLow != null && upHigh != null && upLow != upHigh) {
|
||||||
|
Text(
|
||||||
|
text = "(${RadarViewModel.formatFrequency(upLow)} – ${RadarViewModel.formatFrequency(upHigh)})",
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
// Adjustment buttons
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
FREQ_ADJUSTMENTS.forEach { (delta, label) ->
|
||||||
|
CardButton(
|
||||||
|
onClick = { onAction(RadarAction.AdjustTxFrequency(delta)) },
|
||||||
|
text = label,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// CTCSS inline (only for FM)
|
||||||
|
if (showCtcss) {
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
text = "CTCSS",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
FilterChip(
|
||||||
|
selected = ctcssTone == null,
|
||||||
|
onClick = { onAction(RadarAction.SetCtcssTone(null)) },
|
||||||
|
label = { Text("Off", fontSize = 12.sp) }
|
||||||
|
)
|
||||||
|
RadarViewModel.CTCSS_TONES.forEach { tone ->
|
||||||
|
FilterChip(
|
||||||
|
selected = ctcssTone == tone,
|
||||||
|
onClick = { onAction(RadarAction.SetCtcssTone(tone)) },
|
||||||
|
label = { Text(String.format(Locale.ENGLISH, "%.1f", tone), fontSize = 12.sp) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ControlButtons(
|
||||||
|
isConnected: Boolean,
|
||||||
|
isTracking: Boolean,
|
||||||
|
onAction: (RadarAction) -> Unit
|
||||||
|
) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
if (!isConnected) {
|
||||||
|
CardButton(
|
||||||
|
onClick = { onAction(RadarAction.ConnectRadios) },
|
||||||
|
text = "Connect",
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
CardButton(
|
||||||
|
onClick = { onAction(RadarAction.DisconnectRadios) },
|
||||||
|
text = "Disconnect",
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CardButton(
|
||||||
|
onClick = { onAction(RadarAction.ToggleTracking) },
|
||||||
|
text = if (isTracking) "Stop" else "Track",
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val FREQ_ADJUSTMENTS =
|
||||||
|
listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
/*
|
||||||
|
* 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.feature.radar
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||||
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TransceiversPage(
|
||||||
|
transceivers: List<SatRadio>,
|
||||||
|
selectedUuid: String?,
|
||||||
|
radioControl: RadioControlSubState,
|
||||||
|
onAction: (RadarAction) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
if (transceivers.isEmpty()) {
|
||||||
|
EmptyTransceiversContent(modifier)
|
||||||
|
} else {
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
// Snap the expanded item to the top of the visible area
|
||||||
|
LaunchedEffect(selectedUuid) {
|
||||||
|
if (selectedUuid != null) {
|
||||||
|
val index = transceivers.indexOfFirst { it.uuid == selectedUuid }
|
||||||
|
if (index >= 0) {
|
||||||
|
kotlinx.coroutines.delay(300)
|
||||||
|
listState.animateScrollToItem(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LazyColumn(modifier = modifier.fillMaxSize(), state = listState) {
|
||||||
|
itemsIndexed(items = transceivers, key = { _, radio -> radio.uuid }) { _, radio ->
|
||||||
|
val isExpanded = radio.uuid == selectedUuid
|
||||||
|
TransceiverItem(
|
||||||
|
radio = radio,
|
||||||
|
isExpanded = isExpanded,
|
||||||
|
radioControl = radioControl,
|
||||||
|
onAction = onAction,
|
||||||
|
onToggle = { onAction(RadarAction.SelectTransmitter(radio.uuid)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
|
modifier = Modifier.padding(32.dp)
|
||||||
|
) {
|
||||||
|
Text(text = """¯\_(ツ)_/¯""", fontSize = 32.sp)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.empty_list_message),
|
||||||
|
fontSize = 21.sp,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.radar_no_data),
|
||||||
|
fontSize = 18.sp,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TransceiverItem(
|
||||||
|
radio: SatRadio,
|
||||||
|
isExpanded: Boolean,
|
||||||
|
radioControl: RadioControlSubState,
|
||||||
|
onAction: (RadarAction) -> Unit,
|
||||||
|
onToggle: () -> Unit
|
||||||
|
) {
|
||||||
|
val bgColor = if (isExpanded) MaterialTheme.colorScheme.surfaceContainerHighest
|
||||||
|
else MaterialTheme.colorScheme.surface
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(bgColor)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null
|
||||||
|
) { onToggle() }
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||||
|
) {
|
||||||
|
// Header: [arrow slot] [name - (mode)] [icon slot]
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
// Left icon slot — always reserved
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_arrow),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(20.dp)
|
||||||
|
.rotate(if (isExpanded) 270f else 90f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Title with mode
|
||||||
|
val title = if (radio.isInverted) "INV: ${radio.info}" else radio.info
|
||||||
|
val mode = "${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"}"
|
||||||
|
val fullTitle = "$title ($mode)"
|
||||||
|
Text(
|
||||||
|
text = fullTitle,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.infiniteMarquee()
|
||||||
|
)
|
||||||
|
// Right arrow slot — always reserved
|
||||||
|
val iconTint = if (isExpanded) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.outlineVariant
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_radios),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = iconTint,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frequency rows
|
||||||
|
UnifiedFrequencyRow(
|
||||||
|
label = "TX",
|
||||||
|
frequencyLow = radio.uplinkLow,
|
||||||
|
frequencyHigh = radio.uplinkHigh,
|
||||||
|
isConnected = if (isExpanded) radioControl.txPanel.isConnected else null
|
||||||
|
)
|
||||||
|
UnifiedFrequencyRow(
|
||||||
|
label = "RX",
|
||||||
|
frequencyLow = radio.downlinkLow,
|
||||||
|
frequencyHigh = radio.downlinkHigh,
|
||||||
|
isConnected = if (isExpanded) radioControl.rxPanel.isConnected else null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expanded CAT control area
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isExpanded,
|
||||||
|
enter = expandVertically(),
|
||||||
|
exit = shrinkVertically()
|
||||||
|
) {
|
||||||
|
ExpandedRadioControl(
|
||||||
|
radio = radio,
|
||||||
|
radioControl = radioControl,
|
||||||
|
onAction = onAction
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(thickness = 2.dp, color = MaterialTheme.colorScheme.background)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UnifiedFrequencyRow(
|
||||||
|
label: String,
|
||||||
|
frequencyLow: Long?,
|
||||||
|
frequencyHigh: Long?,
|
||||||
|
isConnected: Boolean?
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
// TX:/RX: label
|
||||||
|
Text(
|
||||||
|
text = "$label:",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.width(24.dp)
|
||||||
|
)
|
||||||
|
// Low frequency
|
||||||
|
FrequencyText(
|
||||||
|
frequency = frequencyLow,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
// Dash — always centered
|
||||||
|
Text(
|
||||||
|
text = "–",
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.width(16.dp)
|
||||||
|
)
|
||||||
|
// High frequency
|
||||||
|
FrequencyText(
|
||||||
|
frequency = frequencyHigh,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
// Connection dot on the right
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.width(24.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(
|
||||||
|
when (isConnected) {
|
||||||
|
true -> Color(0xFF4CAF50)
|
||||||
|
false -> Color(0xFFE57373)
|
||||||
|
null -> MaterialTheme.colorScheme.outlineVariant
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ExpandedRadioControl(
|
||||||
|
radio: SatRadio,
|
||||||
|
radioControl: RadioControlSubState,
|
||||||
|
onAction: (RadarAction) -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp)
|
||||||
|
.padding(bottom = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Frequency tuner
|
||||||
|
if (radioControl.txBaseFrequencyHz != null) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "TX Base: ",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${RadarViewModel.formatFrequency(radioControl.txBaseFrequencyHz)} MHz",
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val upLow = radio.uplinkLow
|
||||||
|
val upHigh = radio.uplinkHigh
|
||||||
|
if (upLow != null && upHigh != null && upLow != upHigh) {
|
||||||
|
Text(
|
||||||
|
text = "(${RadarViewModel.formatFrequency(upLow)} – ${RadarViewModel.formatFrequency(upHigh)})",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
FREQ_ADJUSTMENTS.forEach { (delta, label) ->
|
||||||
|
CardButton(
|
||||||
|
onClick = { onAction(RadarAction.AdjustTxFrequency(delta)) },
|
||||||
|
text = label,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CTCSS (only for FM uplink)
|
||||||
|
if (radio.uplinkMode?.uppercase() == "FM") {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "CTCSS",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
val chipModifier = Modifier.width(64.dp)
|
||||||
|
FilterChip(
|
||||||
|
selected = radioControl.ctcssTone == null,
|
||||||
|
onClick = { onAction(RadarAction.SetCtcssTone(null)) },
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
text = "Off",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = chipModifier
|
||||||
|
)
|
||||||
|
RadarViewModel.CTCSS_TONES.forEach { tone ->
|
||||||
|
FilterChip(
|
||||||
|
selected = radioControl.ctcssTone == tone,
|
||||||
|
onClick = { onAction(RadarAction.SetCtcssTone(tone)) },
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
text = String.format(Locale.ENGLISH, "%.1f", tone),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = chipModifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control buttons
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) {
|
||||||
|
CardButton(
|
||||||
|
onClick = { onAction(RadarAction.ConnectRadios) },
|
||||||
|
text = "Connect",
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
CardButton(
|
||||||
|
onClick = { onAction(RadarAction.DisconnectRadios) },
|
||||||
|
text = "Disconnect",
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CardButton(
|
||||||
|
onClick = { onAction(RadarAction.ToggleTracking) },
|
||||||
|
text = if (radioControl.isTracking) "Stop" else "Track",
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error
|
||||||
|
radioControl.errorMessage?.let { msg ->
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
|
||||||
|
Text(text = msg, color = MaterialTheme.colorScheme.error, fontSize = 14.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
|
||||||
|
val text = frequency?.let {
|
||||||
|
stringResource(id = R.string.radar_link_low, it / 1000000f)
|
||||||
|
} ?: stringResource(R.string.radar_no_link)
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val FREQ_ADJUSTMENTS =
|
||||||
|
listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
plugins {
|
|
||||||
alias(libs.plugins.convention.featurePlugin)
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace = "com.rtbishop.look4sat.feature.radiocontrol"
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest>
|
|
||||||
|
|
||||||
</manifest>
|
|
||||||
-392
@@ -1,392 +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.feature.radiocontrol
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material3.ElevatedCard
|
|
||||||
import androidx.compose.material3.FilterChip
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.keepScreenOn
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
|
||||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
|
||||||
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
|
||||||
import com.rtbishop.look4sat.core.presentation.TimerRow
|
|
||||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
|
||||||
import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
|
||||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
|
||||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RadioControlDestination(navigateUp: () -> Unit) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
|
||||||
val viewModel: RadioControlViewModel = viewModel(factory = RadioControlViewModel.factory(container))
|
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
|
||||||
RadioControlScreen(uiState, viewModel::onAction, navigateUp)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun RadioControlScreen(
|
|
||||||
uiState: RadioControlState,
|
|
||||||
onAction: (RadioControlAction) -> Unit,
|
|
||||||
navigateUp: () -> Unit
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.layoutPadding()
|
|
||||||
.keepScreenOn(),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
|
||||||
) {
|
|
||||||
val currentPass = uiState.currentPass ?: getDefaultPass()
|
|
||||||
val isVertical = isVerticalLayout()
|
|
||||||
if (isVertical) {
|
|
||||||
TopBar {
|
|
||||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
|
||||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isCurrentTimeAos)
|
|
||||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
|
||||||
}
|
|
||||||
TopBar { NextPassRow(pass = currentPass) }
|
|
||||||
} else {
|
|
||||||
TopBar {
|
|
||||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
|
||||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isCurrentTimeAos)
|
|
||||||
NextPassRow(pass = currentPass, modifier = Modifier.weight(1f))
|
|
||||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
RadioPanel(panel = uiState.txPanel)
|
|
||||||
RadioPanel(panel = uiState.rxPanel)
|
|
||||||
|
|
||||||
PositionRow(
|
|
||||||
azimuth = uiState.azimuth,
|
|
||||||
elevation = uiState.elevation,
|
|
||||||
distance = uiState.distance
|
|
||||||
)
|
|
||||||
|
|
||||||
val selectedTransponder = uiState.transponders.find {
|
|
||||||
it.uuid == uiState.selectedTransponderUuid
|
|
||||||
}
|
|
||||||
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
|
||||||
) {
|
|
||||||
item {
|
|
||||||
TransponderSelector(
|
|
||||||
transponders = uiState.transponders,
|
|
||||||
selectedUuid = uiState.selectedTransponderUuid,
|
|
||||||
onAction = onAction
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedTransponder?.uplinkMode?.uppercase() == "FM") {
|
|
||||||
item {
|
|
||||||
CtcssSelector(
|
|
||||||
ctcssTone = uiState.ctcssTone,
|
|
||||||
onAction = onAction
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uiState.txBaseFrequencyHz != null) {
|
|
||||||
item {
|
|
||||||
FrequencyTuner(
|
|
||||||
txBaseFrequencyHz = uiState.txBaseFrequencyHz,
|
|
||||||
selectedTransponder = selectedTransponder,
|
|
||||||
onAction = onAction
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
uiState.errorMessage?.let { msg ->
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
text = msg,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
modifier = Modifier.padding(8.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ControlButtons(
|
|
||||||
isConnected = uiState.txPanel.isConnected || uiState.rxPanel.isConnected,
|
|
||||||
isTracking = uiState.isTracking,
|
|
||||||
onAction = onAction
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun RadioPanel(panel: RadioPanelState) {
|
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
|
||||||
) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(10.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(if (panel.isConnected) Color(0xFF4CAF50) else Color(0xFFE57373))
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(text = panel.label, fontSize = 14.sp)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = panel.frequencyDisplay,
|
|
||||||
fontSize = 24.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = panel.mode ?: "--",
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun PositionRow(azimuth: String, elevation: String, distance: String) {
|
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 6.dp)
|
|
||||||
) {
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
Text(text = "Az", fontSize = 12.sp)
|
|
||||||
Text(text = "$azimuth°", fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
|
||||||
}
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
Text(text = "El", fontSize = 12.sp)
|
|
||||||
Text(text = "$elevation°", fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
|
||||||
}
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
Text(text = "Dist", fontSize = 12.sp)
|
|
||||||
Text(text = "$distance km", fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TransponderSelector(
|
|
||||||
transponders: List<SatRadio>,
|
|
||||||
selectedUuid: String?,
|
|
||||||
onAction: (RadioControlAction) -> Unit
|
|
||||||
) {
|
|
||||||
if (transponders.isEmpty()) {
|
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text(
|
|
||||||
text = "No transponders with uplink+downlink available",
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
|
||||||
transponders.forEach { radio ->
|
|
||||||
TransponderItem(
|
|
||||||
radio = radio,
|
|
||||||
isSelected = radio.uuid == selectedUuid,
|
|
||||||
onSelect = { onAction(RadioControlAction.SelectTransponder(radio.uuid)) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TransponderItem(radio: SatRadio, isSelected: Boolean, onSelect: () -> Unit) {
|
|
||||||
val invLabel = if (radio.isInverted) " INV" else ""
|
|
||||||
val modeLabel = "${radio.uplinkMode ?: "--"}/${radio.downlinkMode ?: "--"}$invLabel"
|
|
||||||
Surface(
|
|
||||||
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer
|
|
||||||
else MaterialTheme.colorScheme.surface,
|
|
||||||
shape = MaterialTheme.shapes.small,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable { onSelect() }
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
|
|
||||||
) {
|
|
||||||
Text(text = radio.info, modifier = Modifier.weight(1f))
|
|
||||||
Text(text = modeLabel, fontSize = 13.sp, fontWeight = FontWeight.Medium)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
|
||||||
@Composable
|
|
||||||
private fun CtcssSelector(
|
|
||||||
ctcssTone: Double?,
|
|
||||||
onAction: (RadioControlAction) -> Unit
|
|
||||||
) {
|
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)) {
|
|
||||||
Text(text = "CTCSS Tone", color = MaterialTheme.colorScheme.primary)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
FilterChip(
|
|
||||||
selected = ctcssTone == null,
|
|
||||||
onClick = { onAction(RadioControlAction.SetCtcssTone(null)) },
|
|
||||||
label = { Text("Off") }
|
|
||||||
)
|
|
||||||
RadioControlViewModel.CTCSS_TONES.forEach { tone ->
|
|
||||||
FilterChip(
|
|
||||||
selected = ctcssTone == tone,
|
|
||||||
onClick = { onAction(RadioControlAction.SetCtcssTone(tone)) },
|
|
||||||
label = { Text(String.format(Locale.ENGLISH, "%.1f", tone)) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val FREQ_ADJUSTMENTS =
|
|
||||||
listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun FrequencyTuner(
|
|
||||||
txBaseFrequencyHz: Long,
|
|
||||||
selectedTransponder: SatRadio?,
|
|
||||||
onAction: (RadioControlAction) -> Unit
|
|
||||||
) {
|
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
|
|
||||||
) {
|
|
||||||
Text(text = "TX Base Frequency", color = MaterialTheme.colorScheme.primary)
|
|
||||||
if (selectedTransponder != null) {
|
|
||||||
val upLow = selectedTransponder.uplinkLow
|
|
||||||
val upHigh = selectedTransponder.uplinkHigh
|
|
||||||
val dnLow = selectedTransponder.downlinkLow
|
|
||||||
val dnHigh = selectedTransponder.downlinkHigh
|
|
||||||
if (upLow != null && upHigh != null && upLow != upHigh) {
|
|
||||||
Text(
|
|
||||||
text = "UP: ${RadioControlViewModel.formatFrequency(upLow)} - ${RadioControlViewModel.formatFrequency(upHigh)}",
|
|
||||||
fontSize = 11.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (dnLow != null && dnHigh != null && dnLow != dnHigh) {
|
|
||||||
Text(
|
|
||||||
text = "DN: ${RadioControlViewModel.formatFrequency(dnLow)} - ${RadioControlViewModel.formatFrequency(dnHigh)}",
|
|
||||||
fontSize = 11.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = "${RadioControlViewModel.formatFrequency(txBaseFrequencyHz)} MHz",
|
|
||||||
fontSize = 20.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
FREQ_ADJUSTMENTS.forEach { (delta, label) ->
|
|
||||||
CardButton(
|
|
||||||
onClick = { onAction(RadioControlAction.AdjustTxFrequency(delta)) },
|
|
||||||
text = label,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ControlButtons(
|
|
||||||
isConnected: Boolean,
|
|
||||||
isTracking: Boolean,
|
|
||||||
onAction: (RadioControlAction) -> Unit
|
|
||||||
) {
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
if (!isConnected) {
|
|
||||||
CardButton(
|
|
||||||
onClick = { onAction(RadioControlAction.ConnectRadios) },
|
|
||||||
text = "Connect",
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
CardButton(
|
|
||||||
onClick = { onAction(RadioControlAction.DisconnectRadios) },
|
|
||||||
text = "Disconnect",
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
CardButton(
|
|
||||||
onClick = { onAction(RadioControlAction.ToggleTracking) },
|
|
||||||
text = if (isTracking) "Stop Tracking" else "Start Tracking",
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-56
@@ -1,56 +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.feature.radiocontrol
|
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
|
||||||
|
|
||||||
data class RadioPanelState(
|
|
||||||
val label: String,
|
|
||||||
val isConnected: Boolean,
|
|
||||||
val frequencyHz: Long?,
|
|
||||||
val frequencyDisplay: String,
|
|
||||||
val mode: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class RadioControlState(
|
|
||||||
val currentPass: OrbitalPass?,
|
|
||||||
val currentTime: String,
|
|
||||||
val isCurrentTimeAos: Boolean,
|
|
||||||
val azimuth: String,
|
|
||||||
val elevation: String,
|
|
||||||
val distance: String,
|
|
||||||
val txPanel: RadioPanelState,
|
|
||||||
val rxPanel: RadioPanelState,
|
|
||||||
val transponders: List<SatRadio>,
|
|
||||||
val selectedTransponderUuid: String?,
|
|
||||||
val txBaseFrequencyHz: Long?,
|
|
||||||
val ctcssTone: Double?,
|
|
||||||
val isTracking: Boolean,
|
|
||||||
val errorMessage: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
sealed interface RadioControlAction {
|
|
||||||
data class SelectTransponder(val uuid: String) : RadioControlAction
|
|
||||||
data class SetTxFrequency(val frequencyHz: Long) : RadioControlAction
|
|
||||||
data class AdjustTxFrequency(val deltaHz: Long) : RadioControlAction
|
|
||||||
data class SetCtcssTone(val toneHz: Double?) : RadioControlAction
|
|
||||||
data object ToggleTracking : RadioControlAction
|
|
||||||
data object ConnectRadios : RadioControlAction
|
|
||||||
data object DisconnectRadios : RadioControlAction
|
|
||||||
}
|
|
||||||
-194
@@ -1,194 +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.feature.radiocontrol
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import androidx.lifecycle.viewmodel.initializer
|
|
||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
|
||||||
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
|
||||||
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
class RadioControlViewModel(
|
|
||||||
private val trackingService: IRadioTrackingService,
|
|
||||||
private val satelliteRepo: ISatelliteRepo,
|
|
||||||
settingsRepo: ISettingsRepo
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val stationPos = settingsRepo.stationPosition.value
|
|
||||||
private var currentPass: OrbitalPass? = null
|
|
||||||
private var transponders: List<SatRadio> = emptyList()
|
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(
|
|
||||||
RadioControlState(
|
|
||||||
currentPass = null,
|
|
||||||
currentTime = "00:00:00",
|
|
||||||
isCurrentTimeAos = true,
|
|
||||||
azimuth = "0.0",
|
|
||||||
elevation = "0.0",
|
|
||||||
distance = "0.0",
|
|
||||||
txPanel = RadioPanelState("TX (Uplink)", false, null, "---", null),
|
|
||||||
rxPanel = RadioPanelState("RX (Downlink)", false, null, "---", null),
|
|
||||||
transponders = emptyList(),
|
|
||||||
selectedTransponderUuid = null,
|
|
||||||
txBaseFrequencyHz = null,
|
|
||||||
ctcssTone = null,
|
|
||||||
isTracking = false,
|
|
||||||
errorMessage = null
|
|
||||||
)
|
|
||||||
)
|
|
||||||
val uiState: StateFlow<RadioControlState> = _uiState
|
|
||||||
|
|
||||||
init {
|
|
||||||
// Resolve pass and load transponders
|
|
||||||
viewModelScope.launch {
|
|
||||||
val passes = satelliteRepo.passes.value
|
|
||||||
val (catNum, aosTime) = satelliteRepo.selectedPass.value
|
|
||||||
val satPass = passes.find { it.catNum == catNum && it.aosTime == aosTime } ?: passes.firstOrNull()
|
|
||||||
currentPass = satPass
|
|
||||||
satPass?.let { pass ->
|
|
||||||
val allRadios = satelliteRepo.getRadiosWithId(pass.catNum)
|
|
||||||
transponders = allRadios.filter { it.downlinkLow != null }
|
|
||||||
_uiState.update { it.copy(currentPass = pass, transponders = transponders) }
|
|
||||||
// If service is already tracking this pass, sync the selected transponder
|
|
||||||
val svcState = trackingService.state.value
|
|
||||||
if (svcState.isActive && svcState.currentPass?.catNum == pass.catNum) {
|
|
||||||
_uiState.update { it.copy(selectedTransponderUuid = svcState.selectedTransponder?.uuid) }
|
|
||||||
}
|
|
||||||
// Tick loop — timer and satellite position updates every second
|
|
||||||
while (isActive) {
|
|
||||||
val timeNow = System.currentTimeMillis()
|
|
||||||
val pos = satelliteRepo.getPosition(pass.orbitalObject, stationPos, timeNow)
|
|
||||||
val (timeStr, isAos) = computeTimer(pass.isDeepSpace, pass.aosTime, pass.losTime, timeNow)
|
|
||||||
_uiState.update { state ->
|
|
||||||
state.copy(
|
|
||||||
currentTime = timeStr,
|
|
||||||
isCurrentTimeAos = isAos,
|
|
||||||
azimuth = String.format(Locale.ENGLISH, "%.1f", pos.azimuth.toDegrees()),
|
|
||||||
elevation = String.format(Locale.ENGLISH, "%.1f", pos.elevation.toDegrees()),
|
|
||||||
distance = String.format(Locale.ENGLISH, "%.0f", pos.distance)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
delay(1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Observe service state for radio-specific updates (panels, frequencies, tracking status)
|
|
||||||
viewModelScope.launch {
|
|
||||||
trackingService.state.collect { svc ->
|
|
||||||
_uiState.update { state ->
|
|
||||||
state.copy(
|
|
||||||
txPanel = RadioPanelState(
|
|
||||||
label = "TX (Uplink)",
|
|
||||||
isConnected = svc.txConnected,
|
|
||||||
frequencyHz = svc.txFrequencyHz,
|
|
||||||
frequencyDisplay = svc.txFrequencyHz?.let { formatFrequency(it) } ?: "---",
|
|
||||||
mode = svc.txMode
|
|
||||||
),
|
|
||||||
rxPanel = RadioPanelState(
|
|
||||||
label = "RX (Downlink)",
|
|
||||||
isConnected = svc.rxConnected,
|
|
||||||
frequencyHz = svc.rxFrequencyHz,
|
|
||||||
frequencyDisplay = svc.rxFrequencyHz?.let { formatFrequency(it) } ?: "---",
|
|
||||||
mode = svc.rxMode
|
|
||||||
),
|
|
||||||
txBaseFrequencyHz = svc.txBaseFrequencyHz,
|
|
||||||
ctcssTone = svc.ctcssTone,
|
|
||||||
isTracking = svc.isActive,
|
|
||||||
selectedTransponderUuid = svc.selectedTransponder?.uuid,
|
|
||||||
errorMessage = svc.errorMessage
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun computeTimer(isDeepSpace: Boolean, aosTime: Long, losTime: Long, timeNow: Long): Pair<String, Boolean> {
|
|
||||||
return when {
|
|
||||||
isDeepSpace -> 0L.toTimerString() to false
|
|
||||||
aosTime > timeNow -> (aosTime - timeNow).toTimerString() to true
|
|
||||||
else -> (losTime - timeNow).toTimerString() to false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onAction(action: RadioControlAction) {
|
|
||||||
when (action) {
|
|
||||||
is RadioControlAction.SelectTransponder -> {
|
|
||||||
val transponder = transponders.find { it.uuid == action.uuid } ?: return
|
|
||||||
trackingService.setTransponder(transponder)
|
|
||||||
}
|
|
||||||
is RadioControlAction.SetTxFrequency -> trackingService.setTxBaseFrequency(action.frequencyHz)
|
|
||||||
is RadioControlAction.AdjustTxFrequency -> trackingService.adjustTxBaseFrequency(action.deltaHz)
|
|
||||||
is RadioControlAction.SetCtcssTone -> trackingService.setCtcssTone(action.toneHz)
|
|
||||||
RadioControlAction.ToggleTracking -> {
|
|
||||||
val svc = trackingService.state.value
|
|
||||||
if (svc.isActive) {
|
|
||||||
trackingService.stopTracking()
|
|
||||||
} else {
|
|
||||||
val pass = currentPass ?: return
|
|
||||||
val transponder = svc.selectedTransponder ?: return
|
|
||||||
trackingService.startTracking(pass, transponder, svc.txBaseFrequencyHz)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
RadioControlAction.ConnectRadios -> viewModelScope.launch { trackingService.connectRadios() }
|
|
||||||
RadioControlAction.DisconnectRadios -> viewModelScope.launch { trackingService.disconnectRadios() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
val CTCSS_TONES = listOf(
|
|
||||||
67.0, 69.3, 71.9, 74.4, 77.0, 79.7, 82.5, 85.4, 88.5, 91.5,
|
|
||||||
94.8, 97.4, 100.0, 103.5, 107.2, 110.9, 114.8, 118.8, 123.0, 127.3, 131.8, 136.5,
|
|
||||||
141.3, 146.2, 151.4, 156.7, 162.2, 167.9, 173.8, 179.9, 186.2, 192.8, 203.5, 210.7,
|
|
||||||
218.1, 225.7, 233.6, 241.8, 250.3
|
|
||||||
)
|
|
||||||
|
|
||||||
fun formatFrequency(frequencyHz: Long): String {
|
|
||||||
if (frequencyHz <= 0) return "---"
|
|
||||||
val mhz = frequencyHz / 1_000_000
|
|
||||||
val khz = (frequencyHz % 1_000_000) / 1_000
|
|
||||||
val hz = frequencyHz % 1_000
|
|
||||||
return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun factory(container: IMainContainer) = viewModelFactory {
|
|
||||||
initializer {
|
|
||||||
RadioControlViewModel(
|
|
||||||
trackingService = container.radioTrackingService,
|
|
||||||
satelliteRepo = container.satelliteRepo,
|
|
||||||
settingsRepo = container.settingsRepo
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,7 +24,6 @@ include(
|
|||||||
":feature:map",
|
":feature:map",
|
||||||
":feature:passes",
|
":feature:passes",
|
||||||
":feature:radar",
|
":feature:radar",
|
||||||
":feature:radiocontrol",
|
|
||||||
":feature:satellites",
|
":feature:satellites",
|
||||||
":feature:settings"
|
":feature:settings"
|
||||||
)
|
)
|
||||||
Reference in new issue
Block a user