Added several tweaks to Settings and RadioControl screens

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

No files matched your search

@@ -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
)
}
@@ -42,9 +42,12 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -81,66 +84,44 @@ fun NavGraphBuilder.settingsDestination() {
modelClass = SettingsViewModel::class.java,
factory = SettingsViewModel.Factory
)
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
SettingsScreen(uiState)
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SettingsScreen(uiState, viewModel::onAction)
}
}
@Composable
private fun SettingsScreen(uiState: SettingsState) {
val locationContract = ActivityResultContracts.RequestMultiplePermissions()
val locationError = stringResource(R.string.prefs_loc_gps_error)
val locationPermCoarse = Manifest.permission.ACCESS_COARSE_LOCATION
val locationPermFine = Manifest.permission.ACCESS_FINE_LOCATION
val locationRequest = rememberLauncherForActivityResult(locationContract) { permissions ->
when {
permissions[locationPermFine] == true -> uiState.sendAction(SettingsAction.SetGpsPosition)
permissions[locationPermCoarse] == true -> uiState.sendAction(SettingsAction.SetGpsPosition)
else -> uiState.sendSystemAction(SystemAction.ShowToast(locationError))
}
}
val contentContract = ActivityResultContracts.GetContent()
val contentRequestForTle = rememberLauncherForActivityResult(contentContract) { uri ->
uri?.let { uiState.sendAction(SettingsAction.UpdateTLEFromFile(uri.toString())) }
}
val contentRequestForTransceivers = rememberLauncherForActivityResult(contentContract) { uri ->
uri?.let { uiState.sendAction(SettingsAction.UpdateTransceiversFromFile(uri.toString())) }
}
private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) {
val dialogs = rememberDialogVisibility()
val permissions = rememberSettingsPermissions(
sendAction = onAction,
onBluetoothGranted = { dialogs.bluetooth = true }
)
// Position settings
val posSettings = uiState.positionSettings
val setGpsPos = { locationRequest.launch(arrayOf(locationPermCoarse, locationPermFine)) }
val setGeoPos = { lat: Double, lon: Double ->
uiState.sendAction(SettingsAction.SetGeoPosition(lat, lon))
// Dialogs
if (dialogs.position) {
PositionDialog(
uiState.positionSettings.stationPos.latitude,
uiState.positionSettings.stationPos.longitude,
dismiss = { dialogs.position = false },
save = { lat, lon -> onAction(SettingsAction.SetGeoPosition(lat, lon)) }
)
}
val setQthPos = { locator: String ->
uiState.sendAction(SettingsAction.SetQthPosition(locator))
if (dialogs.locator) {
LocatorDialog(
uiState.positionSettings.stationPos.qthLocator,
dismiss = { dialogs.locator = false },
save = { onAction(SettingsAction.SetQthPosition(it)) }
)
}
val dismissPos = { uiState.sendAction(SettingsAction.DismissPosMessages) }
val posDialogState = rememberSaveable { mutableStateOf(false) }
val showPosDialog = { posDialogState.value = posDialogState.value.not() }
if (posDialogState.value) {
PositionDialog(posSettings.stationPos.latitude, posSettings.stationPos.longitude, showPosDialog, setGeoPos)
}
val locDialogState = rememberSaveable { mutableStateOf(false) }
val showLocDialog = { locDialogState.value = locDialogState.value.not() }
if (locDialogState.value) {
LocatorDialog(posSettings.stationPos.qthLocator, showLocDialog, setQthPos)
}
// Data sources dialog
val dataSourcesDialogState = rememberSaveable { mutableStateOf(false) }
val showDataSourcesDialog = { dataSourcesDialogState.value = true }
val dismissDataSourcesDialog = { dataSourcesDialogState.value = false }
if (dataSourcesDialogState.value) {
if (dialogs.dataSources) {
DataSourcesDialog(
useCustomTle = uiState.dataSourcesSettings.useCustomTLE,
useCustomTransceivers = uiState.dataSourcesSettings.useCustomTransceivers,
tleUrl = uiState.dataSourcesSettings.tleUrl,
transceiversUrl = uiState.dataSourcesSettings.transceiversUrl,
onImportTle = { contentRequestForTle.launch("*/*") },
onImportTransceivers = { contentRequestForTransceivers.launch("*/*") },
onDismiss = dismissDataSourcesDialog,
onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false },
onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false },
onDismiss = { dialogs.dataSources = false },
onSave = { useCustomTle, useCustomTransceivers, tleUrl, transceiversUrl ->
val current = uiState.dataSourcesSettings
val newSettings = current.copy(
@@ -149,112 +130,55 @@ private fun SettingsScreen(uiState: SettingsState) {
useCustomTransceivers = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) useCustomTransceivers else current.useCustomTransceivers,
transceiversUrl = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) transceiversUrl else current.transceiversUrl
)
if (newSettings != current) {
uiState.sendDataSourcesAction(DataSourcesAction.Update(newSettings))
}
if (useCustomTle || useCustomTransceivers) {
uiState.sendAction(SettingsAction.UpdateFromWeb)
}
if (newSettings != current) onAction(SettingsAction.UpdateDataSources(newSettings))
if (useCustomTle || useCustomTransceivers) onAction(SettingsAction.UpdateFromWeb)
}
)
}
// Data settings
val dataSettings = uiState.dataSettings
val updateFromWeb: () -> Unit = { uiState.sendAction(SettingsAction.UpdateFromWeb) }
val clearAllData: () -> Unit = { uiState.sendAction(SettingsAction.ClearAllData) }
// RC settings
val rcSettings = uiState.rcSettings
// Radio control (FT-817) settings
val radioControlDialogState = rememberSaveable { mutableStateOf(false) }
val showRadioControlDialog = { radioControlDialogState.value = true }
val dismissRadioControlDialog = { radioControlDialogState.value = false }
if (radioControlDialogState.value) {
if (dialogs.network) {
NetworkOutputDialog(
initialSettings = uiState.rcSettings,
onDismiss = { dialogs.network = false },
onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt ->
onAction(
SettingsAction.UpdateRC(
uiState.rcSettings.copy(
rotatorState = rotState, rotatorAddress = rotAddr,
rotatorPort = rotPort, rotatorFormat = rotFmt,
frequencyState = freqState, frequencyAddress = freqAddr,
frequencyPort = freqPort, frequencyFormat = freqFmt
)
)
)
}
)
}
if (dialogs.bluetooth) {
BluetoothOutputDialog(
initialSettings = uiState.rcSettings,
onDismiss = { dialogs.bluetooth = false },
onSave = { rotState, rotAddr, rotFmt, freqState, freqAddr, freqFmt ->
onAction(
SettingsAction.UpdateRC(
uiState.rcSettings.copy(
bluetoothRotatorState = rotState, bluetoothRotatorAddress = rotAddr,
bluetoothRotatorFormat = rotFmt, bluetoothFrequencyState = freqState,
bluetoothFrequencyAddress = freqAddr, bluetoothFrequencyFormat = freqFmt
)
)
)
}
)
}
if (dialogs.radioControl) {
RadioControlDialog(
initialSettings = uiState.radioControlSettings,
onDismiss = dismissRadioControlDialog,
onSave = { settings ->
uiState.sendRadioControlAction(RadioControlSettingsAction.Update(settings))
}
onDismiss = { dialogs.radioControl = false },
onSave = { onAction(SettingsAction.UpdateRadioControl(it)) }
)
}
// Network data output
val networkDialogState = rememberSaveable { mutableStateOf(false) }
val showNetworkDialog = { networkDialogState.value = true }
val dismissNetworkDialog = { networkDialogState.value = false }
if (networkDialogState.value) {
NetworkOutputDialog(
initialSettings = rcSettings,
onDismiss = dismissNetworkDialog,
onSave = { rotatorState, rotatorAddress, rotatorPort, rotatorFormat,
frequencyState, frequencyAddress, frequencyPort, frequencyFormat ->
uiState.sendRCAction(
RCAction.Update(
rcSettings.copy(
rotatorState = rotatorState,
rotatorAddress = rotatorAddress,
rotatorPort = rotatorPort,
rotatorFormat = rotatorFormat,
frequencyState = frequencyState,
frequencyAddress = frequencyAddress,
frequencyPort = frequencyPort,
frequencyFormat = frequencyFormat
)
)
)
}
)
}
// Bluetooth data output
val bluetoothDialogState = rememberSaveable { mutableStateOf(false) }
val bluetoothContract = ActivityResultContracts.RequestPermission()
val bluetoothError = stringResource(R.string.prefs_bt_perm_error)
val bluetoothPerm = when {
Build.VERSION.SDK_INT < Build.VERSION_CODES.S -> Manifest.permission.BLUETOOTH
else -> Manifest.permission.BLUETOOTH_CONNECT
}
val bluetoothRequest = rememberLauncherForActivityResult(bluetoothContract) { isGranted ->
if (!isGranted)
{
uiState.sendRCAction(
RCAction.Update(
rcSettings.copy(bluetoothRotatorState = false, bluetoothFrequencyState = false)
)
)
uiState.sendSystemAction(SystemAction.ShowToast(bluetoothError))
}
else { bluetoothDialogState.value = true }
}
val showBluetoothDialog = { bluetoothRequest.launch(bluetoothPerm) }
val dismissBluetoothDialog = { bluetoothDialogState.value = false }
if (bluetoothDialogState.value) {
BluetoothOutputDialog(
initialSettings = rcSettings,
onDismiss = dismissBluetoothDialog,
onSave = { rotatorState, rotatorAddress, rotatorFormat,
frequencyState, frequencyAddress, frequencyFormat ->
uiState.sendRCAction(
RCAction.Update(
rcSettings.copy(
bluetoothRotatorState = rotatorState,
bluetoothRotatorAddress = rotatorAddress,
bluetoothRotatorFormat = rotatorFormat,
bluetoothFrequencyState = frequencyState,
bluetoothFrequencyAddress = frequencyAddress,
bluetoothFrequencyFormat = frequencyFormat
)
)
)
}
)
}
// Other settings
val otherSettings = uiState.otherSettings
// URLs for top bar
val uriHandler = LocalUriHandler.current
val appUrl = stringResource(R.string.prefs_app_url)
val donateUrl = stringResource(R.string.prefs_donate_url)
@@ -302,13 +226,31 @@ private fun SettingsScreen(uiState: SettingsState) {
modifier = Modifier.clip(MaterialTheme.shapes.medium)
) {
item {
LocationCard(posSettings, setGpsPos, showPosDialog, showLocDialog, dismissPos, uiState.sendSystemAction)
LocationCard(
settings = uiState.positionSettings,
setGpsPos = permissions.launchLocation,
showPosDialog = { dialogs.position = true },
showLocDialog = { dialogs.locator = true },
dismissPosMessage = { onAction(SettingsAction.DismissPosMessages) },
onAction = onAction
)
}
item {
DataCard(
settings = uiState.dataSettings,
updateFromWeb = { onAction(SettingsAction.UpdateFromWeb) },
clearAllData = { onAction(SettingsAction.ClearAllData) },
showDataSourcesDialog = { dialogs.dataSources = true }
)
}
item { DataCard(dataSettings, updateFromWeb, clearAllData, showDataSourcesDialog) }
item(span = { GridItemSpan(maxLineSpan) }) {
OutputCard({ showNetworkDialog() }, { showBluetoothDialog() }, { showRadioControlDialog() })
OutputCard(
onNetworkClick = { dialogs.network = true },
onBluetoothClick = permissions.launchBluetooth,
onRadioControlClick = { dialogs.radioControl = true }
)
}
item { OtherCard(otherSettings, uiState.sendAction) }
item { OtherCard(uiState.otherSettings, onAction) }
item { CardCredits() }
}
}
@@ -329,7 +271,7 @@ private fun LocationCard(
showPosDialog: () -> Unit,
showLocDialog: () -> Unit,
dismissPosMessage: () -> Unit,
sendSystemAction: (SystemAction) -> Unit
onAction: (SettingsAction) -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
@@ -344,7 +286,7 @@ private fun LocationCard(
UpdateIndicator(isUpdating = settings.isUpdating, Modifier.weight(1f))
}
Spacer(modifier = Modifier.height(2.dp))
Text(text = setUpdateTime(updateTime = settings.stationPos.timestamp))
Text(text = formatUpdateTime(updateTime = settings.stationPos.timestamp))
Spacer(modifier = Modifier.height(2.dp))
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = "Lat: ${settings.stationPos.latitude}°")
@@ -376,7 +318,7 @@ private fun LocationCard(
if (settings.messageResId != 0) {
val errorString = stringResource(id = settings.messageResId)
LaunchedEffect(key1 = settings.messageResId) {
sendSystemAction(SystemAction.ShowToast(errorString))
onAction(SettingsAction.ShowToast(errorString))
dismissPosMessage()
}
}
@@ -409,7 +351,7 @@ private fun DataCard(
UpdateIndicator(isUpdating = settings.isUpdating, Modifier.weight(1f))
}
Spacer(modifier = Modifier.height(2.dp))
Text(text = setUpdateTime(updateTime = settings.timestamp))
Text(text = formatUpdateTime(updateTime = settings.timestamp))
Spacer(modifier = Modifier.height(2.dp))
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.prefs_data_entries, settings.entriesTotal))
@@ -491,7 +433,7 @@ private fun OtherCardPreview() = MainTheme {
}
@Composable
private fun OtherCard(settings: OtherSettings, sendAction: (SettingsAction) -> Unit) {
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
@@ -503,16 +445,16 @@ private fun OtherCard(settings: OtherSettings, sendAction: (SettingsAction) -> U
color = MaterialTheme.colorScheme.primary
)
SwitchRow(R.string.prefs_other_switch_utc, settings.stateOfUtc) {
sendAction(SettingsAction.ToggleUtc(it))
onAction(SettingsAction.ToggleUtc(it))
}
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
sendAction(SettingsAction.ToggleUpdate(it))
onAction(SettingsAction.ToggleUpdate(it))
}
SwitchRow(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
sendAction(SettingsAction.ToggleSweep(it))
onAction(SettingsAction.ToggleSweep(it))
}
SwitchRow(R.string.prefs_other_switch_sensors, settings.stateOfSensors) {
sendAction(SettingsAction.ToggleSensor(it))
onAction(SettingsAction.ToggleSensor(it))
}
}
}
@@ -531,7 +473,7 @@ private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boole
}
@Composable
private fun setUpdateTime(updateTime: Long): String {
private fun formatUpdateTime(updateTime: Long): String {
val timePattern = stringResource(id = R.string.prefs_updated_time)
val placeholder = stringResource(id = R.string.pass_time_placeholder)
val updateDate = remember(updateTime) {
@@ -638,3 +580,91 @@ private fun BotCard(onClick: () -> Unit, resId: Int, text: String, modifier: Mod
}
}
}
// region Dialog visibility state holder
@Stable
private class DialogVisibility {
var position by mutableStateOf(false)
var locator by mutableStateOf(false)
var dataSources by mutableStateOf(false)
var network by mutableStateOf(false)
var bluetooth by mutableStateOf(false)
var radioControl by mutableStateOf(false)
}
@Composable
private fun rememberDialogVisibility(): DialogVisibility {
return rememberSaveable(saver = run {
androidx.compose.runtime.saveable.Saver(
save = {
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl)
},
restore = {
DialogVisibility().apply {
position = it[0]; locator = it[1]; dataSources = it[2]
network = it[3]; bluetooth = it[4]; radioControl = it[5]
}
}
)
}) { DialogVisibility() }
}
// endregion
// region Permission launchers holder
@Stable
private class SettingsPermissions(
val launchLocation: () -> Unit,
val launchTleImport: () -> Unit,
val launchTransceiverImport: () -> Unit,
val launchBluetooth: () -> Unit
)
@Composable
private fun rememberSettingsPermissions(
sendAction: (SettingsAction) -> Unit,
onBluetoothGranted: () -> Unit
): SettingsPermissions {
val locationError = stringResource(R.string.prefs_loc_gps_error)
val locationRequest = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
val fine = permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true
val coarse = permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true
if (fine || coarse) sendAction(SettingsAction.SetGpsPosition)
else sendAction(SettingsAction.ShowToast(locationError))
}
val tleRequest = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let { sendAction(SettingsAction.UpdateTLEFromFile(it.toString())) }
}
val transceiversRequest = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let { sendAction(SettingsAction.UpdateTransceiversFromFile(it.toString())) }
}
val bluetoothError = stringResource(R.string.prefs_bt_perm_error)
val bluetoothPerm = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S)
Manifest.permission.BLUETOOTH else Manifest.permission.BLUETOOTH_CONNECT
val bluetoothRequest = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) onBluetoothGranted()
else sendAction(SettingsAction.ShowToast(bluetoothError))
}
return remember {
SettingsPermissions(
launchLocation = {
locationRequest.launch(
arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION)
)
},
launchTleImport = { tleRequest.launch("*/*") },
launchTransceiverImport = { transceiversRequest.launch("*/*") },
launchBluetooth = { bluetoothRequest.launch(bluetoothPerm) }
)
}
}
// endregion
@@ -38,42 +38,36 @@ data class SettingsState(
val otherSettings: OtherSettings,
val rcSettings: RCSettings,
val radioControlSettings: RadioControlSettings,
val dataSourcesSettings: DataSourcesSettings,
val sendAction: (SettingsAction) -> Unit,
val sendRCAction: (RCAction) -> Unit,
val sendRadioControlAction: (RadioControlSettingsAction) -> Unit,
val sendSystemAction: (SystemAction) -> Unit,
val sendDataSourcesAction: (DataSourcesAction) -> Unit
val dataSourcesSettings: DataSourcesSettings
)
sealed class SettingsAction {
data object SetGpsPosition : SettingsAction()
data class SetGeoPosition(val latitude: Double, val longitude: Double) : SettingsAction()
data class SetQthPosition(val locator: String) : SettingsAction()
data object DismissPosMessages : SettingsAction()
data object UpdateFromWeb : SettingsAction()
data class UpdateTLEFromFile(val uri: String) : SettingsAction()
data class UpdateTransceiversFromFile(val uri: String) : SettingsAction()
data object ClearAllData : SettingsAction()
data class ToggleUtc(val value: Boolean) : SettingsAction()
data class ToggleUpdate(val value: Boolean) : SettingsAction()
data class ToggleSweep(val value: Boolean) : SettingsAction()
data class ToggleSensor(val value: Boolean) : SettingsAction()
data class ToggleLightTheme(val value: Boolean) : SettingsAction()
}
sealed interface SettingsAction {
// Position
data object SetGpsPosition : SettingsAction
data class SetGeoPosition(val latitude: Double, val longitude: Double) : SettingsAction
data class SetQthPosition(val locator: String) : SettingsAction
data object DismissPosMessages : SettingsAction
sealed class SystemAction {
data class ShowToast(val message: String) : SystemAction()
}
// Data
data object UpdateFromWeb : SettingsAction
data class UpdateTLEFromFile(val uri: String) : SettingsAction
data class UpdateTransceiversFromFile(val uri: String) : SettingsAction
data object ClearAllData : SettingsAction
sealed class RCAction {
data class Update(val settings: RCSettings) : RCAction()
}
// Toggles
data class ToggleUtc(val value: Boolean) : SettingsAction
data class ToggleUpdate(val value: Boolean) : SettingsAction
data class ToggleSweep(val value: Boolean) : SettingsAction
data class ToggleSensor(val value: Boolean) : SettingsAction
data class ToggleLightTheme(val value: Boolean) : SettingsAction
sealed class DataSourcesAction {
data class Update(val settings: DataSourcesSettings) : DataSourcesAction()
}
// Remote control
data class UpdateRC(val settings: RCSettings) : SettingsAction
data class UpdateRadioControl(val settings: RadioControlSettings) : SettingsAction
sealed class RadioControlSettingsAction {
data class Update(val settings: RadioControlSettings) : RadioControlSettingsAction()
// Data sources
data class UpdateDataSources(val settings: DataSourcesSettings) : SettingsAction
// System
data class ShowToast(val message: String) : SettingsAction
}
@@ -48,12 +48,7 @@ class SettingsViewModel(
otherSettings = settingsRepo.otherSettings.value,
rcSettings = settingsRepo.rcSettings.value,
radioControlSettings = settingsRepo.radioControlSettings.value,
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
sendAction = ::handleAction,
sendRCAction = ::handleAction,
sendRadioControlAction = ::handleAction,
sendSystemAction = ::handleAction,
sendDataSourcesAction = ::handleAction
dataSourcesSettings = settingsRepo.dataSourcesSettings.value
)
)
@@ -104,12 +99,14 @@ class SettingsViewModel(
}
private fun handleAction(action: SettingsAction) {
fun onAction(action: SettingsAction) {
when (action) {
// Position
SettingsAction.SetGpsPosition -> setGpsPosition()
is SettingsAction.SetGeoPosition -> setGeoPosition(action.latitude, action.longitude)
is SettingsAction.SetQthPosition -> setQthPosition(action.locator)
SettingsAction.DismissPosMessages -> dismissPosMessage()
// Data
SettingsAction.UpdateFromWeb -> runDataUpdate { databaseRepo.updateFromRemote() }
is SettingsAction.UpdateTLEFromFile -> runDataUpdate { databaseRepo.updateTLEFromFile(action.uri) }
is SettingsAction.UpdateTransceiversFromFile -> runDataUpdate {
@@ -117,37 +114,19 @@ class SettingsViewModel(
action.uri
)
}
SettingsAction.ClearAllData -> viewModelScope.launch { databaseRepo.clearAllData() }
// Toggles
is SettingsAction.ToggleUtc -> settingsRepo.updateOtherSettings { it.copy(stateOfUtc = action.value) }
is SettingsAction.ToggleUpdate -> settingsRepo.updateOtherSettings { it.copy(stateOfAutoUpdate = action.value) }
is SettingsAction.ToggleSweep -> settingsRepo.updateOtherSettings { it.copy(stateOfSweep = action.value) }
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
}
}
private fun handleAction(action: RCAction) {
when (action) {
is RCAction.Update -> settingsRepo.updateRCSettings(action.settings)
}
}
private fun handleAction(action: SystemAction) {
when (action) {
is SystemAction.ShowToast -> showToast(action.message)
}
}
private fun handleAction(action: DataSourcesAction) {
when (action) {
is DataSourcesAction.Update -> settingsRepo.updateDataSourcesSettings(action.settings)
}
}
private fun handleAction(action: RadioControlSettingsAction) {
when (action) {
is RadioControlSettingsAction.Update -> settingsRepo.updateRadioControlSettings(action.settings)
// Remote control & data sources
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(action.settings)
// System
is SettingsAction.ShowToast -> showToast(action.message)
}
}
@@ -188,7 +167,7 @@ class SettingsViewModel(
// region Data update helpers — consolidated from 3 near-identical functions
/**
* Common data update logic. Sets isUpdating=true, runs the suspend [block],
* Common data update logic. Sets isUpdating=true, runs the suspending [block],
* and resets isUpdating=false on failure.
*/
private fun runDataUpdate(block: suspend () -> Unit) = viewModelScope.launch {