diff --git a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt index b7308c22..2a2d8303 100644 --- a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt +++ b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt @@ -68,7 +68,6 @@ import com.rtbishop.look4sat.core.presentation.hasEnoughWidth import com.rtbishop.look4sat.feature.map.MapDestination import com.rtbishop.look4sat.feature.passes.PassesDestination 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.settings.SettingsDestination @@ -143,10 +142,7 @@ fun MainScreen() { } } entry { - RadarDestination(navigateToRadioControl = { backStack.add(Screen.RadioControl) }) - } - entry { - RadioControlDestination(navigateUp = navigateBack) + RadarDestination(navigateUp = navigateBack) } entry { MapDestination() @@ -175,7 +171,7 @@ fun MainScreen() { val pass = trackingState.currentPass if (pass != null) { container.satelliteRepo.selectPass(pass.catNum, pass.aosTime) - backStack.add(Screen.RadioControl) + backStack.add(Screen.Radar) } } .padding(horizontal = 12.dp, vertical = 6.dp) diff --git a/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt b/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt index b802cdff..1b33372f 100644 --- a/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt +++ b/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt @@ -34,7 +34,6 @@ internal class ApplicationPlugin : Plugin { implementation(project(":feature:map")) implementation(project(":feature:passes")) implementation(project(":feature:radar")) - implementation(project(":feature:radiocontrol")) implementation(project(":feature:satellites")) implementation(project(":feature:settings")) implementation(libs.androidx.core.splashscreen) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt index bdb41cbf..c3f9c156 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt @@ -64,7 +64,7 @@ class RadioTrackingService( Log.i(tag, "Connecting TX=$txAddr RX=$rxAddr") 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 } diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt index 522251cb..bf4349d8 100644 --- a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt +++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt @@ -32,9 +32,6 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey { @Serializable data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar) - @Serializable - data object RadioControl : Screen(0, 0) - @Serializable data object Map : Screen(R.drawable.ic_map, R.string.nav_map) diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt index 52e3c479..5cf92601 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt @@ -23,9 +23,7 @@ import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween -import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.rotate import androidx.compose.ui.keepScreenOn import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentDescription -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.text.style.TextOverflow 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.predict.OrbitalPos import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.utility.toDegrees import com.rtbishop.look4sat.core.presentation.EmptyListCard 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.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.infiniteMarquee import com.rtbishop.look4sat.core.presentation.isVerticalLayout import com.rtbishop.look4sat.core.presentation.layoutPadding +import kotlinx.coroutines.launch + +private enum class RadarPage(val title: String) { + Transceivers("Transceivers"), + Sstv("SSTV") +} @Composable -fun RadarDestination(navigateToRadioControl: () -> Unit = {}) { +fun RadarDestination(navigateUp: () -> Unit) { val context = LocalContext.current val container = (context.applicationContext as IContainerProvider).getMainContainer() val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container)) val uiState by viewModel.uiState.collectAsStateWithLifecycle() - RadarScreen(uiState, viewModel::onAction, navigateToRadioControl) + RadarScreen(uiState, viewModel::onAction, navigateUp) } @Composable private fun RadarScreen( uiState: RadarState, onAction: (RadarAction) -> Unit, - navigateToRadioControl: () -> Unit + navigateUp: () -> Unit ) { val upcomingPass = uiState.currentPass ?: getDefaultPass() val addToCalendar: () -> Unit = { @@ -103,31 +98,95 @@ private fun RadarScreen( val isVertical = isVerticalLayout() if (isVertical) { TopBar { - IconCard(action = addToCalendar, resId = R.drawable.ic_calendar) + IconCard(action = navigateUp, resId = R.drawable.ic_back) 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) } } else { TopBar { - IconCard(action = addToCalendar, resId = R.drawable.ic_calendar) + IconCard(action = navigateUp, resId = R.drawable.ic_back) TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos) 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) { RadarCard(uiState, Modifier.weight(1f)) - TransmittersCard(uiState.transmitters, uiState.selectedTransmitterUuid, onAction, Modifier.weight(1f)) + PagerCard(uiState, onAction, Modifier.weight(1f)) } else { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { 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 private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) { val satellitePos = uiState.orbitalPos @@ -237,180 +296,3 @@ private fun RadarLabel( } } -@Composable -private fun TransmittersCard( - transmitters: List, - 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, - 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 - ) -} diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt index 22702a18..a10adf40 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt @@ -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.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 = emptyList(), + val selectedTransponderUuid: String? = null, + val txBaseFrequencyHz: Long? = null, + val ctcssTone: Double? = null, + val isTracking: Boolean = false, + val errorMessage: String? = null +) + data class RadarState( val currentPass: OrbitalPass? = null, val currentTime: String = "00:00:00", @@ -37,10 +56,20 @@ data class RadarState( val moonPosition: CelestialComputer.MoonPosition? = null, val transmitters: List = emptyList(), val selectedTransmitterUuid: String? = null, - val selectedFrequency: Long? = null + val selectedFrequency: Long? = null, + val radioControl: RadioControlSubState = RadioControlSubState() ) sealed interface RadarAction { data class AddToCalendar(val name: String, val aosTime: Long, val losTime: Long) : 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 } diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt index cc8f219f..132f9642 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt @@ -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.OrbitalPos 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.ISatelliteRepo 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.isActive import kotlinx.coroutines.launch +import java.util.Locale class RadarViewModel( private val bluetoothReporter: IReporter, @@ -48,11 +50,14 @@ class RadarViewModel( private val satelliteRepo: ISatelliteRepo, private val settingsRepo: ISettingsRepo, private val sensorsRepo: ISensorsRepo, - private val addToCalendar: IAddToCalendar + private val addToCalendar: IAddToCalendar, + private val trackingService: IRadioTrackingService ) : ViewModel() { private val stationPos = settingsRepo.stationPosition.value private val magDeclination = sensorsRepo.getMagDeclination(stationPos) + private var transponders: List = emptyList() + private val _uiState = MutableStateFlow( RadarState( isUtc = settingsRepo.otherSettings.value.stateOfUtc, @@ -87,7 +92,20 @@ class RadarViewModel( val satPass = passes.find { it.catNum == catNum && it.aosTime == aosTime } ?: passes.firstOrNull() satPass?.let { 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) if (!pass.isDeepSpace) { val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime) @@ -111,12 +129,42 @@ class RadarViewModel( moonPosition = moonPos ) } - processRadios(transmitters, pass.orbitalObject, timeNow) + processRadios(transmittersList, pass.orbitalObject, timeNow) sendPassData(pos) 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() { @@ -127,7 +175,41 @@ class RadarViewModel( fun onAction(action: RadarAction) { when (action) { 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,37 +260,44 @@ class RadarViewModel( val isFreqEnabled = settingsRepo.rcSettings.value.frequencyState || settingsRepo.rcSettings.value.bluetoothFrequencyState _uiState.update { state -> - if (!isFreqEnabled) { - // Skip update if nothing changed - if (state.transmitters == transmitters && state.selectedTransmitterUuid == null && state.selectedFrequency == null) { - return@update state + // Derive the frequency to report from the user's current selection (if any) + val freq = if (isFreqEnabled && state.selectedTransmitterUuid != null) { + val selectedRadio = transmitters.firstOrNull { it.uuid == state.selectedTransmitterUuid } + selectedRadio?.let { radio -> + val low = radio.downlinkLow + val high = radio.downlinkHigh + when { + low != null && high != null -> (low + high) / 2 + low != null -> low + else -> null + } } - 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 high = radio.downlinkHigh - when { - low != null && high != null -> (low + high) / 2 - low != null -> low - else -> null - } - } - // Skip update if nothing changed - if (state.transmitters == transmitters && state.selectedTransmitterUuid == selectedUuid && state.selectedFrequency == freq) { + } else null + + if (state.transmitters == transmitters && state.selectedFrequency == freq) { return@update state } - state.copy(transmitters = transmitters, selectedTransmitterUuid = selectedUuid, selectedFrequency = freq) + state.copy(transmitters = transmitters, selectedFrequency = freq) } } 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 { RadarViewModel( @@ -217,7 +306,8 @@ class RadarViewModel( satelliteRepo = container.satelliteRepo, settingsRepo = container.settingsRepo, sensorsRepo = container.provideSensorsRepo(), - addToCalendar = container.provideAddToCalendar() + addToCalendar = container.provideAddToCalendar(), + trackingService = container.radioTrackingService ) } } diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadioControlPage.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadioControlPage.kt new file mode 100644 index 00000000..eea7aa99 --- /dev/null +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadioControlPage.kt @@ -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 . + */ +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, + 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") diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt new file mode 100644 index 00000000..4077e478 --- /dev/null +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt @@ -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 . + */ +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, + 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") diff --git a/feature/radiocontrol/build.gradle.kts b/feature/radiocontrol/build.gradle.kts deleted file mode 100644 index 975cb77e..00000000 --- a/feature/radiocontrol/build.gradle.kts +++ /dev/null @@ -1,7 +0,0 @@ -plugins { - alias(libs.plugins.convention.featurePlugin) -} - -android { - namespace = "com.rtbishop.look4sat.feature.radiocontrol" -} diff --git a/feature/radiocontrol/src/main/AndroidManifest.xml b/feature/radiocontrol/src/main/AndroidManifest.xml deleted file mode 100644 index e1000761..00000000 --- a/feature/radiocontrol/src/main/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/feature/radiocontrol/src/main/java/com/rtbishop/look4sat/feature/radiocontrol/RadioControlScreen.kt b/feature/radiocontrol/src/main/java/com/rtbishop/look4sat/feature/radiocontrol/RadioControlScreen.kt deleted file mode 100644 index 3603d74a..00000000 --- a/feature/radiocontrol/src/main/java/com/rtbishop/look4sat/feature/radiocontrol/RadioControlScreen.kt +++ /dev/null @@ -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 . - */ -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, - 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) - ) - } -} diff --git a/feature/radiocontrol/src/main/java/com/rtbishop/look4sat/feature/radiocontrol/RadioControlState.kt b/feature/radiocontrol/src/main/java/com/rtbishop/look4sat/feature/radiocontrol/RadioControlState.kt deleted file mode 100644 index 7afbea73..00000000 --- a/feature/radiocontrol/src/main/java/com/rtbishop/look4sat/feature/radiocontrol/RadioControlState.kt +++ /dev/null @@ -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 . - */ -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, - 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 -} diff --git a/feature/radiocontrol/src/main/java/com/rtbishop/look4sat/feature/radiocontrol/RadioControlViewModel.kt b/feature/radiocontrol/src/main/java/com/rtbishop/look4sat/feature/radiocontrol/RadioControlViewModel.kt deleted file mode 100644 index db9a8a0e..00000000 --- a/feature/radiocontrol/src/main/java/com/rtbishop/look4sat/feature/radiocontrol/RadioControlViewModel.kt +++ /dev/null @@ -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 . - */ -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 = 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 = _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 { - 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 - ) - } - } - } -} diff --git a/settings.gradle.kts b/settings.gradle.kts index 83a3417d..667e5214 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -24,7 +24,6 @@ include( ":feature:map", ":feature:passes", ":feature:radar", - ":feature:radiocontrol", ":feature:satellites", ":feature:settings" )