diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt index 3dd53bd5..c4719d27 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt @@ -65,18 +65,25 @@ class DatabaseRepo( override suspend fun updateFromRemote() = withContext(dispatcher) { val settings = settingsRepo.dataSourcesSettings.value fun normalizeUrl(url: String) = if (url.startsWith("http")) url else "https://$url" - val tleUrls = settings.satelliteUrls.filter { it.isNotBlank() }.map(::normalizeUrl) - val radioUrls = settings.transceiversUrls.filter { it.isNotBlank() }.map(::normalizeUrl) - // launch all network requests concurrently - val tleJobs = tleUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } } - val radioJobs = radioUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } } - // parse fetched data concurrently - val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) -> - stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty() - } - val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) -> - stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty() - } + val tleUrls = settings.satelliteUrls.filterIndexed { i, url -> url.isNotBlank() && settings.isSatelliteEnabled(i) } + val radioUrls = settings.transceiversUrls.filterIndexed { i, url -> url.isNotBlank() && settings.isTransceiverEnabled(i) } + // launch all network requests concurrently, keeping the raw url as key for status reporting + val tleJobs = tleUrls.map { url -> async { url to remoteSource.getNetworkStream(normalizeUrl(url)) } } + val radioJobs = radioUrls.map { url -> async { url to remoteSource.getNetworkStream(normalizeUrl(url)) } } + val tleResults = tleJobs.awaitAll() + val radioResults = radioJobs.awaitAll() + // report the HTTP status code of every source (200, 404, ...) + settingsRepo.updateDataSourcesStatus( + (tleResults + radioResults).associate { (url, result) -> url to result.code } + ) + // parse fetched data concurrently, keeping the first occurrence per primary key + // so sources listed higher in the dialog take priority over lower ones + val importedEntries = tleResults.flatMap { (url, result) -> + result.stream?.let { val nUrl = normalizeUrl(url); parseSatelliteStream(nUrl, unwrapIfZipped(nUrl, it)) }.orEmpty() + }.distinctBy { it.catnum } + val importedRadios = radioResults.flatMap { (url, result) -> + result.stream?.let { val nUrl = normalizeUrl(url); dataParser.parseJSONStream(unwrapIfZipped(nUrl, it)) }.orEmpty() + }.filter { it.uuid.isNotBlank() }.distinctBy { it.uuid } // insert parsed data into the database localSource.insertEntries(importedEntries) localSource.insertRadios(importedRadios) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt index 2fbe2cac..5f7de0a1 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt @@ -94,6 +94,8 @@ class SettingsRepo( private val keyRadarCompassOffsetElev = "radarCompassOffsetElev" private val keySatelliteUrls = "satelliteUrls" private val keyTransceiversUrls = "transceiversUrls" + private val keySatelliteEnabled = "satelliteEnabled" + private val keyTransceiversEnabled = "transceiversEnabled" private val separatorComma = "," private val separatorUrl = "\n" @@ -373,21 +375,73 @@ class SettingsRepo( override val dataSourcesSettings: StateFlow = _dataSourcesSettings override fun updateDataSourcesSettings(settings: DataSourcesSettings) { + // Normalize the enabled lists so they are positionally aligned with the URL lists. + // Missing entries default to enabled (true), keeping the persisted "one flag per URL" + // invariant intact even when a default empty list is used to construct the model. + val normalized = settings.copy( + satelliteEnabled = alignFlags(settings.satelliteUrls, settings.satelliteEnabled), + transceiversEnabled = alignFlags(settings.transceiversUrls, settings.transceiversEnabled) + ) preferences.edit { - putString(keySatelliteUrls, settings.satelliteUrls.joinToString(separatorUrl)) - putString(keyTransceiversUrls, settings.transceiversUrls.joinToString(separatorUrl)) + putString(keySatelliteUrls, normalized.satelliteUrls.joinToString(separatorUrl)) + putString(keyTransceiversUrls, normalized.transceiversUrls.joinToString(separatorUrl)) + putString(keySatelliteEnabled, normalized.satelliteEnabled.joinToString(separatorComma)) + putString(keyTransceiversEnabled, normalized.transceiversEnabled.joinToString(separatorComma)) } - _dataSourcesSettings.value = settings + _dataSourcesSettings.value = normalized } - private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings( - satelliteUrls = preferences.getString(keySatelliteUrls, null) - ?.split(separatorUrl)?.filter { it.isNotBlank() } - ?: Sources.satelliteDataUrls, - transceiversUrls = preferences.getString(keyTransceiversUrls, null) - ?.split(separatorUrl)?.filter { it.isNotBlank() } - ?: Sources.transceiversDataUrls - ) + private fun getDataSourcesSettings(): DataSourcesSettings { + val (satUrls, satEnabled) = parseSources( + preferences.getString(keySatelliteUrls, null), + preferences.getString(keySatelliteEnabled, null), + Sources.satelliteDataUrls + ) + val (txUrls, txEnabled) = parseSources( + preferences.getString(keyTransceiversUrls, null), + preferences.getString(keyTransceiversEnabled, null), + Sources.transceiversDataUrls + ) + return DataSourcesSettings( + satelliteUrls = satUrls, + transceiversUrls = txUrls, + satelliteEnabled = satEnabled, + transceiversEnabled = txEnabled + ) + } + + private fun parseSources( + storedUrls: String?, + storedEnabled: String?, + defaults: List + ): Pair, List> { + if (storedUrls == null) return defaults to defaults.map { true } + val urls = storedUrls.split(separatorUrl) + val flags = if (storedEnabled.isNullOrEmpty()) emptyList() else storedEnabled.split(separatorComma) + val filteredUrls = mutableListOf() + val filteredFlags = mutableListOf() + urls.forEachIndexed { index, url -> + if (url.isNotBlank()) { + filteredUrls.add(url) + filteredFlags.add(flags.getOrNull(index)?.toBoolean() ?: true) + } + } + return filteredUrls to filteredFlags + } + + private fun alignFlags(urls: List, flags: List): List { + if (flags.size >= urls.size) return flags.take(urls.size) + return flags + List(urls.size - flags.size) { true } + } + //endregion + + //region # Data sources status + private val _dataSourcesStatus = MutableStateFlow>(emptyMap()) + override val dataSourcesStatus: StateFlow> = _dataSourcesStatus + + override fun updateDataSourcesStatus(status: Map) { + _dataSourcesStatus.value = status + } //endregion //region # Radio control settings diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt index 37864bb2..5af307fa 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt @@ -20,6 +20,7 @@ package com.rtbishop.look4sat.core.data.source import android.content.ContentResolver import androidx.core.net.toUri import com.rtbishop.look4sat.core.domain.source.IRemoteSource +import com.rtbishop.look4sat.core.domain.source.NetworkResult import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import okhttp3.OkHttpClient @@ -42,20 +43,26 @@ class RemoteSource( } } - override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) { + override suspend fun getNetworkStream(url: String): NetworkResult = withContext(dispatcher) { try { val networkRequest = Request.Builder().url(url).build() val response = httpClient.newCall(networkRequest).execute() if (!response.isSuccessful) { + val code = response.code response.close() - return@withContext null + return@withContext NetworkResult(code, null) } // Return the body stream directly as the caller is responsible for closing it // That returns the connection to OkHttp's pool - response.body.byteStream().buffered() + val body = response.body + if (body == null) { + response.close() + return@withContext NetworkResult(response.code, null) + } + NetworkResult(response.code, body.byteStream().buffered()) } catch (exception: Exception) { println("RemoteSource network stream exception: $exception") - null + NetworkResult(NetworkResult.CONNECTION_ERROR, null) } } diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt index 5a8a1a9c..d4cb7be9 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt @@ -31,6 +31,7 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalObject import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.source.ILocalSource import com.rtbishop.look4sat.core.domain.source.IRemoteSource +import com.rtbishop.look4sat.core.domain.source.NetworkResult import com.rtbishop.look4sat.core.domain.utility.DataParser import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -119,7 +120,10 @@ private class FakeRemoteSource : IRemoteSource { override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke() - override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke() + override suspend fun getNetworkStream(url: String): NetworkResult { + val stream = networkStreams[url]?.invoke() + return if (stream != null) NetworkResult(200, stream) else NetworkResult(404, null) + } override suspend fun getAmSatCatalog(): String? = null @@ -185,6 +189,8 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou override val dataSourcesSettings: MutableStateFlow = MutableStateFlow(dataSources) + override val dataSourcesStatus: MutableStateFlow> = MutableStateFlow(emptyMap()) + override val radioControlSettings: StateFlow = MutableStateFlow( RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600) ) @@ -214,6 +220,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou dataSourcesSettings.value = settings } + override fun updateDataSourcesStatus(status: Map) { + dataSourcesStatus.value = status + } + override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit override fun getSatelliteOffset(catnum: Int): String = "" diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoTest.kt index aa0292da..97ccbf6d 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoTest.kt @@ -138,6 +138,8 @@ class SelectionRepoTest { DataSourcesSettings(emptyList(), emptyList()) ) + override val dataSourcesStatus: StateFlow> = MutableStateFlow(emptyMap()) + override val radioControlSettings: StateFlow = MutableStateFlow( RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600) ) @@ -166,6 +168,8 @@ class SelectionRepoTest { override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit + override fun updateDataSourcesStatus(status: Map) = Unit + override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit override fun getSatelliteOffset(catnum: Int): String = "" diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt index 97e5018a..c3a0401c 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt @@ -67,10 +67,21 @@ data class OtherSettings( val radarCompassOffsetElev: Float = 0f ) +/** + * Data source URLs (TLE / transceivers) and their per-source enabled flags. + * [satelliteEnabled] and [transceiversEnabled] must be positionally aligned with the + * corresponding URL list: the flag at index [i] applies to the URL at index [i]. + * Missing flags are treated as enabled (see [isSatelliteEnabled]/[isTransceiverEnabled]). + */ data class DataSourcesSettings( val satelliteUrls: List, - val transceiversUrls: List -) + val transceiversUrls: List, + val satelliteEnabled: List = emptyList(), + val transceiversEnabled: List = emptyList() +) { + fun isSatelliteEnabled(index: Int): Boolean = satelliteEnabled.getOrElse(index) { true } + fun isTransceiverEnabled(index: Int): Boolean = transceiversEnabled.getOrElse(index) { true } +} data class RadioControlSettings( val enabled: Boolean, diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt index b1b11733..89fdc131 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt @@ -71,6 +71,11 @@ interface ISettingsRepo { fun updateDataSourcesSettings(settings: DataSourcesSettings) //endregion + //region # Data sources status + val dataSourcesStatus: StateFlow> + fun updateDataSourcesStatus(status: Map) + //endregion + //region # Radio control settings val radioControlSettings: StateFlow fun updateRadioControlSettings(settings: RadioControlSettings) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt index 6e1ccfff..f6d47488 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt @@ -19,9 +19,17 @@ package com.rtbishop.look4sat.core.domain.source import java.io.InputStream +/** Result of a network download: the HTTP status code and the response body stream. */ +data class NetworkResult(val code: Int, val stream: InputStream?) { + companion object { + /** Code used when a request fails before any HTTP response is received. */ + const val CONNECTION_ERROR = -1 + } +} + interface IRemoteSource { suspend fun getFileStream(uri: String): InputStream? - suspend fun getNetworkStream(url: String): InputStream? + suspend fun getNetworkStream(url: String): NetworkResult suspend fun getAmSatCatalog(): String? suspend fun getAmSatReports(hours: Int, limit: Int): String? } diff --git a/core/presentation/src/main/res/drawable/ic_drag_handle.xml b/core/presentation/src/main/res/drawable/ic_drag_handle.xml new file mode 100644 index 00000000..94fa450b --- /dev/null +++ b/core/presentation/src/main/res/drawable/ic_drag_handle.xml @@ -0,0 +1,10 @@ + + + + diff --git a/core/presentation/src/main/res/values-es/strings.xml b/core/presentation/src/main/res/values-es/strings.xml index 095ed335..437ec822 100644 --- a/core/presentation/src/main/res/values-es/strings.xml +++ b/core/presentation/src/main/res/values-es/strings.xml @@ -118,6 +118,9 @@ No se importaron transceptores. Usa un SatNOGS (.json) válido. Fuentes personalizadas + Restaurar fuentes predeterminadas + Datos de satélites + Datos de transceptores URL TLE URL transceptores URL (HTTPS) diff --git a/core/presentation/src/main/res/values-ru/strings.xml b/core/presentation/src/main/res/values-ru/strings.xml index 4c73ec04..b6eccb64 100644 --- a/core/presentation/src/main/res/values-ru/strings.xml +++ b/core/presentation/src/main/res/values-ru/strings.xml @@ -117,6 +117,9 @@ Трансиверы не импортированы. Нужен SatNOGS (.json). Свои источники + Восстановить источники по умолчанию + Данные спутников + Данные трансиверов URL TLE URL трансиверов URL (HTTPS) diff --git a/core/presentation/src/main/res/values-si/strings.xml b/core/presentation/src/main/res/values-si/strings.xml index 32af6348..36c1e9d4 100644 --- a/core/presentation/src/main/res/values-si/strings.xml +++ b/core/presentation/src/main/res/values-si/strings.xml @@ -118,6 +118,9 @@ සම්ප්‍රේෂක ආයාත නොවීය. වලංගු SatNOGS (.json) ගොනුවක් තෝරන්න. අභිරුචි මූලාශ්‍ර + පෙරනිමි මූලාශ්‍ර ප්‍රතිස්ථාපනය කරන්න + චන්ද්‍රිකා දත්ත + සම්ප්‍රේෂක දත්ත TLE URL Transceivers URL URL (HTTPS) diff --git a/core/presentation/src/main/res/values-tr/strings.xml b/core/presentation/src/main/res/values-tr/strings.xml index 1f96a4aa..1a259248 100644 --- a/core/presentation/src/main/res/values-tr/strings.xml +++ b/core/presentation/src/main/res/values-tr/strings.xml @@ -149,6 +149,9 @@ Transceiver içe aktarılmadı. Geçerli bir SatNOGS (.json) dosyası seçin. Özel kaynaklar + Varsayılan kaynakları geri yükle + Uydu verileri + Transceiver verileri TLE URL Transceiver URL URL (HTTPS) diff --git a/core/presentation/src/main/res/values-uk/strings.xml b/core/presentation/src/main/res/values-uk/strings.xml index 8c1b66bf..8f04b5c1 100644 --- a/core/presentation/src/main/res/values-uk/strings.xml +++ b/core/presentation/src/main/res/values-uk/strings.xml @@ -118,6 +118,9 @@ Трансивери не імпортовано. Потрібен SatNOGS (.json). Власні джерела + Відновити джерела за замовчуванням + Дані супутників + Дані трансиверів URL TLE URL трансиверів URL (HTTPS) diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index 024915d0..8d1f608e 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -113,6 +113,9 @@ 未导入收发器。请选择有效的 SatNOGS (.json) 文件。 自定义源 + 恢复默认源 + 卫星数据 + 收发器数据 TLE URL 收发器 URL URL (HTTPS) diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index a9b6cf80..471f187b 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -170,6 +170,9 @@ No transceivers imported. Select a valid SatNOGS (.json) file. Data sources + Restore default sources + Satellites data + Transceivers data Data output Network diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt index 41ff4791..1d5de355 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt @@ -17,9 +17,17 @@ */ package com.rtbishop.look4sat.feature.settings +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row @@ -29,9 +37,13 @@ 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.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Checkbox import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -41,25 +53,42 @@ 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.MutableFloatState +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope 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.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput 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 import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import com.rtbishop.look4sat.core.domain.model.RCSettings import com.rtbishop.look4sat.core.domain.model.RadioControlSettings import com.rtbishop.look4sat.core.domain.model.Constants +import com.rtbishop.look4sat.core.domain.source.NetworkResult +import com.rtbishop.look4sat.core.domain.source.Sources import com.rtbishop.look4sat.core.presentation.CardButton import com.rtbishop.look4sat.core.presentation.IconCard import com.rtbishop.look4sat.core.presentation.LocalSpacing @@ -152,10 +181,16 @@ private fun TransceiversDialogPreview() { transceiversUrls = listOf( "db.satnogs.org/api/transmitters/?format=json&status=active" ), + satelliteEnabled = listOf(true, false), + transceiversEnabled = listOf(true), + statusCodes = mapOf( + "celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv" to 200, + "amsat.org/tle/current/nasabare.txt" to 404 + ), onImportTle = {}, onImportTransceivers = {}, onDismiss = {}, - onSave = { _, _ -> } + onSave = { _, _, _, _ -> } ) } } @@ -164,10 +199,13 @@ private fun TransceiversDialogPreview() { fun DataSourcesDialog( satelliteUrls: List, transceiversUrls: List, + satelliteEnabled: List, + transceiversEnabled: List, + statusCodes: Map, onImportTle: () -> Unit, onImportTransceivers: () -> Unit, onDismiss: () -> Unit, - onSave: (List, List) -> Unit + onSave: (List, List, List, List) -> Unit ) { val padding = LocalSpacing.current.large // Use stable Long IDs to avoid key collisions (e.g. multiple empty "" entries). @@ -178,13 +216,56 @@ fun DataSourcesDialog( 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() } + val satEnabled = remember { + mutableStateMapOf().apply { + satUrls.forEachIndexed { i, (id, _) -> this[id] = satelliteEnabled.getOrElse(i) { true } } + } + } + val txEnabled = remember { + mutableStateMapOf().apply { + txUrls.forEachIndexed { i, (id, _) -> this[id] = transceiversEnabled.getOrElse(i) { true } } + } + } + val listState = rememberLazyListState() + val satDraggedId = remember { mutableStateOf(-1L) } + val txDraggedId = remember { mutableStateOf(-1L) } + val onRestoreDefaults = { + nextId.longValue = (Sources.satelliteDataUrls.size + Sources.transceiversDataUrls.size).toLong() + satUrls.clear() + satUrls.addAll(Sources.satelliteDataUrls.mapIndexed { i, url -> i.toLong() to url }) + txUrls.clear() + txUrls.addAll( + Sources.transceiversDataUrls.mapIndexed { i, url -> + (Sources.satelliteDataUrls.size + i).toLong() to url + } + ) + satEnabled.clear() + txEnabled.clear() + Unit + } + val onAccept = { + val satFiltered = satUrls.filter { it.second.isNotBlank() } + val txFiltered = txUrls.filter { it.second.isNotBlank() } + try { + onSave( + satFiltered.map { it.second }, + txFiltered.map { it.second }, + satFiltered.map { satEnabled[it.first] ?: true }, + txFiltered.map { txEnabled[it.first] ?: true } + ) + } finally { + onDismiss() + } + } + val satTitle = stringResource(R.string.prefs_data_sources_sat_title) + val transceiversTitle = stringResource(R.string.prefs_data_sources_transceivers_title) ConfirmDialog( title = stringResource(id = R.string.prefs_data_sources_title), onCancel = onDismiss, onAccept = onAccept, ) { LazyColumn( + state = listState, modifier = Modifier .fillMaxHeight(0.84f) .padding(horizontal = padding), @@ -208,21 +289,38 @@ fun DataSourcesDialog( ) } } + item { + CardButton( + onClick = onRestoreDefaults, + text = stringResource(R.string.prefs_data_sources_restore), + modifier = Modifier.fillMaxWidth() + ) + } sourceSection( sectionKey = "sat", - label = "Satellites data", + label = satTitle, urls = satUrls, + listState = listState, + draggedId = satDraggedId, + statusCodes = statusCodes, + enabledMap = satEnabled, + onToggle = { id -> satEnabled[id] = !(satEnabled[id] ?: true) }, onAdd = { satUrls.add(nextId.longValue++ to "") }, - onMoveUp = { i -> if (i > 0) satUrls.add(i - 1, satUrls.removeAt(i)) }, + onMove = { from, to -> satUrls.add(to, satUrls.removeAt(from)) }, onRemove = { i -> satUrls.removeAt(i) }, onUrlChange = { i, v -> satUrls[i] = satUrls[i].first to v } ) sourceSection( sectionKey = "tx", - label = "Transceivers data", + label = transceiversTitle, urls = txUrls, + listState = listState, + draggedId = txDraggedId, + statusCodes = statusCodes, + enabledMap = txEnabled, + onToggle = { id -> txEnabled[id] = !(txEnabled[id] ?: true) }, onAdd = { txUrls.add(nextId.longValue++ to "") }, - onMoveUp = { i -> if (i > 0) txUrls.add(i - 1, txUrls.removeAt(i)) }, + onMove = { from, to -> txUrls.add(to, txUrls.removeAt(from)) }, onRemove = { i -> txUrls.removeAt(i) }, onUrlChange = { i, v -> txUrls[i] = txUrls[i].first to v } ) @@ -234,8 +332,13 @@ private fun LazyListScope.sourceSection( sectionKey: String, label: String, urls: List>, + listState: LazyListState, + draggedId: MutableState, + statusCodes: Map, + enabledMap: Map, + onToggle: (Long) -> Unit, onAdd: () -> Unit, - onMoveUp: (Int) -> Unit, + onMove: (Int, Int) -> Unit, onRemove: (Int) -> Unit, onUrlChange: (Int, String) -> Unit ) { @@ -251,38 +354,267 @@ private fun LazyListScope.sourceSection( IconCard(action = onAdd, resId = R.drawable.ic_add, containerColor = MaterialTheme.colorScheme.surfaceVariant) } } - itemsIndexed(urls, key = { _, entry -> "$sectionKey-${entry.first}" }) { index, (_, url) -> + itemsIndexed(urls, key = { _, entry -> "$sectionKey-${entry.first}" }) { index, (id, 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, + val enabled = enabledMap[id] ?: true + val rowState = remember { DragRowState() } + val scope = rememberCoroutineScope() + val isDragging = draggedId.value == id + val isLifted = isDragging || rowState.isSettling.value + LaunchedEffect(isDragging) { + if (!isDragging) return@LaunchedEffect + autoScroll(listState, rowState.startCenterY, rowState.fingerOffset, rowState.scrollComp) + } + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .animateItem(fadeInSpec = spring(), fadeOutSpec = spring()) - ) + .draggedVisual( + isLifted = isLifted, + translationY = if (rowState.isSettling.value) { + rowState.settleAnim.value + } else { + rowState.offsetY.floatValue + rowState.scrollComp.floatValue + } + ) + .animateItem( + fadeInSpec = spring(), + // The dragged row repositions instantly, while its neighbours spring + // out of the way (the "squeeze" effect). + placementSpec = if (isDragging) { + tween(durationMillis = 0) + } else { + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMediumLow + ) + }, + fadeOutSpec = spring() + ) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(40.dp) + .dragHandle(listState, sectionKey, id, urls, draggedId, rowState, scope, onMove) + ) { + Icon( + painter = painterResource(R.drawable.ic_drag_handle), + contentDescription = null, + tint = enabledTint + ) + } + OutlinedTextField( + value = url, + onValueChange = { onUrlChange(index, it) }, + label = { Text("Source URL") }, + supportingText = statusCodes[url]?.let { code -> + { Text(statusLabel(code), color = statusColor(code), fontSize = 12.sp) } + }, + trailingIcon = { + IconButton(onClick = { onRemove(index) }) { + Icon( + painter = painterResource(R.drawable.ic_delete), + contentDescription = null + ) + } + }, + singleLine = true, + enabled = enabled, + modifier = Modifier.weight(1f) + ) + Checkbox( + checked = enabled, + onCheckedChange = { onToggle(id) } + ) + } } } +/** + * Per-row drag state, kept in one object to keep the drag-handle modifier signature small. + * + * [offsetY] is the compensated visual displacement during a drag (finger travel minus the + * heights of already-swapped neighbours), so the row stays glued to the finger. [fingerOffset] + * tracks the raw finger travel for edge auto-scroll and swap detection. [settleAnim] smoothly + * flies the lifted row back into its slot once the finger is released. + */ +private class DragRowState { + val offsetY = mutableFloatStateOf(0f) + val fingerOffset = mutableFloatStateOf(0f) + val scrollComp = mutableFloatStateOf(0f) + val startCenterY = mutableFloatStateOf(0f) + val settleAnim = Animatable(0f) + val isSettling = mutableStateOf(false) +} + +/** Spring shared by neighbour "squeeze" and the settle-back animation: soft and slightly bouncy. */ +private val reorderSpring = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMediumLow +) + +/** + * Drag handle gesture that performs live reordering while dragging. + * + * [fingerOffset] tracks the raw finger travel, used for edge auto-scroll and for + * deciding when the dragged row's centre crosses a neighbour's midpoint. [offsetY] + * additionally subtracts the heights of already-swapped neighbours so the visual + * translation (see [draggedVisual]) keeps the row glued to the finger even as the + * layout slot moves. Rendering of the whole field is applied on the field itself so + * the entire row follows the finger, not just the handle icon. + */ +@Composable +private fun Modifier.dragHandle( + listState: LazyListState, + sectionKey: String, + entryId: Long, + urls: List>, + draggedId: MutableState, + rowState: DragRowState, + scope: CoroutineScope, + onMove: (from: Int, to: Int) -> Unit +): Modifier = pointerInput(entryId, sectionKey) { + fun reorderLive() { + val myIndex = urls.indexOfFirst { it.first == entryId } + if (myIndex !in urls.indices) return + val myCenter = rowState.startCenterY.floatValue + rowState.fingerOffset.floatValue + val visible = listState.layoutInfo.visibleItemsInfo + // Dragging down: swap when the dragged centre passes the next row's midpoint. + if (myIndex < urls.lastIndex) { + val next = visible.firstOrNull { it.key == "$sectionKey-${urls[myIndex + 1].first}" } + if (next != null && myCenter > next.offset + next.size / 2f) { + onMove(myIndex, myIndex + 1) + rowState.offsetY.floatValue -= next.size.toFloat() + return + } + } + // Dragging up: swap when the dragged centre passes the previous row's midpoint. + if (myIndex > 0) { + val prev = visible.firstOrNull { it.key == "$sectionKey-${urls[myIndex - 1].first}" } + if (prev != null && myCenter < prev.offset + prev.size / 2f) { + onMove(myIndex, myIndex - 1) + rowState.offsetY.floatValue += prev.size.toFloat() + } + } + } + + // Reset the drag bookkeeping and fly the lifted row back into its slot. + // All state resets happen inside the launched block so the settle animation takes + // over from the current visual position without a one-frame jump: isSettling is + // flipped to true (switching rendering to settleAnim, already snapped to the last + // offset) before the drag flags are cleared. + fun finishDrag() { + val lastOffset = rowState.offsetY.floatValue + rowState.scrollComp.floatValue + if (kotlin.math.abs(lastOffset) < 1f) { + rowState.fingerOffset.floatValue = 0f + rowState.offsetY.floatValue = 0f + rowState.scrollComp.floatValue = 0f + draggedId.value = -1L + return + } + scope.launch { + rowState.settleAnim.snapTo(lastOffset) + rowState.isSettling.value = true + rowState.fingerOffset.floatValue = 0f + rowState.offsetY.floatValue = 0f + rowState.scrollComp.floatValue = 0f + draggedId.value = -1L + rowState.settleAnim.animateTo(0f, reorderSpring) + rowState.isSettling.value = false + } + } + + detectDragGesturesAfterLongPress( + onDragStart = { + rowState.isSettling.value = false + val layout = listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == "$sectionKey-$entryId" } + rowState.startCenterY.floatValue = (layout?.offset ?: 0) + (layout?.size ?: 0) / 2f + rowState.fingerOffset.floatValue = 0f + rowState.offsetY.floatValue = 0f + rowState.scrollComp.floatValue = 0f + draggedId.value = entryId + }, + onDragEnd = ::finishDrag, + onDragCancel = ::finishDrag + ) { change, dragAmount -> + change.consume() + if (draggedId.value != entryId) return@detectDragGesturesAfterLongPress + rowState.fingerOffset.floatValue += dragAmount.y + rowState.offsetY.floatValue += dragAmount.y + reorderLive() + } +} + +@Composable +private fun Modifier.draggedVisual(isLifted: Boolean, translationY: Float): Modifier { + val shape = MaterialTheme.shapes.small + // Smoothly scale the row up/down as the lifted card appears and disappears. + val scale by animateFloatAsState( + targetValue = if (isLifted) 1.02f else 1f, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "dragScale" + ) + return this + .graphicsLayer { + if (isLifted) { + this.translationY = translationY + scaleX = scale + scaleY = scale + } + } + .then( + if (isLifted) { + // Solid card on top so the lifted row fully covers the row beneath it + // instead of showing a translucent overlap of both rows. + Modifier + .zIndex(1f) + .shadow(8.dp, shape, clip = false) + .background(MaterialTheme.colorScheme.surface, shape) + } else { + Modifier + } + ) +} + +/** + * Scrolls the list while dragging so the entry follows the finger past the viewport edges. + * The visual centre is tracked independently of the entry's layout slot (which can scroll out + * of [LazyListState.layoutInfo.visibleItemsInfo] during a long drag); [startCenterY] is the + * entry's viewport centre captured at drag start and [fingerOffset] is the raw finger delta. + */ +private suspend fun autoScroll( + listState: LazyListState, + startCenterY: MutableFloatState, + fingerOffset: MutableFloatState, + scrollComp: MutableFloatState +) { + val threshold = 48f + val maxSpeed = 24f + while (true) { + val info = listState.layoutInfo + val center = startCenterY.floatValue + fingerOffset.floatValue + val top = info.viewportStartOffset + threshold + val bottom = info.viewportEndOffset - threshold + val delta = when { + center < top -> -(top - center).coerceAtMost(maxSpeed) + center > bottom -> (center - bottom).coerceAtMost(maxSpeed) + else -> 0f + } + if (delta != 0f) scrollComp.floatValue += listState.scrollBy(delta) + delay(16L) + } +} + +private fun statusLabel(code: Int): String = if (code == NetworkResult.CONNECTION_ERROR) "ERR" else code.toString() + +@Composable +private fun statusColor(code: Int): Color = when { + code == NetworkResult.CONNECTION_ERROR -> MaterialTheme.colorScheme.error + code in 200..299 -> Color(0xFF66BB6A) + else -> MaterialTheme.colorScheme.error +} + @Preview(showBackground = true) @Composable fun PreviewNetworkOutputDialog() { diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt index 356392e8..e0fccc7b 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt @@ -120,11 +120,19 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> DataSourcesDialog( satelliteUrls = uiState.dataSourcesSettings.satelliteUrls, transceiversUrls = uiState.dataSourcesSettings.transceiversUrls, + satelliteEnabled = uiState.dataSourcesSettings.satelliteEnabled, + transceiversEnabled = uiState.dataSourcesSettings.transceiversEnabled, + statusCodes = uiState.dataSourcesStatus, onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false }, onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false }, onDismiss = { dialogs.dataSources = false }, - onSave = { satUrls, txUrls -> - val newSettings = DataSourcesSettings(satelliteUrls = satUrls, transceiversUrls = txUrls) + onSave = { satUrls, txUrls, satEnabled, txEnabled -> + val newSettings = DataSourcesSettings( + satelliteUrls = satUrls, + transceiversUrls = txUrls, + satelliteEnabled = satEnabled, + transceiversEnabled = txEnabled + ) if (newSettings != uiState.dataSourcesSettings) onAction(SettingsAction.UpdateDataSources(newSettings)) onAction(SettingsAction.UpdateFromWeb) } diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt index c0e0917b..b56c4fa3 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt @@ -42,6 +42,7 @@ data class SettingsState( val rcSettings: RCSettings, val radioControlSettings: RadioControlSettings, val dataSourcesSettings: DataSourcesSettings, + val dataSourcesStatus: Map = emptyMap(), val pairedBluetoothDevices: List> = emptyList() ) diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt index adcac2ad..336f7bc6 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt @@ -49,6 +49,7 @@ class SettingsViewModel( rcSettings = settingsRepo.rcSettings.value, radioControlSettings = settingsRepo.radioControlSettings.value, dataSourcesSettings = settingsRepo.dataSourcesSettings.value, + dataSourcesStatus = settingsRepo.dataSourcesStatus.value, pairedBluetoothDevices = container.providePairedBluetoothDevices() ) ) @@ -92,6 +93,11 @@ class SettingsViewModel( _uiState.update { it.copy(dataSourcesSettings = settings) } } } + viewModelScope.launch { + settingsRepo.dataSourcesStatus.collect { status -> + _uiState.update { it.copy(dataSourcesStatus = status) } + } + } viewModelScope.launch { settingsRepo.radioControlSettings.collect { settings -> _uiState.update { it.copy(radioControlSettings = settings) }