mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Merged RadarScreen and RadioControlScreen functionality
This commit is contained in:
1 parent
22607e7710
commit
18c8ac1822
15 files changed
+1059
-898
No files matched your search
@@ -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<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.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(
|
||||
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<SatRadio> = 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
|
||||
}
|
||||
+119
-29
@@ -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<SatRadio> = 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user