From a94f95874b40538975d46bb153084c488cf6a58d Mon Sep 17 00:00:00 2001 From: Arty Bishop Date: Fri, 10 Apr 2026 13:43:42 +0100 Subject: [PATCH] Added several tweaks to Settings and RadioControl screens --- .../rtbishop/look4sat/feature/map/MapState.kt | 10 +- .../look4sat/feature/passes/PassesDialog.kt | 24 +- .../look4sat/feature/passes/PassesState.kt | 14 +- .../feature/passes/PassesViewModel.kt | 20 +- .../look4sat/feature/radar/RadarState.kt | 6 +- .../look4sat/feature/radar/RadarViewModel.kt | 8 + .../radiocontrol/RadioControlScreen.kt | 146 ++++---- .../feature/radiocontrol/RadioControlState.kt | 19 +- .../radiocontrol/RadioControlViewModel.kt | 51 ++- .../feature/satellites/SatellitesState.kt | 18 +- .../feature/settings/SettingsDialog.kt | 250 ++++++------- .../feature/settings/SettingsScreen.kt | 350 ++++++++++-------- .../feature/settings/SettingsState.kt | 58 ++- .../feature/settings/SettingsViewModel.kt | 45 +-- 14 files changed, 514 insertions(+), 505 deletions(-) diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapState.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapState.kt index 473af52c..bc45e788 100644 --- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapState.kt +++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapState.kt @@ -33,11 +33,11 @@ data class MapState( val positions: Map? = 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( diff --git a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesDialog.kt b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesDialog.kt index 6630263d..acfa450e 100644 --- a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesDialog.kt +++ b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesDialog.kt @@ -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, + 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) } } diff --git a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesState.kt b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesState.kt index e9afc8a9..446a051c 100644 --- a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesState.kt +++ b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesState.kt @@ -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) : 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) : PassesAction + data object RefreshPasses : PassesAction + data object TogglePassesDialog : PassesAction + data object ToggleRadiosDialog : PassesAction } diff --git a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt index 0633f39f..4d9e5420 100644 --- a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt +++ b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt @@ -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 = _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 diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt index a23ae4c1..477ec994 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt @@ -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 } diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt index d11e0379..65a8183c 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt @@ -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) } } diff --git a/feature/radiocontrol/src/main/java/com/rtbishop/look4sat/feature/radiocontrol/RadioControlScreen.kt b/feature/radiocontrol/src/main/java/com/rtbishop/look4sat/feature/radiocontrol/RadioControlScreen.kt index 36e541a9..0d26a4fd 100644 --- a/feature/radiocontrol/src/main/java/com/rtbishop/look4sat/feature/radiocontrol/RadioControlScreen.kt +++ b/feature/radiocontrol/src/main/java/com/rtbishop/look4sat/feature/radiocontrol/RadioControlScreen.kt @@ -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, + 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)) { - 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", - modifier = Modifier.weight(1f) - ) + FREQ_ADJUSTMENTS.forEach { (delta, label) -> + CardButton( + 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) ) } diff --git a/feature/radiocontrol/src/main/java/com/rtbishop/look4sat/feature/radiocontrol/RadioControlState.kt b/feature/radiocontrol/src/main/java/com/rtbishop/look4sat/feature/radiocontrol/RadioControlState.kt index fd886144..7afbea73 100644 --- a/feature/radiocontrol/src/main/java/com/rtbishop/look4sat/feature/radiocontrol/RadioControlState.kt +++ b/feature/radiocontrol/src/main/java/com/rtbishop/look4sat/feature/radiocontrol/RadioControlState.kt @@ -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 } diff --git a/feature/radiocontrol/src/main/java/com/rtbishop/look4sat/feature/radiocontrol/RadioControlViewModel.kt b/feature/radiocontrol/src/main/java/com/rtbishop/look4sat/feature/radiocontrol/RadioControlViewModel.kt index 8c62b5dd..5d035a4e 100644 --- a/feature/radiocontrol/src/main/java/com/rtbishop/look4sat/feature/radiocontrol/RadioControlViewModel.kt +++ b/feature/radiocontrol/src/main/java/com/rtbishop/look4sat/feature/radiocontrol/RadioControlViewModel.kt @@ -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 = 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 = _uiState @@ -72,6 +75,7 @@ class RadioControlViewModel( val catNum = savedStateHandle.get("catNum") ?: 0 val aosTime = savedStateHandle.get("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) } } + // 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 { 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 -> 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( 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 { + 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 diff --git a/feature/satellites/src/main/java/com/rtbishop/look4sat/feature/satellites/SatellitesState.kt b/feature/satellites/src/main/java/com/rtbishop/look4sat/feature/satellites/SatellitesState.kt index a6c4bdba..c82bc162 100644 --- a/feature/satellites/src/main/java/com/rtbishop/look4sat/feature/satellites/SatellitesState.kt +++ b/feature/satellites/src/main/java/com/rtbishop/look4sat/feature/satellites/SatellitesState.kt @@ -28,13 +28,13 @@ data class SatellitesState( val typesList: List = 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) : 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) : SatellitesAction + data object ToggleTypesDialog : SatellitesAction + data object UnselectAll : SatellitesAction } diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt index 3482f46f..a3a57557 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt @@ -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,71 +271,29 @@ 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 } - ) - } - 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 - ) - } + 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) + ) 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 } - ) - } - 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 - ) - } + 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) + ) } } } @@ -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,75 +364,80 @@ 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 } - ) - } - 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 - ) - } + 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) + ) Spacer(modifier = Modifier.height(6.dp)) - 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 } - ) - } - 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 - ) - } + 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(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 fun RadioControlDialog( initialSettings: RadioControlSettings, @@ -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 ) } diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt index 002d119d..edf47e6b 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt @@ -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 diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt index 813a9b4c..7b3461d5 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt @@ -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 } diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt index 78ba2b72..cf8bcc00 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt @@ -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 {