Added the ability to fully customize data sources via import

This commit is contained in:
Arty Bishop committed 2026-08-12 13:01:45 +02:00
1 parent 24eebdef74
commit 2f4e3f5802
12 files changed
+190 -213

No files matched your search

@@ -17,26 +17,39 @@
*/
package com.rtbishop.look4sat.feature.settings
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.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.runtime.Composable
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -46,6 +59,7 @@ 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.IconCard
import com.rtbishop.look4sat.core.presentation.LocalSpacing
import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.R
@@ -129,133 +143,144 @@ fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Uni
private fun TransceiversDialogPreview() {
MainTheme {
DataSourcesDialog(
useCustomTle = true,
useCustomTransceivers = true,
tleUrl = "https://example.com/tle.txt",
transceiversUrl = "https://example.com/tx.json",
requestCustomSourcesPermission = { onGranted, _ -> onGranted() },
satelliteUrls = listOf(
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"amsat.org/tle/current/nasabare.txt"
),
transceiversUrls = listOf(
"db.satnogs.org/api/transmitters/?format=json&status=active"
),
onImportTle = {},
onImportTransceivers = {},
onDismiss = {},
onSave = { _, _, _, _ -> }
onSave = { _, _ -> }
)
}
}
@Composable
fun DataSourcesDialog(
useCustomTle: Boolean,
useCustomTransceivers: Boolean,
tleUrl: String,
transceiversUrl: String,
requestCustomSourcesPermission: (onGranted: () -> Unit, onDenied: () -> Unit) -> Unit,
satelliteUrls: List<String>,
transceiversUrls: List<String>,
onImportTle: () -> Unit,
onImportTransceivers: () -> Unit,
onDismiss: () -> Unit,
onSave: (Boolean, Boolean, String, String) -> Unit
onSave: (List<String>, List<String>) -> Unit
) {
val padding = LocalSpacing.current.large
val isEnabledCustomTle = rememberSaveable { mutableStateOf(useCustomTle) }
val isEnabledCustomTransceivers = rememberSaveable { mutableStateOf(useCustomTransceivers) }
val urlTle = rememberSaveable { mutableStateOf(tleUrl) }
val urlTransceivers = rememberSaveable { mutableStateOf(transceiversUrl) }
val onAccept = {
onSave(
isEnabledCustomTle.value,
isEnabledCustomTransceivers.value,
urlTle.value,
urlTransceivers.value
)
onDismiss()
// Use stable Long IDs to avoid key collisions (e.g. multiple empty "" entries).
val nextId = remember { mutableLongStateOf((satelliteUrls.size + transceiversUrls.size).toLong()) }
val satUrls = remember {
satelliteUrls.mapIndexed { i, url -> i.toLong() to url }.toMutableStateList()
}
val onCancel = { onDismiss() }
val txUrls = remember {
transceiversUrls.mapIndexed { i, url -> (satelliteUrls.size + i).toLong() to url }.toMutableStateList()
}
val onAccept = { onSave(satUrls.map { it.second }, txUrls.map { it.second }); onDismiss() }
ConfirmDialog(
title = stringResource(id = R.string.prefs_data_sources_title),
onCancel = onCancel,
onCancel = onDismiss,
onAccept = onAccept,
) {
Column(modifier = Modifier.padding(horizontal = padding)) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
CardButton(
onClick = {
onImportTle()
onDismiss()
},
text = "TLE/3LE (.txt)\nOMM (.csv)",
modifier = Modifier.weight(1f)
)
CardButton(
onClick = {
onImportTransceivers()
onDismiss()
},
text = "Transceivers\nSatNOGS (.json)",
modifier = Modifier.weight(1f)
)
LazyColumn(
modifier = Modifier
.fillMaxHeight(0.84f)
.padding(horizontal = padding),
verticalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = PaddingValues(vertical = 6.dp)
) {
item {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
) {
CardButton(
onClick = { onImportTle(); onDismiss() },
text = "TLE/3LE (.txt)\nOMM (.csv)",
modifier = Modifier.weight(1f)
)
CardButton(
onClick = { onImportTransceivers(); onDismiss() },
text = "Transceivers\nSatNOGS (.json)",
modifier = Modifier.weight(1f)
)
}
}
Spacer(modifier = Modifier.height(6.dp))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = R.string.prefs_data_sources_tle_switch))
Switch(
checked = isEnabledCustomTle.value,
onCheckedChange = { enabled ->
if (!enabled) {
isEnabledCustomTle.value = false
} else {
requestCustomSourcesPermission(
{ isEnabledCustomTle.value = true },
{ isEnabledCustomTle.value = false }
)
}
}
)
}
OutlinedTextField(
value = urlTle.value,
onValueChange = { urlTle.value = it },
label = { Text(text = stringResource(id = R.string.prefs_data_sources_url_title)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
enabled = isEnabledCustomTle.value,
sourceSection(
sectionKey = "sat",
label = "Satellites data",
urls = satUrls,
onAdd = { satUrls.add(nextId.longValue++ to "") },
onMoveUp = { i -> if (i > 0) satUrls.add(i - 1, satUrls.removeAt(i)) },
onRemove = { i -> satUrls.removeAt(i) },
onUrlChange = { i, v -> satUrls[i] = satUrls[i].first to v }
)
Spacer(modifier = Modifier.height(6.dp))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = R.string.prefs_data_sources_transceivers_switch))
Switch(
checked = isEnabledCustomTransceivers.value,
onCheckedChange = { enabled ->
if (!enabled) {
isEnabledCustomTransceivers.value = false
} else {
requestCustomSourcesPermission(
{ isEnabledCustomTransceivers.value = true },
{ isEnabledCustomTransceivers.value = false }
)
}
}
)
}
OutlinedTextField(
value = urlTransceivers.value,
onValueChange = { urlTransceivers.value = it },
label = { Text(text = stringResource(id = R.string.prefs_data_sources_url_title)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
enabled = isEnabledCustomTransceivers.value,
sourceSection(
sectionKey = "tx",
label = "Transceivers data",
urls = txUrls,
onAdd = { txUrls.add(nextId.longValue++ to "") },
onMoveUp = { i -> if (i > 0) txUrls.add(i - 1, txUrls.removeAt(i)) },
onRemove = { i -> txUrls.removeAt(i) },
onUrlChange = { i, v -> txUrls[i] = txUrls[i].first to v }
)
Spacer(modifier = Modifier.height(12.dp))
}
}
}
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() {
@@ -64,6 +64,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
@@ -93,22 +94,10 @@ fun SettingsDestination() {
@Composable
private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) {
val dialogs = rememberDialogVisibility()
val pendingCustomSourcesGrant = remember { mutableStateOf<(() -> Unit)?>(null) }
val pendingCustomSourcesDeny = remember { mutableStateOf<(() -> Unit)?>(null) }
val permissions = rememberSettingsPermissions(
sendAction = onAction,
onBluetoothGranted = { dialogs.bluetooth = true },
onNetworkGranted = { dialogs.network = true },
onCustomSourcesPermissionGranted = {
pendingCustomSourcesGrant.value?.invoke()
pendingCustomSourcesGrant.value = null
pendingCustomSourcesDeny.value = null
},
onCustomSourcesPermissionDenied = {
pendingCustomSourcesDeny.value?.invoke()
pendingCustomSourcesGrant.value = null
pendingCustomSourcesDeny.value = null
}
onNetworkGranted = { dialogs.network = true }
)
// Dialogs
@@ -129,30 +118,15 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
}
if (dialogs.dataSources) {
DataSourcesDialog(
useCustomTle = uiState.dataSourcesSettings.useCustomTLE,
useCustomTransceivers = uiState.dataSourcesSettings.useCustomTransceivers,
tleUrl = uiState.dataSourcesSettings.tleUrl,
transceiversUrl = uiState.dataSourcesSettings.transceiversUrl,
requestCustomSourcesPermission = { onGranted, onDenied ->
pendingCustomSourcesGrant.value = onGranted
pendingCustomSourcesDeny.value = onDenied
permissions.launchCustomSourcesPermission()
},
satelliteUrls = uiState.dataSourcesSettings.satelliteUrls,
transceiversUrls = uiState.dataSourcesSettings.transceiversUrls,
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
)
if (newSettings != current) onAction(SettingsAction.UpdateDataSources(newSettings))
if (newSettings.useCustomTLE || newSettings.useCustomTransceivers) {
onAction(SettingsAction.UpdateFromWeb)
}
onSave = { satUrls, txUrls ->
val newSettings = DataSourcesSettings(satelliteUrls = satUrls, transceiversUrls = txUrls)
if (newSettings != uiState.dataSourcesSettings) onAction(SettingsAction.UpdateDataSources(newSettings))
onAction(SettingsAction.UpdateFromWeb)
}
)
}
@@ -752,17 +726,14 @@ private class SettingsPermissions(
val launchTleImport: () -> Unit,
val launchTransceiverImport: () -> Unit,
val launchBluetooth: () -> Unit,
val launchNetwork: () -> Unit,
val launchCustomSourcesPermission: () -> Unit
val launchNetwork: () -> Unit
)
@Composable
private fun rememberSettingsPermissions(
sendAction: (SettingsAction) -> Unit,
onBluetoothGranted: () -> Unit,
onNetworkGranted: () -> Unit,
onCustomSourcesPermissionGranted: () -> Unit,
onCustomSourcesPermissionDenied: () -> Unit
onNetworkGranted: () -> Unit
): SettingsPermissions {
val locationError = stringResource(R.string.prefs_loc_gps_error)
val locationRequest = rememberLauncherForActivityResult(
@@ -799,15 +770,6 @@ private fun rememberSettingsPermissions(
else sendAction(SettingsAction.ShowToast(networkError))
}
val customSourcesRequest = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) {
onCustomSourcesPermissionGranted()
} else {
onCustomSourcesPermissionDenied()
sendAction(SettingsAction.ShowToast(networkError))
}
}
return remember {
SettingsPermissions(
launchLocation = {
@@ -824,13 +786,6 @@ private fun rememberSettingsPermissions(
} else {
onNetworkGranted()
}
},
launchCustomSourcesPermission = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
customSourcesRequest.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
} else {
onCustomSourcesPermissionGranted()
}
}
)
}