merge: upstream rt-bishop main (18 commits) with conflict resolution

Merges rt-bishop/Look4Sat main (a42a5f1f, 18 commits: AMSAT status page,
customizable data sources, Doppler calculator, radar compass offset, per-sat
offset memory, localized date formats) into the fork's 30-commit audit
baseline.

Conflict resolution policy (user-directed):
- AMSAT feature (AmSatRepository, SatStatusScreen/ViewModel, SatStatus model):
  upstream version, which the user judged better built. MainScreen routes
  Screen.AmSat through SatStatusDestination().
- Localization: our values-zh/values-tr restored (upstream's merge dropped the
  fork-only strings); new upstream strings (sat_group counts, compass offset,
  frequency offset help) added in EN + ZH.
- fork-only features kept (CW decoder, Mutual/Roaming, WaveLog, APRS, Log tab,
  custom TLE/transceiver source switches): ours.
- Both sides' additions merged where independent: radar compass offset fields
  (Settings/SettingsRepo/RadarState), calculatorOffsetKHz action, wider linear
  transponder detection, deduplicateTransponders + its tests, sunrise/sunset
  tests merged with the moon hour-angle test.
- Sources kept as the fork's map structure (DatabaseRepo depends on it);
  satelliteModes list re-added for SelectionRepo. getSatelliteTypesIds /
  setSatelliteTypeIds re-added to ISettingsRepo+SettingsRepo; SharedDialog
  re-added to Components; providePairedBluetoothDevices added to
  IMainContainer/MainContainer.
- Icons renamed upstream (ic_satellites->ic_sputnik, ic_radar->ic_satellite)
  applied to Navigation/MutualScreen.

Build verified: all modules compileReleaseKotlin + unit tests
(core:domain, core:data, feature:map, feature:roaming) green.
This commit is contained in:
mckero committed 2026-08-20 12:35:01 +00:00
commit a654735337
68 files changed
+2206 -1038

No files matched your search

@@ -17,45 +17,58 @@
*/
package com.rtbishop.look4sat.feature.settings
import android.bluetooth.BluetoothManager
import android.content.Context
import androidx.compose.animation.core.spring
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
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.source.Sources
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.Constants
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.LocalSpacing
import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.ConfirmDialog
import com.rtbishop.look4sat.core.presentation.SharedDialog
@Preview(showBackground = true)
@@ -70,19 +83,28 @@ fun PositionDialog(lat: Double, lon: Double, dismiss: () -> Unit, save: (Double,
val lonValue = rememberSaveable { mutableStateOf(lon.toString()) }
val titleText = stringResource(id = R.string.prefs_station_title)
val onAccept = { saveValues(latValue.value, lonValue.value, save).also { dismiss() } }
SharedDialog(title = titleText, onCancel = dismiss, onAccept = onAccept) {
OutlinedTextField(
value = latValue.value,
onValueChange = { latValue.value = it },
label = { Text(text = stringResource(id = R.string.prefs_station_lat_text)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
)
OutlinedTextField(
value = lonValue.value,
onValueChange = { lonValue.value = it },
label = { Text(text = stringResource(id = R.string.prefs_station_lon_text)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
)
ConfirmDialog(title = titleText, onCancel = dismiss, onAccept = onAccept) {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = LocalSpacing.current.large)
) {
OutlinedTextField(
value = latValue.value,
onValueChange = { latValue.value = it },
label = { Text(text = stringResource(id = R.string.prefs_station_lat_text)) },
singleLine = true,
modifier = Modifier.weight(1f),
)
OutlinedTextField(
value = lonValue.value,
onValueChange = { lonValue.value = it },
label = { Text(text = stringResource(id = R.string.prefs_station_lon_text)) },
singleLine = true,
modifier = Modifier.weight(1f),
)
}
Spacer(modifier = Modifier.height(0.dp))
}
}
@@ -105,35 +127,23 @@ private fun LocatorDialogPreview() {
fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Unit) {
val locator = rememberSaveable { mutableStateOf(qthLocator) }
val onAccept = { save(locator.value).also { dismiss() } }
SharedDialog(title = stringResource(R.string.prefs_locator_title), onCancel = dismiss, onAccept = onAccept) {
ConfirmDialog(
title = stringResource(R.string.prefs_locator_title),
onCancel = dismiss,
onAccept = onAccept
) {
OutlinedTextField(
value = locator.value,
onValueChange = { locator.value = it },
label = { Text(text = stringResource(id = R.string.prefs_locator_text)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = LocalSpacing.current.large),
)
Spacer(modifier = Modifier.height(0.dp))
}
}
@Preview(showBackground = true)
@Composable
private fun TransceiversDialogPreview() {
MainTheme {
DataSourcesDialog(
useCustomTle = true,
useCustomTransceivers = true,
tleUrl = "https://example.com/tle.txt",
transceiversUrl = "https://example.com/tx.json",
requestCustomSourcesPermission = { onGranted, _ -> onGranted() },
onImportTle = {},
onImportTransceivers = {},
onDismiss = {},
onSave = { _, _, _, _ -> }
)
}
}
@Composable
fun DataSourcesDialog(
useCustomTle: Boolean,
@@ -265,6 +275,59 @@ fun DataSourcesDialog(
}
}
private fun LazyListScope.sourceSection(
sectionKey: String,
label: String,
urls: List<Pair<Long, String>>,
onAdd: () -> Unit,
onMoveUp: (Int) -> Unit,
onRemove: (Int) -> Unit,
onUrlChange: (Int, String) -> Unit
) {
item {
Spacer(modifier = Modifier.height(6.dp))
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
text = label,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f)
)
IconCard(action = onAdd, resId = R.drawable.ic_add, containerColor = MaterialTheme.colorScheme.surfaceVariant)
}
}
itemsIndexed(urls, key = { _, entry -> "$sectionKey-${entry.first}" }) { index, (_, url) ->
val enabledTint = MaterialTheme.colorScheme.onSurfaceVariant
OutlinedTextField(
value = url,
onValueChange = { onUrlChange(index, it) },
label = { Text("Source URL") },
leadingIcon = {
IconButton(onClick = { onMoveUp(index) }) {
Icon(
painter = painterResource(R.drawable.ic_arrow),
contentDescription = null,
tint = if (index > 0) enabledTint else enabledTint.copy(alpha = 0.32f),
modifier = Modifier.rotate(270f)
)
}
},
trailingIcon = {
IconButton(onClick = { onRemove(index) }) {
Icon(
painter = painterResource(R.drawable.ic_delete),
contentDescription = null
)
}
},
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.animateItem(fadeInSpec = spring(), fadeOutSpec = spring())
)
}
}
@Preview(showBackground = true)
@Composable
fun PreviewNetworkOutputDialog() {
@@ -279,6 +342,7 @@ fun PreviewNetworkOutputDialog() {
frequencyAddress = "127.0.0.1",
frequencyPort = "4532",
frequencyFormat = $$"F $FREQ",
frequencyOffsetHz = 0L,
bluetoothRotatorState = false,
bluetoothRotatorFormat = $$"P $AZ $EL",
bluetoothRotatorName = "Default",
@@ -288,7 +352,7 @@ fun PreviewNetworkOutputDialog() {
bluetoothFrequencyFormat = $$"F $FREQ"
),
onDismiss = {},
onSave = { _, _, _, _, _, _, _, _ -> }
onSave = { _, _, _, _, _, _, _, _, _ -> }
)
}
}
@@ -299,7 +363,7 @@ fun NetworkOutputDialog(
onDismiss: () -> Unit,
onSave: (
Boolean, String, String, String,
Boolean, String, String, String
Boolean, String, String, String, Long
) -> Unit
) {
val padding = LocalSpacing.current.large
@@ -313,16 +377,19 @@ fun NetworkOutputDialog(
mutableStateOf("${initialSettings.frequencyAddress}:${initialSettings.frequencyPort}")
}
val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.frequencyFormat) }
val frequencyOffsetHz = rememberSaveable { mutableStateOf(initialSettings.frequencyOffsetHz.toString()) }
val onAccept = {
val (rotIp, rotPort) = splitAddress(rotatorAddress.value)
val (freqIp, freqPort) = splitAddress(frequencyAddress.value)
val offsetHz = (frequencyOffsetHz.value.trim().toLongOrNull() ?: 0L)
.coerceIn(Constants.FREQ_OFFSET_MIN_HZ, Constants.FREQ_OFFSET_MAX_HZ)
onSave(
rotatorState.value, rotIp, rotPort, rotatorFormat.value,
frequencyState.value, freqIp, freqPort, frequencyFormat.value
frequencyState.value, freqIp, freqPort, frequencyFormat.value, offsetHz
)
onDismiss()
}
SharedDialog(
ConfirmDialog(
title = stringResource(R.string.prefs_net_title),
onCancel = onDismiss,
onAccept = onAccept
@@ -351,6 +418,27 @@ fun NetworkOutputDialog(
onFormatChange = { frequencyFormat.value = it },
formatLabel = stringResource(R.string.prefs_net_frequency_format_hint)
)
Spacer(modifier = Modifier.height(6.dp))
OutlinedTextField(
value = frequencyOffsetHz.value,
onValueChange = { frequencyOffsetHz.value = it },
singleLine = true,
label = { Text(stringResource(R.string.prefs_net_frequency_offset_hint)) },
supportingText = { Text(stringResource(R.string.prefs_net_frequency_offset_help)) },
trailingIcon = {
IconButton(
onClick = { frequencyOffsetHz.value = "0" },
enabled = frequencyState.value && frequencyOffsetHz.value != "0"
) {
Icon(
painter = painterResource(R.drawable.ic_close),
contentDescription = null
)
}
},
modifier = Modifier.fillMaxWidth(),
enabled = frequencyState.value
)
Spacer(modifier = Modifier.height(12.dp))
}
}
@@ -379,6 +467,7 @@ fun PreviewBluetoothOutputDialog() {
frequencyAddress = "127.0.0.1",
frequencyPort = "4532",
frequencyFormat = $$"F $FREQ",
frequencyOffsetHz = 0L,
bluetoothRotatorState = false,
bluetoothRotatorFormat = $$"P $AZ $EL",
bluetoothRotatorName = "Default",
@@ -404,11 +493,15 @@ fun BluetoothOutputDialog(
) {
val padding = LocalSpacing.current.large
val rotatorState = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorState) }
val rotatorAddress = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorAddress) }
val rotatorAddress =
rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorAddress) }
val rotatorFormat = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorFormat) }
val frequencyState = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyState) }
val frequencyAddress = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyAddress) }
val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyFormat) }
val frequencyState =
rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyState) }
val frequencyAddress =
rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyAddress) }
val frequencyFormat =
rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyFormat) }
val onAccept = {
onSave(
rotatorState.value, rotatorAddress.value, rotatorFormat.value,
@@ -416,7 +509,7 @@ fun BluetoothOutputDialog(
)
onDismiss()
}
SharedDialog(
ConfirmDialog(
title = stringResource(R.string.prefs_bt_title),
onCancel = onDismiss,
onAccept = onAccept
@@ -497,23 +590,22 @@ private fun OutputChannelSection(
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RadioControlDialog(
initialSettings: RadioControlSettings,
pairedBluetoothDevices: List<Pair<String, String>>,
onDismiss: () -> Unit,
onSave: (RadioControlSettings) -> Unit
) {
val context = androidx.compose.ui.platform.LocalContext.current
val padding = LocalSpacing.current.large
val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) }
val padding = LocalSpacing.current.large
val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) }
val radioModel = rememberSaveable { mutableStateOf(initialSettings.radioModel) }
val splitMode = rememberSaveable { mutableStateOf(initialSettings.splitMode) }
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 { mutableIntStateOf(initialSettings.baudRate) }
val splitMode = rememberSaveable { mutableStateOf(initialSettings.splitMode) }
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 { mutableIntStateOf(initialSettings.baudRate) }
val selectingFor = rememberSaveable { mutableStateOf("") } // "tx", "rx", or ""
val isIcom = radioModel.value == RadioControlSettings.MODEL_ICOM_IC705
@@ -525,159 +617,159 @@ fun RadioControlDialog(
}
val baudRates = if (isIcom) RadioControlSettings.BAUD_RATES_ICOM
else RadioControlSettings.BAUD_RATES_YAESU
else RadioControlSettings.BAUD_RATES_YAESU
// If current baud rate is not in the new list, default to the first available
LaunchedEffect(baudRates) {
if (baudRate.intValue !in baudRates) baudRate.intValue = baudRates.first()
}
val pairedDevices: List<Pair<String, String>> = remember {
buildList {
try {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
manager.adapter?.bondedDevices?.forEach {
add(Pair(it.name ?: "Unknown", it.address ?: ""))
}
} catch (_: SecurityException) { }
}
}
val onAccept = {
onSave(
RadioControlSettings(
enabled = enabled.value,
radioModel = radioModel.value,
enabled = enabled.value,
radioModel = radioModel.value,
txRadioAddress = txAddress.value,
rxRadioAddress = if (isSingleRadio) "" else rxAddress.value,
txRadioName = txName.value,
rxRadioName = if (isSingleRadio) "" else rxName.value,
baudRate = baudRate.intValue,
splitMode = splitMode.value
txRadioName = txName.value,
rxRadioName = if (isSingleRadio) "" else rxName.value,
baudRate = baudRate.intValue,
splitMode = splitMode.value
)
)
onDismiss()
}
SharedDialog(
title = stringResource(R.string.rc_settings_title),
ConfirmDialog(
title = stringResource(R.string.rc_settings_title),
onCancel = onDismiss,
onAccept = onAccept
) {
Column(modifier = Modifier.padding(horizontal = padding)) {
// Enable switch
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
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 — FlowRow so chips wrap on small screens
Text(
text = stringResource(R.string.rc_radio_model),
text = stringResource(R.string.rc_radio_model),
fontWeight = FontWeight.Medium,
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
color = MaterialTheme.colorScheme.primary
)
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.fillMaxWidth()
) {
RadioControlSettings.SUPPORTED_RADIOS.forEach { model ->
FilterChip(
selected = radioModel.value == model,
onClick = { radioModel.value = model },
label = { Text(model, fontSize = 12.sp) },
enabled = enabled.value
onClick = { radioModel.value = model },
label = {
Text(
text = compactRadioModelLabel(model),
fontSize = 12.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
},
enabled = enabled.value,
modifier = Modifier.weight(1f)
)
}
}
Spacer(modifier = Modifier.height(6.dp))
// IC-705 split-mode toggle (only shown for IC-705)
if (isIcom) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.weight(1f)) {
Text("Split mode (single radio)", fontWeight = FontWeight.Medium)
Text(
text = "Use VFO-A/B split on one IC-705 instead of two radios",
fontSize = 12.sp,
color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = splitMode.value,
onCheckedChange = { splitMode.value = it },
enabled = enabled.value
)
}
Spacer(modifier = Modifier.height(6.dp))
val isSplitModeAvailable = enabled.value && isIcom
val splitModeLabelColor = if (isSplitModeAvailable) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.56f)
}
// TX Radio (always shown; in split mode this is the single IC-705)
val txLabel = if (isSingleRadio) "Radio (IC-705)" else "TX Radio (Uplink)"
Text(txLabel, fontWeight = FontWeight.Medium)
if (txAddress.value.isNotBlank()) {
Text("${txName.value} — ${txAddress.value}", fontSize = 13.sp)
}
CardButton(
onClick = { selectingFor.value = "tx" },
text = if (isSingleRadio) "Select Device" else "Select TX Device",
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
)
) {
Text(
text = "Split mode (IC-705 only)",
fontWeight = FontWeight.Medium,
color = splitModeLabelColor
)
Switch(
checked = splitMode.value,
onCheckedChange = { splitMode.value = it },
enabled = isSplitModeAvailable
)
}
Spacer(modifier = Modifier.height(6.dp))
// RX Radio (hidden in split mode — the same radio handles both)
if (!isSingleRadio) {
Text("RX Radio (Downlink)", fontWeight = FontWeight.Medium)
if (rxAddress.value.isNotBlank()) {
Text("${rxName.value} — ${rxAddress.value}", fontSize = 13.sp)
}
Text("Radio devices", fontWeight = FontWeight.Medium)
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
) {
CardButton(
onClick = { selectingFor.value = "rx" },
text = "Select RX Device",
modifier = Modifier.fillMaxWidth()
onClick = { selectingFor.value = "tx" },
text = if (isSingleRadio) "Select Radio" else "Select TX",
modifier = Modifier.weight(1f)
)
CardButton(
onClick = { if (!isSingleRadio) selectingFor.value = "rx" },
text = if (isSingleRadio) "RX = TX" else "Select RX",
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.height(6.dp))
}
if (txAddress.value.isNotBlank()) {
Text("TX: ${txName.value} — ${txAddress.value}", fontSize = 13.sp)
}
if (isSingleRadio && txAddress.value.isNotBlank()) {
Text("RX: same as TX", fontSize = 13.sp)
} else if (rxAddress.value.isNotBlank()) {
Text("RX: ${rxName.value} — ${rxAddress.value}", fontSize = 13.sp)
}
Spacer(modifier = Modifier.height(6.dp))
// Paired device picker (inline, shown while selecting)
if (selectingFor.value.isNotBlank()) {
Text(
text = "Paired Bluetooth Devices:",
text = "Paired Bluetooth Devices:",
fontWeight = FontWeight.Medium,
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(2.dp))
if (pairedDevices.isEmpty()) {
if (pairedBluetoothDevices.isEmpty()) {
Text(
"No paired devices found. Pair your BT adapter in Android Bluetooth settings first.",
fontSize = 13.sp
)
} else {
pairedDevices.forEach { (name, address) ->
pairedBluetoothDevices.forEach { (name, address) ->
androidx.compose.material3.Surface(
modifier = Modifier
.fillMaxWidth()
.clickable {
if (selectingFor.value == "tx") {
txAddress.value = address
txName.value = name
txName.value = name
if (isSingleRadio) {
rxAddress.value = address
rxName.value = name
}
} else {
rxAddress.value = address
rxName.value = name
rxName.value = name
}
selectingFor.value = ""
}
.padding(vertical = 4.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(name, modifier = Modifier.weight(1f))
@@ -689,15 +781,26 @@ fun RadioControlDialog(
Spacer(modifier = Modifier.height(6.dp))
}
// Baud rate — FlowRow so all chips fit on narrow screens
Text("Baud Rate:", fontWeight = FontWeight.Medium)
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(2.dp),
maxItemsInEachRow = 6,
modifier = Modifier.fillMaxWidth()
) {
baudRates.forEach { rate ->
FilterChip(
selected = rate == baudRate.intValue,
onClick = { baudRate.intValue = rate },
label = { Text(rate.toString(), fontSize = 12.sp) },
enabled = enabled.value
onClick = { baudRate.intValue = rate },
label = {
Text(
text = baudLabel(rate),
fontSize = 12.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
},
enabled = enabled.value,
modifier = Modifier.weight(1f)
)
}
}
@@ -705,3 +808,20 @@ fun RadioControlDialog(
}
}
}
private fun compactRadioModelLabel(model: String): String = when (model) {
RadioControlSettings.MODEL_YAESU_FT817 -> "FT-817/818"
RadioControlSettings.MODEL_YAESU_FT857 -> "FT-857/897"
RadioControlSettings.MODEL_ICOM_IC705 -> "IC-705"
else -> model
}
private fun baudLabel(rate: Int): String = when (rate) {
4800 -> "4k"
9600 -> "9k"
19200 -> "19k"
38400 -> "38k"
57600 -> "57k"
115200 -> "115k"
else -> rate.toString()
}
@@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
@@ -34,6 +35,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.layout.width
@@ -54,6 +56,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
@@ -94,6 +97,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
@@ -104,6 +108,7 @@ import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.ScreenColumn
import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.WhatsNewDialog
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
import com.rtbishop.look4sat.core.domain.navigation.MenuLayout
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
@@ -233,18 +238,15 @@ private fun SettingsScreen(
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(
useCustomTLE = if (!useCustomTle || tleUrl.isNotBlank()) useCustomTle else current.useCustomTLE,
tleUrl = if (!useCustomTle || tleUrl.isNotBlank()) tleUrl else current.tleUrl,
useCustomTransceivers = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) useCustomTransceivers else current.useCustomTransceivers,
transceiversUrl = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) transceiversUrl else current.transceiversUrl
onSave = { useTle, useTx, tleUrl, txUrl ->
val newSettings = DataSourcesSettings(
useCustomTLE = useTle,
useCustomTransceivers = useTx,
tleUrl = tleUrl,
transceiversUrl = txUrl
)
if (newSettings != current) onAction(SettingsAction.UpdateDataSources(newSettings))
if (newSettings.useCustomTLE || newSettings.useCustomTransceivers) {
onAction(SettingsAction.UpdateFromWeb)
}
if (newSettings != uiState.dataSourcesSettings) onAction(SettingsAction.UpdateDataSources(newSettings))
onAction(SettingsAction.UpdateFromWeb)
}
)
}
@@ -252,14 +254,15 @@ private fun SettingsScreen(
NetworkOutputDialog(
initialSettings = uiState.rcSettings,
onDismiss = { dialogs.network = false },
onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt ->
onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt, freqOffsetHz ->
onAction(
SettingsAction.UpdateRC(
uiState.rcSettings.copy(
rotatorState = rotState, rotatorAddress = rotAddr,
rotatorPort = rotPort, rotatorFormat = rotFmt,
frequencyState = freqState, frequencyAddress = freqAddr,
frequencyPort = freqPort, frequencyFormat = freqFmt
frequencyPort = freqPort, frequencyFormat = freqFmt,
frequencyOffsetHz = freqOffsetHz
)
)
)
@@ -286,10 +289,14 @@ private fun SettingsScreen(
if (dialogs.radioControl) {
RadioControlDialog(
initialSettings = uiState.radioControlSettings,
pairedBluetoothDevices = uiState.pairedBluetoothDevices,
onDismiss = { dialogs.radioControl = false },
onSave = { onAction(SettingsAction.UpdateRadioControl(it)) }
)
}
if (dialogs.whatsNew) {
WhatsNewDialog(onDismiss = { dialogs.whatsNew = false })
}
// URLs for top bar
val uriHandler = LocalUriHandler.current
@@ -310,11 +317,11 @@ private fun SettingsScreen(
if (isVerticalLayout) {
TopBar {
TopCard(
onClick = { safeOpenUri(appUrl) },
onClick = { dialogs.whatsNew = true },
version = uiState.appVersionName,
modifier = Modifier.weight(1f)
)
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like)
}
TopBar {
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
@@ -336,9 +343,9 @@ private fun SettingsScreen(
}
} else {
TopBar {
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like)
TopCard(
onClick = { safeOpenUri(appUrl) },
onClick = { dialogs.whatsNew = true },
version = uiState.appVersionName,
modifier = Modifier.weight(1f)
)
@@ -410,6 +417,7 @@ private fun SettingsScreen(
)
}
item { CardCredits() }
}
}
}
@@ -590,37 +598,57 @@ private fun OtherCardPreview() = MainTheme {
shouldSeeWarning = false,
shouldSeeWhatsNew = false
)
OtherCard(settings = values) {}
OtherCard(settings = values, onAction = {})
}
@Composable
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
ElevatedCard(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit, modifier: Modifier = Modifier) {
ElevatedCard(modifier = modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text(
text = stringResource(id = R.string.prefs_other_title),
color = MaterialTheme.colorScheme.primary
)
SwitchRow(R.string.prefs_other_switch_utc, settings.stateOfUtc) {
onAction(SettingsAction.ToggleUtc(it))
Spacer(modifier = Modifier.height(4.dp))
// Display preferences: UTC clock + night filter
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SwitchTile(R.string.prefs_other_switch_utc, settings.stateOfUtc) {
onAction(SettingsAction.ToggleUtc(it))
}
SwitchTile(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
onAction(SettingsAction.ToggleNightMode(it))
}
}
Spacer(modifier = Modifier.height(4.dp))
// Radar behavior: sweep animation + sensor control
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SwitchTile(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
onAction(SettingsAction.ToggleSweep(it))
}
SwitchTile(R.string.prefs_other_switch_sensors, settings.stateOfSensors) {
onAction(SettingsAction.ToggleSensor(it))
}
}
Spacer(modifier = Modifier.height(4.dp))
// Data management (full width)
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
onAction(SettingsAction.ToggleUpdate(it))
}
SwitchRow(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
onAction(SettingsAction.ToggleSweep(it))
}
SwitchRow(R.string.prefs_other_switch_sensors, settings.stateOfSensors) {
onAction(SettingsAction.ToggleSensor(it))
}
SwitchRow(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
onAction(SettingsAction.ToggleNightMode(it))
}
Spacer(modifier = Modifier.height(4.dp))
// Compass calibration sliders at the bottom
CompassOffsetRow(
labelResId = R.string.prefs_other_compass_offset,
value = settings.radarCompassOffset,
range = -180f..180f
) { onAction(SettingsAction.SetRadarCompassOffset(it)) }
Spacer(modifier = Modifier.height(4.dp))
CompassOffsetRow(
labelResId = R.string.prefs_other_compass_offset_elev,
value = settings.radarCompassOffsetElev,
range = -90f..90f
) { onAction(SettingsAction.SetRadarCompassOffsetElev(it)) }
Spacer(modifier = Modifier.height(4.dp))
}
}
}
@@ -996,13 +1024,52 @@ private fun DragOrderList(
}
@Composable
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?) {
private fun CompassOffsetRow(
labelResId: Int,
value: Float,
range: ClosedFloatingPointRange<Float>,
onValueChange: (Float) -> Unit
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = labelResId))
Text(text = "${value.toInt()}°")
}
Spacer(modifier = Modifier.height(4.dp))
Slider(
value = value,
onValueChange = onValueChange,
valueRange = range
)
}
@Composable
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = labelResId))
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
@Composable
private fun RowScope.SwitchTile(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Text(
text = stringResource(id = labelResId),
modifier = Modifier.weight(1f)
)
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
@@ -1040,6 +1107,7 @@ private fun CardCreditsPreview() = MainTheme { CardCredits() }
private fun CardCredits(modifier: Modifier = Modifier) {
ElevatedCard(
modifier = modifier.fillMaxWidth()
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -1080,7 +1148,7 @@ private fun TopCard(onClick: () -> Unit, modifier: Modifier = Modifier, version:
.clickable { onClick() }) {
Spacer(Modifier)
Icon(
painter = painterResource(id = R.drawable.ic_satellites),
painter = painterResource(id = R.drawable.ic_sputnik),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
@@ -1130,6 +1198,7 @@ private class DialogVisibility {
var network by mutableStateOf(false)
var bluetooth by mutableStateOf(false)
var radioControl by mutableStateOf(false)
var whatsNew by mutableStateOf(false)
}
@Composable
@@ -1137,12 +1206,12 @@ 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)
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.whatsNew)
},
restore = {
DialogVisibility().apply {
position = it[0]; locator = it[1]; dataSources = it[2]
network = it[3]; bluetooth = it[4]; radioControl = it[5]
network = it[3]; bluetooth = it[4]; radioControl = it[5]; whatsNew = it[6]
}
}
)
@@ -1211,7 +1280,7 @@ private fun rememberSettingsPermissions(
onCustomSourcesPermissionGranted()
} else {
onCustomSourcesPermissionDenied()
sendAction(SettingsAction.ShowToast(networkError))
sendAction(SettingsAction.ShowToast(satellitesImportError))
}
}
@@ -1233,11 +1302,9 @@ private fun rememberSettingsPermissions(
}
},
launchCustomSourcesPermission = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
customSourcesRequest.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
} else {
onCustomSourcesPermissionGranted()
}
val storagePerm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
Manifest.permission.READ_MEDIA_IMAGES else Manifest.permission.READ_EXTERNAL_STORAGE
customSourcesRequest.launch(storagePerm)
}
)
}
@@ -41,7 +41,8 @@ data class SettingsState(
val otherSettings: OtherSettings,
val rcSettings: RCSettings,
val radioControlSettings: RadioControlSettings,
val dataSourcesSettings: DataSourcesSettings
val dataSourcesSettings: DataSourcesSettings,
val pairedBluetoothDevices: List<Pair<String, String>> = emptyList()
)
sealed interface SettingsAction {
@@ -80,6 +81,9 @@ sealed interface SettingsAction {
data object UploadWavelogQueue : SettingsAction
// WaveLog: update LoTW satellite list
data object UpdateLotwSatellites : SettingsAction
data class SetRadarCompassOffset(val value: Float) : SettingsAction
data class SetRadarCompassOffsetElev(val value: Float) : SettingsAction
// Remote control
data class UpdateRC(val settings: RCSettings) : SettingsAction
@@ -39,6 +39,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
class SettingsViewModel(
private val container: IMainContainer,
private val databaseRepo: IDatabaseRepo,
private val settingsRepo: ISettingsRepo,
private val showToast: IShowToast,
@@ -55,7 +56,8 @@ class SettingsViewModel(
otherSettings = settingsRepo.otherSettings.value,
rcSettings = settingsRepo.rcSettings.value,
radioControlSettings = settingsRepo.radioControlSettings.value,
dataSourcesSettings = settingsRepo.dataSourcesSettings.value
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
pairedBluetoothDevices = container.providePairedBluetoothDevices()
)
)
@@ -140,6 +142,9 @@ class SettingsViewModel(
SettingsAction.ResetMenuOrder -> settingsRepo.updateOtherSettings {
it.copy(screenOrder = emptyList(), subMenuOrder = emptyList())
}
is SettingsAction.SetRadarCompassOffset -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffset = action.value) }
is SettingsAction.SetRadarCompassOffsetElev -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffsetElev = action.value) }
// Remote control & data sources
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
@@ -315,6 +320,7 @@ class SettingsViewModel(
fun factory(container: IMainContainer) = viewModelFactory {
initializer {
SettingsViewModel(
container = container,
databaseRepo = container.databaseRepo,
settingsRepo = container.settingsRepo,
showToast = container.provideShowToast(),