Merged RadarScreen and RadioControlScreen functionality

This commit is contained in:
Arty Bishop committed 2026-05-30 14:14:40 +01:00
1 parent 22607e7710
commit 18c8ac1822
15 files changed
+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
}
@@ -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")