Added several tweaks to Settings and RadioControl screens

This commit is contained in:
Arty Bishop committed 2026-04-10 14:13:53 +01:00
1 parent 4a3a01729b
commit a94f95874b
14 files changed
+514 -505

No files matched your search

@@ -33,11 +33,11 @@ data class MapState(
val positions: Map<OrbitalObject, GeoPos>? = null val positions: Map<OrbitalObject, GeoPos>? = null
) )
sealed class MapAction { sealed interface MapAction {
data object SelectPrev : MapAction() data object SelectPrev : MapAction
data object SelectNext : MapAction() data object SelectNext : MapAction
data class SelectItem(val item: OrbitalObject) : MapAction() data class SelectItem(val item: OrbitalObject) : MapAction
data class SelectDefaultItem(val catnum: Int) : MapAction() data class SelectDefaultItem(val catnum: Int) : MapAction
} }
data class MapData( data class MapData(
@@ -60,6 +60,8 @@ private val allModes = listOf(
"PSK", "PSK31", "PSK63", "QPSK", "QPSK31", "QPSK63", "SSTV", "USB", "WSJT" "PSK", "PSK31", "PSK63", "QPSK", "QPSK31", "QPSK63", "SSTV", "USB", "WSJT"
) )
private val hourSteps = listOf(1, 2, 4, 8, 12, 24, 48, 72, 96, 120, 144, 168, 192, 216, 240)
@Preview @Preview
@Composable @Composable
private fun PassesDialogPreview() { private fun PassesDialogPreview() {
@@ -68,26 +70,27 @@ private fun PassesDialogPreview() {
@Composable @Composable
internal fun PassesDialog(hours: Int, elevation: Double, cancel: () -> Unit, accept: (Int, Double) -> Unit) { internal fun PassesDialog(hours: Int, elevation: Double, cancel: () -> Unit, accept: (Int, Double) -> Unit) {
val hoursValue = remember { mutableIntStateOf(hours) } val hoursIndex = remember { mutableIntStateOf(hourSteps.indexOfFirst { it >= hours }.coerceAtLeast(0)) }
val elevationValueNew = remember { mutableDoubleStateOf(elevation) } val elevationValueNew = remember { mutableDoubleStateOf(elevation) }
val onAccept = { val onAccept = {
accept(hoursValue.intValue, elevationValueNew.doubleValue).also { cancel() } accept(hourSteps[hoursIndex.intValue], elevationValueNew.doubleValue).also { cancel() }
} }
SharedDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) { SharedDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) {
SliderRow( SliderRow(
title = stringResource(R.string.pass_filter_elev), title = stringResource(R.string.pass_filter_elev),
value = elevationValueNew.doubleValue, value = elevationValueNew.doubleValue,
valuePostfix = "°", displayValue = "${elevationValueNew.doubleValue.toInt()}°",
valueResId = R.drawable.ic_elevation, valueResId = R.drawable.ic_elevation,
valueRange = 0f..60f valueRange = 0f..60f
) { elevationValueNew.doubleValue = it.toDouble() } ) { elevationValueNew.doubleValue = it.toDouble() }
SliderRow( SliderRow(
title = stringResource(R.string.pass_filter_hours), title = stringResource(R.string.pass_filter_hours),
value = hoursValue.intValue.toDouble(), value = hoursIndex.intValue.toDouble(),
valuePostfix = "h", displayValue = "${hourSteps[hoursIndex.intValue]}h",
valueResId = R.drawable.ic_clock, valueResId = R.drawable.ic_clock,
valueRange = 1f..240f valueRange = 0f..(hourSteps.size - 1).toFloat(),
) { hoursValue.intValue = it.toInt() } steps = hourSteps.size - 2
) { hoursIndex.intValue = it.toInt().coerceIn(0, hourSteps.size - 1) }
} }
} }
@@ -95,9 +98,10 @@ internal fun PassesDialog(hours: Int, elevation: Double, cancel: () -> Unit, acc
private fun SliderRow( private fun SliderRow(
title: String, title: String,
value: Double, value: Double,
valuePostfix: String, displayValue: String,
valueResId: Int, valueResId: Int,
valueRange: ClosedFloatingPointRange<Float>, valueRange: ClosedFloatingPointRange<Float>,
steps: Int = 0,
onChange: (Float) -> Unit onChange: (Float) -> Unit
) { ) {
Column( Column(
@@ -122,13 +126,13 @@ private fun SliderRow(
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
) )
Text( Text(
text = "${value.toInt()}$valuePostfix", text = displayValue,
fontSize = 18.sp, fontSize = 18.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
} }
Slider(value = value.toFloat(), onValueChange = onChange, valueRange = valueRange) Slider(value = value.toFloat(), onValueChange = onChange, valueRange = valueRange, steps = steps)
} }
} }
@@ -34,11 +34,11 @@ data class PassesState(
val shouldSeeWhatsNew: Boolean = false val shouldSeeWhatsNew: Boolean = false
) )
sealed class PassesAction { sealed interface PassesAction {
data object DismissWhatsNew : PassesAction() data object DismissWhatsNew : PassesAction
data class FilterPasses(val hoursAhead: Int, val minElevation: Double) : PassesAction() data class FilterPasses(val hoursAhead: Int, val minElevation: Double) : PassesAction
data class FilterRadios(val modes: List<String>) : PassesAction() data class FilterRadios(val modes: List<String>) : PassesAction
data object RefreshPasses : PassesAction() data object RefreshPasses : PassesAction
data object TogglePassesDialog : PassesAction() data object TogglePassesDialog : PassesAction
data object ToggleRadiosDialog : PassesAction() data object ToggleRadiosDialog : PassesAction
} }
@@ -34,6 +34,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -57,15 +58,22 @@ class PassesViewModel(
val uiState: StateFlow<PassesState> = _uiState val uiState: StateFlow<PassesState> = _uiState
init { init {
// React to raw pass list changes — stops the refreshing indicator on first emission // Show refreshing indicator whenever the selected satellites list changes
viewModelScope.launch { viewModelScope.launch {
var initialLoadDone = false settingsRepo.selectedIds.collectLatest { selectedIds ->
satelliteRepo.passes.collectLatest { _ -> _uiState.update { it.copy(isRefreshing = true) }
if (!initialLoadDone) { if (selectedIds.isEmpty()) {
initialLoadDone = true // No satellites selected — show indicator briefly, then stop
delay(1000) // Artificial delay to show the refreshing state on first load delay(1000)
_uiState.update { it.copy(isRefreshing = false) } _uiState.update { it.copy(isRefreshing = false) }
} }
// For non-empty selections, the passes collector below will clear isRefreshing
}
}
// Stop refreshing whenever new passes arrive (from initial load, selection change, or filter)
viewModelScope.launch {
satelliteRepo.passes.drop(1).collect { _ ->
_uiState.update { it.copy(isRefreshing = false) }
} }
} }
// Local tick loop — computes pass progress and countdown timer every second // Local tick loop — computes pass progress and countdown timer every second
@@ -36,7 +36,7 @@ data class RadarState(
val selectedFrequency: Long? = null val selectedFrequency: Long? = null
) )
sealed class RadarAction { sealed interface RadarAction {
data class AddToCalendar(val name: String, val aosTime: Long, val losTime: Long) : RadarAction() data class AddToCalendar(val name: String, val aosTime: Long, val losTime: Long) : RadarAction
data class SelectTransmitter(val uuid: String) : RadarAction() data class SelectTransmitter(val uuid: String) : RadarAction
} }
@@ -174,6 +174,10 @@ class RadarViewModel(
settingsRepo.rcSettings.value.frequencyState || settingsRepo.rcSettings.value.bluetoothFrequencyState settingsRepo.rcSettings.value.frequencyState || settingsRepo.rcSettings.value.bluetoothFrequencyState
_uiState.update { state -> _uiState.update { state ->
if (!isFreqEnabled) { if (!isFreqEnabled) {
// Skip update if nothing changed
if (state.transmitters == transmitters && state.selectedTransmitterUuid == null && state.selectedFrequency == null) {
return@update state
}
return@update state.copy( return@update state.copy(
transmitters = transmitters, transmitters = transmitters,
selectedTransmitterUuid = null, selectedTransmitterUuid = null,
@@ -191,6 +195,10 @@ class RadarViewModel(
else -> null else -> null
} }
} }
// Skip update if nothing changed
if (state.transmitters == transmitters && state.selectedTransmitterUuid == selectedUuid && state.selectedFrequency == freq) {
return@update state
}
state.copy(transmitters = transmitters, selectedTransmitterUuid = selectedUuid, selectedFrequency = freq) state.copy(transmitters = transmitters, selectedTransmitterUuid = selectedUuid, selectedFrequency = freq)
} }
} }
@@ -76,12 +76,16 @@ fun NavGraphBuilder.radioControlDestination(navigateUp: () -> Unit) {
composable(route, args) { composable(route, args) {
val viewModel = viewModel(RadioControlViewModel::class.java, factory = RadioControlViewModel.Factory) val viewModel = viewModel(RadioControlViewModel::class.java, factory = RadioControlViewModel.Factory)
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
RadioControlScreen(uiState, navigateUp) RadioControlScreen(uiState, viewModel::onAction, navigateUp)
} }
} }
@Composable @Composable
private fun RadioControlScreen(uiState: RadioControlState, navigateUp: () -> Unit) { private fun RadioControlScreen(
uiState: RadioControlState,
onAction: (RadioControlAction) -> Unit,
navigateUp: () -> Unit
) {
Column( Column(
modifier = Modifier modifier = Modifier
.layoutPadding() .layoutPadding()
@@ -115,21 +119,39 @@ private fun RadioControlScreen(uiState: RadioControlState, navigateUp: () -> Uni
distance = uiState.distance distance = uiState.distance
) )
val selectedTransponder = uiState.transponders.find {
it.uuid == uiState.selectedTransponderUuid
}
LazyColumn( LazyColumn(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
item { TransponderSelector(uiState) } item {
TransponderSelector(
val selectedTransponder = uiState.transponders.find { transponders = uiState.transponders,
it.uuid == uiState.selectedTransponderUuid selectedUuid = uiState.selectedTransponderUuid,
onAction = onAction
)
} }
if (selectedTransponder?.uplinkMode?.uppercase() == "FM") { if (selectedTransponder?.uplinkMode?.uppercase() == "FM") {
item { CtcssSelector(uiState) } item {
CtcssSelector(
ctcssTone = uiState.ctcssTone,
onAction = onAction
)
}
} }
if (uiState.txBaseFrequencyHz != null) { if (uiState.txBaseFrequencyHz != null) {
item { FrequencyTuner(uiState) } item {
FrequencyTuner(
txBaseFrequencyHz = uiState.txBaseFrequencyHz,
selectedTransponder = selectedTransponder,
onAction = onAction
)
}
} }
uiState.errorMessage?.let { msg -> uiState.errorMessage?.let { msg ->
@@ -143,7 +165,11 @@ private fun RadioControlScreen(uiState: RadioControlState, navigateUp: () -> Uni
} }
} }
ControlButtons(uiState) ControlButtons(
isConnected = uiState.txPanel.isConnected || uiState.rxPanel.isConnected,
isTracking = uiState.isTracking,
onAction = onAction
)
} }
} }
@@ -208,8 +234,12 @@ private fun PositionRow(azimuth: String, elevation: String, distance: String) {
} }
@Composable @Composable
private fun TransponderSelector(uiState: RadioControlState) { private fun TransponderSelector(
if (uiState.transponders.isEmpty()) { transponders: List<SatRadio>,
selectedUuid: String?,
onAction: (RadioControlAction) -> Unit
) {
if (transponders.isEmpty()) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) { ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Text( Text(
text = "No transponders with uplink+downlink available", text = "No transponders with uplink+downlink available",
@@ -222,11 +252,11 @@ private fun TransponderSelector(uiState: RadioControlState) {
return return
} }
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
uiState.transponders.forEach { radio -> transponders.forEach { radio ->
TransponderItem( TransponderItem(
radio = radio, radio = radio,
isSelected = radio.uuid == uiState.selectedTransponderUuid, isSelected = radio.uuid == selectedUuid,
onSelect = { uiState.sendAction(RadioControlAction.SelectTransponder(radio.uuid)) } onSelect = { onAction(RadioControlAction.SelectTransponder(radio.uuid)) }
) )
} }
} }
@@ -257,21 +287,24 @@ private fun TransponderItem(radio: SatRadio, isSelected: Boolean, onSelect: () -
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
private fun CtcssSelector(uiState: RadioControlState) { private fun CtcssSelector(
ctcssTone: Double?,
onAction: (RadioControlAction) -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) { ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)) { Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)) {
Text(text = "CTCSS Tone", color = MaterialTheme.colorScheme.primary) Text(text = "CTCSS Tone", color = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
FilterChip( FilterChip(
selected = uiState.ctcssTone == null, selected = ctcssTone == null,
onClick = { uiState.sendAction(RadioControlAction.SetCtcssTone(null)) }, onClick = { onAction(RadioControlAction.SetCtcssTone(null)) },
label = { Text("Off") } label = { Text("Off") }
) )
RadioControlViewModel.CTCSS_TONES.forEach { tone -> RadioControlViewModel.CTCSS_TONES.forEach { tone ->
FilterChip( FilterChip(
selected = uiState.ctcssTone == tone, selected = ctcssTone == tone,
onClick = { uiState.sendAction(RadioControlAction.SetCtcssTone(tone)) }, onClick = { onAction(RadioControlAction.SetCtcssTone(tone)) },
label = { Text(String.format(Locale.ENGLISH, "%.1f", tone)) } label = { Text(String.format(Locale.ENGLISH, "%.1f", tone)) }
) )
} }
@@ -280,21 +313,26 @@ private fun CtcssSelector(uiState: RadioControlState) {
} }
} }
private val FREQ_ADJUSTMENTS =
listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
@Composable @Composable
private fun FrequencyTuner(uiState: RadioControlState) { private fun FrequencyTuner(
val freq = uiState.txBaseFrequencyHz ?: return txBaseFrequencyHz: Long,
val transponder = uiState.transponders.find { it.uuid == uiState.selectedTransponderUuid } selectedTransponder: SatRadio?,
onAction: (RadioControlAction) -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) { ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp) modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
) { ) {
Text(text = "TX Base Frequency", color = MaterialTheme.colorScheme.primary) Text(text = "TX Base Frequency", color = MaterialTheme.colorScheme.primary)
if (transponder != null) { if (selectedTransponder != null) {
val upLow = transponder.uplinkLow val upLow = selectedTransponder.uplinkLow
val upHigh = transponder.uplinkHigh val upHigh = selectedTransponder.uplinkHigh
val dnLow = transponder.downlinkLow val dnLow = selectedTransponder.downlinkLow
val dnHigh = transponder.downlinkHigh val dnHigh = selectedTransponder.downlinkHigh
if (upLow != null && upHigh != null && upLow != upHigh) { if (upLow != null && upHigh != null && upLow != upHigh) {
Text( Text(
text = "UP: ${RadioControlViewModel.formatFrequency(upLow)} - ${RadioControlViewModel.formatFrequency(upHigh)}", text = "UP: ${RadioControlViewModel.formatFrequency(upLow)} - ${RadioControlViewModel.formatFrequency(upHigh)}",
@@ -312,68 +350,48 @@ private fun FrequencyTuner(uiState: RadioControlState) {
} }
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = "${RadioControlViewModel.formatFrequency(freq)} MHz", text = "${RadioControlViewModel.formatFrequency(txBaseFrequencyHz)} MHz",
fontSize = 20.sp, fontSize = 20.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
CardButton( FREQ_ADJUSTMENTS.forEach { (delta, label) ->
onClick = { uiState.sendAction(RadioControlAction.AdjustTxFrequency(-10_000)) }, CardButton(
text = "-10k", onClick = { onAction(RadioControlAction.AdjustTxFrequency(delta)) },
modifier = Modifier.weight(1f) text = label,
) modifier = Modifier.weight(1f)
CardButton( )
onClick = { uiState.sendAction(RadioControlAction.AdjustTxFrequency(-1_000)) }, }
text = "-1k",
modifier = Modifier.weight(1f)
)
CardButton(
onClick = { uiState.sendAction(RadioControlAction.AdjustTxFrequency(-100)) },
text = "-100",
modifier = Modifier.weight(1f)
)
CardButton(
onClick = { uiState.sendAction(RadioControlAction.AdjustTxFrequency(100)) },
text = "+100",
modifier = Modifier.weight(1f)
)
CardButton(
onClick = { uiState.sendAction(RadioControlAction.AdjustTxFrequency(1_000)) },
text = "+1k",
modifier = Modifier.weight(1f)
)
CardButton(
onClick = { uiState.sendAction(RadioControlAction.AdjustTxFrequency(10_000)) },
text = "+10k",
modifier = Modifier.weight(1f)
)
} }
} }
} }
} }
@Composable @Composable
private fun ControlButtons(uiState: RadioControlState) { private fun ControlButtons(
val isConnected = uiState.txPanel.isConnected || uiState.rxPanel.isConnected isConnected: Boolean,
isTracking: Boolean,
onAction: (RadioControlAction) -> Unit
) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
if (!isConnected) { if (!isConnected) {
CardButton( CardButton(
onClick = { uiState.sendAction(RadioControlAction.ConnectRadios) }, onClick = { onAction(RadioControlAction.ConnectRadios) },
text = "Connect", text = "Connect",
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
} else { } else {
CardButton( CardButton(
onClick = { uiState.sendAction(RadioControlAction.DisconnectRadios) }, onClick = { onAction(RadioControlAction.DisconnectRadios) },
text = "Disconnect", text = "Disconnect",
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
} }
CardButton( CardButton(
onClick = { uiState.sendAction(RadioControlAction.ToggleTracking) }, onClick = { onAction(RadioControlAction.ToggleTracking) },
text = if (uiState.isTracking) "Stop Tracking" else "Start Tracking", text = if (isTracking) "Stop Tracking" else "Start Tracking",
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
} }
@@ -42,16 +42,15 @@ data class RadioControlState(
val txBaseFrequencyHz: Long?, val txBaseFrequencyHz: Long?,
val ctcssTone: Double?, val ctcssTone: Double?,
val isTracking: Boolean, val isTracking: Boolean,
val errorMessage: String?, val errorMessage: String?
val sendAction: (RadioControlAction) -> Unit
) )
sealed class RadioControlAction { sealed interface RadioControlAction {
data class SelectTransponder(val uuid: String) : RadioControlAction() data class SelectTransponder(val uuid: String) : RadioControlAction
data class SetTxFrequency(val frequencyHz: Long) : RadioControlAction() data class SetTxFrequency(val frequencyHz: Long) : RadioControlAction
data class AdjustTxFrequency(val deltaHz: Long) : RadioControlAction() data class AdjustTxFrequency(val deltaHz: Long) : RadioControlAction
data class SetCtcssTone(val toneHz: Double?) : RadioControlAction() data class SetCtcssTone(val toneHz: Double?) : RadioControlAction
data object ToggleTracking : RadioControlAction() data object ToggleTracking : RadioControlAction
data object ConnectRadios : RadioControlAction() data object ConnectRadios : RadioControlAction
data object DisconnectRadios : RadioControlAction() data object DisconnectRadios : RadioControlAction
} }
@@ -30,10 +30,13 @@ import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.utility.toDegrees
import com.rtbishop.look4sat.core.domain.utility.toTimerString import com.rtbishop.look4sat.core.domain.utility.toTimerString
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Locale import java.util.Locale
@@ -46,6 +49,7 @@ class RadioControlViewModel(
private var currentPass: OrbitalPass? = null private var currentPass: OrbitalPass? = null
private var transponders: List<SatRadio> = emptyList() private var transponders: List<SatRadio> = emptyList()
private val stationPos = settingsRepo.stationPosition.value
private val _uiState = MutableStateFlow( private val _uiState = MutableStateFlow(
RadioControlState( RadioControlState(
@@ -62,8 +66,7 @@ class RadioControlViewModel(
txBaseFrequencyHz = null, txBaseFrequencyHz = null,
ctcssTone = null, ctcssTone = null,
isTracking = false, isTracking = false,
errorMessage = null, errorMessage = null
sendAction = ::handleAction
) )
) )
val uiState: StateFlow<RadioControlState> = _uiState val uiState: StateFlow<RadioControlState> = _uiState
@@ -72,6 +75,7 @@ class RadioControlViewModel(
val catNum = savedStateHandle.get<Int>("catNum") ?: 0 val catNum = savedStateHandle.get<Int>("catNum") ?: 0
val aosTime = savedStateHandle.get<Long>("aosTime") ?: 0L val aosTime = savedStateHandle.get<Long>("aosTime") ?: 0L
// Resolve pass and load transponders
viewModelScope.launch { viewModelScope.launch {
val passes = satelliteRepo.passes.value val passes = satelliteRepo.passes.value
val pass = passes.find { it.catNum == catNum && it.aosTime == aosTime } val pass = passes.find { it.catNum == catNum && it.aosTime == aosTime }
@@ -90,29 +94,30 @@ class RadioControlViewModel(
it.copy(selectedTransponderUuid = svcState.selectedTransponder?.uuid) it.copy(selectedTransponderUuid = svcState.selectedTransponder?.uuid)
} }
} }
// Tick loop — timer and satellite position updates every second
while (isActive) {
val timeNow = System.currentTimeMillis()
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow)
val (timeStr, isAos) = computeTimer(satPass.isDeepSpace, satPass.aosTime, satPass.losTime, timeNow)
_uiState.update { state ->
state.copy(
currentTime = timeStr,
isCurrentTimeAos = isAos,
azimuth = String.format(Locale.ENGLISH, "%.1f", pos.azimuth.toDegrees()),
elevation = String.format(Locale.ENGLISH, "%.1f", pos.elevation.toDegrees()),
distance = String.format(Locale.ENGLISH, "%.0f", pos.distance)
)
}
delay(1000)
}
} }
} }
// Observe service state and map to UI state // Observe service state for radio-specific updates (panels, frequencies, tracking status)
viewModelScope.launch { viewModelScope.launch {
trackingService.state.collect { svc -> trackingService.state.collect { svc ->
val timeNow = System.currentTimeMillis()
val pass = currentPass
val timeStr = when {
pass == null -> "00:00:00"
pass.isDeepSpace -> 0L.toTimerString()
pass.aosTime > timeNow -> pass.aosTime.minus(timeNow).toTimerString()
else -> pass.losTime.minus(timeNow).toTimerString()
}
val isAos = pass != null && !pass.isDeepSpace && pass.aosTime > timeNow
_uiState.update { state -> _uiState.update { state ->
state.copy( state.copy(
currentTime = timeStr,
isCurrentTimeAos = isAos,
azimuth = String.format(Locale.ENGLISH, "%.1f", svc.azimuth),
elevation = String.format(Locale.ENGLISH, "%.1f", svc.elevation),
distance = String.format(Locale.ENGLISH, "%.0f", svc.distance),
txPanel = RadioPanelState( txPanel = RadioPanelState(
label = "TX (Uplink)", label = "TX (Uplink)",
isConnected = svc.txConnected, isConnected = svc.txConnected,
@@ -138,7 +143,15 @@ class RadioControlViewModel(
} }
} }
private fun handleAction(action: RadioControlAction) { private fun computeTimer(isDeepSpace: Boolean, aosTime: Long, losTime: Long, timeNow: Long): Pair<String, Boolean> {
return when {
isDeepSpace -> 0L.toTimerString() to false
aosTime > timeNow -> (aosTime - timeNow).toTimerString() to true
else -> (losTime - timeNow).toTimerString() to false
}
}
fun onAction(action: RadioControlAction) {
when (action) { when (action) {
is RadioControlAction.SelectTransponder -> { is RadioControlAction.SelectTransponder -> {
val transponder = transponders.find { it.uuid == action.uuid } ?: return val transponder = transponders.find { it.uuid == action.uuid } ?: return
@@ -28,13 +28,13 @@ data class SatellitesState(
val typesList: List<String> = emptyList() val typesList: List<String> = emptyList()
) )
sealed class SatellitesAction { sealed interface SatellitesAction {
data object DismissWarning : SatellitesAction() data object DismissWarning : SatellitesAction
data object SaveSelection : SatellitesAction() data object SaveSelection : SatellitesAction
data class SearchFor(val query: String) : SatellitesAction() data class SearchFor(val query: String) : SatellitesAction
data object SelectAll : SatellitesAction() data object SelectAll : SatellitesAction
data class SelectSingle(val id: Int, val isTicked: Boolean) : SatellitesAction() data class SelectSingle(val id: Int, val isTicked: Boolean) : SatellitesAction
data class SelectTypes(val types: List<String>) : SatellitesAction() data class SelectTypes(val types: List<String>) : SatellitesAction
data object ToggleTypesDialog : SatellitesAction() data object ToggleTypesDialog : SatellitesAction
data object UnselectAll : SatellitesAction() data object UnselectAll : SatellitesAction
} }
@@ -17,6 +17,8 @@
*/ */
package com.rtbishop.look4sat.feature.settings package com.rtbishop.look4sat.feature.settings
import android.bluetooth.BluetoothManager
import android.content.Context
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -29,6 +31,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
@@ -257,14 +260,8 @@ fun NetworkOutputDialog(
val (rotIp, rotPort) = splitAddress(rotatorAddress.value) val (rotIp, rotPort) = splitAddress(rotatorAddress.value)
val (freqIp, freqPort) = splitAddress(frequencyAddress.value) val (freqIp, freqPort) = splitAddress(frequencyAddress.value)
onSave( onSave(
rotatorState.value, rotatorState.value, rotIp, rotPort, rotatorFormat.value,
rotIp, frequencyState.value, freqIp, freqPort, frequencyFormat.value
rotPort,
rotatorFormat.value,
frequencyState.value,
freqIp,
freqPort,
frequencyFormat.value
) )
onDismiss() onDismiss()
} }
@@ -274,71 +271,29 @@ fun NetworkOutputDialog(
onAccept = onAccept onAccept = onAccept
) { ) {
Column(modifier = Modifier.padding(horizontal = padding)) { Column(modifier = Modifier.padding(horizontal = padding)) {
Row( OutputChannelSection(
horizontalArrangement = Arrangement.SpaceBetween, switchLabel = stringResource(R.string.prefs_net_rotator_switch),
verticalAlignment = Alignment.CenterVertically, enabled = rotatorState.value,
modifier = Modifier.fillMaxWidth() onEnabledChange = { rotatorState.value = it },
) { address = rotatorAddress.value,
Text(stringResource(R.string.prefs_net_rotator_switch)) onAddressChange = { rotatorAddress.value = it },
Switch( addressLabel = stringResource(R.string.prefs_net_rotator_address_hint),
checked = rotatorState.value, format = rotatorFormat.value,
onCheckedChange = { rotatorState.value = it } onFormatChange = { rotatorFormat.value = it },
) formatLabel = stringResource(R.string.prefs_net_rotator_format_hint)
} )
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = rotatorAddress.value,
onValueChange = { rotatorAddress.value = it },
singleLine = true,
label = { Text(stringResource(R.string.prefs_net_rotator_address_hint)) },
modifier = Modifier.weight(0.6f),
enabled = rotatorState.value
)
OutlinedTextField(
value = rotatorFormat.value,
onValueChange = { rotatorFormat.value = it },
singleLine = true,
label = { Text(stringResource(R.string.prefs_net_rotator_format_hint)) },
modifier = Modifier.weight(0.4f),
enabled = rotatorState.value
)
}
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
Row( OutputChannelSection(
horizontalArrangement = Arrangement.SpaceBetween, switchLabel = stringResource(R.string.prefs_net_frequency_switch),
verticalAlignment = Alignment.CenterVertically, enabled = frequencyState.value,
modifier = Modifier.fillMaxWidth() onEnabledChange = { frequencyState.value = it },
) { address = frequencyAddress.value,
Text(stringResource(R.string.prefs_net_frequency_switch)) onAddressChange = { frequencyAddress.value = it },
Switch( addressLabel = stringResource(R.string.prefs_net_frequency_address_hint),
checked = frequencyState.value, format = frequencyFormat.value,
onCheckedChange = { frequencyState.value = it } onFormatChange = { frequencyFormat.value = it },
) formatLabel = stringResource(R.string.prefs_net_frequency_format_hint)
} )
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = frequencyAddress.value,
onValueChange = { frequencyAddress.value = it },
singleLine = true,
label = { Text(stringResource(R.string.prefs_net_frequency_address_hint)) },
modifier = Modifier.weight(0.6f),
enabled = frequencyState.value
)
OutlinedTextField(
value = frequencyFormat.value,
onValueChange = { frequencyFormat.value = it },
singleLine = true,
label = { Text(stringResource(R.string.prefs_net_frequency_format_hint)) },
modifier = Modifier.weight(0.4f),
enabled = frequencyState.value
)
}
} }
} }
} }
@@ -398,12 +353,8 @@ fun BluetoothOutputDialog(
val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyFormat) } val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyFormat) }
val onAccept = { val onAccept = {
onSave( onSave(
rotatorState.value, rotatorState.value, rotatorAddress.value, rotatorFormat.value,
rotatorAddress.value, frequencyState.value, frequencyAddress.value, frequencyFormat.value
rotatorFormat.value,
frequencyState.value,
frequencyAddress.value,
frequencyFormat.value
) )
onDismiss() onDismiss()
} }
@@ -413,75 +364,80 @@ fun BluetoothOutputDialog(
onAccept = onAccept onAccept = onAccept
) { ) {
Column(modifier = Modifier.padding(horizontal = padding)) { Column(modifier = Modifier.padding(horizontal = padding)) {
Row( OutputChannelSection(
horizontalArrangement = Arrangement.SpaceBetween, switchLabel = stringResource(R.string.prefs_bt_rotator_switch),
verticalAlignment = Alignment.CenterVertically, enabled = rotatorState.value,
modifier = Modifier.fillMaxWidth() onEnabledChange = { rotatorState.value = it },
) { address = rotatorAddress.value,
Text(stringResource(R.string.prefs_bt_rotator_switch)) onAddressChange = { rotatorAddress.value = it },
Switch( addressLabel = stringResource(R.string.prefs_bt_rotator_device_hint),
checked = rotatorState.value, format = rotatorFormat.value,
onCheckedChange = { rotatorState.value = it } onFormatChange = { rotatorFormat.value = it },
) formatLabel = stringResource(R.string.prefs_bt_rotator_output_hint)
} )
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = rotatorAddress.value,
onValueChange = { rotatorAddress.value = it },
singleLine = true,
label = { Text(stringResource(R.string.prefs_bt_rotator_device_hint)) },
modifier = Modifier.weight(0.6f),
enabled = rotatorState.value
)
OutlinedTextField(
value = rotatorFormat.value,
onValueChange = { rotatorFormat.value = it },
singleLine = true,
label = { Text(stringResource(R.string.prefs_bt_rotator_output_hint)) },
modifier = Modifier.weight(0.4f),
enabled = rotatorState.value
)
}
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
Row( OutputChannelSection(
horizontalArrangement = Arrangement.SpaceBetween, switchLabel = stringResource(R.string.prefs_bt_frequency_switch),
verticalAlignment = Alignment.CenterVertically, enabled = frequencyState.value,
modifier = Modifier.fillMaxWidth() onEnabledChange = { frequencyState.value = it },
) { address = frequencyAddress.value,
Text(stringResource(R.string.prefs_bt_frequency_switch)) onAddressChange = { frequencyAddress.value = it },
Switch( addressLabel = stringResource(R.string.prefs_bt_frequency_device_hint),
checked = frequencyState.value, format = frequencyFormat.value,
onCheckedChange = { frequencyState.value = it } onFormatChange = { frequencyFormat.value = it },
) formatLabel = stringResource(R.string.prefs_bt_frequency_output_hint)
} )
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = frequencyAddress.value,
onValueChange = { frequencyAddress.value = it },
singleLine = true,
label = { Text(stringResource(R.string.prefs_bt_frequency_device_hint)) },
modifier = Modifier.weight(0.6f),
enabled = frequencyState.value
)
OutlinedTextField(
value = frequencyFormat.value,
onValueChange = { frequencyFormat.value = it },
singleLine = true,
label = { Text(stringResource(R.string.prefs_bt_frequency_output_hint)) },
modifier = Modifier.weight(0.4f),
enabled = frequencyState.value
)
}
} }
} }
} }
/**
* Reusable section for a switch-toggled output channel with address and format fields.
* Used by both Network and Bluetooth output dialogs.
*/
@Composable
private fun OutputChannelSection(
switchLabel: String,
enabled: Boolean,
onEnabledChange: (Boolean) -> Unit,
address: String,
onAddressChange: (String) -> Unit,
addressLabel: String,
format: String,
onFormatChange: (String) -> Unit,
formatLabel: String
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(switchLabel)
Switch(checked = enabled, onCheckedChange = onEnabledChange)
}
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = address,
onValueChange = onAddressChange,
singleLine = true,
label = { Text(addressLabel) },
modifier = Modifier.weight(0.6f),
enabled = enabled
)
OutlinedTextField(
value = format,
onValueChange = onFormatChange,
singleLine = true,
label = { Text(formatLabel) },
modifier = Modifier.weight(0.4f),
enabled = enabled
)
}
}
@Composable @Composable
fun RadioControlDialog( fun RadioControlDialog(
initialSettings: RadioControlSettings, initialSettings: RadioControlSettings,
@@ -497,12 +453,12 @@ fun RadioControlDialog(
val rxAddress = rememberSaveable { mutableStateOf(initialSettings.rxRadioAddress) } val rxAddress = rememberSaveable { mutableStateOf(initialSettings.rxRadioAddress) }
val txName = rememberSaveable { mutableStateOf(initialSettings.txRadioName) } val txName = rememberSaveable { mutableStateOf(initialSettings.txRadioName) }
val rxName = rememberSaveable { mutableStateOf(initialSettings.rxRadioName) } val rxName = rememberSaveable { mutableStateOf(initialSettings.rxRadioName) }
val baudRate = rememberSaveable { mutableStateOf(initialSettings.baudRate) } val baudRate = rememberSaveable { mutableIntStateOf(initialSettings.baudRate) }
val selectingFor = rememberSaveable { mutableStateOf("") } // "tx", "rx", or "" val selectingFor = rememberSaveable { mutableStateOf("") } // "tx", "rx", or ""
val pairedDevices = remember { val pairedDevices = remember {
try { try {
val manager = context.getSystemService(android.content.Context.BLUETOOTH_SERVICE) as android.bluetooth.BluetoothManager val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
manager.adapter?.bondedDevices?.map { Pair(it.name ?: "Unknown", it.address) } ?: emptyList() manager.adapter?.bondedDevices?.map { Pair(it.name ?: "Unknown", it.address) } ?: emptyList()
} catch (_: SecurityException) { } catch (_: SecurityException) {
emptyList() emptyList()
@@ -518,7 +474,7 @@ fun RadioControlDialog(
rxRadioAddress = rxAddress.value, rxRadioAddress = rxAddress.value,
txRadioName = txName.value, txRadioName = txName.value,
rxRadioName = rxName.value, rxRadioName = rxName.value,
baudRate = baudRate.value baudRate = baudRate.intValue
) )
) )
onDismiss() onDismiss()
@@ -629,8 +585,8 @@ fun RadioControlDialog(
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
baudRates.forEach { rate -> baudRates.forEach { rate ->
CardButton( CardButton(
onClick = { baudRate.value = rate }, onClick = { baudRate.intValue = rate },
text = if (rate == baudRate.value) "[$rate]" else rate.toString(), text = if (rate == baudRate.intValue) "[$rate]" else rate.toString(),
modifier = Modifier modifier = Modifier
) )
} }
@@ -42,9 +42,12 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -81,66 +84,44 @@ fun NavGraphBuilder.settingsDestination() {
modelClass = SettingsViewModel::class.java, modelClass = SettingsViewModel::class.java,
factory = SettingsViewModel.Factory factory = SettingsViewModel.Factory
) )
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SettingsScreen(uiState) SettingsScreen(uiState, viewModel::onAction)
} }
} }
@Composable @Composable
private fun SettingsScreen(uiState: SettingsState) { private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) {
val locationContract = ActivityResultContracts.RequestMultiplePermissions() val dialogs = rememberDialogVisibility()
val locationError = stringResource(R.string.prefs_loc_gps_error) val permissions = rememberSettingsPermissions(
val locationPermCoarse = Manifest.permission.ACCESS_COARSE_LOCATION sendAction = onAction,
val locationPermFine = Manifest.permission.ACCESS_FINE_LOCATION onBluetoothGranted = { dialogs.bluetooth = true }
val locationRequest = rememberLauncherForActivityResult(locationContract) { permissions -> )
when {
permissions[locationPermFine] == true -> uiState.sendAction(SettingsAction.SetGpsPosition)
permissions[locationPermCoarse] == true -> uiState.sendAction(SettingsAction.SetGpsPosition)
else -> uiState.sendSystemAction(SystemAction.ShowToast(locationError))
}
}
val contentContract = ActivityResultContracts.GetContent()
val contentRequestForTle = rememberLauncherForActivityResult(contentContract) { uri ->
uri?.let { uiState.sendAction(SettingsAction.UpdateTLEFromFile(uri.toString())) }
}
val contentRequestForTransceivers = rememberLauncherForActivityResult(contentContract) { uri ->
uri?.let { uiState.sendAction(SettingsAction.UpdateTransceiversFromFile(uri.toString())) }
}
// Position settings // Dialogs
val posSettings = uiState.positionSettings if (dialogs.position) {
val setGpsPos = { locationRequest.launch(arrayOf(locationPermCoarse, locationPermFine)) } PositionDialog(
val setGeoPos = { lat: Double, lon: Double -> uiState.positionSettings.stationPos.latitude,
uiState.sendAction(SettingsAction.SetGeoPosition(lat, lon)) uiState.positionSettings.stationPos.longitude,
dismiss = { dialogs.position = false },
save = { lat, lon -> onAction(SettingsAction.SetGeoPosition(lat, lon)) }
)
} }
val setQthPos = { locator: String -> if (dialogs.locator) {
uiState.sendAction(SettingsAction.SetQthPosition(locator)) LocatorDialog(
uiState.positionSettings.stationPos.qthLocator,
dismiss = { dialogs.locator = false },
save = { onAction(SettingsAction.SetQthPosition(it)) }
)
} }
val dismissPos = { uiState.sendAction(SettingsAction.DismissPosMessages) } if (dialogs.dataSources) {
val posDialogState = rememberSaveable { mutableStateOf(false) }
val showPosDialog = { posDialogState.value = posDialogState.value.not() }
if (posDialogState.value) {
PositionDialog(posSettings.stationPos.latitude, posSettings.stationPos.longitude, showPosDialog, setGeoPos)
}
val locDialogState = rememberSaveable { mutableStateOf(false) }
val showLocDialog = { locDialogState.value = locDialogState.value.not() }
if (locDialogState.value) {
LocatorDialog(posSettings.stationPos.qthLocator, showLocDialog, setQthPos)
}
// Data sources dialog
val dataSourcesDialogState = rememberSaveable { mutableStateOf(false) }
val showDataSourcesDialog = { dataSourcesDialogState.value = true }
val dismissDataSourcesDialog = { dataSourcesDialogState.value = false }
if (dataSourcesDialogState.value) {
DataSourcesDialog( DataSourcesDialog(
useCustomTle = uiState.dataSourcesSettings.useCustomTLE, useCustomTle = uiState.dataSourcesSettings.useCustomTLE,
useCustomTransceivers = uiState.dataSourcesSettings.useCustomTransceivers, useCustomTransceivers = uiState.dataSourcesSettings.useCustomTransceivers,
tleUrl = uiState.dataSourcesSettings.tleUrl, tleUrl = uiState.dataSourcesSettings.tleUrl,
transceiversUrl = uiState.dataSourcesSettings.transceiversUrl, transceiversUrl = uiState.dataSourcesSettings.transceiversUrl,
onImportTle = { contentRequestForTle.launch("*/*") }, onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false },
onImportTransceivers = { contentRequestForTransceivers.launch("*/*") }, onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false },
onDismiss = dismissDataSourcesDialog, onDismiss = { dialogs.dataSources = false },
onSave = { useCustomTle, useCustomTransceivers, tleUrl, transceiversUrl -> onSave = { useCustomTle, useCustomTransceivers, tleUrl, transceiversUrl ->
val current = uiState.dataSourcesSettings val current = uiState.dataSourcesSettings
val newSettings = current.copy( val newSettings = current.copy(
@@ -149,112 +130,55 @@ private fun SettingsScreen(uiState: SettingsState) {
useCustomTransceivers = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) useCustomTransceivers else current.useCustomTransceivers, useCustomTransceivers = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) useCustomTransceivers else current.useCustomTransceivers,
transceiversUrl = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) transceiversUrl else current.transceiversUrl transceiversUrl = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) transceiversUrl else current.transceiversUrl
) )
if (newSettings != current) { if (newSettings != current) onAction(SettingsAction.UpdateDataSources(newSettings))
uiState.sendDataSourcesAction(DataSourcesAction.Update(newSettings)) if (useCustomTle || useCustomTransceivers) onAction(SettingsAction.UpdateFromWeb)
}
if (useCustomTle || useCustomTransceivers) {
uiState.sendAction(SettingsAction.UpdateFromWeb)
}
} }
) )
} }
if (dialogs.network) {
// Data settings NetworkOutputDialog(
val dataSettings = uiState.dataSettings initialSettings = uiState.rcSettings,
val updateFromWeb: () -> Unit = { uiState.sendAction(SettingsAction.UpdateFromWeb) } onDismiss = { dialogs.network = false },
val clearAllData: () -> Unit = { uiState.sendAction(SettingsAction.ClearAllData) } onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt ->
onAction(
// RC settings SettingsAction.UpdateRC(
val rcSettings = uiState.rcSettings uiState.rcSettings.copy(
rotatorState = rotState, rotatorAddress = rotAddr,
// Radio control (FT-817) settings rotatorPort = rotPort, rotatorFormat = rotFmt,
val radioControlDialogState = rememberSaveable { mutableStateOf(false) } frequencyState = freqState, frequencyAddress = freqAddr,
val showRadioControlDialog = { radioControlDialogState.value = true } frequencyPort = freqPort, frequencyFormat = freqFmt
val dismissRadioControlDialog = { radioControlDialogState.value = false } )
if (radioControlDialogState.value) { )
)
}
)
}
if (dialogs.bluetooth) {
BluetoothOutputDialog(
initialSettings = uiState.rcSettings,
onDismiss = { dialogs.bluetooth = false },
onSave = { rotState, rotAddr, rotFmt, freqState, freqAddr, freqFmt ->
onAction(
SettingsAction.UpdateRC(
uiState.rcSettings.copy(
bluetoothRotatorState = rotState, bluetoothRotatorAddress = rotAddr,
bluetoothRotatorFormat = rotFmt, bluetoothFrequencyState = freqState,
bluetoothFrequencyAddress = freqAddr, bluetoothFrequencyFormat = freqFmt
)
)
)
}
)
}
if (dialogs.radioControl) {
RadioControlDialog( RadioControlDialog(
initialSettings = uiState.radioControlSettings, initialSettings = uiState.radioControlSettings,
onDismiss = dismissRadioControlDialog, onDismiss = { dialogs.radioControl = false },
onSave = { settings -> onSave = { onAction(SettingsAction.UpdateRadioControl(it)) }
uiState.sendRadioControlAction(RadioControlSettingsAction.Update(settings))
}
) )
} }
// Network data output // URLs for top bar
val networkDialogState = rememberSaveable { mutableStateOf(false) }
val showNetworkDialog = { networkDialogState.value = true }
val dismissNetworkDialog = { networkDialogState.value = false }
if (networkDialogState.value) {
NetworkOutputDialog(
initialSettings = rcSettings,
onDismiss = dismissNetworkDialog,
onSave = { rotatorState, rotatorAddress, rotatorPort, rotatorFormat,
frequencyState, frequencyAddress, frequencyPort, frequencyFormat ->
uiState.sendRCAction(
RCAction.Update(
rcSettings.copy(
rotatorState = rotatorState,
rotatorAddress = rotatorAddress,
rotatorPort = rotatorPort,
rotatorFormat = rotatorFormat,
frequencyState = frequencyState,
frequencyAddress = frequencyAddress,
frequencyPort = frequencyPort,
frequencyFormat = frequencyFormat
)
)
)
}
)
}
// Bluetooth data output
val bluetoothDialogState = rememberSaveable { mutableStateOf(false) }
val bluetoothContract = ActivityResultContracts.RequestPermission()
val bluetoothError = stringResource(R.string.prefs_bt_perm_error)
val bluetoothPerm = when {
Build.VERSION.SDK_INT < Build.VERSION_CODES.S -> Manifest.permission.BLUETOOTH
else -> Manifest.permission.BLUETOOTH_CONNECT
}
val bluetoothRequest = rememberLauncherForActivityResult(bluetoothContract) { isGranted ->
if (!isGranted)
{
uiState.sendRCAction(
RCAction.Update(
rcSettings.copy(bluetoothRotatorState = false, bluetoothFrequencyState = false)
)
)
uiState.sendSystemAction(SystemAction.ShowToast(bluetoothError))
}
else { bluetoothDialogState.value = true }
}
val showBluetoothDialog = { bluetoothRequest.launch(bluetoothPerm) }
val dismissBluetoothDialog = { bluetoothDialogState.value = false }
if (bluetoothDialogState.value) {
BluetoothOutputDialog(
initialSettings = rcSettings,
onDismiss = dismissBluetoothDialog,
onSave = { rotatorState, rotatorAddress, rotatorFormat,
frequencyState, frequencyAddress, frequencyFormat ->
uiState.sendRCAction(
RCAction.Update(
rcSettings.copy(
bluetoothRotatorState = rotatorState,
bluetoothRotatorAddress = rotatorAddress,
bluetoothRotatorFormat = rotatorFormat,
bluetoothFrequencyState = frequencyState,
bluetoothFrequencyAddress = frequencyAddress,
bluetoothFrequencyFormat = frequencyFormat
)
)
)
}
)
}
// Other settings
val otherSettings = uiState.otherSettings
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val appUrl = stringResource(R.string.prefs_app_url) val appUrl = stringResource(R.string.prefs_app_url)
val donateUrl = stringResource(R.string.prefs_donate_url) val donateUrl = stringResource(R.string.prefs_donate_url)
@@ -302,13 +226,31 @@ private fun SettingsScreen(uiState: SettingsState) {
modifier = Modifier.clip(MaterialTheme.shapes.medium) modifier = Modifier.clip(MaterialTheme.shapes.medium)
) { ) {
item { item {
LocationCard(posSettings, setGpsPos, showPosDialog, showLocDialog, dismissPos, uiState.sendSystemAction) LocationCard(
settings = uiState.positionSettings,
setGpsPos = permissions.launchLocation,
showPosDialog = { dialogs.position = true },
showLocDialog = { dialogs.locator = true },
dismissPosMessage = { onAction(SettingsAction.DismissPosMessages) },
onAction = onAction
)
}
item {
DataCard(
settings = uiState.dataSettings,
updateFromWeb = { onAction(SettingsAction.UpdateFromWeb) },
clearAllData = { onAction(SettingsAction.ClearAllData) },
showDataSourcesDialog = { dialogs.dataSources = true }
)
} }
item { DataCard(dataSettings, updateFromWeb, clearAllData, showDataSourcesDialog) }
item(span = { GridItemSpan(maxLineSpan) }) { item(span = { GridItemSpan(maxLineSpan) }) {
OutputCard({ showNetworkDialog() }, { showBluetoothDialog() }, { showRadioControlDialog() }) OutputCard(
onNetworkClick = { dialogs.network = true },
onBluetoothClick = permissions.launchBluetooth,
onRadioControlClick = { dialogs.radioControl = true }
)
} }
item { OtherCard(otherSettings, uiState.sendAction) } item { OtherCard(uiState.otherSettings, onAction) }
item { CardCredits() } item { CardCredits() }
} }
} }
@@ -329,7 +271,7 @@ private fun LocationCard(
showPosDialog: () -> Unit, showPosDialog: () -> Unit,
showLocDialog: () -> Unit, showLocDialog: () -> Unit,
dismissPosMessage: () -> Unit, dismissPosMessage: () -> Unit,
sendSystemAction: (SystemAction) -> Unit onAction: (SettingsAction) -> Unit
) { ) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) { ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column( Column(
@@ -344,7 +286,7 @@ private fun LocationCard(
UpdateIndicator(isUpdating = settings.isUpdating, Modifier.weight(1f)) UpdateIndicator(isUpdating = settings.isUpdating, Modifier.weight(1f))
} }
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
Text(text = setUpdateTime(updateTime = settings.stationPos.timestamp)) Text(text = formatUpdateTime(updateTime = settings.stationPos.timestamp))
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = "Lat: ${settings.stationPos.latitude}°") Text(text = "Lat: ${settings.stationPos.latitude}°")
@@ -376,7 +318,7 @@ private fun LocationCard(
if (settings.messageResId != 0) { if (settings.messageResId != 0) {
val errorString = stringResource(id = settings.messageResId) val errorString = stringResource(id = settings.messageResId)
LaunchedEffect(key1 = settings.messageResId) { LaunchedEffect(key1 = settings.messageResId) {
sendSystemAction(SystemAction.ShowToast(errorString)) onAction(SettingsAction.ShowToast(errorString))
dismissPosMessage() dismissPosMessage()
} }
} }
@@ -409,7 +351,7 @@ private fun DataCard(
UpdateIndicator(isUpdating = settings.isUpdating, Modifier.weight(1f)) UpdateIndicator(isUpdating = settings.isUpdating, Modifier.weight(1f))
} }
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
Text(text = setUpdateTime(updateTime = settings.timestamp)) Text(text = formatUpdateTime(updateTime = settings.timestamp))
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.prefs_data_entries, settings.entriesTotal)) Text(text = stringResource(R.string.prefs_data_entries, settings.entriesTotal))
@@ -491,7 +433,7 @@ private fun OtherCardPreview() = MainTheme {
} }
@Composable @Composable
private fun OtherCard(settings: OtherSettings, sendAction: (SettingsAction) -> Unit) { private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
ElevatedCard( ElevatedCard(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -503,16 +445,16 @@ private fun OtherCard(settings: OtherSettings, sendAction: (SettingsAction) -> U
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
SwitchRow(R.string.prefs_other_switch_utc, settings.stateOfUtc) { SwitchRow(R.string.prefs_other_switch_utc, settings.stateOfUtc) {
sendAction(SettingsAction.ToggleUtc(it)) onAction(SettingsAction.ToggleUtc(it))
} }
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) { SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
sendAction(SettingsAction.ToggleUpdate(it)) onAction(SettingsAction.ToggleUpdate(it))
} }
SwitchRow(R.string.prefs_other_switch_sweep, settings.stateOfSweep) { SwitchRow(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
sendAction(SettingsAction.ToggleSweep(it)) onAction(SettingsAction.ToggleSweep(it))
} }
SwitchRow(R.string.prefs_other_switch_sensors, settings.stateOfSensors) { SwitchRow(R.string.prefs_other_switch_sensors, settings.stateOfSensors) {
sendAction(SettingsAction.ToggleSensor(it)) onAction(SettingsAction.ToggleSensor(it))
} }
} }
} }
@@ -531,7 +473,7 @@ private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boole
} }
@Composable @Composable
private fun setUpdateTime(updateTime: Long): String { private fun formatUpdateTime(updateTime: Long): String {
val timePattern = stringResource(id = R.string.prefs_updated_time) val timePattern = stringResource(id = R.string.prefs_updated_time)
val placeholder = stringResource(id = R.string.pass_time_placeholder) val placeholder = stringResource(id = R.string.pass_time_placeholder)
val updateDate = remember(updateTime) { val updateDate = remember(updateTime) {
@@ -638,3 +580,91 @@ private fun BotCard(onClick: () -> Unit, resId: Int, text: String, modifier: Mod
} }
} }
} }
// region Dialog visibility state holder
@Stable
private class DialogVisibility {
var position by mutableStateOf(false)
var locator by mutableStateOf(false)
var dataSources by mutableStateOf(false)
var network by mutableStateOf(false)
var bluetooth by mutableStateOf(false)
var radioControl by mutableStateOf(false)
}
@Composable
private fun rememberDialogVisibility(): DialogVisibility {
return rememberSaveable(saver = run {
androidx.compose.runtime.saveable.Saver(
save = {
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl)
},
restore = {
DialogVisibility().apply {
position = it[0]; locator = it[1]; dataSources = it[2]
network = it[3]; bluetooth = it[4]; radioControl = it[5]
}
}
)
}) { DialogVisibility() }
}
// endregion
// region Permission launchers holder
@Stable
private class SettingsPermissions(
val launchLocation: () -> Unit,
val launchTleImport: () -> Unit,
val launchTransceiverImport: () -> Unit,
val launchBluetooth: () -> Unit
)
@Composable
private fun rememberSettingsPermissions(
sendAction: (SettingsAction) -> Unit,
onBluetoothGranted: () -> Unit
): SettingsPermissions {
val locationError = stringResource(R.string.prefs_loc_gps_error)
val locationRequest = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
val fine = permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true
val coarse = permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true
if (fine || coarse) sendAction(SettingsAction.SetGpsPosition)
else sendAction(SettingsAction.ShowToast(locationError))
}
val tleRequest = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let { sendAction(SettingsAction.UpdateTLEFromFile(it.toString())) }
}
val transceiversRequest = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let { sendAction(SettingsAction.UpdateTransceiversFromFile(it.toString())) }
}
val bluetoothError = stringResource(R.string.prefs_bt_perm_error)
val bluetoothPerm = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S)
Manifest.permission.BLUETOOTH else Manifest.permission.BLUETOOTH_CONNECT
val bluetoothRequest = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) onBluetoothGranted()
else sendAction(SettingsAction.ShowToast(bluetoothError))
}
return remember {
SettingsPermissions(
launchLocation = {
locationRequest.launch(
arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION)
)
},
launchTleImport = { tleRequest.launch("*/*") },
launchTransceiverImport = { transceiversRequest.launch("*/*") },
launchBluetooth = { bluetoothRequest.launch(bluetoothPerm) }
)
}
}
// endregion
@@ -38,42 +38,36 @@ data class SettingsState(
val otherSettings: OtherSettings, val otherSettings: OtherSettings,
val rcSettings: RCSettings, val rcSettings: RCSettings,
val radioControlSettings: RadioControlSettings, val radioControlSettings: RadioControlSettings,
val dataSourcesSettings: DataSourcesSettings, val dataSourcesSettings: DataSourcesSettings
val sendAction: (SettingsAction) -> Unit,
val sendRCAction: (RCAction) -> Unit,
val sendRadioControlAction: (RadioControlSettingsAction) -> Unit,
val sendSystemAction: (SystemAction) -> Unit,
val sendDataSourcesAction: (DataSourcesAction) -> Unit
) )
sealed class SettingsAction { sealed interface SettingsAction {
data object SetGpsPosition : SettingsAction() // Position
data class SetGeoPosition(val latitude: Double, val longitude: Double) : SettingsAction() data object SetGpsPosition : SettingsAction
data class SetQthPosition(val locator: String) : SettingsAction() data class SetGeoPosition(val latitude: Double, val longitude: Double) : SettingsAction
data object DismissPosMessages : SettingsAction() data class SetQthPosition(val locator: String) : SettingsAction
data object UpdateFromWeb : SettingsAction() data object DismissPosMessages : SettingsAction
data class UpdateTLEFromFile(val uri: String) : SettingsAction()
data class UpdateTransceiversFromFile(val uri: String) : SettingsAction()
data object ClearAllData : SettingsAction()
data class ToggleUtc(val value: Boolean) : SettingsAction()
data class ToggleUpdate(val value: Boolean) : SettingsAction()
data class ToggleSweep(val value: Boolean) : SettingsAction()
data class ToggleSensor(val value: Boolean) : SettingsAction()
data class ToggleLightTheme(val value: Boolean) : SettingsAction()
}
sealed class SystemAction { // Data
data class ShowToast(val message: String) : SystemAction() data object UpdateFromWeb : SettingsAction
} data class UpdateTLEFromFile(val uri: String) : SettingsAction
data class UpdateTransceiversFromFile(val uri: String) : SettingsAction
data object ClearAllData : SettingsAction
sealed class RCAction { // Toggles
data class Update(val settings: RCSettings) : RCAction() data class ToggleUtc(val value: Boolean) : SettingsAction
} data class ToggleUpdate(val value: Boolean) : SettingsAction
data class ToggleSweep(val value: Boolean) : SettingsAction
data class ToggleSensor(val value: Boolean) : SettingsAction
data class ToggleLightTheme(val value: Boolean) : SettingsAction
sealed class DataSourcesAction { // Remote control
data class Update(val settings: DataSourcesSettings) : DataSourcesAction() data class UpdateRC(val settings: RCSettings) : SettingsAction
} data class UpdateRadioControl(val settings: RadioControlSettings) : SettingsAction
sealed class RadioControlSettingsAction { // Data sources
data class Update(val settings: RadioControlSettings) : RadioControlSettingsAction() data class UpdateDataSources(val settings: DataSourcesSettings) : SettingsAction
// System
data class ShowToast(val message: String) : SettingsAction
} }
@@ -48,12 +48,7 @@ class SettingsViewModel(
otherSettings = settingsRepo.otherSettings.value, otherSettings = settingsRepo.otherSettings.value,
rcSettings = settingsRepo.rcSettings.value, rcSettings = settingsRepo.rcSettings.value,
radioControlSettings = settingsRepo.radioControlSettings.value, radioControlSettings = settingsRepo.radioControlSettings.value,
dataSourcesSettings = settingsRepo.dataSourcesSettings.value, dataSourcesSettings = settingsRepo.dataSourcesSettings.value
sendAction = ::handleAction,
sendRCAction = ::handleAction,
sendRadioControlAction = ::handleAction,
sendSystemAction = ::handleAction,
sendDataSourcesAction = ::handleAction
) )
) )
@@ -104,12 +99,14 @@ class SettingsViewModel(
} }
private fun handleAction(action: SettingsAction) { fun onAction(action: SettingsAction) {
when (action) { when (action) {
// Position
SettingsAction.SetGpsPosition -> setGpsPosition() SettingsAction.SetGpsPosition -> setGpsPosition()
is SettingsAction.SetGeoPosition -> setGeoPosition(action.latitude, action.longitude) is SettingsAction.SetGeoPosition -> setGeoPosition(action.latitude, action.longitude)
is SettingsAction.SetQthPosition -> setQthPosition(action.locator) is SettingsAction.SetQthPosition -> setQthPosition(action.locator)
SettingsAction.DismissPosMessages -> dismissPosMessage() SettingsAction.DismissPosMessages -> dismissPosMessage()
// Data
SettingsAction.UpdateFromWeb -> runDataUpdate { databaseRepo.updateFromRemote() } SettingsAction.UpdateFromWeb -> runDataUpdate { databaseRepo.updateFromRemote() }
is SettingsAction.UpdateTLEFromFile -> runDataUpdate { databaseRepo.updateTLEFromFile(action.uri) } is SettingsAction.UpdateTLEFromFile -> runDataUpdate { databaseRepo.updateTLEFromFile(action.uri) }
is SettingsAction.UpdateTransceiversFromFile -> runDataUpdate { is SettingsAction.UpdateTransceiversFromFile -> runDataUpdate {
@@ -117,37 +114,19 @@ class SettingsViewModel(
action.uri action.uri
) )
} }
SettingsAction.ClearAllData -> viewModelScope.launch { databaseRepo.clearAllData() } SettingsAction.ClearAllData -> viewModelScope.launch { databaseRepo.clearAllData() }
// Toggles
is SettingsAction.ToggleUtc -> settingsRepo.updateOtherSettings { it.copy(stateOfUtc = action.value) } is SettingsAction.ToggleUtc -> settingsRepo.updateOtherSettings { it.copy(stateOfUtc = action.value) }
is SettingsAction.ToggleUpdate -> settingsRepo.updateOtherSettings { it.copy(stateOfAutoUpdate = action.value) } is SettingsAction.ToggleUpdate -> settingsRepo.updateOtherSettings { it.copy(stateOfAutoUpdate = action.value) }
is SettingsAction.ToggleSweep -> settingsRepo.updateOtherSettings { it.copy(stateOfSweep = action.value) } is SettingsAction.ToggleSweep -> settingsRepo.updateOtherSettings { it.copy(stateOfSweep = action.value) }
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) } is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) } is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
} // Remote control & data sources
} is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
private fun handleAction(action: RCAction) { is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(action.settings)
when (action) { // System
is RCAction.Update -> settingsRepo.updateRCSettings(action.settings) is SettingsAction.ShowToast -> showToast(action.message)
}
}
private fun handleAction(action: SystemAction) {
when (action) {
is SystemAction.ShowToast -> showToast(action.message)
}
}
private fun handleAction(action: DataSourcesAction) {
when (action) {
is DataSourcesAction.Update -> settingsRepo.updateDataSourcesSettings(action.settings)
}
}
private fun handleAction(action: RadioControlSettingsAction) {
when (action) {
is RadioControlSettingsAction.Update -> settingsRepo.updateRadioControlSettings(action.settings)
} }
} }
@@ -188,7 +167,7 @@ class SettingsViewModel(
// region Data update helpers — consolidated from 3 near-identical functions // region Data update helpers — consolidated from 3 near-identical functions
/** /**
* Common data update logic. Sets isUpdating=true, runs the suspend [block], * Common data update logic. Sets isUpdating=true, runs the suspending [block],
* and resets isUpdating=false on failure. * and resets isUpdating=false on failure.
*/ */
private fun runDataUpdate(block: suspend () -> Unit) = viewModelScope.launch { private fun runDataUpdate(block: suspend () -> Unit) = viewModelScope.launch {