Merged RadarScreen and RadioControlScreen functionality

This commit is contained in:
Arty Bishop committed 2026-05-30 14:14:40 +01:00
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)
@@ -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)
@@ -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
} }
@@ -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")
-7
View File
@@ -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>
@@ -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)
)
}
}
@@ -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
}
@@ -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
)
}
}
}
}
-1
View File
@@ -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"
) )