From 8265b73d7590ca84efe04db4be1c235c2befd2bd Mon Sep 17 00:00:00 2001 From: Arty Bishop Date: Sat, 18 Apr 2026 15:46:31 +0100 Subject: [PATCH] Added DeepSpace passes filter, fixed refresh issue (#208) --- .../core/data/repository/SatelliteRepo.kt | 59 +++++++++--------- .../core/data/repository/SettingsRepo.kt | 5 +- .../look4sat/core/domain/model/Settings.kt | 1 + .../core/domain/repository/ISatelliteRepo.kt | 3 + .../src/main/res/values/strings.xml | 1 + .../look4sat/feature/passes/PassesDialog.kt | 38 +++++++++++- .../look4sat/feature/passes/PassesScreen.kt | 5 +- .../look4sat/feature/passes/PassesState.kt | 3 +- .../feature/passes/PassesViewModel.kt | 61 +++++++++---------- gradle/libs.versions.toml | 4 +- 10 files changed, 112 insertions(+), 68 deletions(-) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SatelliteRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SatelliteRepo.kt index 9a04d0bc..33fdaa14 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SatelliteRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SatelliteRepo.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -45,6 +46,9 @@ class SatelliteRepo( private val _passes = MutableStateFlow>(emptyList()) override val passes: StateFlow> = _passes + private val _isCalculating = MutableStateFlow(false) + override val isCalculating: StateFlow = _isCalculating + private val _satellites = MutableStateFlow>(emptyList()) override val satellites: StateFlow> = _satellites @@ -53,7 +57,7 @@ class SatelliteRepo( override suspend fun initRepository() = withContext(dispatcher) { settingsRepo.selectedIds.collect { selectedIds -> _satellites.update { localStorage.getEntriesWithIds(selectedIds) } - val (hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value + val (_, hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes) } } @@ -95,41 +99,40 @@ class SatelliteRepo( } override suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List) { + _isCalculating.value = true // Normalize to the start of the current minute so that coarse 60-second stepping // in getLeoPass always begins from the same phase, producing stable AOS/LOS times val normalizedTime = time / 60_000L * 60_000L val currentSatellites = _satellites.value - if (currentSatellites.isNotEmpty()) { - withContext(dispatcher) { - val idsWithModes = localStorage.getIdsWithModes(modes) - val stationPos = settingsRepo.stationPosition.value - val filteredSatellites = if (idsWithModes.isEmpty()) { - currentSatellites - } else { - currentSatellites.filter { it.data.catnum in idsWithModes } - } - // Compute passes for each satellite in parallel - val passLists = coroutineScope { - filteredSatellites.map { satellite -> - async { satellite.getPasses(stationPos, normalizedTime, hoursAhead) } - }.awaitAll() - } - // Flatten and filter in a single pass - val timeFuture = normalizedTime + (hoursAhead * 60L * 60L * 1000L) - val newPasses = ArrayList() - for (list in passLists) { - for (pass in list) { - if (pass.losTime > time && pass.aosTime < timeFuture && pass.maxElevation > minElevation) { - newPasses.add(pass) - } + withContext(dispatcher) { + val idsWithModes = localStorage.getIdsWithModes(modes) + val stationPos = settingsRepo.stationPosition.value + val filteredSatellites = if (idsWithModes.isEmpty()) { + currentSatellites + } else { + currentSatellites.filter { it.data.catnum in idsWithModes } + } + // Compute passes for each satellite in parallel + val passLists = coroutineScope { + filteredSatellites.map { satellite -> + async { satellite.getPasses(stationPos, normalizedTime, hoursAhead) } + }.awaitAll() + } + // Flatten and filter in a single pass + val timeFuture = normalizedTime + (hoursAhead * 60L * 60L * 1000L) + val newPasses = ArrayList() + for (list in passLists) { + for (pass in list) { + if (pass.losTime > time && pass.aosTime < timeFuture && pass.maxElevation > minElevation) { + newPasses.add(pass) } } - newPasses.sortBy { it.aosTime } - _passes.update { newPasses } } - } else { - _passes.update { emptyList() } + newPasses.sortBy { it.aosTime } + delay(1000) // Simulate loading time for better UX + _passes.update { newPasses } } + _isCalculating.value = false } private fun OrbitalObject.getPasses(pos: GeoPos, time: Long, hours: Int): List { 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 9a74e454..263f2245 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 @@ -51,6 +51,7 @@ class SettingsRepo( private val keyBluetoothFrequencyState = "bluetoothFrequencyState" private val keyBluetoothFrequencyAddress = "bluetoothFrequencyAddress" private val keyBluetoothFrequencyFormat = "bluetoothFrequencyFormat" + private val keyFilterShowDeepSpace = "filterShowDeepSpace" private val keyFilterHoursAhead = "filterHoursAhead" private val keyFilterMinElevation = "filterMinElevation" private val keyNumberOfRadios = "numberOfRadios" @@ -121,6 +122,7 @@ class SettingsRepo( override val passesSettings: StateFlow = _passesSettings override fun setPassesSettings(settings: PassesSettings) = preferences.edit { + putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace) putInt(keyFilterHoursAhead, settings.hoursAhead) putLong(keyFilterMinElevation, settings.minElevation.toRawBits()) putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma)) @@ -128,11 +130,12 @@ class SettingsRepo( } private fun getPassesSettings(): PassesSettings { + val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true) val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24) val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits())) val selectedModesString = preferences.getString(keySelectedModes, null) val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList() - return PassesSettings(hoursAhead, minElevation, selectedModes) + return PassesSettings(showDeepSpace, hoursAhead, minElevation, selectedModes) } //endregion diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt index 2cb86239..11cb77ed 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt @@ -24,6 +24,7 @@ data class DatabaseState( ) data class PassesSettings( + val showDeepSpace: Boolean = true, val hoursAhead: Int, val minElevation: Double, val selectedModes: List diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISatelliteRepo.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISatelliteRepo.kt index e50e8b57..fe8d4258 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISatelliteRepo.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISatelliteRepo.kt @@ -31,6 +31,9 @@ interface ISatelliteRepo { /** Raw calculated passes (without live progress). Updated on selection/filter change. */ val passes: StateFlow> + /** Whether the repo is currently calculating passes. */ + val isCalculating: StateFlow + /** Load satellite objects from DB based on the current selection. */ suspend fun initRepository() diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 77e4f8a3..f1b5fc4d 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -32,6 +32,7 @@ Filter passes Minimal elevation Hours ahead + DeepSpace (period >225min) -- : -- : -- Select modulation type %05d diff --git a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesDialog.kt b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesDialog.kt index acfa450e..a5f5f44c 100644 --- a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesDialog.kt +++ b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesDialog.kt @@ -32,12 +32,15 @@ import androidx.compose.material3.Checkbox import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableDoubleStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource @@ -65,17 +68,29 @@ private val hourSteps = listOf(1, 2, 4, 8, 12, 24, 48, 72, 96, 120, 144, 168, 19 @Preview @Composable private fun PassesDialogPreview() { - MainTheme { PassesDialog(24, 16.0, {}) { _, _ -> } } + MainTheme { PassesDialog(24, 16.0, true, {}) { _, _, _ -> } } } @Composable -internal fun PassesDialog(hours: Int, elevation: Double, cancel: () -> Unit, accept: (Int, Double) -> Unit) { +internal fun PassesDialog( + hours: Int, + elevation: Double, + showDeepSpace: Boolean, + cancel: () -> Unit, + accept: (Int, Double, Boolean) -> Unit +) { val hoursIndex = remember { mutableIntStateOf(hourSteps.indexOfFirst { it >= hours }.coerceAtLeast(0)) } val elevationValueNew = remember { mutableDoubleStateOf(elevation) } + var deepSpaceEnabled by remember { mutableStateOf(showDeepSpace) } val onAccept = { - accept(hourSteps[hoursIndex.intValue], elevationValueNew.doubleValue).also { cancel() } + accept(hourSteps[hoursIndex.intValue], elevationValueNew.doubleValue, deepSpaceEnabled).also { cancel() } } SharedDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) { + ToggleRow( + title = stringResource(R.string.pass_filter_deep_space), + checked = deepSpaceEnabled, + onCheckedChange = { deepSpaceEnabled = it } + ) SliderRow( title = stringResource(R.string.pass_filter_elev), value = elevationValueNew.doubleValue, @@ -188,3 +203,20 @@ internal fun RadiosDialog(modes: List, cancel: () -> Unit, accept: (List } } } + +@Composable +private fun ToggleRow(title: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = LocalSpacing.current.large) + ) { + Text( + text = title, + fontSize = 16.sp, + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.onSurface + ) + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} diff --git a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesScreen.kt b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesScreen.kt index 34e051e0..2d59ae8b 100644 --- a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesScreen.kt +++ b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesScreen.kt @@ -100,9 +100,10 @@ private fun PassesScreen( PassesDialog( hours = uiState.hours, elevation = uiState.elevation, + showDeepSpace = uiState.showDeepSpace, cancel = { onAction(PassesAction.TogglePassesDialog) } - ) { hours, elevation -> - onAction(PassesAction.FilterPasses(hours, elevation)) + ) { hours, elevation, showDeepSpace -> + onAction(PassesAction.FilterPasses(hours, elevation, showDeepSpace)) } } if (uiState.isRadiosDialogShown) { diff --git a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesState.kt b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesState.kt index 446a051c..ba5459d1 100644 --- a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesState.kt +++ b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesState.kt @@ -29,6 +29,7 @@ data class PassesState( val isNextTimeAos: Boolean = true, val hours: Int = 24, val elevation: Double = 16.0, + val showDeepSpace: Boolean = true, val modes: List = emptyList(), val itemsList: List = emptyList(), val shouldSeeWhatsNew: Boolean = false @@ -36,7 +37,7 @@ data class PassesState( sealed interface PassesAction { data object DismissWhatsNew : PassesAction - data class FilterPasses(val hoursAhead: Int, val minElevation: Double) : PassesAction + data class FilterPasses(val hoursAhead: Int, val minElevation: Double, val showDeepSpace: Boolean) : PassesAction data class FilterRadios(val modes: List) : PassesAction data object RefreshPasses : PassesAction data object TogglePassesDialog : PassesAction diff --git a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt index 4d9e5420..cec12625 100644 --- a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt +++ b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt @@ -34,7 +34,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -51,6 +50,7 @@ class PassesViewModel( nextPass = defaultPass, hours = settingsRepo.passesSettings.value.hoursAhead, elevation = settingsRepo.passesSettings.value.minElevation, + showDeepSpace = settingsRepo.passesSettings.value.showDeepSpace, modes = settingsRepo.passesSettings.value.selectedModes, shouldSeeWhatsNew = settingsRepo.otherSettings.value.shouldSeeWhatsNew ) @@ -58,29 +58,20 @@ class PassesViewModel( val uiState: StateFlow = _uiState init { - // Show refreshing indicator whenever the selected satellites list changes + // Refresh indicator: mirrors the repo's isCalculating state viewModelScope.launch { - settingsRepo.selectedIds.collectLatest { selectedIds -> - _uiState.update { it.copy(isRefreshing = true) } - if (selectedIds.isEmpty()) { - // No satellites selected — show indicator briefly, then stop - delay(1000) - _uiState.update { it.copy(isRefreshing = false) } - } - // For non-empty selections, the passes collector below will clear isRefreshing - } - } - // Stop refreshing whenever new passes arrive (from initial load, selection change, or filter) - viewModelScope.launch { - satelliteRepo.passes.drop(1).collect { _ -> - _uiState.update { it.copy(isRefreshing = false) } + satelliteRepo.isCalculating.collect { calculating -> + _uiState.update { it.copy(isRefreshing = calculating) } } } // Local tick loop — computes pass progress and countdown timer every second viewModelScope.launch { while (isActive) { val timeNow = System.currentTimeMillis() - val processed = computePassProgress(satelliteRepo.passes.value, timeNow) + val showDeepSpace = _uiState.value.showDeepSpace + val allPasses = satelliteRepo.passes.value + val filtered = if (showDeepSpace) allPasses else allPasses.filter { !it.isDeepSpace } + val processed = computePassProgress(filtered, timeNow) val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow) _uiState.update { it.copy( @@ -108,11 +99,17 @@ class PassesViewModel( fun onAction(action: PassesAction) { when (action) { PassesAction.DismissWhatsNew -> settingsRepo.setWhatsNewDismissed() - is PassesAction.FilterPasses -> applyFilter(action.hoursAhead, action.minElevation, _uiState.value.modes) - is PassesAction.FilterRadios -> applyFilter(_uiState.value.hours, _uiState.value.elevation, action.modes) + is PassesAction.FilterPasses -> + applyFilter(action.hoursAhead, action.minElevation, action.showDeepSpace, _uiState.value.modes) + + is PassesAction.FilterRadios -> + applyFilter(_uiState.value.hours, _uiState.value.elevation, _uiState.value.showDeepSpace, action.modes) PassesAction.RefreshPasses -> refreshPasses() - PassesAction.TogglePassesDialog -> _uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) } - PassesAction.ToggleRadiosDialog -> _uiState.update { it.copy(isRadiosDialogShown = !it.isRadiosDialogShown) } + PassesAction.TogglePassesDialog -> + _uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) } + + PassesAction.ToggleRadiosDialog -> + _uiState.update { it.copy(isRadiosDialogShown = !it.isRadiosDialogShown) } } } @@ -153,22 +150,24 @@ class PassesViewModel( return Triple(defaultPass, "00:00:00", true) } - private fun applyFilter(hoursAhead: Int, minElevation: Double, modes: List) = viewModelScope.launch { - _uiState.update { it.copy(isRefreshing = true) } - settingsRepo.setPassesSettings(PassesSettings(hoursAhead, minElevation, modes)) - _uiState.update { it.copy(hours = hoursAhead, elevation = minElevation, modes = modes) } - satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes) - _uiState.update { it.copy(isRefreshing = false) } + private fun applyFilter( + hoursAhead: Int, + minElevation: Double, + showDeepSpace: Boolean, + modes: List + ) = viewModelScope.launch { + settingsRepo.setPassesSettings(PassesSettings(showDeepSpace, hoursAhead, minElevation, modes)) + _uiState.update { + it.copy(hours = hoursAhead, elevation = minElevation, showDeepSpace = showDeepSpace, modes = modes) + } + if (showDeepSpace) satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes) } private fun refreshPasses() = viewModelScope.launch { - _uiState.update { it.copy(isRefreshing = true) } - val (hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value + val (_, hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes) - _uiState.update { it.copy(isRefreshing = false) } } - companion object { val Factory: ViewModelProvider.Factory = viewModelFactory { val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6be8cced..40f0173f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ appVersionCode = "420" #noinspection UnusedVersionCatalogEntry appVersionName = "4.2.0" -#noinspection UnusedVersionCatalogEntry +#noinspection GradleDependency,UnusedVersionCatalogEntry compileSdk = "36" #noinspection UnusedVersionCatalogEntry minSdk = "24" @@ -12,7 +12,7 @@ jdkVersion = "17" #noinspection UnusedVersionCatalogEntry packageName = "com.rtbishop.look4sat" -android-gradle-plugin = "9.1.0" +android-gradle-plugin = "9.1.1" google-ksp = "2.3.6" kotlin = "2.3.20"