Added DeepSpace passes filter, fixed refresh issue (#208)

This commit is contained in:
Arty Bishop authored and Arty Bishop committed 2026-04-18 15:56:28 +01:00
1 parent d2b3184084
commit 8265b73d75
10 files changed
+89 -45

No files matched your search

@@ -31,6 +31,7 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -45,6 +46,9 @@ class SatelliteRepo(
private val _passes = MutableStateFlow<List<OrbitalPass>>(emptyList()) private val _passes = MutableStateFlow<List<OrbitalPass>>(emptyList())
override val passes: StateFlow<List<OrbitalPass>> = _passes override val passes: StateFlow<List<OrbitalPass>> = _passes
private val _isCalculating = MutableStateFlow(false)
override val isCalculating: StateFlow<Boolean> = _isCalculating
private val _satellites = MutableStateFlow<List<OrbitalObject>>(emptyList()) private val _satellites = MutableStateFlow<List<OrbitalObject>>(emptyList())
override val satellites: StateFlow<List<OrbitalObject>> = _satellites override val satellites: StateFlow<List<OrbitalObject>> = _satellites
@@ -53,7 +57,7 @@ class SatelliteRepo(
override suspend fun initRepository() = withContext(dispatcher) { override suspend fun initRepository() = withContext(dispatcher) {
settingsRepo.selectedIds.collect { selectedIds -> settingsRepo.selectedIds.collect { selectedIds ->
_satellites.update { localStorage.getEntriesWithIds(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) calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
} }
} }
@@ -95,11 +99,11 @@ class SatelliteRepo(
} }
override suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>) { override suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>) {
_isCalculating.value = true
// Normalize to the start of the current minute so that coarse 60-second stepping // 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 // in getLeoPass always begins from the same phase, producing stable AOS/LOS times
val normalizedTime = time / 60_000L * 60_000L val normalizedTime = time / 60_000L * 60_000L
val currentSatellites = _satellites.value val currentSatellites = _satellites.value
if (currentSatellites.isNotEmpty()) {
withContext(dispatcher) { withContext(dispatcher) {
val idsWithModes = localStorage.getIdsWithModes(modes) val idsWithModes = localStorage.getIdsWithModes(modes)
val stationPos = settingsRepo.stationPosition.value val stationPos = settingsRepo.stationPosition.value
@@ -125,11 +129,10 @@ class SatelliteRepo(
} }
} }
newPasses.sortBy { it.aosTime } newPasses.sortBy { it.aosTime }
delay(1000) // Simulate loading time for better UX
_passes.update { newPasses } _passes.update { newPasses }
} }
} else { _isCalculating.value = false
_passes.update { emptyList() }
}
} }
private fun OrbitalObject.getPasses(pos: GeoPos, time: Long, hours: Int): List<OrbitalPass> { private fun OrbitalObject.getPasses(pos: GeoPos, time: Long, hours: Int): List<OrbitalPass> {
@@ -51,6 +51,7 @@ class SettingsRepo(
private val keyBluetoothFrequencyState = "bluetoothFrequencyState" private val keyBluetoothFrequencyState = "bluetoothFrequencyState"
private val keyBluetoothFrequencyAddress = "bluetoothFrequencyAddress" private val keyBluetoothFrequencyAddress = "bluetoothFrequencyAddress"
private val keyBluetoothFrequencyFormat = "bluetoothFrequencyFormat" private val keyBluetoothFrequencyFormat = "bluetoothFrequencyFormat"
private val keyFilterShowDeepSpace = "filterShowDeepSpace"
private val keyFilterHoursAhead = "filterHoursAhead" private val keyFilterHoursAhead = "filterHoursAhead"
private val keyFilterMinElevation = "filterMinElevation" private val keyFilterMinElevation = "filterMinElevation"
private val keyNumberOfRadios = "numberOfRadios" private val keyNumberOfRadios = "numberOfRadios"
@@ -121,6 +122,7 @@ class SettingsRepo(
override val passesSettings: StateFlow<PassesSettings> = _passesSettings override val passesSettings: StateFlow<PassesSettings> = _passesSettings
override fun setPassesSettings(settings: PassesSettings) = preferences.edit { override fun setPassesSettings(settings: PassesSettings) = preferences.edit {
putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace)
putInt(keyFilterHoursAhead, settings.hoursAhead) putInt(keyFilterHoursAhead, settings.hoursAhead)
putLong(keyFilterMinElevation, settings.minElevation.toRawBits()) putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma)) putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
@@ -128,11 +130,12 @@ class SettingsRepo(
} }
private fun getPassesSettings(): PassesSettings { private fun getPassesSettings(): PassesSettings {
val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true)
val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24) val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24)
val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits())) val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits()))
val selectedModesString = preferences.getString(keySelectedModes, null) val selectedModesString = preferences.getString(keySelectedModes, null)
val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList() val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList()
return PassesSettings(hoursAhead, minElevation, selectedModes) return PassesSettings(showDeepSpace, hoursAhead, minElevation, selectedModes)
} }
//endregion //endregion
@@ -24,6 +24,7 @@ data class DatabaseState(
) )
data class PassesSettings( data class PassesSettings(
val showDeepSpace: Boolean = true,
val hoursAhead: Int, val hoursAhead: Int,
val minElevation: Double, val minElevation: Double,
val selectedModes: List<String> val selectedModes: List<String>
@@ -31,6 +31,9 @@ interface ISatelliteRepo {
/** Raw calculated passes (without live progress). Updated on selection/filter change. */ /** Raw calculated passes (without live progress). Updated on selection/filter change. */
val passes: StateFlow<List<OrbitalPass>> val passes: StateFlow<List<OrbitalPass>>
/** Whether the repo is currently calculating passes. */
val isCalculating: StateFlow<Boolean>
/** Load satellite objects from DB based on the current selection. */ /** Load satellite objects from DB based on the current selection. */
suspend fun initRepository() suspend fun initRepository()
@@ -32,6 +32,7 @@
<string name="pass_filter_title">Filter passes</string> <string name="pass_filter_title">Filter passes</string>
<string name="pass_filter_elev">Minimal elevation</string> <string name="pass_filter_elev">Minimal elevation</string>
<string name="pass_filter_hours">Hours ahead</string> <string name="pass_filter_hours">Hours ahead</string>
<string name="pass_filter_deep_space">DeepSpace (period >225min)</string>
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string> <string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
<string name="pass_modes_title">Select modulation type</string> <string name="pass_modes_title">Select modulation type</string>
<string name="pass_satId" translatable="false">%05d</string> <string name="pass_satId" translatable="false">%05d</string>
@@ -32,12 +32,15 @@ import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableDoubleStateOf import androidx.compose.runtime.mutableDoubleStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource 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 @Preview
@Composable @Composable
private fun PassesDialogPreview() { private fun PassesDialogPreview() {
MainTheme { PassesDialog(24, 16.0, {}) { _, _ -> } } MainTheme { PassesDialog(24, 16.0, true, {}) { _, _, _ -> } }
} }
@Composable @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 hoursIndex = remember { mutableIntStateOf(hourSteps.indexOfFirst { it >= hours }.coerceAtLeast(0)) }
val elevationValueNew = remember { mutableDoubleStateOf(elevation) } val elevationValueNew = remember { mutableDoubleStateOf(elevation) }
var deepSpaceEnabled by remember { mutableStateOf(showDeepSpace) }
val onAccept = { 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) { 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( SliderRow(
title = stringResource(R.string.pass_filter_elev), title = stringResource(R.string.pass_filter_elev),
value = elevationValueNew.doubleValue, value = elevationValueNew.doubleValue,
@@ -188,3 +203,20 @@ internal fun RadiosDialog(modes: List<String>, 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)
}
}
@@ -100,9 +100,10 @@ private fun PassesScreen(
PassesDialog( PassesDialog(
hours = uiState.hours, hours = uiState.hours,
elevation = uiState.elevation, elevation = uiState.elevation,
showDeepSpace = uiState.showDeepSpace,
cancel = { onAction(PassesAction.TogglePassesDialog) } cancel = { onAction(PassesAction.TogglePassesDialog) }
) { hours, elevation -> ) { hours, elevation, showDeepSpace ->
onAction(PassesAction.FilterPasses(hours, elevation)) onAction(PassesAction.FilterPasses(hours, elevation, showDeepSpace))
} }
} }
if (uiState.isRadiosDialogShown) { if (uiState.isRadiosDialogShown) {
@@ -29,6 +29,7 @@ data class PassesState(
val isNextTimeAos: Boolean = true, val isNextTimeAos: Boolean = true,
val hours: Int = 24, val hours: Int = 24,
val elevation: Double = 16.0, val elevation: Double = 16.0,
val showDeepSpace: Boolean = true,
val modes: List<String> = emptyList(), val modes: List<String> = emptyList(),
val itemsList: List<OrbitalPass> = emptyList(), val itemsList: List<OrbitalPass> = emptyList(),
val shouldSeeWhatsNew: Boolean = false val shouldSeeWhatsNew: Boolean = false
@@ -36,7 +37,7 @@ data class PassesState(
sealed interface PassesAction { sealed interface PassesAction {
data object DismissWhatsNew : 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<String>) : PassesAction data class FilterRadios(val modes: List<String>) : PassesAction
data object RefreshPasses : PassesAction data object RefreshPasses : PassesAction
data object TogglePassesDialog : PassesAction data object TogglePassesDialog : PassesAction
@@ -34,7 +34,6 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -51,6 +50,7 @@ class PassesViewModel(
nextPass = defaultPass, nextPass = defaultPass,
hours = settingsRepo.passesSettings.value.hoursAhead, hours = settingsRepo.passesSettings.value.hoursAhead,
elevation = settingsRepo.passesSettings.value.minElevation, elevation = settingsRepo.passesSettings.value.minElevation,
showDeepSpace = settingsRepo.passesSettings.value.showDeepSpace,
modes = settingsRepo.passesSettings.value.selectedModes, modes = settingsRepo.passesSettings.value.selectedModes,
shouldSeeWhatsNew = settingsRepo.otherSettings.value.shouldSeeWhatsNew shouldSeeWhatsNew = settingsRepo.otherSettings.value.shouldSeeWhatsNew
) )
@@ -58,29 +58,20 @@ class PassesViewModel(
val uiState: StateFlow<PassesState> = _uiState val uiState: StateFlow<PassesState> = _uiState
init { init {
// Show refreshing indicator whenever the selected satellites list changes // Refresh indicator: mirrors the repo's isCalculating state
viewModelScope.launch { viewModelScope.launch {
settingsRepo.selectedIds.collectLatest { selectedIds -> satelliteRepo.isCalculating.collect { calculating ->
_uiState.update { it.copy(isRefreshing = true) } _uiState.update { it.copy(isRefreshing = calculating) }
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) }
} }
} }
// Local tick loop — computes pass progress and countdown timer every second // Local tick loop — computes pass progress and countdown timer every second
viewModelScope.launch { viewModelScope.launch {
while (isActive) { while (isActive) {
val timeNow = System.currentTimeMillis() 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) val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -108,11 +99,17 @@ class PassesViewModel(
fun onAction(action: PassesAction) { fun onAction(action: PassesAction) {
when (action) { when (action) {
PassesAction.DismissWhatsNew -> settingsRepo.setWhatsNewDismissed() PassesAction.DismissWhatsNew -> settingsRepo.setWhatsNewDismissed()
is PassesAction.FilterPasses -> applyFilter(action.hoursAhead, action.minElevation, _uiState.value.modes) is PassesAction.FilterPasses ->
is PassesAction.FilterRadios -> applyFilter(_uiState.value.hours, _uiState.value.elevation, action.modes) 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.RefreshPasses -> refreshPasses()
PassesAction.TogglePassesDialog -> _uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) } PassesAction.TogglePassesDialog ->
PassesAction.ToggleRadiosDialog -> _uiState.update { it.copy(isRadiosDialogShown = !it.isRadiosDialogShown) } _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) return Triple(defaultPass, "00:00:00", true)
} }
private fun applyFilter(hoursAhead: Int, minElevation: Double, modes: List<String>) = viewModelScope.launch { private fun applyFilter(
_uiState.update { it.copy(isRefreshing = true) } hoursAhead: Int,
settingsRepo.setPassesSettings(PassesSettings(hoursAhead, minElevation, modes)) minElevation: Double,
_uiState.update { it.copy(hours = hoursAhead, elevation = minElevation, modes = modes) } showDeepSpace: Boolean,
satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes) modes: List<String>
_uiState.update { it.copy(isRefreshing = false) } ) = 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 { 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) satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
_uiState.update { it.copy(isRefreshing = false) }
} }
companion object { companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory { val Factory: ViewModelProvider.Factory = viewModelFactory {
val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY
+2 -2
View File
@@ -3,7 +3,7 @@
appVersionCode = "420" appVersionCode = "420"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionName = "4.2.0" appVersionName = "4.2.0"
#noinspection UnusedVersionCatalogEntry #noinspection GradleDependency,UnusedVersionCatalogEntry
compileSdk = "36" compileSdk = "36"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
minSdk = "24" minSdk = "24"
@@ -12,7 +12,7 @@ jdkVersion = "17"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
packageName = "com.rtbishop.look4sat" packageName = "com.rtbishop.look4sat"
android-gradle-plugin = "9.1.0" android-gradle-plugin = "9.1.1"
google-ksp = "2.3.6" google-ksp = "2.3.6"
kotlin = "2.3.20" kotlin = "2.3.20"