From 6f3e3351c99d7b37c4997c94e5573642d383aee6 Mon Sep 17 00:00:00 2001 From: Arty Bishop Date: Sat, 12 Sep 2026 18:46:09 +0100 Subject: [PATCH] Tweaked data source management, migrated DB and Settings --- .../core/data/database/Look4SatDao.kt | 16 + .../look4sat/core/data/database/Look4SatDb.kt | 22 +- .../core/data/database/entity/SatEntry.kt | 3 +- .../core/data/database/entity/SatRadio.kt | 4 +- .../core/data/injection/MainContainer.kt | 23 +- .../core/data/repository/DatabaseRepo.kt | 86 +++- .../core/data/repository/SettingsRepo.kt | 37 +- .../look4sat/core/data/source/LocalSource.kt | 26 +- .../core/data/repository/DatabaseRepoTest.kt | 196 +++++++- .../core/data/repository/SelectionRepoTest.kt | 15 +- .../look4sat/core/domain/model/SatRadio.kt | 22 +- .../core/domain/predict/OrbitalData.kt | 46 +- .../core/domain/repository/ISettingsRepo.kt | 1 + .../core/domain/source/ILocalSource.kt | 9 +- .../look4sat/core/domain/source/Sources.kt | 16 +- .../core/domain/utility/DataParser.kt | 91 ++-- .../look4sat/core/domain/DataParserTest.kt | 46 +- .../look4sat/core/presentation/Components.kt | 6 +- .../core/presentation/DragDropSwipe.kt | 272 +++++++++++ .../look4sat/core/presentation/DragReorder.kt | 311 +++++++++++++ .../look4sat/core/presentation/MainTheme.kt | 2 +- .../src/main/res/drawable/ic_drag_handle.xml | 7 +- .../src/main/res/drawable/ic_pound.xml | 9 + .../src/main/res/drawable/ic_restore.xml | 9 + .../src/main/res/values-es/strings.xml | 2 + .../src/main/res/values-ru/strings.xml | 2 + .../src/main/res/values-si/strings.xml | 2 + .../src/main/res/values-tr/strings.xml | 2 + .../src/main/res/values-uk/strings.xml | 2 + .../src/main/res/values-zh/strings.xml | 2 + .../src/main/res/values/strings.xml | 2 + .../feature/settings/SettingsDialog.kt | 430 +++++------------- .../feature/settings/SettingsScreen.kt | 4 +- gradle/libs.versions.toml | 4 +- 34 files changed, 1290 insertions(+), 437 deletions(-) create mode 100644 core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/DragDropSwipe.kt create mode 100644 core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/DragReorder.kt create mode 100644 core/presentation/src/main/res/drawable/ic_pound.xml create mode 100644 core/presentation/src/main/res/drawable/ic_restore.xml diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/Look4SatDao.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/Look4SatDao.kt index 957c8fae..eb50cc38 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/Look4SatDao.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/Look4SatDao.kt @@ -19,6 +19,7 @@ package com.rtbishop.look4sat.core.data.database import androidx.room.Dao import androidx.room.Insert +import androidx.room.MapColumn import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction @@ -39,6 +40,18 @@ interface Look4SatDao { @Query("SELECT * FROM entries WHERE catnum IN (:selectedIds)") suspend fun getEntriesWithIds(selectedIds: List): List + @Query("SELECT catnum, epoch FROM entries") + suspend fun getEntriesEpochs(): Map<@MapColumn("catnum") Int, @MapColumn("epoch") Double> + + @Query("SELECT catnum, name FROM entries") + suspend fun getEntriesNames(): Map<@MapColumn("catnum") Int, @MapColumn("name") String> + + @Query("UPDATE entries SET name = :name WHERE catnum = :catnum") + suspend fun renameEntry(catnum: Int, name: String) + + @Query("DELETE FROM entries WHERE catnum IN (:ids)") + suspend fun deleteEntriesWithIds(ids: List) + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertEntries(entries: List) @@ -65,4 +78,7 @@ interface Look4SatDao { @Query("DELETE FROM radios") suspend fun deleteRadios() + + @Query("DELETE FROM radios WHERE isCustom = 0") + suspend fun deleteManagedRadios() } diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/Look4SatDb.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/Look4SatDb.kt index 43b22955..b15b3a7d 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/Look4SatDb.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/Look4SatDb.kt @@ -19,19 +19,23 @@ package com.rtbishop.look4sat.core.data.database import androidx.room.Database import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase import com.rtbishop.look4sat.core.data.database.entity.SatEntry import com.rtbishop.look4sat.core.data.database.entity.SatRadio -@Database(entities = [SatEntry::class, SatRadio::class], version = 1, exportSchema = false) +const val DATABASE_NAME = "Look4SatDBv400" + +@Database(entities = [SatEntry::class, SatRadio::class], version = 2, exportSchema = false) abstract class Look4SatDb : RoomDatabase() { abstract fun look4SatDao(): Look4SatDao } -//val MIGRATION_1_2 = object : Migration(1, 2) { -// override fun migrate(database: SupportSQLiteDatabase) { -// database.execSQL("CREATE TABLE entries_backup (name TEXT NOT NULL, epoch REAL NOT NULL, meanmo REAL NOT NULL, eccn REAL NOT NULL, incl REAL NOT NULL, raan REAL NOT NULL, argper REAL NOT NULL, meanan REAL NOT NULL, catnum INTEGER NOT NULL, bstar REAL NOT NULL, xincl REAL NOT NULL, xnodeo REAL NOT NULL, omegao REAL NOT NULL, xmo REAL NOT NULL, xno REAL NOT NULL, orbitalPeriod REAL NOT NULL, isDeepSpace INTEGER NOT NULL, comment TEXT, PRIMARY KEY(catnum))") -// database.execSQL("INSERT INTO entries_backup (name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar, xincl, xnodeo, omegao, xmo, xno, orbitalPeriod, isDeepSpace, comment) SELECT name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar, xincl, xnodeo, omegao, xmo, xno, 1440 / meanmo, 1440 / meanmo >= 225.0, comment FROM entries") -// database.execSQL("DROP TABLE entries") -// database.execSQL("ALTER TABLE entries_backup RENAME TO entries") -// } -//} +/** Adds the mean motion derivative, needed to tell decayed satellites apart, and marks the + * transceivers that were imported from a file, so that remote updates leave them alone. */ +val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE entries ADD COLUMN ndot REAL NOT NULL DEFAULT 0.0") + db.execSQL("ALTER TABLE radios ADD COLUMN isCustom INTEGER NOT NULL DEFAULT 0") + } +} diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/entity/SatEntry.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/entity/SatEntry.kt index 117356b5..211d1c3a 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/entity/SatEntry.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/entity/SatEntry.kt @@ -30,5 +30,6 @@ data class SatEntry( val argper: Double, val meanan: Double, val catnum: Int, - val bstar: Double + val bstar: Double, + val ndot: Double = 0.0 ) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/entity/SatRadio.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/entity/SatRadio.kt index ba9eccdf..6e3a3476 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/entity/SatRadio.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/entity/SatRadio.kt @@ -32,5 +32,7 @@ data class SatRadio( val uplinkHigh: Long?, val uplinkMode: String?, val isInverted: Boolean, - val catnum: Int? + val catnum: Int?, + /** Set for manually imported transceivers, which remote updates must not replace. */ + val isCustom: Boolean = false ) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt index 05778db5..bb02b182 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt @@ -22,7 +22,10 @@ import android.content.Context import android.hardware.SensorManager import android.hardware.display.DisplayManager import android.location.LocationManager +import androidx.core.content.pm.PackageInfoCompat import androidx.room.Room +import com.rtbishop.look4sat.core.data.database.DATABASE_NAME +import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2 import com.rtbishop.look4sat.core.data.database.Look4SatDb import com.rtbishop.look4sat.core.data.framework.BluetoothReporter import com.rtbishop.look4sat.core.data.framework.Ft817Controller @@ -149,18 +152,24 @@ class MainContainer(private val context: Context) : IMainContainer { private fun provideDatabaseRepo(): IDatabaseRepo { val dbDispatcher = Dispatchers.Default val dataParser = DataParser(dbDispatcher) - val remoteSource = provideRemoteSource() return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo) } private fun provideLocalSource(): ILocalSource { - val builder = Room.databaseBuilder(context, Look4SatDb::class.java, "Look4SatDBv400") - val database = builder.fallbackToDestructiveMigration(false).build() + val builder = Room.databaseBuilder(context, Look4SatDb::class.java, DATABASE_NAME) + val database = builder.addMigrations(MIGRATION_1_2).fallbackToDestructiveMigration(false).build() return LocalSource(database.look4SatDao()) } private fun provideRemoteSource(): IRemoteSource { - return RemoteSource(Dispatchers.IO, context.contentResolver, OkHttpClient.Builder().build()) + val version = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4" + val userAgent = "Look4Sat/$version (+https://github.com/rt-bishop/Look4Sat)" + // Data providers ask clients to identify themselves, so that they can reach out to the + // developer instead of silently blocking every user of the app behind a misbehaving request + val client = OkHttpClient.Builder().addInterceptor { chain -> + chain.proceed(chain.request().newBuilder().header("User-Agent", userAgent).build()) + }.build() + return RemoteSource(Dispatchers.IO, context.contentResolver, client) } private fun provideSatelliteRepo(): ISatelliteRepo { @@ -175,7 +184,9 @@ class MainContainer(private val context: Context) : IMainContainer { val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager val appPrefsFileName = "${context.packageName}_preferences" val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE) - val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4" - return SettingsRepo(manager, appPreferences, appVersionName) + val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0) + val appVersionName = packageInfo.versionName ?: "4.0.4" + val appVersionCode = PackageInfoCompat.getLongVersionCode(packageInfo) + return SettingsRepo(manager, appPreferences, appVersionName, appVersionCode) } } 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 c4719d27..f0a3b9c5 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 @@ -43,8 +43,9 @@ class DatabaseRepo( override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) { var importedCount = 0 remoteSource.getFileStream(uri)?.let { stream -> - val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream)) - localSource.insertEntries(entries) + val entries = mergeEntries(listOf(parseSatelliteStream(uri, unwrapIfZipped(uri, stream)))) + insertFresherEntries(entries) + // report what the file contained: a valid file holding only stale data is not an error importedCount = entries.size } setUpdateSuccessful(System.currentTimeMillis()) @@ -55,7 +56,7 @@ class DatabaseRepo( var importedCount = 0 remoteSource.getFileStream(uri)?.let { stream -> val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream)) - localSource.insertRadios(transceivers) + localSource.insertRadios(transceivers, isCustom = true) importedCount = transceivers.size } setUpdateSuccessful(System.currentTimeMillis()) @@ -76,18 +77,27 @@ class DatabaseRepo( 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) -> + // parse fetched data concurrently, merging satellites by freshness instead of source order + val parsedPerSource = tleResults.map { (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) - setUpdateSuccessful(System.currentTimeMillis()) + insertFresherEntries(mergeEntries(parsedPerSource)) + // transceivers are a full snapshot: sources publish active entries only, so a retired + // transceiver simply disappears from the feed and has to be dropped locally as well. + // Imported ones are kept: no source can refresh them, so nothing would bring them back + if (importedRadios.isNotEmpty()) { + localSource.deleteManagedRadios() + localSource.insertRadios(importedRadios, isCustom = false) + } + // keep the previous timestamp when every source failed, so the next launch retries + val hasFetchedData = parsedPerSource.any { entries -> entries.isNotEmpty() } || importedRadios.isNotEmpty() + val previousTimestamp = settingsRepo.databaseState.value.updateTimestamp + if (hasFetchedData) pruneStaleEntries() + setUpdateSuccessful(if (hasFetchedData) System.currentTimeMillis() else previousTimestamp) } override suspend fun clearAllData() = withContext(dispatcher) { @@ -123,6 +133,62 @@ class DatabaseRepo( line.count { it == ',' } >= 4 } + /** + * Merges the data of every source: orbital elements always come from the set with the newest + * epoch, while the name comes from the first source that provides it. Source order is thus a + * naming preference only, which also keeps names stable when sources leapfrog each other. + */ + private fun mergeEntries(sourceEntries: List>): List { + val preferredNames = mutableMapOf() + val freshestEntries = mutableMapOf() + sourceEntries.forEach { entries -> + entries.forEach { entry -> + val name = entry.name.trim() + if (name.isNotBlank()) preferredNames.getOrPut(entry.catnum) { name } + val current = freshestEntries[entry.catnum] + if (current == null || entry.epochDaynum > current.epochDaynum) { + freshestEntries[entry.catnum] = entry + } + } + } + return freshestEntries.values.map { entry -> + val name = preferredNames[entry.catnum] + if (name == null || name == entry.name) entry else entry.copy(name = name) + } + } + + /** Stores the entries that are newer than the ones already saved, renaming the rest in place. */ + private suspend fun insertFresherEntries(entries: List) { + val storedEpochs = localSource.getEntriesEpochs() + val (fresherEntries, staleEntries) = entries.partition { entry -> + val storedEpoch = storedEpochs[entry.catnum] + storedEpoch == null || entry.epochDaynum > OrbitalData.epochToDaynum(storedEpoch) + } + localSource.insertEntries(fresherEntries) + // a reordered source list has to rename satellites right away, even the ones holding + // elements that are newer than the ones just parsed + if (staleEntries.isNotEmpty()) { + val storedNames = localSource.getEntriesNames() + val renamedEntries = staleEntries.filter { entry -> entry.name != storedNames[entry.catnum] } + if (renamedEntries.isNotEmpty()) { + localSource.renameEntries(renamedEntries.associate { it.catnum to it.name }) + } + } + } + + /** + * Drops satellites that no enabled source has refreshed for a month: they either decayed or + * disappeared from every catalog. Manually imported data ages out the same way, and every + * source republishes active satellites well within that window. + */ + private suspend fun pruneStaleEntries() { + val currentDaynum = OrbitalData.timeToDaynum(System.currentTimeMillis()) + val staleIds = localSource.getEntriesEpochs() + .filterValues { epoch -> currentDaynum - OrbitalData.epochToDaynum(epoch) > 30.0 } + .keys.toList() + if (staleIds.isNotEmpty()) localSource.deleteEntriesWithIds(staleIds) + } + private suspend fun setUpdateSuccessful(timestamp: Long) { settingsRepo.updateDatabaseState( DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp) 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 8903b960..2401ec21 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 @@ -45,7 +45,8 @@ import java.util.Locale class SettingsRepo( private val locationManager: LocationManager, private val preferences: SharedPreferences, - override val appVersionName: String + override val appVersionName: String, + override val appVersionCode: Long, ) : ISettingsRepo, LocationListenerCompat { private val keyBluetoothRotatorAddress = "bluetoothAddress" @@ -87,19 +88,33 @@ class SettingsRepo( private val keyStationTimestamp = "stationTimestamp" private val keyUpdateTimestamp = "updateTimestamp" private val keyShouldSeeWarning = "shouldSeeWarning" - private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName" + private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew" private val keySstvMode = "sstvMode" private val keyLowElevation = "lowElevation" private val keyHighElevation = "highElevation" private val keyRadarCompassOffset = "radarCompassOffset" private val keyRadarCompassOffsetElev = "radarCompassOffsetElev" - private val keySatelliteUrls = "satelliteUrls" + private val keySatellitesUrls = "satellitesUrls" private val keyTransceiversUrls = "transceiversUrls" - private val keySatelliteEnabled = "satelliteEnabled" - private val keyTransceiversEnabled = "transceiversEnabled" + private val keySatellitesUrlsEnabled = "satellitesUrlsEnabled" + private val keyTransceiversUrlsEnabled = "transceiversUrlsEnabled" + private val keySettingsVersion = "settingsVersion" private val separatorComma = "," private val separatorUrl = "\n" + init { + // The meaning of the data source order changed, so the stored lists are dropped and the defaults + // are applied again. The update timestamp is cleared as well, to refresh the migrated data + // on the first launch instead of waiting up to 48 hours for the automatic update. + if (preferences.getInt(keySettingsVersion, 0) < appVersionCode) { + preferences.edit { + listOf(keyShouldSeeWhatsNew, keySatellitesUrls, keyTransceiversUrls, keyUpdateTimestamp) + .forEach { key -> remove(key) } + putInt(keySettingsVersion, appVersionCode.toInt()) + } + } + } + //region # Satellites selection settings private val _satelliteSelection = MutableStateFlow(getSelectedIds()) private val _satelliteModeSelection = MutableStateFlow(getSelectedSatModes()) @@ -384,23 +399,23 @@ class SettingsRepo( transceiversEnabled = alignFlags(settings.transceiversUrls, settings.transceiversEnabled) ) preferences.edit { - putString(keySatelliteUrls, normalized.satelliteUrls.joinToString(separatorUrl)) + putString(keySatellitesUrls, normalized.satelliteUrls.joinToString(separatorUrl)) putString(keyTransceiversUrls, normalized.transceiversUrls.joinToString(separatorUrl)) - putString(keySatelliteEnabled, normalized.satelliteEnabled.joinToString(separatorComma)) - putString(keyTransceiversEnabled, normalized.transceiversEnabled.joinToString(separatorComma)) + putString(keySatellitesUrlsEnabled, normalized.satelliteEnabled.joinToString(separatorComma)) + putString(keyTransceiversUrlsEnabled, normalized.transceiversEnabled.joinToString(separatorComma)) } _dataSourcesSettings.value = normalized } private fun getDataSourcesSettings(): DataSourcesSettings { val (satUrls, satEnabled) = parseSources( - preferences.getString(keySatelliteUrls, null), - preferences.getString(keySatelliteEnabled, null), + preferences.getString(keySatellitesUrls, null), + preferences.getString(keySatellitesUrlsEnabled, null), Sources.satelliteDataUrls ) val (txUrls, txEnabled) = parseSources( preferences.getString(keyTransceiversUrls, null), - preferences.getString(keyTransceiversEnabled, null), + preferences.getString(keyTransceiversUrlsEnabled, null), Sources.transceiversDataUrls ) return DataSourcesSettings( diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/LocalSource.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/LocalSource.kt index b377e807..f4925503 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/LocalSource.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/LocalSource.kt @@ -43,6 +43,16 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource { return selectedOrbitalObjects } + override suspend fun getEntriesEpochs() = look4SatDao.getEntriesEpochs() + + override suspend fun getEntriesNames() = look4SatDao.getEntriesNames() + + override suspend fun renameEntries(names: Map) = + names.forEach { (catnum, name) -> look4SatDao.renameEntry(catnum, name) } + + override suspend fun deleteEntriesWithIds(ids: List) = + ids.chunked(999).forEach { idsPart -> look4SatDao.deleteEntriesWithIds(idsPart) } + override suspend fun insertEntries(entries: List) = look4SatDao.insertEntries(entries.toEntity()) override suspend fun deleteEntries() = look4SatDao.deleteEntries() @@ -51,12 +61,12 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource { private fun FrameworkEntry.toDomain() = OrbitalData( this.name, this.epoch, this.meanmo, this.eccn, this.incl, - this.raan, this.argper, this.meanan, this.catnum, this.bstar + this.raan, this.argper, this.meanan, this.catnum, this.bstar, this.ndot ) private fun OrbitalData.toEntity() = FrameworkEntry( this.name, this.epoch, this.meanmo, this.eccn, this.incl, - this.raan, this.argper, this.meanan, this.catnum, this.bstar + this.raan, this.argper, this.meanan, this.catnum, this.bstar, this.ndot ) private fun List.toEntity() = this.map { item -> item.toEntity() } @@ -73,15 +83,17 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource { return look4SatDao.getRadiosWithId(id).toDomainRadios() } - override suspend fun insertRadios(radios: List) { - look4SatDao.insertRadios(radios.toFrameworkRadios()) + override suspend fun insertRadios(radios: List, isCustom: Boolean) { + look4SatDao.insertRadios(radios.map { radio -> radio.toFramework(isCustom) }) } override suspend fun deleteRadios() = look4SatDao.deleteRadios() - private fun DomainRadio.toFramework() = FrameworkRadio( + override suspend fun deleteManagedRadios() = look4SatDao.deleteManagedRadios() + + private fun DomainRadio.toFramework(isCustom: Boolean = false) = FrameworkRadio( this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode, - this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum + this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum, isCustom ) private fun FrameworkRadio.toDomain() = DomainRadio( @@ -89,8 +101,6 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource { this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum ) - private fun List.toFrameworkRadios() = this.map { radio -> radio.toFramework() } - private fun List.toDomainRadios() = this.map { radio -> radio.toDomain() } //endregion 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 3dcd0b50..d62ec09a 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 @@ -42,6 +42,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import java.io.InputStream +import java.time.LocalDate @OptIn(ExperimentalCoroutinesApi::class) class DatabaseRepoTest { @@ -49,6 +50,10 @@ class DatabaseRepoTest { private val dispatcher = StandardTestDispatcher() private val dataParser = DataParser(dispatcher) + // fixtures carry a current epoch: stale entries are pruned on every remote update + private val todayEpoch = LocalDate.now().let { "%02d%03d".format(it.year % 100, it.dayOfYear) } + private val fresherEpoch = "$todayEpoch.71955234".toDouble() + @Test fun `manual satellite import parses csv stream from content uri`() = runTest(dispatcher) { val uri = "content://look4sat/import/satellites" @@ -102,18 +107,176 @@ class DatabaseRepoTest { assertTrue(localSource.insertedEntries.any { it.catnum == 25544 }) } + @Test + fun `remote update takes the freshest elements and the preferred name`() = runTest(dispatcher) { + val primaryUrl = "https://example.com/primary.txt" + val secondaryUrl = "https://example.com/secondary.txt" + val localSource = FakeLocalSource() + val remoteSource = FakeRemoteSource().apply { + networkStreams[primaryUrl] = { validTleStream() } + networkStreams[secondaryUrl] = { fresherTleStream() } + } + val settingsRepo = FakeSettingsRepo( + dataSources = DataSourcesSettings( + satelliteUrls = listOf(primaryUrl, secondaryUrl), + transceiversUrls = emptyList() + ) + ) + val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo) + + repository.updateFromRemote() + + val entry = localSource.insertedEntries.single { it.catnum == 25544 } + assertEquals("ISS (ZARYA)", entry.name) + assertEquals(fresherEpoch, entry.epoch, 1e-8) + } + + @Test + fun `manual satellite import does not overwrite fresher data`() = runTest(dispatcher) { + val freshUri = "content://look4sat/import/fresh" + val staleUri = "content://look4sat/import/stale" + val localSource = FakeLocalSource() + val remoteSource = FakeRemoteSource().apply { + fileStreams[freshUri] = { fresherTleStream() } + fileStreams[staleUri] = { validTleStream() } + } + val settingsRepo = FakeSettingsRepo() + val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo) + + assertEquals(1, repository.updateTLEFromFile(freshUri)) + assertEquals(1, repository.updateTLEFromFile(staleUri)) + assertEquals(fresherEpoch, localSource.insertedEntries.single().epoch, 1e-8) + } + + @Test + fun `remote update drops transceivers that are no longer published`() = runTest(dispatcher) { + val radioUrl = "https://example.com/transmitters.json" + val localSource = FakeLocalSource() + val remoteSource = FakeRemoteSource() + val settingsRepo = FakeSettingsRepo( + dataSources = DataSourcesSettings( + satelliteUrls = emptyList(), + transceiversUrls = listOf(radioUrl) + ) + ) + val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo) + + remoteSource.networkStreams[radioUrl] = { twoRadiosStream() } + repository.updateFromRemote() + assertEquals(2, localSource.insertedRadios.size) + + remoteSource.networkStreams[radioUrl] = { singleRadioStream() } + repository.updateFromRemote() + assertEquals(listOf("uuid-alive"), localSource.insertedRadios.map { it.uuid }) + } + + @Test + fun `remote update renames satellites even when their elements are not newer`() = runTest(dispatcher) { + val preferredUrl = "https://example.com/preferred.txt" + val freshUrl = "https://example.com/fresh.txt" + val localSource = FakeLocalSource() + val remoteSource = FakeRemoteSource().apply { + networkStreams[freshUrl] = { fresherTleStream() } + } + val settingsRepo = FakeSettingsRepo( + dataSources = DataSourcesSettings( + satelliteUrls = listOf(freshUrl), + transceiversUrls = emptyList() + ) + ) + val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo) + repository.updateFromRemote() + assertEquals("ISS", localSource.insertedEntries.single().name) + + // the user moves a source with older elements, but a better name, to the top of the list + remoteSource.networkStreams[preferredUrl] = { validTleStream() } + settingsRepo.dataSourcesSettings.value = DataSourcesSettings( + satelliteUrls = listOf(preferredUrl, freshUrl), + transceiversUrls = emptyList() + ) + repository.updateFromRemote() + + val entry = localSource.insertedEntries.single() + assertEquals("ISS (ZARYA)", entry.name) + assertEquals(fresherEpoch, entry.epoch, 1e-8) + } + + @Test + fun `failed remote update records the attempt without claiming success`() = runTest(dispatcher) { + val failingUrl = "https://example.com/offline.txt" + val localSource = FakeLocalSource() + val remoteSource = FakeRemoteSource() + val settingsRepo = FakeSettingsRepo( + dataSources = DataSourcesSettings( + satelliteUrls = listOf(failingUrl), + transceiversUrls = emptyList() + ) + ) + settingsRepo.databaseState.value = DatabaseState(0, 0, 1_000L) + val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo) + repository.updateFromRemote() + + // the update timestamp stays put so the data is not presented as fresh, while the recorded + // attempt keeps the next app launch from hammering a source that is already failing + assertEquals(1_000L, settingsRepo.databaseState.value.updateTimestamp) + } + + @Test + fun `remote update keeps the transceivers imported from a file`() = runTest(dispatcher) { + val radioUri = "content://imported.json" + val radioUrl = "https://example.com/transmitters.json" + val localSource = FakeLocalSource() + val remoteSource = FakeRemoteSource().apply { + fileStreams[radioUri] = { customRadioStream() } + networkStreams[radioUrl] = { singleRadioStream() } + } + val settingsRepo = FakeSettingsRepo( + dataSources = DataSourcesSettings( + satelliteUrls = emptyList(), + transceiversUrls = listOf(radioUrl) + ) + ) + val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo) + repository.updateTransceiversFromFile(radioUri) + repository.updateFromRemote() + + // the remote snapshot replaces what the sources publish, the imported entry stays put + assertEquals(setOf("uuid-custom", "uuid-alive"), localSource.insertedRadios.map { it.uuid }.toSet()) + } + private fun validCsvStream(): InputStream = """ OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT - ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0 + ISS (ZARYA),1998-067A,${LocalDate.now()}T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0 """.trimIndent().byteInputStream() private fun validTleStream(): InputStream = """ ISS (ZARYA) - 1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990 + 1 25544U 98067A $todayEpoch.51955234 .00001288 00000+0 31985-4 0 9990 2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205 """.trimIndent().byteInputStream() + + private fun fresherTleStream(): InputStream = """ + ISS + 1 25544U 98067A $todayEpoch.71955234 .00001288 00000+0 31985-4 0 9990 + 2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205 + """.trimIndent().byteInputStream() + + private fun twoRadiosStream(): InputStream = """ + [$radioAlive,{"uuid":"uuid-retired","description":"Retired","alive":true,"downlink_low":437800000, + "mode":"FM","invert":false,"norad_cat_id":25544}] + """.trimIndent().byteInputStream() + + private fun singleRadioStream(): InputStream = "[$radioAlive]".byteInputStream() + + private fun customRadioStream(): InputStream = """ + [{"uuid":"uuid-custom","description":"Local beacon","alive":true,"downlink_low":144800000, + "mode":"FM","invert":false,"norad_cat_id":25544}] + """.trimIndent().byteInputStream() } +private const val radioAlive = """{"uuid":"uuid-alive","description":"Voice repeater","alive":true, + "downlink_low":145800000,"mode":"FM","invert":false,"norad_cat_id":25544}""" + private class FakeRemoteSource : IRemoteSource { val fileStreams: MutableMap InputStream> = mutableMapOf() val networkStreams: MutableMap InputStream> = mutableMapOf() @@ -134,7 +297,8 @@ private class FakeRemoteSource : IRemoteSource { private class FakeLocalSource : ILocalSource { val insertedEntries = mutableListOf() - private val insertedRadios = mutableListOf() + val insertedRadios = mutableListOf() + private val customRadios = mutableListOf() override suspend fun getEntriesTotal(): Int = insertedEntries.size @@ -142,6 +306,21 @@ private class FakeLocalSource : ILocalSource { override suspend fun getEntriesWithIds(ids: List): List = emptyList() + override suspend fun getEntriesEpochs(): Map = + insertedEntries.associate { entry -> entry.catnum to entry.epoch } + + override suspend fun getEntriesNames(): Map = + insertedEntries.associate { entry -> entry.catnum to entry.name } + + override suspend fun renameEntries(names: Map) = names.forEach { (catnum, name) -> + val index = insertedEntries.indexOfFirst { entry -> entry.catnum == catnum } + if (index >= 0) insertedEntries[index] = insertedEntries[index].copy(name = name) + } + + override suspend fun deleteEntriesWithIds(ids: List) { + insertedEntries.removeAll { entry -> entry.catnum in ids } + } + override suspend fun insertEntries(entries: List) { insertedEntries += entries } @@ -156,18 +335,27 @@ private class FakeLocalSource : ILocalSource { override suspend fun getRadiosWithId(id: Int): List = emptyList() - override suspend fun insertRadios(radios: List) { + override suspend fun insertRadios(radios: List, isCustom: Boolean) { + insertedRadios.removeAll { stored -> radios.any { radio -> radio.uuid == stored.uuid } } insertedRadios += radios + if (isCustom) customRadios += radios.map { radio -> radio.uuid } + else customRadios -= radios.map { radio -> radio.uuid }.toSet() } override suspend fun deleteRadios() { insertedRadios.clear() + customRadios.clear() + } + + override suspend fun deleteManagedRadios() { + insertedRadios.removeAll { radio -> radio.uuid !in customRadios } } } private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSourcesSettings()) : ISettingsRepo { override val appVersionName: String = "test" + override val appVersionCode: Long = 1L override val selectedIds: StateFlow> = MutableStateFlow(emptyList()) 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 30582090..8b383c4c 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 @@ -93,6 +93,14 @@ class SelectionRepoTest { override suspend fun getEntriesWithIds(ids: List): List = emptyList() + override suspend fun getEntriesEpochs(): Map = emptyMap() + + override suspend fun getEntriesNames(): Map = emptyMap() + + override suspend fun renameEntries(names: Map) = Unit + + override suspend fun deleteEntriesWithIds(ids: List) = Unit + override suspend fun insertEntries(entries: List) = Unit override suspend fun deleteEntries() = Unit @@ -103,9 +111,11 @@ class SelectionRepoTest { override suspend fun getRadiosWithId(id: Int): List = emptyList() - override suspend fun insertRadios(radios: List) = Unit + override suspend fun insertRadios(radios: List, isCustom: Boolean) = Unit override suspend fun deleteRadios() = Unit + + override suspend fun deleteManagedRadios() = Unit } private class FakeSettingsRepo( @@ -113,6 +123,7 @@ class SelectionRepoTest { ) : ISettingsRepo { override val appVersionName: String = "test" + override val appVersionCode: Long = 1L override val selectedIds: StateFlow> = MutableStateFlow(emptyList()) @@ -181,5 +192,3 @@ class SelectionRepoTest { override fun setAmSatCallsign(callsign: String) = Unit } } - - diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatRadio.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatRadio.kt index b057fb4e..364c734f 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatRadio.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatRadio.kt @@ -20,17 +20,19 @@ package com.rtbishop.look4sat.core.domain.model import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +/** Only the uuid is mandatory: third party sources rarely publish the full SatNOGS schema, and a + * missing optional field should not cost the whole transceiver. */ @Serializable data class SatRadio( @SerialName("uuid") val uuid: String, - @SerialName("description") val info: String, - @SerialName("alive") val isAlive: Boolean, - @SerialName("downlink_low") val downlinkLow: Long?, - @SerialName("downlink_high") val downlinkHigh: Long?, - @SerialName("mode") val downlinkMode: String?, - @SerialName("uplink_low") val uplinkLow: Long?, - @SerialName("uplink_high") val uplinkHigh: Long?, - @SerialName("uplink_mode") val uplinkMode: String?, - @SerialName("invert") val isInverted: Boolean, - @SerialName("norad_cat_id") val catnum: Int? + @SerialName("description") val info: String = "", + @SerialName("alive") val isAlive: Boolean = true, + @SerialName("downlink_low") val downlinkLow: Long? = null, + @SerialName("downlink_high") val downlinkHigh: Long? = null, + @SerialName("mode") val downlinkMode: String? = null, + @SerialName("uplink_low") val uplinkLow: Long? = null, + @SerialName("uplink_high") val uplinkHigh: Long? = null, + @SerialName("uplink_mode") val uplinkMode: String? = null, + @SerialName("invert") val isInverted: Boolean = false, + @SerialName("norad_cat_id") val catnum: Int? = null ) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/OrbitalData.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/OrbitalData.kt index c9336cee..90c26530 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/OrbitalData.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/OrbitalData.kt @@ -37,30 +37,42 @@ data class OrbitalData( val xno: Double = meanmo * TWO_PI / MIN_PER_DAY val orbitalPeriod: Double = MIN_PER_DAY / meanmo val isDeepSpace: Boolean = orbitalPeriod >= 225.0 // NearEarth (period < 225 min) or DeepSpace (period >= 225 min) + + /** Absolute age of this element set, used to pick the freshest data among several sources. */ + val epochDaynum: Double = epochToDaynum(epoch) + fun getObject(): OrbitalObject = if (isDeepSpace) DeepSpaceObject(this) else NearEarthObject(this) /** Check if satellite has likely decayed by the given time. */ fun hasDecayed(currentTimeMillis: Long): Boolean { if (ndot == 0.0) return false - val currentDaynum = (currentTimeMillis - 315446400000L) / 86400000.0 - val epochDaynum = epochToDaynum(epoch) - return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, currentDaynum) + return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, timeToDaynum(currentTimeMillis)) } - private fun epochToDaynum(epoch: Double): Double { - var year = kotlin.math.floor(epoch * 1E-3) - val day = (epoch * 1E-3 - year) * 1000.0 - year = if (year < 57) year + 2000 else year + 1900 - // daynum = days since 31 Dec 1979, Julian date of 31Dec79 = 2444238.5 - val jan1Jd = julianDateOfYear(year) - return jan1Jd + day - 2444238.5 - } + companion object { + /** Days since 31 Dec 1979, the reference point every daynum in the app is based on. */ + fun timeToDaynum(timeMillis: Long): Double = (timeMillis - 315446400000L) / 86400000.0 - private fun julianDateOfYear(theYear: Double): Double { - val aYear = theYear - 1 - val a = kotlin.math.floor(aYear / 100).toLong() - val b = 2 - a + a / 4 - val i = kotlin.math.floor(365.25 * aYear).toLong() - return i + (30.6001 * 14).toLong() + 1720994.5 + b + /** + * Days since 31 Dec 1979 for a TLE style YYDDD.ffffffff epoch. Raw epochs are not + * comparable across decades (a 1999 epoch reads as 99xxx, a 2026 one as 26xxx), so + * freshness checks must always go through this conversion. + */ + fun epochToDaynum(epoch: Double): Double { + var year = kotlin.math.floor(epoch * 1E-3) + val day = (epoch * 1E-3 - year) * 1000.0 + year = if (year < 57) year + 2000 else year + 1900 + // daynum = days since 31 Dec 1979, Julian date of 31Dec79 = 2444238.5 + val jan1Jd = julianDateOfYear(year) + return jan1Jd + day - 2444238.5 + } + + private fun julianDateOfYear(theYear: Double): Double { + val aYear = theYear - 1 + val a = kotlin.math.floor(aYear / 100).toLong() + val b = 2 - a + a / 4 + val i = kotlin.math.floor(365.25 * aYear).toLong() + return i + (30.6001 * 14).toLong() + 1720994.5 + b + } } } 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 c432df0a..a0fdcb9b 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 @@ -29,6 +29,7 @@ import kotlinx.coroutines.flow.StateFlow interface ISettingsRepo { val appVersionName: String + val appVersionCode: Long //region # Satellites selection settings val selectedIds: StateFlow> diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/ILocalSource.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/ILocalSource.kt index 93e403a5..f5c1e79e 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/ILocalSource.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/ILocalSource.kt @@ -26,11 +26,18 @@ interface ILocalSource { suspend fun getEntriesTotal(): Int suspend fun getEntriesList(): List suspend fun getEntriesWithIds(ids: List): List + suspend fun getEntriesEpochs(): Map + suspend fun getEntriesNames(): Map + suspend fun renameEntries(names: Map) + suspend fun deleteEntriesWithIds(ids: List) suspend fun insertEntries(entries: List) suspend fun deleteEntries() suspend fun getIdsWithModes(modes: List): List suspend fun getRadiosTotal(): Int suspend fun getRadiosWithId(id: Int): List - suspend fun insertRadios(radios: List) + suspend fun insertRadios(radios: List, isCustom: Boolean) suspend fun deleteRadios() + + /** Drops the transceivers provided by the remote sources, keeping the imported ones. */ + suspend fun deleteManagedRadios() } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/Sources.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/Sources.kt index 6236f072..46adb044 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/Sources.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/Sources.kt @@ -18,17 +18,19 @@ package com.rtbishop.look4sat.core.domain.source object Sources { + // Ordered from the most specific to the most generic source: the first source that knows a + // satellite gets to name it, with the full catalog acting as the fallback. val satelliteDataUrls = listOf( - "celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv", - "db.satnogs.org/api/tle/?format=3le", - "amsat.org/tle/current/nasabare.txt", - "mmccants.org/tles/classfd.zip", + "live.ariss.org/iss.txt", "r4uab.ru/satonline.txt", - "live.ariss.org/iss.txt" + "mmccants.org/tles/classfd.zip", + "amsat.org/tle/current/nasabare.txt", + "celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv", + "db.satnogs.org/api/tle/?format=3le" ) val transceiversDataUrls = listOf( - "db.satnogs.org/api/transmitters/?format=json&status=active", - "r4uab.ru/transmitters.json" + "r4uab.ru/transmitters.json", + "db.satnogs.org/api/transmitters/?format=json&status=active" ) val satelliteModes = listOf( "4FSK", "64-QAM", "AFSK", "AFSK TUBiX10", "AHRPT", "AM", "APT", "ASK", "BPSK", diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DataParser.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DataParser.kt index a2d52d3a..31f9e38d 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DataParser.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DataParser.kt @@ -33,10 +33,19 @@ class DataParser(private val dispatcher: CoroutineDispatcher) { ignoreUnknownKeys = true coerceInputValues = true } + private val alpha5Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ" + private val celestrakCSVColumns = mapOf( + "OBJECT_NAME" to 0, "EPOCH" to 2, "MEAN_MOTION" to 3, "ECCENTRICITY" to 4, + "INCLINATION" to 5, "RA_OF_ASC_NODE" to 6, "ARG_OF_PERICENTER" to 7, + "MEAN_ANOMALY" to 8, "NORAD_CAT_ID" to 11, "BSTAR" to 14, "MEAN_MOTION_DOT" to 15 + ) suspend fun parseCSVStream(stream: InputStream): List = withContext(dispatcher) { stream.bufferedReader().useLines { lines -> - lines.drop(1).mapNotNull { parseCSV(it.split(",")) }.toList() + val iterator = lines.iterator() + if (!iterator.hasNext()) return@useLines emptyList() + val columns = parseCSVColumns(iterator.next()) + iterator.asSequence().mapNotNull { line -> parseCSV(line.split(","), columns) }.toList() } } @@ -58,35 +67,63 @@ class DataParser(private val dispatcher: CoroutineDispatcher) { }.getOrDefault(emptyList()) } - private fun parseCSV(values: List): OrbitalData? = runCatching { - val name = values[0] - val timestamp = values[2] - val year = timestamp.substring(0, 4) - val month = timestamp.substring(5, 7).toInt() - val dayOfMonth = timestamp.substring(8, 10).toInt() - val dayInt = getDayOfYear(year.toInt(), month, dayOfMonth) - val day = dayInt.toString().padStart(3, '0') - val hour = timestamp.substring(11, 13).toInt() * 3600000 - val min = timestamp.substring(14, 16).toInt() * 60000 - val sec = timestamp.substring(17, 19).toInt() * 1000 - val ms = timestamp.substring(20, 26).toInt() / 1000.0 - val frac = ((hour + min + sec + ms) / 86400000.0).toString().substring(1) - val epoch = "${year.substring(2)}$day$frac".toDouble() + private fun parseCSV(values: List, columns: Map): OrbitalData? = runCatching { + fun value(column: String) = values[columns.getValue(column)].trim() + fun optionalValue(column: String) = + columns[column]?.let { index -> values.getOrNull(index) }?.trim()?.toDoubleOrNull() ?: 0.0 OrbitalData( - name = name, - epoch = epoch, - meanmo = values[3].toDouble(), - eccn = values[4].toDouble(), - incl = values[5].toDouble(), - raan = values[6].toDouble(), - argper = values[7].toDouble(), - meanan = values[8].toDouble(), - catnum = values[11].toInt(), - bstar = values[14].toDouble(), - ndot = values[15].toDouble() + name = value("OBJECT_NAME"), + epoch = parseTimestamp(value("EPOCH")), + meanmo = value("MEAN_MOTION").toDouble(), + eccn = value("ECCENTRICITY").toDouble(), + incl = value("INCLINATION").toDouble(), + raan = value("RA_OF_ASC_NODE").toDouble(), + argper = value("ARG_OF_PERICENTER").toDouble(), + meanan = value("MEAN_ANOMALY").toDouble(), + catnum = parseCatnum(value("NORAD_CAT_ID")), + bstar = optionalValue("BSTAR"), + ndot = optionalValue("MEAN_MOTION_DOT") ) }.onFailure { println("CSV parsing exception: $it") }.getOrNull() + /** + * OMM columns are located by name, as providers agree on the names but not on the order. + * Falls back to the Celestrak layout when the header is missing or unrecognized. + */ + private fun parseCSVColumns(header: String): Map { + val columns = header.split(",").withIndex().associate { (index, name) -> + name.trim().trim('"').uppercase() to index + } + return if (columns.containsKey("NORAD_CAT_ID")) columns else celestrakCSVColumns + } + + /** + * ISO 8601 timestamp as a TLE style YYDDD.ffffffff epoch. Fractional seconds are optional and + * a trailing timezone marker is ignored, as not every provider formats the epoch alike. + */ + private fun parseTimestamp(timestamp: String): Double { + val year = timestamp.substring(0, 4).toInt() + val month = timestamp.substring(5, 7).toInt() + val dayOfMonth = timestamp.substring(8, 10).toInt() + val hours = timestamp.substring(11, 13).toInt() + val minutes = timestamp.substring(14, 16).toInt() + val seconds = timestamp.substring(17).takeWhile { it.isDigit() || it == '.' }.toDouble() + val dayFraction = (hours * 3600 + minutes * 60 + seconds) / 86400.0 + return (year % 100) * 1000 + getDayOfYear(year, month, dayOfMonth) + dayFraction + } + + /** + * Catalog numbers above 99999 do not fit the 5 digit TLE field, so they are encoded as Alpha-5: + * the leading two digits become a letter, with I and O skipped to avoid confusion with 1 and 0. + */ + private fun parseCatnum(value: String): Int { + val catnum = value.trim() + if (catnum.first().isDigit()) return catnum.toInt() + val alphaIndex = alpha5Alphabet.indexOf(catnum.first().uppercaseChar()) + require(alphaIndex >= 0) { "Unknown Alpha-5 catalog number: $catnum" } + return (alphaIndex + 10) * 10000 + catnum.drop(1).trim().toInt() + } + private fun parseTLE(tle: List): OrbitalData? = runCatching { val line1 = tle[1] val line2 = tle[2] @@ -99,7 +136,7 @@ class DataParser(private val dispatcher: CoroutineDispatcher) { raan = line2.substring(17, 25).toDouble(), argper = line2.substring(34, 42).toDouble(), meanan = line2.substring(43, 51).toDouble(), - catnum = line1.substring(2, 7).trim().toInt(), + catnum = parseCatnum(line1.substring(2, 7)), bstar = 1e-5 * line1.substring(53, 59).toDouble() / 10.0.pow(line1.substring(60, 61).toDouble()), ndot = line1.substring(33, 43).trim().toDouble() ) diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DataParserTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DataParserTest.kt index 9e767cf7..ff81418b 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DataParserTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DataParserTest.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Test +import kotlin.math.abs @ExperimentalCoroutinesApi class DataParserTest { @@ -61,8 +62,49 @@ class DataParserTest { fun `Given valid CSV stream returns valid data`() = runTest(testDispatcher) { val parsedList = dataParser.parseCSVStream(validCSVStream) assert(parsedList.size == 2) - assert(parsedList[0].epoch == 21320.51955234) - assert(parsedList[1].epoch == 24069.23963816) + assert(abs(parsedList[0].epoch - 21320.51955234) < 1e-8) + assert(abs(parsedList[1].epoch - 24069.23963816) < 1e-8) + } + + @Test + fun `Given CSV stream with reordered columns returns valid data`() = runTest(testDispatcher) { + val csvStream = """ + NORAD_CAT_ID,EPOCH,OBJECT_NAME,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY + 25544,2021-11-16T12:28:09.322176,ISS (ZARYA),.31985E-4,.1288E-4,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876 + """.trimIndent().byteInputStream() + val sat = dataParser.parseCSVStream(csvStream)[0] + assert(sat.name == "ISS (ZARYA)") + assert(sat.catnum == 25544) + assert(sat.meanmo == 15.48582035) + assert(sat.bstar == 0.31985E-4) + assert(abs(sat.epoch - 21320.51955234) < 1e-8) + } + + @Test + fun `Given CSV epoch without fractional seconds returns valid data`() = runTest(testDispatcher) { + val csvStream = """ + OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT + ISS (ZARYA),1998-067A,2021-11-16T12:28:09Z,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0 + ISS (ZARYA),1998-067A,2021-11-16T00:00:30,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0 + """.trimIndent().byteInputStream() + val parsedList = dataParser.parseCSVStream(csvStream) + assert(parsedList.size == 2) + // the dropped microseconds are worth a few microdays, the rest of the epoch is intact + assert(abs(parsedList[0].epoch - 21320.51955234) < 1e-5) + // just past midnight: the old formatter produced a broken 21320.0E-4 style epoch here + assert(abs(parsedList[1].epoch - 21320.00034722) < 1e-8) + } + + @Test + fun `Given TLE stream with alpha-5 catalog number returns valid data`() = runTest(testDispatcher) { + val tleStream = """ + NAVSTAR 43 + 1 T0111U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990 + 2 T0111 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205 + """.trimIndent().byteInputStream() + val sat = dataParser.parseTLEStream(tleStream)[0] + // A stands for 10 and I and O are skipped, so T is 27 and T0111 stands for 270111 + assert(sat.catnum == 270111) } @Test diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt index 4b6ffa73..f7f864a4 100644 --- a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt +++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt @@ -52,7 +52,7 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2 import androidx.compose.runtime.Composable import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember @@ -430,11 +430,11 @@ private fun DialogShell( @Composable fun hasEnoughHeight(): Boolean = - currentWindowAdaptiveInfo().windowSizeClass.isHeightAtLeastBreakpoint(480) + currentWindowAdaptiveInfoV2().windowSizeClass.isHeightAtLeastBreakpoint(480) @Composable fun hasEnoughWidth(): Boolean = - currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(600) + currentWindowAdaptiveInfoV2().windowSizeClass.isWidthAtLeastBreakpoint(600) @Composable fun isVerticalLayout(): Boolean = !hasEnoughWidth() diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/DragDropSwipe.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/DragDropSwipe.kt new file mode 100644 index 00000000..9f7a1ce2 --- /dev/null +++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/DragDropSwipe.kt @@ -0,0 +1,272 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.rtbishop.look4sat.core.presentation + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.dropShadow +import androidx.compose.ui.draw.innerShadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds + +//region DragDrop Region +@Composable +fun rememberDragDropState(lazyListState: LazyListState, onMove: (Int, Int) -> Unit): DragDropState { + val scope = rememberCoroutineScope() + val state = remember(lazyListState) { + DragDropState(state = lazyListState, onMove = onMove, scope = scope) + } + LaunchedEffect(state) { + while (true) { + val diff = state.scrollChannel.receive() + lazyListState.scrollBy(diff) + } + } + return state +} + +class DragDropState internal constructor( + private val state: LazyListState, + private val scope: CoroutineScope, + private val onMove: (Int, Int) -> Unit +) { + var draggingItemIndex by mutableStateOf(null) + private set + + internal val scrollChannel = Channel() + + private var draggingItemDraggedDelta by mutableFloatStateOf(0f) + private var draggingItemInitialOffset by mutableIntStateOf(0) + internal val draggingItemOffset: Float + get() = draggingItemLayoutInfo?.let { item -> + draggingItemInitialOffset + draggingItemDraggedDelta - item.offset + } ?: 0f + + private val draggingItemLayoutInfo: LazyListItemInfo? + get() = state.layoutInfo.visibleItemsInfo.firstOrNull { it.index == draggingItemIndex } + + internal var previousIndexOfDraggedItem by mutableStateOf(null) + private set + internal var previousItemOffset = Animatable(0f) + private set + + internal fun onDragStart(offset: Offset) { + state.layoutInfo.visibleItemsInfo.firstOrNull { item -> + offset.y.toInt() in item.offset..(item.offset + item.size) + }?.also { + draggingItemIndex = it.index + draggingItemInitialOffset = it.offset + } + } + + internal fun onDragInterrupted() { + if (draggingItemIndex != null) { + previousIndexOfDraggedItem = draggingItemIndex + val startOffset = draggingItemOffset + scope.launch { + previousItemOffset.snapTo(startOffset) + previousItemOffset.animateTo( + 0f, spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 1f) + ) + previousIndexOfDraggedItem = null + } + } + draggingItemDraggedDelta = 0f + draggingItemIndex = null + draggingItemInitialOffset = 0 + } + + internal fun onDrag(offset: Offset) { + draggingItemDraggedDelta += offset.y + + val draggingItem = draggingItemLayoutInfo ?: return + val startOffset = draggingItem.offset + draggingItemOffset + val endOffset = startOffset + draggingItem.size + val middleOffset = startOffset + (endOffset - startOffset) / 2f + + val targetItem = state.layoutInfo.visibleItemsInfo.find { item -> + middleOffset.toInt() in item.offset..item.offsetEnd && draggingItem.index != item.index + } + if (targetItem != null) { + if (draggingItem.index == state.firstVisibleItemIndex || targetItem.index == state.firstVisibleItemIndex) { + state.requestScrollToItem(state.firstVisibleItemIndex, state.firstVisibleItemScrollOffset) + } + onMove.invoke(draggingItem.index, targetItem.index) + draggingItemIndex = targetItem.index + } else { + val overscroll = when { + draggingItemDraggedDelta > 0 -> (endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f) + draggingItemDraggedDelta < 0 -> (startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f) + else -> 0f + } + if (overscroll != 0f) { + scrollChannel.trySend(overscroll) + } + } + } + + private val LazyListItemInfo.offsetEnd: Int + get() = this.offset + this.size +} + +fun Modifier.dragContainer(dragDropState: DragDropState): Modifier { + return pointerInput(dragDropState) { + detectDragGesturesAfterLongPress( + onDrag = { change, offset -> + change.consume() + dragDropState.onDrag(offset = offset) + }, + onDragStart = { offset -> dragDropState.onDragStart(offset) }, + onDragEnd = { dragDropState.onDragInterrupted() }, + onDragCancel = { dragDropState.onDragInterrupted() } + ) + } +} + +@Composable +fun LazyItemScope.DraggableItem( + dragDropState: DragDropState, + index: Int, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.(isDragging: Boolean) -> Unit +) { + val zIndexMod = Modifier.zIndex(1f) + val dragging = index == dragDropState.draggingItemIndex + val draggingModifier = when { + dragging -> zIndexMod.graphicsLayer { translationY = dragDropState.draggingItemOffset } + index == dragDropState.previousIndexOfDraggedItem -> { + zIndexMod.graphicsLayer { translationY = dragDropState.previousItemOffset.value } + } + else -> Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null) + } + Column(modifier = modifier.then(draggingModifier)) { content(dragging) } +} +//endregion + +//region Swipe Region +@Composable +fun SwipeableItem(onRemove: () -> Unit, content: @Composable () -> Unit) { + val coroutineScope = rememberCoroutineScope() + val dismissThresholdPx = with(LocalDensity.current) { 120.dp.toPx() } + val dismissState = rememberSwipeToDismissBoxState { dismissThresholdPx } + val isVisible = remember { mutableStateOf(true) } + val willTrigger by remember { derivedStateOf { dismissState.targetValue != SwipeToDismissBoxValue.Settled } } + val hapticFeedback = LocalHapticFeedback.current + LaunchedEffect(willTrigger) { + val feedbackType = if (willTrigger) HapticFeedbackType.LongPress else HapticFeedbackType.SegmentTick + hapticFeedback.performHapticFeedback(feedbackType) + } + AnimatedVisibility(visible = isVisible.value, exit = fadeOut(spring())) { + SwipeToDismissBox( + state = dismissState, + enableDismissFromStartToEnd = true, + enableDismissFromEndToStart = false, + backgroundContent = { DismissBackground(willTrigger) }, + content = { content() }, + onDismiss = { + isVisible.value = false + coroutineScope.launch { dismissState.reset() } + } + ) + } + LaunchedEffect(isVisible.value) { + if (!isVisible.value) { delay(250.milliseconds).also { onRemove() } } + } +} + +@Composable +fun DismissBackground(willTrigger: Boolean, bgColor: Color = Color(0xFFFF3C3A)) { + val iconScale by animateFloatAsState(targetValue = if (willTrigger) 1f else .8f) + val slide by animateDpAsState(targetValue = if (willTrigger) 32.dp else (12).dp) + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier + .fillMaxSize() + .border(width = 2.dp, shape = MaterialTheme.shapes.extraLarge, color = bgColor) + .dropShadow(shape = MaterialTheme.shapes.extraLarge) { + color = bgColor + radius = 40f + alpha = if (willTrigger) .2f else 0f + } + .innerShadow(shape = MaterialTheme.shapes.extraLarge) { + color = bgColor + radius = 40f + alpha = if (willTrigger) 1f else .2f + } + ) { + Icon( + painter = painterResource(R.drawable.ic_delete), + contentDescription = null, + modifier = Modifier + .size(32.dp) + .fillMaxHeight() + .graphicsLayer { + scaleX = iconScale + scaleY = iconScale + translationX = slide.toPx() + } + ) + } +} +//endregion diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/DragReorder.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/DragReorder.kt new file mode 100644 index 00000000..0ccc8094 --- /dev/null +++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/DragReorder.kt @@ -0,0 +1,311 @@ +/* + * Look4Sat. Amateur radio satellite tracker and pass predictor. + * Copyright (C) 2019-2026 Arty Bishop and contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.rtbishop.look4sat.core.presentation + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +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.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableFloatState +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds + +/** + * Long-press drag-to-reorder for rows inside a `LazyColumn`, with built-in edge auto-scroll and + * a settle-back animation when the finger is released. + * + * Reordering only ever swaps an item with its neighbor *inside the `items` list* passed to + * [rememberDragRowState] — so multiple independent groups of rows (e.g. two sections separated + * by header items) can share one [DragReorderState]/`LazyColumn` simply by giving each section + * its own backing list and calling [rememberDragRowState] with that section's list. Keys must be + * globally unique across the whole `LazyColumn` and match exactly what is passed as the + * `key = { ... }` lambda of `items`/`itemsIndexed`. + * + * Usage: + * ``` + * val dragState = rememberDragReorderState(listState) + * itemsIndexed(entries, key = { _, e -> e.id }) { index, entry -> + * val row = rememberDragRowState(dragState, entries, entry, key = { it.id }) { from, to -> + * entries.add(to, entries.removeAt(from)) + * } + * Row( + * modifier = Modifier + * .dragLift(row.isLifted, row.translationY) + * .animateItem(placementSpec = if (row.isDragging) tween(0) else spring()) + * ) { + * Icon(modifier = Modifier.dragHandle(row), painter = ..., contentDescription = null) + * Text(entry.label) + * } + * } + * ``` + */ +@Stable +class DragReorderState internal constructor(internal val listState: LazyListState) { + internal var draggedKey by mutableStateOf(null) +} + +@Composable +fun rememberDragReorderState(listState: LazyListState): DragReorderState = + remember(listState) { DragReorderState(listState) } + +/** Used for the settle-back animation once the finger is released: quick and snappy. */ +private val reorderSettleSpec = tween(durationMillis = 150, easing = FastOutSlowInEasing) + +/** + * Per-row drag bookkeeping, obtained via [rememberDragRowState]. + * + * [dragStartOffset] is this row's viewport offset captured once at drag start, [fingerOffset] is + * the raw accumulated finger travel (used for edge auto-scroll and swap detection). [translationY] + * is derived every frame from those two plus the row's *current* live layout offset, so it + * automatically stays correct across neighbor swaps and list auto-scroll without any manual + * offset-compensation math. + */ +@Stable +class DragRowState internal constructor( + private val dragState: DragReorderState, + private val itemsState: State>, + private val itemKey: Any, + private val key: (T) -> Any, + private val onMoveState: State<(Int, Int) -> Unit>, + private val scope: CoroutineScope +) { + internal val fingerOffset: MutableFloatState = mutableFloatStateOf(0f) + internal val dragStartOffset: MutableFloatState = mutableFloatStateOf(0f) + internal val startCenterY: MutableFloatState = mutableFloatStateOf(0f) + internal val settleAnim = Animatable(0f) + + var isSettling: Boolean by mutableStateOf(false) + private set + + val isDragging: Boolean get() = dragState.draggedKey == itemKey + val isLifted: Boolean get() = isDragging || isSettling + + val translationY: Float + get() = when { + isSettling -> settleAnim.value + isDragging -> currentTranslation() + else -> 0f + } + + /** + * How far this row must be pushed away from its *current* layout slot so it stays glued to + * the finger. Because [dragStartOffset] is fixed at drag start while the row's live layout + * offset moves as neighbors swap places or the list auto-scrolls, this difference naturally + * absorbs both effects with no extra bookkeeping. + */ + private fun currentTranslation(): Float { + val liveOffset = dragState.listState.layoutInfo.visibleItemsInfo + .firstOrNull { it.key == itemKey }?.offset?.toFloat() + ?: dragStartOffset.floatValue + return dragStartOffset.floatValue + fingerOffset.floatValue - liveOffset + } + + internal fun onDragStart() { + isSettling = false + val layout = dragState.listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == itemKey } + dragStartOffset.floatValue = (layout?.offset ?: 0).toFloat() + startCenterY.floatValue = (layout?.offset ?: 0) + (layout?.size ?: 0) / 2f + fingerOffset.floatValue = 0f + dragState.draggedKey = itemKey + } + + internal fun onDrag(deltaY: Float) { + if (dragState.draggedKey != itemKey) return + fingerOffset.floatValue += deltaY + reorderLive() + } + + private fun reorderLive() { + val items = itemsState.value + val myIndex = items.indexOfFirst { key(it) == itemKey } + if (myIndex !in items.indices) return + val myCenter = startCenterY.floatValue + fingerOffset.floatValue + val visible = dragState.listState.layoutInfo.visibleItemsInfo + val onMove = onMoveState.value + // Dragging down: swap when the dragged center passes the next row's midpoint. + if (myIndex < items.lastIndex) { + val nextKey = key(items[myIndex + 1]) + val next = visible.firstOrNull { it.key == nextKey } + if (next != null && myCenter > next.offset + next.size / 2f) { + onMove(myIndex, myIndex + 1) + return + } + } + // Dragging up: swap when the dragged center passes the previous row's midpoint. + if (myIndex > 0) { + val prevKey = key(items[myIndex - 1]) + val prev = visible.firstOrNull { it.key == prevKey } + if (prev != null && myCenter < prev.offset + prev.size / 2f) { + onMove(myIndex, myIndex - 1) + } + } + } + + // 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. + internal fun onDragEnd() { + val lastOffset = currentTranslation() + if (kotlin.math.abs(lastOffset) < 1f) { + fingerOffset.floatValue = 0f + dragState.draggedKey = null + return + } + scope.launch { + settleAnim.snapTo(lastOffset) + isSettling = true + fingerOffset.floatValue = 0f + dragState.draggedKey = null + settleAnim.animateTo(0f, reorderSettleSpec) + isSettling = false + } + } +} + +/** + * Remembers a [DragRowState] for one row of a drag-reorderable list. + * + * [items] must be the exact (optionally section-scoped) list backing the enclosing + * `items`/`itemsIndexed` call, and [key] must return the same value used as that call's + * `key = { ... }` lambda. Reordering only ever swaps neighbors within [items], so passing a + * section-local list is what confines dragging to one section of a multi-section `LazyColumn`. + */ +@Composable +fun rememberDragRowState( + dragState: DragReorderState, + items: List, + item: T, + key: (T) -> Any, + onMove: (from: Int, to: Int) -> Unit +): DragRowState { + val scope = rememberCoroutineScope() + val itemsState = rememberUpdatedState(items) + val onMoveState = rememberUpdatedState(onMove) + val itemKey = key(item) + val rowState = remember(dragState, itemKey) { + DragRowState(dragState, itemsState, itemKey, key, onMoveState, scope) + } + LaunchedEffect(rowState.isDragging) { + if (!rowState.isDragging) return@LaunchedEffect + autoScrollWhileDragging(dragState.listState, rowState.startCenterY, rowState.fingerOffset) + } + return rowState +} + +/** Drag-handle gesture: attach to a small handle icon to start/drive/end reordering of [rowState]'s row. */ +fun Modifier.dragHandle(rowState: DragRowState<*>): Modifier = pointerInput(rowState) { + detectDragGesturesAfterLongPress( + onDragStart = { rowState.onDragStart() }, + onDragEnd = { rowState.onDragEnd() }, + onDragCancel = { rowState.onDragEnd() } + ) { change, dragAmount -> + change.consume() + rowState.onDrag(dragAmount.y) + } +} + +/** + * Visual treatment for a draggable row: while [isLifted], translates the row by [translationY], + * scales it up slightly and raises it above its neighbors with a shadow and a solid background so + * it fully covers the row beneath instead of showing a translucent overlap of both rows. + */ +@Composable +fun Modifier.dragLift(isLifted: Boolean, translationY: Float): Modifier { + val shape = MaterialTheme.shapes.small + 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) { + 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 center 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 center captured at drag start and [fingerOffset] is the raw finger delta. The + * resulting scroll is picked up automatically by [DragRowState.translationY] on the next frame, + * so no separate scroll-compensation bookkeeping is required here. + */ +private suspend fun autoScrollWhileDragging( + listState: LazyListState, + startCenterY: MutableFloatState, + fingerOffset: 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) listState.scrollBy(delta) + delay(16L.milliseconds) + } +} diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/MainTheme.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/MainTheme.kt index 80f4ade9..fe5a4acf 100644 --- a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/MainTheme.kt +++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/MainTheme.kt @@ -46,7 +46,7 @@ data object Spacing { val extraSmall = 6.dp val small = 8.dp val medium = 12.dp - val large = 16.dp + val large = 12.dp val extraLarge = 24.dp } diff --git a/core/presentation/src/main/res/drawable/ic_drag_handle.xml b/core/presentation/src/main/res/drawable/ic_drag_handle.xml index 94fa450b..64d052f5 100644 --- a/core/presentation/src/main/res/drawable/ic_drag_handle.xml +++ b/core/presentation/src/main/res/drawable/ic_drag_handle.xml @@ -1,10 +1,9 @@ - + android:viewportWidth="24" + android:viewportHeight="24"> + android:pathData="M20,9H4v2h16V9zM4,15h16v-2H4V15z" /> diff --git a/core/presentation/src/main/res/drawable/ic_pound.xml b/core/presentation/src/main/res/drawable/ic_pound.xml new file mode 100644 index 00000000..725bf813 --- /dev/null +++ b/core/presentation/src/main/res/drawable/ic_pound.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/presentation/src/main/res/drawable/ic_restore.xml b/core/presentation/src/main/res/drawable/ic_restore.xml new file mode 100644 index 00000000..0f599ccc --- /dev/null +++ b/core/presentation/src/main/res/drawable/ic_restore.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/presentation/src/main/res/values-es/strings.xml b/core/presentation/src/main/res/values-es/strings.xml index 437ec822..7a9c3d59 100644 --- a/core/presentation/src/main/res/values-es/strings.xml +++ b/core/presentation/src/main/res/values-es/strings.xml @@ -121,6 +121,8 @@ Restaurar fuentes predeterminadas Datos de satélites Datos de transceptores + Prioridad arriba: nombres de satélites + Prioridad arriba: 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 b6eccb64..bbd6160c 100644 --- a/core/presentation/src/main/res/values-ru/strings.xml +++ b/core/presentation/src/main/res/values-ru/strings.xml @@ -120,6 +120,8 @@ Восстановить источники по умолчанию Данные спутников Данные трансиверов + Приоритет сверху: названия спутников + Приоритет сверху: данные трансиверов 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 36c1e9d4..50dbd2e2 100644 --- a/core/presentation/src/main/res/values-si/strings.xml +++ b/core/presentation/src/main/res/values-si/strings.xml @@ -121,6 +121,8 @@ පෙරනිමි මූලාශ්‍ර ප්‍රතිස්ථාපනය කරන්න චන්ද්‍රිකා දත්ත සම්ප්‍රේෂක දත්ත + ඉහළ ප්‍රමුඛතාව: චන්ද්‍රිකා නම් + ඉහළ ප්‍රමුඛතාව: සම්ප්‍රේෂක දත්ත 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 1a259248..aa601777 100644 --- a/core/presentation/src/main/res/values-tr/strings.xml +++ b/core/presentation/src/main/res/values-tr/strings.xml @@ -152,6 +152,8 @@ Varsayılan kaynakları geri yükle Uydu verileri Transceiver verileri + Öncelik üstte: uydu adları + Öncelik üstte: 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 8f04b5c1..93f1553f 100644 --- a/core/presentation/src/main/res/values-uk/strings.xml +++ b/core/presentation/src/main/res/values-uk/strings.xml @@ -121,6 +121,8 @@ Відновити джерела за замовчуванням Дані супутників Дані трансиверів + Пріоритет зверху: назви супутників + Пріоритет зверху: дані трансиверів 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 9d8d4b03..27551f3d 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -179,6 +179,8 @@ 恢复默认源 卫星数据 收发器数据 + 靠前优先:卫星名称 + 靠前优先:收发器数据 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 4de786ef..c458cb30 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -221,6 +221,8 @@ Restore default sources Satellites data Transceivers data + Top priority affects satellite names + Top priority affects 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 1587d11a..9f524a7d 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,17 +17,10 @@ */ 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.FastOutSlowInEasing 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 @@ -37,10 +30,9 @@ 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.layout.width 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 @@ -52,25 +44,16 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Switch import androidx.compose.material3.Text 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.snapshots.SnapshotStateList import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -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 @@ -78,22 +61,22 @@ 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 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.DragReorderState 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 kotlin.time.Duration.Companion.milliseconds +import com.rtbishop.look4sat.core.presentation.dragHandle +import com.rtbishop.look4sat.core.presentation.dragLift +import com.rtbishop.look4sat.core.presentation.rememberDragReorderState +import com.rtbishop.look4sat.core.presentation.rememberDragRowState @Preview(showBackground = true) @Composable @@ -194,6 +177,13 @@ private fun TransceiversDialogPreview() { } } +/** + * Single source of truth for one data-source row: url, enabled flag and stable id all live + * together so a reorder, a toggle or an edit is a single list mutation instead of several parallel + * lists/maps having to stay in sync (which used to fan out into extra recompositions). + */ +private data class SourceEntry(val id: Long, val url: String, val enabled: Boolean = true) + @Composable fun DataSourcesDialog( satelliteUrls: List, @@ -209,54 +199,46 @@ fun DataSourcesDialog( val padding = LocalSpacing.current.large // 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 satEntries = remember { + satelliteUrls.mapIndexed { i, url -> + SourceEntry(i.toLong(), url, satelliteEnabled.getOrElse(i) { true }) + }.toMutableStateList() } - val txUrls = remember { - transceiversUrls.mapIndexed { i, url -> (satelliteUrls.size + i).toLong() to url }.toMutableStateList() - } - 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 txEntries = remember { + transceiversUrls.mapIndexed { i, url -> + SourceEntry((satelliteUrls.size + i).toLong(), url, transceiversEnabled.getOrElse(i) { true }) + }.toMutableStateList() } val listState = rememberLazyListState() - val satDraggedId = remember { mutableLongStateOf(-1L) } - val txDraggedId = remember { mutableLongStateOf(-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() + val dragState = rememberDragReorderState(listState) + val onRestoreSatDefaults = { + satEntries.clear() + satEntries.addAll(Sources.satelliteDataUrls.map { url -> SourceEntry(nextId.longValue++, url) }) + Unit + } + val onRestoreTxDefaults = { + txEntries.clear() + txEntries.addAll(Sources.transceiversDataUrls.map { url -> SourceEntry(nextId.longValue++, url) }) + Unit } val onAccept = { - val satFiltered = satUrls.filter { it.second.isNotBlank() } - val txFiltered = txUrls.filter { it.second.isNotBlank() } + val satFiltered = satEntries.filter { it.url.isNotBlank() } + val txFiltered = txEntries.filter { it.url.isNotBlank() } try { onSave( - satFiltered.map { it.second }, - txFiltered.map { it.second }, - satFiltered.map { satEnabled[it.first] ?: true }, - txFiltered.map { txEnabled[it.first] ?: true } + satFiltered.map { it.url }, + txFiltered.map { it.url }, + satFiltered.map { it.enabled }, + txFiltered.map { it.enabled } ) } finally { onDismiss() } } val satTitle = stringResource(R.string.prefs_data_sources_sat_title) + val satHint = stringResource(R.string.prefs_data_sources_sat_hint) val transceiversTitle = stringResource(R.string.prefs_data_sources_transceivers_title) + val transceiversHint = stringResource(R.string.prefs_data_sources_transceivers_hint) ConfirmDialog( title = stringResource(id = R.string.prefs_data_sources_title), onCancel = onDismiss, @@ -268,7 +250,7 @@ fun DataSourcesDialog( .fillMaxHeight(0.84f) .padding(horizontal = padding), verticalArrangement = Arrangement.spacedBy(4.dp), - contentPadding = PaddingValues(vertical = 6.dp) + contentPadding = PaddingValues(vertical = 0.dp) ) { item { Row( @@ -287,40 +269,33 @@ fun DataSourcesDialog( ) } } - item { - CardButton( - onClick = onRestoreDefaults, - text = stringResource(R.string.prefs_data_sources_restore), - modifier = Modifier.fillMaxWidth() - ) - } sourceSection( sectionKey = "sat", label = satTitle, - urls = satUrls, - listState = listState, - draggedId = satDraggedId, + hint = satHint, + entries = satEntries, + dragState = dragState, statusCodes = statusCodes, - enabledMap = satEnabled, - onToggle = { id -> satEnabled[id] = !(satEnabled[id] ?: true) }, - onAdd = { satUrls.add(nextId.longValue++ to "") }, - onMove = { from, to -> satUrls.add(to, satUrls.removeAt(from)) }, - onRemove = { i -> satUrls.removeAt(i) }, - onUrlChange = { i, v -> satUrls[i] = satUrls[i].first to v } + onAdd = { satEntries.add(SourceEntry(nextId.longValue++, "")) }, + onRestore = onRestoreSatDefaults, + onMove = { from, to -> satEntries.add(to, satEntries.removeAt(from)) }, + onRemove = { i -> satEntries.removeAt(i) }, + onToggle = { i -> satEntries[i] = satEntries[i].copy(enabled = !satEntries[i].enabled) }, + onUrlChange = { i, v -> satEntries[i] = satEntries[i].copy(url = v) } ) sourceSection( sectionKey = "tx", label = transceiversTitle, - urls = txUrls, - listState = listState, - draggedId = txDraggedId, + hint = transceiversHint, + entries = txEntries, + dragState = dragState, statusCodes = statusCodes, - enabledMap = txEnabled, - onToggle = { id -> txEnabled[id] = !(txEnabled[id] ?: true) }, - onAdd = { txUrls.add(nextId.longValue++ to "") }, - onMove = { from, to -> txUrls.add(to, txUrls.removeAt(from)) }, - onRemove = { i -> txUrls.removeAt(i) }, - onUrlChange = { i, v -> txUrls[i] = txUrls[i].first to v } + onAdd = { txEntries.add(SourceEntry(nextId.longValue++, "")) }, + onRestore = onRestoreTxDefaults, + onMove = { from, to -> txEntries.add(to, txEntries.removeAt(from)) }, + onRemove = { i -> txEntries.removeAt(i) }, + onToggle = { i -> txEntries[i] = txEntries[i].copy(enabled = !txEntries[i].enabled) }, + onUrlChange = { i, v -> txEntries[i] = txEntries[i].copy(url = v) } ) } } @@ -329,15 +304,15 @@ fun DataSourcesDialog( private fun LazyListScope.sourceSection( sectionKey: String, label: String, - urls: List>, - listState: LazyListState, - draggedId: MutableState, + hint: String, + entries: SnapshotStateList, + dragState: DragReorderState, statusCodes: Map, - enabledMap: Map, - onToggle: (Long) -> Unit, onAdd: () -> Unit, + onRestore: () -> Unit, onMove: (Int, Int) -> Unit, onRemove: (Int) -> Unit, + onToggle: (Int) -> Unit, onUrlChange: (Int, String) -> Unit ) { item { @@ -349,65 +324,71 @@ private fun LazyListScope.sourceSection( color = MaterialTheme.colorScheme.primary, modifier = Modifier.weight(1f) ) - IconCard(action = onAdd, resId = R.drawable.ic_add, containerColor = MaterialTheme.colorScheme.surfaceVariant) + IconCard( + action = onRestore, + resId = R.drawable.ic_restore, + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + Spacer(modifier = Modifier.width(6.dp)) + IconCard( + action = onAdd, + resId = R.drawable.ic_add, + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) } + Text( + text = hint, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp, bottom = 8.dp) + ) } - itemsIndexed(urls, key = { _, entry -> "$sectionKey-${entry.first}" }) { index, (id, url) -> + itemsIndexed(entries, key = { _, entry -> "$sectionKey-${entry.id}" }) { index, entry -> val enabledTint = MaterialTheme.colorScheme.onSurfaceVariant - 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) - } + val rowState = rememberDragRowState( + dragState = dragState, + items = entries, + item = entry, + key = { "$sectionKey-${it.id}" }, + onMove = onMove + ) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .draggedVisual( - isLifted = isLifted, - translationY = if (rowState.isSettling.value) { - rowState.settleAnim.value - } else { - rowState.offsetY.floatValue + rowState.scrollComp.floatValue - } - ) + .dragLift(isLifted = rowState.isLifted, translationY = rowState.translationY) .animateItem( - fadeInSpec = spring(), - // The dragged row repositions instantly, while its neighbors spring - // out of the way (the "squeeze" effect). - placementSpec = if (isDragging) { + fadeInSpec = tween(durationMillis = 200), + // The dragged row repositions instantly, while its neighbors smoothly + // slide out of the way (the "squeeze" effect). + placementSpec = if (rowState.isDragging) { tween(durationMillis = 0) } else { - spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessMediumLow - ) + tween(durationMillis = 250, easing = FastOutSlowInEasing) }, - fadeOutSpec = spring() + fadeOutSpec = tween(durationMillis = 200) ) ) { - Box( - contentAlignment = Alignment.Center, + Icon( + painter = painterResource(R.drawable.ic_drag_handle), + contentDescription = null, + tint = enabledTint, 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 - ) - } + .padding(top = textFieldLabelOffset) + .dragHandle(rowState) + ) + Spacer(modifier = Modifier.width(12.dp)) OutlinedTextField( - value = url, + value = entry.url, onValueChange = { onUrlChange(index, it) }, - label = { Text("Source URL") }, - supportingText = statusCodes[url]?.let { code -> - { Text(statusLabel(code), color = statusColor(code), fontSize = 12.sp) } + label = { + Row { + Text("Source URL") + statusCodes[entry.url]?.let { code -> + Text(" - ${statusLabel(code)}", color = statusColor(code), fontSize = 12.sp) + } + } }, trailingIcon = { IconButton(onClick = { onRemove(index) }) { @@ -418,191 +399,24 @@ private fun LazyListScope.sourceSection( } }, singleLine = true, - enabled = enabled, + enabled = entry.enabled, modifier = Modifier.weight(1f) ) Checkbox( - checked = enabled, - onCheckedChange = { onToggle(id) } + checked = entry.enabled, + onCheckedChange = { onToggle(index) }, + modifier = Modifier.padding(top = textFieldLabelOffset) ) } } } /** - * 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 neighbors), 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. + * Compensates for the visual weight of the [OutlinedTextField]'s floating label: the label + * pushes the perceived center of the field's content down a bit, so the drag handle and + * checkbox are nudged down by the same amount to stay visually centered on the input line. */ -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 neighbor "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 center crosses a neighbor's midpoint. offsetY - * additionally subtracts the heights of already-swapped neighbors 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 center 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 center 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 center 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 center 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.milliseconds) - } -} +private val textFieldLabelOffset = 8.dp private fun statusLabel(code: Int): String = if (code == NetworkResult.CONNECTION_ERROR) "ERR" else code.toString() 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 18f86c00..ea67b40b 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 @@ -209,7 +209,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> version = uiState.appVersionName, modifier = Modifier.weight(1f) ) - PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like) + PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound) } TopBar { Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) { @@ -231,7 +231,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> } } else { TopBar { - PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like) + PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound) TopCard( onClick = { dialogs.whatsNew = true }, version = uiState.appVersionName, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fa9a8752..a1353f60 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,8 +1,8 @@ [versions] #noinspection UnusedVersionCatalogEntry -appVersionCode = "446" +appVersionCode = "447" #noinspection UnusedVersionCatalogEntry -appVersionName = "4.4.6" +appVersionName = "4.4.7" #noinspection UnusedVersionCatalogEntry compileSdk = "37" #noinspection UnusedVersionCatalogEntry