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
+493 -484

No files matched your search

@@ -33,11 +33,11 @@ data class MapState(
val positions: Map<OrbitalObject, GeoPos>? = null
)
sealed class MapAction {
data object SelectPrev : MapAction()
data object SelectNext : MapAction()
data class SelectItem(val item: OrbitalObject) : MapAction()
data class SelectDefaultItem(val catnum: Int) : MapAction()
sealed interface MapAction {
data object SelectPrev : MapAction
data object SelectNext : MapAction
data class SelectItem(val item: OrbitalObject) : MapAction
data class SelectDefaultItem(val catnum: Int) : MapAction
}
data class MapData(
@@ -60,6 +60,8 @@ private val allModes = listOf(
"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
@Composable
private fun PassesDialogPreview() {
@@ -68,26 +70,27 @@ private fun PassesDialogPreview() {
@Composable
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 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) {
SliderRow(
title = stringResource(R.string.pass_filter_elev),
value = elevationValueNew.doubleValue,
valuePostfix = "°",
displayValue = "${elevationValueNew.doubleValue.toInt()}°",
valueResId = R.drawable.ic_elevation,
valueRange = 0f..60f
) { elevationValueNew.doubleValue = it.toDouble() }
SliderRow(
title = stringResource(R.string.pass_filter_hours),
value = hoursValue.intValue.toDouble(),
valuePostfix = "h",
value = hoursIndex.intValue.toDouble(),
displayValue = "${hourSteps[hoursIndex.intValue]}h",
valueResId = R.drawable.ic_clock,
valueRange = 1f..240f
) { hoursValue.intValue = it.toInt() }
valueRange = 0f..(hourSteps.size - 1).toFloat(),
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(
title: String,
value: Double,
valuePostfix: String,
displayValue: String,
valueResId: Int,
valueRange: ClosedFloatingPointRange<Float>,
steps: Int = 0,
onChange: (Float) -> Unit
) {
Column(
@@ -122,13 +126,13 @@ private fun SliderRow(
modifier = Modifier.size(20.dp)
)
Text(
text = "${value.toInt()}$valuePostfix",
text = displayValue,
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
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
)
sealed class PassesAction {
data object DismissWhatsNew : PassesAction()
data class FilterPasses(val hoursAhead: Int, val minElevation: Double) : PassesAction()
data class FilterRadios(val modes: List<String>) : PassesAction()
data object RefreshPasses : PassesAction()
data object TogglePassesDialog : PassesAction()
data object ToggleRadiosDialog : PassesAction()
sealed interface PassesAction {
data object DismissWhatsNew : PassesAction
data class FilterPasses(val hoursAhead: Int, val minElevation: Double) : PassesAction
data class FilterRadios(val modes: List<String>) : PassesAction
data object RefreshPasses : PassesAction
data object TogglePassesDialog : PassesAction
data object ToggleRadiosDialog : PassesAction
}
@@ -34,6 +34,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@@ -57,15 +58,22 @@ class PassesViewModel(
val uiState: StateFlow<PassesState> = _uiState
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 {
var initialLoadDone = false
satelliteRepo.passes.collectLatest { _ ->
if (!initialLoadDone) {
initialLoadDone = true
delay(1000) // Artificial delay to show the refreshing state on first load
settingsRepo.selectedIds.collectLatest { selectedIds ->
_uiState.update { it.copy(isRefreshing = true) }
if (selectedIds.isEmpty()) {
// No satellites selected — show indicator briefly, then stop
delay(1000)
_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
@@ -36,7 +36,7 @@ data class RadarState(
val selectedFrequency: Long? = null
)
sealed class RadarAction {
data class AddToCalendar(val name: String, val aosTime: Long, val losTime: Long) : RadarAction()
data class SelectTransmitter(val uuid: String) : RadarAction()
sealed interface RadarAction {
data class AddToCalendar(val name: String, val aosTime: Long, val losTime: Long) : RadarAction
data class SelectTransmitter(val uuid: String) : RadarAction
}
@@ -174,6 +174,10 @@ class RadarViewModel(
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
}
return@update state.copy(
transmitters = transmitters,
selectedTransmitterUuid = null,
@@ -191,6 +195,10 @@ class RadarViewModel(
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)
}
}
@@ -76,12 +76,16 @@ fun NavGraphBuilder.radioControlDestination(navigateUp: () -> Unit) {
composable(route, args) {
val viewModel = viewModel(RadioControlViewModel::class.java, factory = RadioControlViewModel.Factory)
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
RadioControlScreen(uiState, navigateUp)
RadioControlScreen(uiState, viewModel::onAction, navigateUp)
}
}
@Composable
private fun RadioControlScreen(uiState: RadioControlState, navigateUp: () -> Unit) {
private fun RadioControlScreen(
uiState: RadioControlState,
onAction: (RadioControlAction) -> Unit,
navigateUp: () -> Unit
) {
Column(
modifier = Modifier
.layoutPadding()
@@ -115,21 +119,39 @@ private fun RadioControlScreen(uiState: RadioControlState, navigateUp: () -> Uni
distance = uiState.distance
)
val selectedTransponder = uiState.transponders.find {
it.uuid == uiState.selectedTransponderUuid
}
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
item { TransponderSelector(uiState) }
val selectedTransponder = uiState.transponders.find {
it.uuid == uiState.selectedTransponderUuid
item {
TransponderSelector(
transponders = uiState.transponders,
selectedUuid = uiState.selectedTransponderUuid,
onAction = onAction
)
}
if (selectedTransponder?.uplinkMode?.uppercase() == "FM") {
item { CtcssSelector(uiState) }
item {
CtcssSelector(
ctcssTone = uiState.ctcssTone,
onAction = onAction
)
}
}
if (uiState.txBaseFrequencyHz != null) {
item { FrequencyTuner(uiState) }
item {
FrequencyTuner(
txBaseFrequencyHz = uiState.txBaseFrequencyHz,
selectedTransponder = selectedTransponder,
onAction = onAction
)
}
}
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
private fun TransponderSelector(uiState: RadioControlState) {
if (uiState.transponders.isEmpty()) {
private fun TransponderSelector(
transponders: List<SatRadio>,
selectedUuid: String?,
onAction: (RadioControlAction) -> Unit
) {
if (transponders.isEmpty()) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Text(
text = "No transponders with uplink+downlink available",
@@ -222,11 +252,11 @@ private fun TransponderSelector(uiState: RadioControlState) {
return
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
uiState.transponders.forEach { radio ->
transponders.forEach { radio ->
TransponderItem(
radio = radio,
isSelected = radio.uuid == uiState.selectedTransponderUuid,
onSelect = { uiState.sendAction(RadioControlAction.SelectTransponder(radio.uuid)) }
isSelected = radio.uuid == selectedUuid,
onSelect = { onAction(RadioControlAction.SelectTransponder(radio.uuid)) }
)
}
}
@@ -257,21 +287,24 @@ private fun TransponderItem(radio: SatRadio, isSelected: Boolean, onSelect: () -
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun CtcssSelector(uiState: RadioControlState) {
private fun CtcssSelector(
ctcssTone: Double?,
onAction: (RadioControlAction) -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)) {
Text(text = "CTCSS Tone", color = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.height(4.dp))
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
FilterChip(
selected = uiState.ctcssTone == null,
onClick = { uiState.sendAction(RadioControlAction.SetCtcssTone(null)) },
selected = ctcssTone == null,
onClick = { onAction(RadioControlAction.SetCtcssTone(null)) },
label = { Text("Off") }
)
RadioControlViewModel.CTCSS_TONES.forEach { tone ->
FilterChip(
selected = uiState.ctcssTone == tone,
onClick = { uiState.sendAction(RadioControlAction.SetCtcssTone(tone)) },
selected = ctcssTone == tone,
onClick = { onAction(RadioControlAction.SetCtcssTone(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
private fun FrequencyTuner(uiState: RadioControlState) {
val freq = uiState.txBaseFrequencyHz ?: return
val transponder = uiState.transponders.find { it.uuid == uiState.selectedTransponderUuid }
private fun FrequencyTuner(
txBaseFrequencyHz: Long,
selectedTransponder: SatRadio?,
onAction: (RadioControlAction) -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
) {
Text(text = "TX Base Frequency", color = MaterialTheme.colorScheme.primary)
if (transponder != null) {
val upLow = transponder.uplinkLow
val upHigh = transponder.uplinkHigh
val dnLow = transponder.downlinkLow
val dnHigh = transponder.downlinkHigh
if (selectedTransponder != null) {
val upLow = selectedTransponder.uplinkLow
val upHigh = selectedTransponder.uplinkHigh
val dnLow = selectedTransponder.downlinkLow
val dnHigh = selectedTransponder.downlinkHigh
if (upLow != null && upHigh != null && upLow != upHigh) {
Text(
text = "UP: ${RadioControlViewModel.formatFrequency(upLow)} - ${RadioControlViewModel.formatFrequency(upHigh)}",
@@ -312,68 +350,48 @@ private fun FrequencyTuner(uiState: RadioControlState) {
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "${RadioControlViewModel.formatFrequency(freq)} MHz",
text = "${RadioControlViewModel.formatFrequency(txBaseFrequencyHz)} MHz",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
FREQ_ADJUSTMENTS.forEach { (delta, label) ->
CardButton(
onClick = { uiState.sendAction(RadioControlAction.AdjustTxFrequency(-10_000)) },
text = "-10k",
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",
onClick = { onAction(RadioControlAction.AdjustTxFrequency(delta)) },
text = label,
modifier = Modifier.weight(1f)
)
}
}
}
}
}
@Composable
private fun ControlButtons(uiState: RadioControlState) {
val isConnected = uiState.txPanel.isConnected || uiState.rxPanel.isConnected
private fun ControlButtons(
isConnected: Boolean,
isTracking: Boolean,
onAction: (RadioControlAction) -> Unit
) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
if (!isConnected) {
CardButton(
onClick = { uiState.sendAction(RadioControlAction.ConnectRadios) },
onClick = { onAction(RadioControlAction.ConnectRadios) },
text = "Connect",
modifier = Modifier.weight(1f)
)
} else {
CardButton(
onClick = { uiState.sendAction(RadioControlAction.DisconnectRadios) },
onClick = { onAction(RadioControlAction.DisconnectRadios) },
text = "Disconnect",
modifier = Modifier.weight(1f)
)
}
CardButton(
onClick = { uiState.sendAction(RadioControlAction.ToggleTracking) },
text = if (uiState.isTracking) "Stop Tracking" else "Start Tracking",
onClick = { onAction(RadioControlAction.ToggleTracking) },
text = if (isTracking) "Stop Tracking" else "Start Tracking",
modifier = Modifier.weight(1f)
)
}
@@ -42,16 +42,15 @@ data class RadioControlState(
val txBaseFrequencyHz: Long?,
val ctcssTone: Double?,
val isTracking: Boolean,
val errorMessage: String?,
val sendAction: (RadioControlAction) -> Unit
val errorMessage: String?
)
sealed class RadioControlAction {
data class SelectTransponder(val uuid: String) : RadioControlAction()
data class SetTxFrequency(val frequencyHz: Long) : RadioControlAction()
data class AdjustTxFrequency(val deltaHz: Long) : RadioControlAction()
data class SetCtcssTone(val toneHz: Double?) : RadioControlAction()
data object ToggleTracking : RadioControlAction()
data object ConnectRadios : RadioControlAction()
data object DisconnectRadios : RadioControlAction()
sealed interface RadioControlAction {
data class SelectTransponder(val uuid: String) : RadioControlAction
data class SetTxFrequency(val frequencyHz: Long) : RadioControlAction
data class AdjustTxFrequency(val deltaHz: Long) : RadioControlAction
data class SetCtcssTone(val toneHz: Double?) : RadioControlAction
data object ToggleTracking : RadioControlAction
data object ConnectRadios : RadioControlAction
data object DisconnectRadios : RadioControlAction
}
@@ -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.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.utility.toDegrees
import com.rtbishop.look4sat.core.domain.utility.toTimerString
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.Locale
@@ -46,6 +49,7 @@ class RadioControlViewModel(
private var currentPass: OrbitalPass? = null
private var transponders: List<SatRadio> = emptyList()
private val stationPos = settingsRepo.stationPosition.value
private val _uiState = MutableStateFlow(
RadioControlState(
@@ -62,8 +66,7 @@ class RadioControlViewModel(
txBaseFrequencyHz = null,
ctcssTone = null,
isTracking = false,
errorMessage = null,
sendAction = ::handleAction
errorMessage = null
)
)
val uiState: StateFlow<RadioControlState> = _uiState
@@ -72,6 +75,7 @@ class RadioControlViewModel(
val catNum = savedStateHandle.get<Int>("catNum") ?: 0
val aosTime = savedStateHandle.get<Long>("aosTime") ?: 0L
// Resolve pass and load transponders
viewModelScope.launch {
val passes = satelliteRepo.passes.value
val pass = passes.find { it.catNum == catNum && it.aosTime == aosTime }
@@ -90,29 +94,30 @@ class RadioControlViewModel(
it.copy(selectedTransponderUuid = svcState.selectedTransponder?.uuid)
}
}
}
}
// Observe service state and map to UI state
viewModelScope.launch {
trackingService.state.collect { svc ->
// Tick loop — timer and satellite position updates every second
while (isActive) {
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
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", svc.azimuth),
elevation = String.format(Locale.ENGLISH, "%.1f", svc.elevation),
distance = String.format(Locale.ENGLISH, "%.0f", svc.distance),
azimuth = String.format(Locale.ENGLISH, "%.1f", pos.azimuth.toDegrees()),
elevation = String.format(Locale.ENGLISH, "%.1f", pos.elevation.toDegrees()),
distance = String.format(Locale.ENGLISH, "%.0f", pos.distance)
)
}
delay(1000)
}
}
}
// Observe service state for radio-specific updates (panels, frequencies, tracking status)
viewModelScope.launch {
trackingService.state.collect { svc ->
_uiState.update { state ->
state.copy(
txPanel = RadioPanelState(
label = "TX (Uplink)",
isConnected = svc.txConnected,
@@ -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) {
is RadioControlAction.SelectTransponder -> {
val transponder = transponders.find { it.uuid == action.uuid } ?: return
@@ -28,13 +28,13 @@ data class SatellitesState(
val typesList: List<String> = emptyList()
)
sealed class SatellitesAction {
data object DismissWarning : SatellitesAction()
data object SaveSelection : SatellitesAction()
data class SearchFor(val query: String) : SatellitesAction()
data object SelectAll : SatellitesAction()
data class SelectSingle(val id: Int, val isTicked: Boolean) : SatellitesAction()
data class SelectTypes(val types: List<String>) : SatellitesAction()
data object ToggleTypesDialog : SatellitesAction()
data object UnselectAll : SatellitesAction()
sealed interface SatellitesAction {
data object DismissWarning : SatellitesAction
data object SaveSelection : SatellitesAction
data class SearchFor(val query: String) : SatellitesAction
data object SelectAll : SatellitesAction
data class SelectSingle(val id: Int, val isTicked: Boolean) : SatellitesAction
data class SelectTypes(val types: List<String>) : SatellitesAction
data object ToggleTypesDialog : SatellitesAction
data object UnselectAll : SatellitesAction
}
@@ -17,6 +17,8 @@
*/
package com.rtbishop.look4sat.feature.settings
import android.bluetooth.BluetoothManager
import android.content.Context
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -29,6 +31,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
@@ -257,14 +260,8 @@ fun NetworkOutputDialog(
val (rotIp, rotPort) = splitAddress(rotatorAddress.value)
val (freqIp, freqPort) = splitAddress(frequencyAddress.value)
onSave(
rotatorState.value,
rotIp,
rotPort,
rotatorFormat.value,
frequencyState.value,
freqIp,
freqPort,
frequencyFormat.value
rotatorState.value, rotIp, rotPort, rotatorFormat.value,
frequencyState.value, freqIp, freqPort, frequencyFormat.value
)
onDismiss()
}
@@ -274,72 +271,30 @@ fun NetworkOutputDialog(
onAccept = onAccept
) {
Column(modifier = Modifier.padding(horizontal = padding)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.prefs_net_rotator_switch))
Switch(
checked = rotatorState.value,
onCheckedChange = { rotatorState.value = it }
OutputChannelSection(
switchLabel = stringResource(R.string.prefs_net_rotator_switch),
enabled = rotatorState.value,
onEnabledChange = { rotatorState.value = it },
address = rotatorAddress.value,
onAddressChange = { rotatorAddress.value = it },
addressLabel = stringResource(R.string.prefs_net_rotator_address_hint),
format = rotatorFormat.value,
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))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.prefs_net_frequency_switch))
Switch(
checked = frequencyState.value,
onCheckedChange = { frequencyState.value = it }
OutputChannelSection(
switchLabel = stringResource(R.string.prefs_net_frequency_switch),
enabled = frequencyState.value,
onEnabledChange = { frequencyState.value = it },
address = frequencyAddress.value,
onAddressChange = { frequencyAddress.value = it },
addressLabel = stringResource(R.string.prefs_net_frequency_address_hint),
format = frequencyFormat.value,
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 onAccept = {
onSave(
rotatorState.value,
rotatorAddress.value,
rotatorFormat.value,
frequencyState.value,
frequencyAddress.value,
frequencyFormat.value
rotatorState.value, rotatorAddress.value, rotatorFormat.value,
frequencyState.value, frequencyAddress.value, frequencyFormat.value
)
onDismiss()
}
@@ -413,73 +364,78 @@ fun BluetoothOutputDialog(
onAccept = onAccept
) {
Column(modifier = Modifier.padding(horizontal = padding)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.prefs_bt_rotator_switch))
Switch(
checked = rotatorState.value,
onCheckedChange = { rotatorState.value = it }
OutputChannelSection(
switchLabel = stringResource(R.string.prefs_bt_rotator_switch),
enabled = rotatorState.value,
onEnabledChange = { rotatorState.value = it },
address = rotatorAddress.value,
onAddressChange = { rotatorAddress.value = it },
addressLabel = stringResource(R.string.prefs_bt_rotator_device_hint),
format = rotatorFormat.value,
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))
OutputChannelSection(
switchLabel = stringResource(R.string.prefs_bt_frequency_switch),
enabled = frequencyState.value,
onEnabledChange = { frequencyState.value = it },
address = frequencyAddress.value,
onAddressChange = { frequencyAddress.value = it },
addressLabel = stringResource(R.string.prefs_bt_frequency_device_hint),
format = frequencyFormat.value,
onFormatChange = { frequencyFormat.value = it },
formatLabel = stringResource(R.string.prefs_bt_frequency_output_hint)
)
}
}
}
/**
* 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(stringResource(R.string.prefs_bt_frequency_switch))
Switch(
checked = frequencyState.value,
onCheckedChange = { frequencyState.value = it }
)
Text(switchLabel)
Switch(checked = enabled, onCheckedChange = onEnabledChange)
}
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = frequencyAddress.value,
onValueChange = { frequencyAddress.value = it },
value = address,
onValueChange = onAddressChange,
singleLine = true,
label = { Text(stringResource(R.string.prefs_bt_frequency_device_hint)) },
label = { Text(addressLabel) },
modifier = Modifier.weight(0.6f),
enabled = frequencyState.value
enabled = enabled
)
OutlinedTextField(
value = frequencyFormat.value,
onValueChange = { frequencyFormat.value = it },
value = format,
onValueChange = onFormatChange,
singleLine = true,
label = { Text(stringResource(R.string.prefs_bt_frequency_output_hint)) },
label = { Text(formatLabel) },
modifier = Modifier.weight(0.4f),
enabled = frequencyState.value
enabled = enabled
)
}
}
}
}
@Composable
@@ -497,12 +453,12 @@ fun RadioControlDialog(
val rxAddress = rememberSaveable { mutableStateOf(initialSettings.rxRadioAddress) }
val txName = rememberSaveable { mutableStateOf(initialSettings.txRadioName) }
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 pairedDevices = remember {
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()
} catch (_: SecurityException) {
emptyList()
@@ -518,7 +474,7 @@ fun RadioControlDialog(
rxRadioAddress = rxAddress.value,
txRadioName = txName.value,
rxRadioName = rxName.value,
baudRate = baudRate.value
baudRate = baudRate.intValue
)
)
onDismiss()
@@ -629,8 +585,8 @@ fun RadioControlDialog(
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
baudRates.forEach { rate ->
CardButton(
onClick = { baudRate.value = rate },
text = if (rate == baudRate.value) "[$rate]" else rate.toString(),
onClick = { baudRate.intValue = rate },
text = if (rate == baudRate.intValue) "[$rate]" else rate.toString(),
modifier = Modifier
)
}
@@ -42,9 +42,12 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -81,66 +84,44 @@ fun NavGraphBuilder.settingsDestination() {
modelClass = SettingsViewModel::class.java,
factory = SettingsViewModel.Factory
)
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
SettingsScreen(uiState)
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SettingsScreen(uiState, viewModel::onAction)
}
}
@Composable
private fun SettingsScreen(uiState: SettingsState) {
val locationContract = ActivityResultContracts.RequestMultiplePermissions()
val locationError = stringResource(R.string.prefs_loc_gps_error)
val locationPermCoarse = Manifest.permission.ACCESS_COARSE_LOCATION
val locationPermFine = Manifest.permission.ACCESS_FINE_LOCATION
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())) }
}
private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) {
val dialogs = rememberDialogVisibility()
val permissions = rememberSettingsPermissions(
sendAction = onAction,
onBluetoothGranted = { dialogs.bluetooth = true }
)
// Position settings
val posSettings = uiState.positionSettings
val setGpsPos = { locationRequest.launch(arrayOf(locationPermCoarse, locationPermFine)) }
val setGeoPos = { lat: Double, lon: Double ->
uiState.sendAction(SettingsAction.SetGeoPosition(lat, lon))
// Dialogs
if (dialogs.position) {
PositionDialog(
uiState.positionSettings.stationPos.latitude,
uiState.positionSettings.stationPos.longitude,
dismiss = { dialogs.position = false },
save = { lat, lon -> onAction(SettingsAction.SetGeoPosition(lat, lon)) }
)
}
val setQthPos = { locator: String ->
uiState.sendAction(SettingsAction.SetQthPosition(locator))
if (dialogs.locator) {
LocatorDialog(
uiState.positionSettings.stationPos.qthLocator,
dismiss = { dialogs.locator = false },
save = { onAction(SettingsAction.SetQthPosition(it)) }
)
}
val dismissPos = { uiState.sendAction(SettingsAction.DismissPosMessages) }
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) {
if (dialogs.dataSources) {
DataSourcesDialog(
useCustomTle = uiState.dataSourcesSettings.useCustomTLE,
useCustomTransceivers = uiState.dataSourcesSettings.useCustomTransceivers,
tleUrl = uiState.dataSourcesSettings.tleUrl,
transceiversUrl = uiState.dataSourcesSettings.transceiversUrl,
onImportTle = { contentRequestForTle.launch("*/*") },
onImportTransceivers = { contentRequestForTransceivers.launch("*/*") },
onDismiss = dismissDataSourcesDialog,
onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false },
onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false },
onDismiss = { dialogs.dataSources = false },
onSave = { useCustomTle, useCustomTransceivers, tleUrl, transceiversUrl ->
val current = uiState.dataSourcesSettings
val newSettings = current.copy(
@@ -149,112 +130,55 @@ private fun SettingsScreen(uiState: SettingsState) {
useCustomTransceivers = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) useCustomTransceivers else current.useCustomTransceivers,
transceiversUrl = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) transceiversUrl else current.transceiversUrl
)
if (newSettings != current) {
uiState.sendDataSourcesAction(DataSourcesAction.Update(newSettings))
}
if (useCustomTle || useCustomTransceivers) {
uiState.sendAction(SettingsAction.UpdateFromWeb)
}
if (newSettings != current) onAction(SettingsAction.UpdateDataSources(newSettings))
if (useCustomTle || useCustomTransceivers) onAction(SettingsAction.UpdateFromWeb)
}
)
}
// Data settings
val dataSettings = uiState.dataSettings
val updateFromWeb: () -> Unit = { uiState.sendAction(SettingsAction.UpdateFromWeb) }
val clearAllData: () -> Unit = { uiState.sendAction(SettingsAction.ClearAllData) }
// RC settings
val rcSettings = uiState.rcSettings
// Radio control (FT-817) settings
val radioControlDialogState = rememberSaveable { mutableStateOf(false) }
val showRadioControlDialog = { radioControlDialogState.value = true }
val dismissRadioControlDialog = { radioControlDialogState.value = false }
if (radioControlDialogState.value) {
if (dialogs.network) {
NetworkOutputDialog(
initialSettings = uiState.rcSettings,
onDismiss = { dialogs.network = false },
onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt ->
onAction(
SettingsAction.UpdateRC(
uiState.rcSettings.copy(
rotatorState = rotState, rotatorAddress = rotAddr,
rotatorPort = rotPort, rotatorFormat = rotFmt,
frequencyState = freqState, frequencyAddress = freqAddr,
frequencyPort = freqPort, frequencyFormat = freqFmt
)
)
)
}
)
}
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(
initialSettings = uiState.radioControlSettings,
onDismiss = dismissRadioControlDialog,
onSave = { settings ->
uiState.sendRadioControlAction(RadioControlSettingsAction.Update(settings))
}
onDismiss = { dialogs.radioControl = false },
onSave = { onAction(SettingsAction.UpdateRadioControl(it)) }
)
}
// Network data output
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
// URLs for top bar
val uriHandler = LocalUriHandler.current
val appUrl = stringResource(R.string.prefs_app_url)
val donateUrl = stringResource(R.string.prefs_donate_url)
@@ -302,13 +226,31 @@ private fun SettingsScreen(uiState: SettingsState) {
modifier = Modifier.clip(MaterialTheme.shapes.medium)
) {
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) }) {
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() }
}
}
@@ -329,7 +271,7 @@ private fun LocationCard(
showPosDialog: () -> Unit,
showLocDialog: () -> Unit,
dismissPosMessage: () -> Unit,
sendSystemAction: (SystemAction) -> Unit
onAction: (SettingsAction) -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
@@ -344,7 +286,7 @@ private fun LocationCard(
UpdateIndicator(isUpdating = settings.isUpdating, Modifier.weight(1f))
}
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))
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = "Lat: ${settings.stationPos.latitude}°")
@@ -376,7 +318,7 @@ private fun LocationCard(
if (settings.messageResId != 0) {
val errorString = stringResource(id = settings.messageResId)
LaunchedEffect(key1 = settings.messageResId) {
sendSystemAction(SystemAction.ShowToast(errorString))
onAction(SettingsAction.ShowToast(errorString))
dismissPosMessage()
}
}
@@ -409,7 +351,7 @@ private fun DataCard(
UpdateIndicator(isUpdating = settings.isUpdating, Modifier.weight(1f))
}
Spacer(modifier = Modifier.height(2.dp))
Text(text = setUpdateTime(updateTime = settings.timestamp))
Text(text = formatUpdateTime(updateTime = settings.timestamp))
Spacer(modifier = Modifier.height(2.dp))
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.prefs_data_entries, settings.entriesTotal))
@@ -491,7 +433,7 @@ private fun OtherCardPreview() = MainTheme {
}
@Composable
private fun OtherCard(settings: OtherSettings, sendAction: (SettingsAction) -> Unit) {
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
@@ -503,16 +445,16 @@ private fun OtherCard(settings: OtherSettings, sendAction: (SettingsAction) -> U
color = MaterialTheme.colorScheme.primary
)
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) {
sendAction(SettingsAction.ToggleUpdate(it))
onAction(SettingsAction.ToggleUpdate(it))
}
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) {
sendAction(SettingsAction.ToggleSensor(it))
onAction(SettingsAction.ToggleSensor(it))
}
}
}
@@ -531,7 +473,7 @@ private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boole
}
@Composable
private fun setUpdateTime(updateTime: Long): String {
private fun formatUpdateTime(updateTime: Long): String {
val timePattern = stringResource(id = R.string.prefs_updated_time)
val placeholder = stringResource(id = R.string.pass_time_placeholder)
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 rcSettings: RCSettings,
val radioControlSettings: RadioControlSettings,
val dataSourcesSettings: DataSourcesSettings,
val sendAction: (SettingsAction) -> Unit,
val sendRCAction: (RCAction) -> Unit,
val sendRadioControlAction: (RadioControlSettingsAction) -> Unit,
val sendSystemAction: (SystemAction) -> Unit,
val sendDataSourcesAction: (DataSourcesAction) -> Unit
val dataSourcesSettings: DataSourcesSettings
)
sealed class SettingsAction {
data object SetGpsPosition : SettingsAction()
data class SetGeoPosition(val latitude: Double, val longitude: Double) : SettingsAction()
data class SetQthPosition(val locator: String) : SettingsAction()
data object DismissPosMessages : SettingsAction()
data object UpdateFromWeb : 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 interface SettingsAction {
// Position
data object SetGpsPosition : SettingsAction
data class SetGeoPosition(val latitude: Double, val longitude: Double) : SettingsAction
data class SetQthPosition(val locator: String) : SettingsAction
data object DismissPosMessages : SettingsAction
sealed class SystemAction {
data class ShowToast(val message: String) : SystemAction()
}
// Data
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 {
data class Update(val settings: RCSettings) : RCAction()
}
// Toggles
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 {
data class Update(val settings: DataSourcesSettings) : DataSourcesAction()
}
// Remote control
data class UpdateRC(val settings: RCSettings) : SettingsAction
data class UpdateRadioControl(val settings: RadioControlSettings) : SettingsAction
sealed class RadioControlSettingsAction {
data class Update(val settings: RadioControlSettings) : RadioControlSettingsAction()
// Data sources
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,
rcSettings = settingsRepo.rcSettings.value,
radioControlSettings = settingsRepo.radioControlSettings.value,
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
sendAction = ::handleAction,
sendRCAction = ::handleAction,
sendRadioControlAction = ::handleAction,
sendSystemAction = ::handleAction,
sendDataSourcesAction = ::handleAction
dataSourcesSettings = settingsRepo.dataSourcesSettings.value
)
)
@@ -104,12 +99,14 @@ class SettingsViewModel(
}
private fun handleAction(action: SettingsAction) {
fun onAction(action: SettingsAction) {
when (action) {
// Position
SettingsAction.SetGpsPosition -> setGpsPosition()
is SettingsAction.SetGeoPosition -> setGeoPosition(action.latitude, action.longitude)
is SettingsAction.SetQthPosition -> setQthPosition(action.locator)
SettingsAction.DismissPosMessages -> dismissPosMessage()
// Data
SettingsAction.UpdateFromWeb -> runDataUpdate { databaseRepo.updateFromRemote() }
is SettingsAction.UpdateTLEFromFile -> runDataUpdate { databaseRepo.updateTLEFromFile(action.uri) }
is SettingsAction.UpdateTransceiversFromFile -> runDataUpdate {
@@ -117,37 +114,19 @@ class SettingsViewModel(
action.uri
)
}
SettingsAction.ClearAllData -> viewModelScope.launch { databaseRepo.clearAllData() }
// Toggles
is SettingsAction.ToggleUtc -> settingsRepo.updateOtherSettings { it.copy(stateOfUtc = action.value) }
is SettingsAction.ToggleUpdate -> settingsRepo.updateOtherSettings { it.copy(stateOfAutoUpdate = action.value) }
is SettingsAction.ToggleSweep -> settingsRepo.updateOtherSettings { it.copy(stateOfSweep = action.value) }
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
}
}
private fun handleAction(action: RCAction) {
when (action) {
is RCAction.Update -> settingsRepo.updateRCSettings(action.settings)
}
}
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)
// Remote control & data sources
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(action.settings)
// System
is SettingsAction.ShowToast -> showToast(action.message)
}
}
@@ -188,7 +167,7 @@ class SettingsViewModel(
// 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.
*/
private fun runDataUpdate(block: suspend () -> Unit) = viewModelScope.launch {