Add CAT radio control for full-duplex satellite operation (#207)
This commit is contained in:
1 parent
868155c532
commit
2df0b9ba3d
28 files changed
+2126
-8
No files matched your search
@@ -77,7 +77,10 @@ import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||
|
||||
fun NavGraphBuilder.radarDestination(navigateUp: () -> Unit) {
|
||||
fun NavGraphBuilder.radarDestination(
|
||||
navigateUp: () -> Unit,
|
||||
navigateToRadioControl: (Int, Long) -> Unit = { _, _ -> }
|
||||
) {
|
||||
val radarRoute = "${Screen.Radar.route}?catNum={catNum}&aosTime={aosTime}"
|
||||
val radarArgs = listOf(
|
||||
navArgument("catNum") { defaultValue = 0 },
|
||||
@@ -86,17 +89,26 @@ fun NavGraphBuilder.radarDestination(navigateUp: () -> Unit) {
|
||||
composable(radarRoute, radarArgs) {
|
||||
val viewModel = viewModel(RadarViewModel::class.java, factory = RadarViewModel.Factory)
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
RadarScreen(uiState, navigateUp)
|
||||
RadarScreen(uiState, navigateUp, navigateToRadioControl)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RadarScreen(uiState: RadarState, navigateUp: () -> Unit) {
|
||||
private fun RadarScreen(
|
||||
uiState: RadarState,
|
||||
navigateUp: () -> Unit,
|
||||
navigateToRadioControl: (Int, Long) -> Unit
|
||||
) {
|
||||
val addToCalendar: () -> Unit = {
|
||||
uiState.currentPass?.let { pass ->
|
||||
uiState.sendAction(RadarAction.AddToCalendar(pass.name, pass.aosTime, pass.losTime))
|
||||
}
|
||||
}
|
||||
val openRadioControl: () -> Unit = {
|
||||
uiState.currentPass?.let { pass ->
|
||||
navigateToRadioControl(pass.catNum, pass.aosTime)
|
||||
}
|
||||
}
|
||||
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
||||
if (upcomingPass.losTime < System.currentTimeMillis()) navigateUp()
|
||||
Column(
|
||||
@@ -110,6 +122,7 @@ private fun RadarScreen(uiState: RadarState, navigateUp: () -> Unit) {
|
||||
TopBar {
|
||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isCurrentTimeAos)
|
||||
IconCard(action = openRadioControl, resId = R.drawable.ic_radios)
|
||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
||||
}
|
||||
TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
|
||||
@@ -118,6 +131,7 @@ private fun RadarScreen(uiState: RadarState, navigateUp: () -> Unit) {
|
||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isCurrentTimeAos)
|
||||
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
|
||||
IconCard(action = openRadioControl, resId = R.drawable.ic_radios)
|
||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
plugins {
|
||||
alias(libs.plugins.convention.featurePlugin)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.rtbishop.look4sat.feature.radiocontrol"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest>
|
||||
|
||||
</manifest>
|
||||
+373
@@ -0,0 +1,373 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.radiocontrol
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.keepScreenOn
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navArgument
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.Screen
|
||||
import com.rtbishop.look4sat.core.presentation.TimerRow
|
||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||
|
||||
fun NavGraphBuilder.radioControlDestination(navigateUp: () -> Unit) {
|
||||
val route = "${Screen.RadioControl.route}?catNum={catNum}&aosTime={aosTime}"
|
||||
val args = listOf(
|
||||
navArgument("catNum") { defaultValue = 0 },
|
||||
navArgument("aosTime") { defaultValue = 0L }
|
||||
)
|
||||
composable(route, args) {
|
||||
val viewModel = viewModel(RadioControlViewModel::class.java, factory = RadioControlViewModel.Factory)
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
RadioControlScreen(uiState, navigateUp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RadioControlScreen(uiState: RadioControlState, navigateUp: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.layoutPadding()
|
||||
.keepScreenOn(),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
TopBar {
|
||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isCurrentTimeAos)
|
||||
uiState.currentPass?.let { pass ->
|
||||
Text(
|
||||
text = pass.name,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.padding(horizontal = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
RadioPanel(panel = uiState.txPanel)
|
||||
RadioPanel(panel = uiState.rxPanel)
|
||||
|
||||
PositionRow(
|
||||
azimuth = uiState.azimuth,
|
||||
elevation = uiState.elevation,
|
||||
distance = uiState.distance
|
||||
)
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
item { TransponderSelector(uiState) }
|
||||
|
||||
val selectedTransponder = uiState.transponders.find {
|
||||
it.uuid == uiState.selectedTransponderUuid
|
||||
}
|
||||
if (selectedTransponder?.uplinkMode?.uppercase() == "FM") {
|
||||
item { CtcssSelector(uiState) }
|
||||
}
|
||||
|
||||
if (uiState.txBaseFrequencyHz != null) {
|
||||
item { FrequencyTuner(uiState) }
|
||||
}
|
||||
|
||||
uiState.errorMessage?.let { msg ->
|
||||
item {
|
||||
Text(
|
||||
text = msg,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ControlButtons(uiState)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RadioPanel(panel: RadioPanelState) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (panel.isConnected) Color(0xFF4CAF50) else Color(0xFFE57373))
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = panel.label, fontSize = 14.sp)
|
||||
}
|
||||
Text(
|
||||
text = panel.frequencyDisplay,
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = panel.mode ?: "--",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PositionRow(azimuth: String, elevation: String, distance: String) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp)
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(text = "Az", fontSize = 12.sp)
|
||||
Text(text = "$azimuth°", fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(text = "El", fontSize = 12.sp)
|
||||
Text(text = "$elevation°", fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(text = "Dist", fontSize = 12.sp)
|
||||
Text(text = "$distance km", fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TransponderSelector(uiState: RadioControlState) {
|
||||
if (uiState.transponders.isEmpty()) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = "No transponders with uplink+downlink available",
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
uiState.transponders.forEach { radio ->
|
||||
TransponderItem(
|
||||
radio = radio,
|
||||
isSelected = radio.uuid == uiState.selectedTransponderUuid,
|
||||
onSelect = { uiState.sendAction(RadioControlAction.SelectTransponder(radio.uuid)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TransponderItem(radio: SatRadio, isSelected: Boolean, onSelect: () -> Unit) {
|
||||
val invLabel = if (radio.isInverted) " INV" else ""
|
||||
val modeLabel = "${radio.uplinkMode ?: "--"}/${radio.downlinkMode ?: "--"}$invLabel"
|
||||
Surface(
|
||||
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surface,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSelect() }
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
) {
|
||||
Text(text = radio.info, modifier = Modifier.weight(1f))
|
||||
Text(text = modeLabel, fontSize = 13.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun CtcssSelector(uiState: RadioControlState) {
|
||||
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)) },
|
||||
label = { Text("Off") }
|
||||
)
|
||||
RadioControlViewModel.CTCSS_TONES.forEach { tone ->
|
||||
FilterChip(
|
||||
selected = uiState.ctcssTone == tone,
|
||||
onClick = { uiState.sendAction(RadioControlAction.SetCtcssTone(tone)) },
|
||||
label = { Text(String.format("%.1f", tone)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FrequencyTuner(uiState: RadioControlState) {
|
||||
val freq = uiState.txBaseFrequencyHz ?: return
|
||||
val transponder = uiState.transponders.find { it.uuid == uiState.selectedTransponderUuid }
|
||||
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 (upLow != null && upHigh != null && upLow != upHigh) {
|
||||
Text(
|
||||
text = "UP: ${RadioControlViewModel.formatFrequency(upLow)} - ${RadioControlViewModel.formatFrequency(upHigh)}",
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (dnLow != null && dnHigh != null && dnLow != dnHigh) {
|
||||
Text(
|
||||
text = "DN: ${RadioControlViewModel.formatFrequency(dnLow)} - ${RadioControlViewModel.formatFrequency(dnHigh)}",
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "${RadioControlViewModel.formatFrequency(freq)} 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ControlButtons(uiState: RadioControlState) {
|
||||
val isConnected = uiState.txPanel.isConnected || uiState.rxPanel.isConnected
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
if (!isConnected) {
|
||||
CardButton(
|
||||
onClick = { uiState.sendAction(RadioControlAction.ConnectRadios) },
|
||||
text = "Connect",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
} else {
|
||||
CardButton(
|
||||
onClick = { uiState.sendAction(RadioControlAction.DisconnectRadios) },
|
||||
text = "Disconnect",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
CardButton(
|
||||
onClick = { uiState.sendAction(RadioControlAction.ToggleTracking) },
|
||||
text = if (uiState.isTracking) "Stop Tracking" else "Start Tracking",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.radiocontrol
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
|
||||
data class RadioPanelState(
|
||||
val label: String,
|
||||
val isConnected: Boolean,
|
||||
val frequencyHz: Long?,
|
||||
val frequencyDisplay: String,
|
||||
val mode: String?
|
||||
)
|
||||
|
||||
data class RadioControlState(
|
||||
val currentPass: OrbitalPass?,
|
||||
val currentTime: String,
|
||||
val isCurrentTimeAos: Boolean,
|
||||
val azimuth: String,
|
||||
val elevation: String,
|
||||
val distance: String,
|
||||
val txPanel: RadioPanelState,
|
||||
val rxPanel: RadioPanelState,
|
||||
val transponders: List<SatRadio>,
|
||||
val selectedTransponderUuid: String?,
|
||||
val txBaseFrequencyHz: Long?,
|
||||
val ctcssTone: Double?,
|
||||
val isTracking: Boolean,
|
||||
val errorMessage: String?,
|
||||
val sendAction: (RadioControlAction) -> Unit
|
||||
)
|
||||
|
||||
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()
|
||||
}
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.radiocontrol
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.createSavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
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.repository.RadioTrackingState
|
||||
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
|
||||
class RadioControlViewModel(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val trackingService: IRadioTrackingService,
|
||||
private val satelliteRepo: ISatelliteRepo,
|
||||
private val settingsRepo: ISettingsRepo
|
||||
) : ViewModel() {
|
||||
|
||||
private var currentPass: OrbitalPass? = null
|
||||
private var transponders: List<SatRadio> = emptyList()
|
||||
|
||||
private val _uiState = MutableStateFlow(
|
||||
RadioControlState(
|
||||
currentPass = null,
|
||||
currentTime = "00:00:00",
|
||||
isCurrentTimeAos = true,
|
||||
azimuth = "0.0",
|
||||
elevation = "0.0",
|
||||
distance = "0.0",
|
||||
txPanel = RadioPanelState("TX (Uplink)", false, null, "---", null),
|
||||
rxPanel = RadioPanelState("RX (Downlink)", false, null, "---", null),
|
||||
transponders = emptyList(),
|
||||
selectedTransponderUuid = null,
|
||||
txBaseFrequencyHz = null,
|
||||
ctcssTone = null,
|
||||
isTracking = false,
|
||||
errorMessage = null,
|
||||
sendAction = ::handleAction
|
||||
)
|
||||
)
|
||||
val uiState: StateFlow<RadioControlState> = _uiState
|
||||
|
||||
init {
|
||||
val catNum = savedStateHandle.get<Int>("catNum") ?: 0
|
||||
val aosTime = savedStateHandle.get<Long>("aosTime") ?: 0L
|
||||
|
||||
viewModelScope.launch {
|
||||
val passes = satelliteRepo.passes.value
|
||||
val pass = passes.find { it.catNum == catNum && it.aosTime == aosTime }
|
||||
?: passes.firstOrNull()
|
||||
currentPass = pass
|
||||
pass?.let { satPass ->
|
||||
val allRadios = satelliteRepo.getRadiosWithId(satPass.catNum)
|
||||
transponders = allRadios.filter { it.downlinkLow != null }
|
||||
_uiState.update {
|
||||
it.copy(currentPass = satPass, transponders = transponders)
|
||||
}
|
||||
// If service is already tracking this pass, sync the selected transponder
|
||||
val svcState = trackingService.state.value
|
||||
if (svcState.isActive && svcState.currentPass?.catNum == satPass.catNum) {
|
||||
_uiState.update {
|
||||
it.copy(selectedTransponderUuid = svcState.selectedTransponder?.uuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Observe service state and map to UI state
|
||||
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 {
|
||||
it.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,
|
||||
frequencyHz = svc.txFrequencyHz,
|
||||
frequencyDisplay = svc.txFrequencyHz?.let { formatFrequency(it) } ?: "---",
|
||||
mode = svc.txMode
|
||||
),
|
||||
rxPanel = RadioPanelState(
|
||||
label = "RX (Downlink)",
|
||||
isConnected = svc.rxConnected,
|
||||
frequencyHz = svc.rxFrequencyHz,
|
||||
frequencyDisplay = svc.rxFrequencyHz?.let { formatFrequency(it) } ?: "---",
|
||||
mode = svc.rxMode
|
||||
),
|
||||
txBaseFrequencyHz = svc.txBaseFrequencyHz,
|
||||
ctcssTone = svc.ctcssTone,
|
||||
isTracking = svc.isActive,
|
||||
selectedTransponderUuid = svc.selectedTransponder?.uuid,
|
||||
errorMessage = svc.errorMessage
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleAction(action: RadioControlAction) {
|
||||
when (action) {
|
||||
is RadioControlAction.SelectTransponder -> {
|
||||
val transponder = transponders.find { it.uuid == action.uuid } ?: return
|
||||
trackingService.setTransponder(transponder)
|
||||
}
|
||||
is RadioControlAction.SetTxFrequency -> trackingService.setTxBaseFrequency(action.frequencyHz)
|
||||
is RadioControlAction.AdjustTxFrequency -> trackingService.adjustTxBaseFrequency(action.deltaHz)
|
||||
is RadioControlAction.SetCtcssTone -> trackingService.setCtcssTone(action.toneHz)
|
||||
RadioControlAction.ToggleTracking -> {
|
||||
val svc = trackingService.state.value
|
||||
if (svc.isActive) {
|
||||
trackingService.stopTracking()
|
||||
} else {
|
||||
val pass = currentPass ?: return
|
||||
val transponder = svc.selectedTransponder ?: return
|
||||
trackingService.startTracking(pass, transponder, svc.txBaseFrequencyHz)
|
||||
}
|
||||
}
|
||||
RadioControlAction.ConnectRadios -> viewModelScope.launch { trackingService.connectRadios() }
|
||||
RadioControlAction.DisconnectRadios -> viewModelScope.launch { trackingService.disconnectRadios() }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
val CTCSS_TONES = listOf(67.0, 69.3, 71.9, 74.4, 77.0, 79.7, 82.5, 85.4, 88.5, 91.5,
|
||||
94.8, 97.4, 100.0, 103.5, 107.2, 110.9, 114.8, 118.8, 123.0, 127.3, 131.8, 136.5,
|
||||
141.3, 146.2, 151.4, 156.7, 162.2, 167.9, 173.8, 179.9, 186.2, 192.8, 203.5, 210.7,
|
||||
218.1, 225.7, 233.6, 241.8, 250.3)
|
||||
|
||||
fun formatFrequency(frequencyHz: Long): String {
|
||||
if (frequencyHz <= 0) return "---"
|
||||
val mhz = frequencyHz / 1_000_000
|
||||
val khz = (frequencyHz % 1_000_000) / 1_000
|
||||
val hz = frequencyHz % 1_000
|
||||
return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz)
|
||||
}
|
||||
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY
|
||||
initializer {
|
||||
val container = (this[applicationKey] as IContainerProvider).getMainContainer()
|
||||
RadioControlViewModel(
|
||||
createSavedStateHandle(),
|
||||
container.radioTrackingService,
|
||||
container.satelliteRepo,
|
||||
container.settingsRepo
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+162
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.settings
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -29,13 +30,16 @@ import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||
@@ -477,3 +481,161 @@ fun BluetoothOutputDialog(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RadioControlDialog(
|
||||
initialSettings: RadioControlSettings,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (RadioControlSettings) -> Unit
|
||||
) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val padding = LocalSpacing.current.large
|
||||
val baudRates = listOf(4800, 9600, 38400)
|
||||
val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) }
|
||||
val radioModel = rememberSaveable { mutableStateOf(initialSettings.radioModel) }
|
||||
val txAddress = rememberSaveable { mutableStateOf(initialSettings.txRadioAddress) }
|
||||
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 selectingFor = rememberSaveable { mutableStateOf("") } // "tx", "rx", or ""
|
||||
|
||||
val pairedDevices = remember {
|
||||
try {
|
||||
val manager = context.getSystemService(android.content.Context.BLUETOOTH_SERVICE) as android.bluetooth.BluetoothManager
|
||||
manager.adapter?.bondedDevices?.map { Pair(it.name ?: "Unknown", it.address) } ?: emptyList()
|
||||
} catch (_: SecurityException) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
val onAccept = {
|
||||
onSave(
|
||||
RadioControlSettings(
|
||||
enabled = enabled.value,
|
||||
radioModel = radioModel.value,
|
||||
txRadioAddress = txAddress.value,
|
||||
rxRadioAddress = rxAddress.value,
|
||||
txRadioName = txName.value,
|
||||
rxRadioName = rxName.value,
|
||||
baudRate = baudRate.value
|
||||
)
|
||||
)
|
||||
onDismiss()
|
||||
}
|
||||
SharedDialog(
|
||||
title = stringResource(R.string.rc_settings_title),
|
||||
onCancel = onDismiss,
|
||||
onAccept = onAccept
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = padding)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(stringResource(R.string.rc_enable_switch))
|
||||
Switch(checked = enabled.value, onCheckedChange = { enabled.value = it })
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
// Radio model selection
|
||||
Text(
|
||||
stringResource(R.string.rc_radio_model),
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium,
|
||||
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
RadioControlSettings.SUPPORTED_RADIOS.forEach { model ->
|
||||
androidx.compose.material3.FilterChip(
|
||||
selected = radioModel.value == model,
|
||||
onClick = { radioModel.value = model },
|
||||
label = { Text(model, fontSize = 12.sp) },
|
||||
enabled = enabled.value
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
// TX Radio selection
|
||||
Text("TX Radio (Uplink)", fontWeight = androidx.compose.ui.text.font.FontWeight.Medium)
|
||||
if (txAddress.value.isNotBlank()) {
|
||||
Text("${txName.value} - ${txAddress.value}", fontSize = 13.sp)
|
||||
}
|
||||
CardButton(
|
||||
onClick = { selectingFor.value = "tx" },
|
||||
text = "Select TX Device",
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
// RX Radio selection
|
||||
Text("RX Radio (Downlink)", fontWeight = androidx.compose.ui.text.font.FontWeight.Medium)
|
||||
if (rxAddress.value.isNotBlank()) {
|
||||
Text("${rxName.value} - ${rxAddress.value}", fontSize = 13.sp)
|
||||
}
|
||||
CardButton(
|
||||
onClick = { selectingFor.value = "rx" },
|
||||
text = "Select RX Device",
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// Paired devices list (shown when selecting)
|
||||
if (selectingFor.value.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = "Paired Bluetooth Devices:",
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium,
|
||||
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
|
||||
)
|
||||
if (pairedDevices.isEmpty()) {
|
||||
Text("No paired devices found. Pair your BT adapter in Android Bluetooth settings first.")
|
||||
} else {
|
||||
pairedDevices.forEach { (name, address) ->
|
||||
androidx.compose.material3.Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
if (selectingFor.value == "tx") {
|
||||
txAddress.value = address
|
||||
txName.value = name
|
||||
} else {
|
||||
rxAddress.value = address
|
||||
rxName.value = name
|
||||
}
|
||||
selectingFor.value = ""
|
||||
}
|
||||
.padding(vertical = 4.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(name, modifier = Modifier.weight(1f))
|
||||
Text(address, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Baud Rate:")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
baudRates.forEach { rate ->
|
||||
CardButton(
|
||||
onClick = { baudRate.value = rate },
|
||||
text = if (rate == baudRate.value) "[$rate]" else rate.toString(),
|
||||
modifier = Modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
-3
@@ -167,6 +167,20 @@ private fun SettingsScreen(uiState: SettingsState) {
|
||||
// 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) {
|
||||
RadioControlDialog(
|
||||
initialSettings = uiState.radioControlSettings,
|
||||
onDismiss = dismissRadioControlDialog,
|
||||
onSave = { settings ->
|
||||
uiState.sendRadioControlAction(RadioControlSettingsAction.Update(settings))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Network data output
|
||||
val networkDialogState = rememberSaveable { mutableStateOf(false) }
|
||||
val showNetworkDialog = { networkDialogState.value = true }
|
||||
@@ -291,7 +305,9 @@ private fun SettingsScreen(uiState: SettingsState) {
|
||||
LocationCard(posSettings, setGpsPos, showPosDialog, showLocDialog, dismissPos, uiState.sendSystemAction)
|
||||
}
|
||||
item { DataCard(dataSettings, updateFromWeb, clearAllData, showDataSourcesDialog) }
|
||||
item(span = { GridItemSpan(maxLineSpan) }) { OutputCard({ showNetworkDialog() }, { showBluetoothDialog() }) }
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
OutputCard({ showNetworkDialog() }, { showBluetoothDialog() }, { showRadioControlDialog() })
|
||||
}
|
||||
item { OtherCard(otherSettings, uiState.sendAction) }
|
||||
item { CardCredits() }
|
||||
}
|
||||
@@ -423,12 +439,13 @@ private fun DataCard(
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun OutputCardPreview() = MainTheme { OutputCard({}, {}) }
|
||||
private fun OutputCardPreview() = MainTheme { OutputCard({}, {}, {}) }
|
||||
|
||||
@Composable
|
||||
private fun OutputCard(
|
||||
onNetworkClick: () -> Unit,
|
||||
onBluetoothClick: () -> Unit
|
||||
onBluetoothClick: () -> Unit,
|
||||
onRadioControlClick: () -> Unit
|
||||
) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
@@ -448,6 +465,11 @@ private fun OutputCard(
|
||||
text = stringResource(id = R.string.prefs_bt_output),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
CardButton(
|
||||
onClick = onRadioControlClick,
|
||||
text = stringResource(id = R.string.prefs_cat_output),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ package com.rtbishop.look4sat.feature.settings
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
|
||||
data class PositionSettings(
|
||||
@@ -36,9 +37,11 @@ data class SettingsState(
|
||||
val dataSettings: DataSettings,
|
||||
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
|
||||
)
|
||||
@@ -70,3 +73,7 @@ sealed class RCAction {
|
||||
sealed class DataSourcesAction {
|
||||
data class Update(val settings: DataSourcesSettings) : DataSourcesAction()
|
||||
}
|
||||
|
||||
sealed class RadioControlSettingsAction {
|
||||
data class Update(val settings: RadioControlSettings) : RadioControlSettingsAction()
|
||||
}
|
||||
+13
@@ -47,9 +47,11 @@ class SettingsViewModel(
|
||||
dataSettings = defaultDataSettings,
|
||||
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
|
||||
)
|
||||
@@ -94,6 +96,11 @@ class SettingsViewModel(
|
||||
_uiState.update { it.copy(dataSourcesSettings = settings) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
settingsRepo.radioControlSettings.collect { settings ->
|
||||
_uiState.update { it.copy(radioControlSettings = settings) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -138,6 +145,12 @@ class SettingsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleAction(action: RadioControlSettingsAction) {
|
||||
when (action) {
|
||||
is RadioControlSettingsAction.Update -> settingsRepo.updateRadioControlSettings(action.settings)
|
||||
}
|
||||
}
|
||||
|
||||
// region Position helpers — consolidated from 3 near-identical functions
|
||||
|
||||
private fun setGpsPosition() {
|
||||
|
||||
Reference in new issue
Block a user