feat: satellite page transponder/activity filters as virtual types

The satellite selection screen's 'Select satellite type' dialog now lists
three virtual types first (AMSAT Live FM, AMSAT Live Linear, Live SSTV)
before the TLE-source types. Selecting one filters the satellite list to
that transponder/activity set; multiple selections union. 'All' is kept.

- SelectionRepo.getTypesList: virtual types prepended (All preserved)
- SelectionRepo.resolveTypeIds: FM/Linear -> AMSAT catnum sets,
  Live SSTV -> mode=SSTV radios; other types via persisted IDs
- Tests: virtual-type filtering, union, clear, list-order assertion
This commit is contained in:
atsunatsu committed 2026-09-23 23:25:52 +08:00
1 parent 85a4b9cfa3
commit 0163c7b217
3 files changed
+102 -9

No files matched your search

@@ -43,11 +43,13 @@ class SelectionRepo(
// Resolve type IDs once when types change, then filter items reactively. // Resolve type IDs once when types change, then filter items reactively.
// The HashSet gives O(1) catnum lookups instead of O(n) with a List. // The HashSet gives O(1) catnum lookups instead of O(n) with a List.
// The three virtual types ("AMSAT Live FM", "AMSAT Live Linear", "Live SSTV")
// are resolved from live transponder data instead of SharedPreferences.
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> -> private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
val catnumSet: Set<Int>? = if (types.isEmpty()) { val catnumSet: Set<Int>? = if (types.isEmpty()) {
null // null = no filtering null // null = no filtering
} else { } else {
val ids = settingsRepo.getSatelliteTypesIds(types) val ids = resolveTypeIds(types)
if (ids.isEmpty()) null else ids.toHashSet() if (ids.isEmpty()) null else ids.toHashSet()
} }
currentItems.map { items -> currentItems.map { items ->
@@ -61,8 +63,28 @@ class SelectionRepo(
override fun getCurrentTypes() = currentTypes.value override fun getCurrentTypes() = currentTypes.value
override fun getTypesList() = Sources.satelliteDataUrls.keys.sorted().toMutableList().apply { /**
removeAt(0) * Resolves a list of type names to satellite catnums. The three virtual
* transponder/activity types are resolved from live data; all other types
* come from the per-type ID lists persisted by [ISettingsRepo].
*/
private suspend fun resolveTypeIds(types: List<String>): List<Int> {
val idsSet = mutableSetOf<Int>()
types.forEach { type ->
when (type) {
"AMSAT Live FM" -> idsSet.addAll(settingsRepo.getAmSatFmCatnums())
"AMSAT Live Linear" -> idsSet.addAll(settingsRepo.getAmSatLinearCatnums())
"Live SSTV" -> idsSet.addAll(localSource.getIdsWithModes(listOf("SSTV")))
else -> idsSet.addAll(settingsRepo.getSatelliteTypesIds(listOf(type)))
}
}
return idsSet.toList()
}
override fun getTypesList() = buildList {
// 三个转发器/活动虚拟类型排在最前.
addAll(listOf("AMSAT Live FM", "AMSAT Live Linear", "Live SSTV"))
addAll(Sources.satelliteDataUrls.keys.sorted().toMutableList().apply { removeAt(0) })
} }
override suspend fun getEntriesFlow() = withContext(dispatcher) { override suspend fun getEntriesFlow() = withContext(dispatcher) {
@@ -112,22 +112,86 @@ class SelectionRepoSearchTest {
assertTrue(repo.getEntriesFlow().first().isEmpty()) assertTrue(repo.getEntriesFlow().first().isEmpty())
} }
private fun createRepo(items: List<SatItem>): ISelectionRepo { @Test
fun `FM virtual type shows only AMSAT FM list satellites`() = runTest {
val repo = createRepo(
items = sampleItems,
amSatFm = setOf(25544, 39444)
)
repo.setTypes(listOf("AMSAT Live FM"))
val results = repo.getEntriesFlow().first()
assertEquals(listOf(25544, 39444), results.map { it.catnum })
}
@Test
fun `SSTV virtual type shows only satellites with SSTV radios`() = runTest {
val repo = createRepo(
items = sampleItems,
sstvIds = listOf(43803)
)
repo.setTypes(listOf("Live SSTV"))
val results = repo.getEntriesFlow().first()
assertEquals(listOf(43803), results.map { it.catnum })
}
@Test
fun `multiple virtual types union their satellites`() = runTest {
val repo = createRepo(
items = sampleItems,
amSatFm = setOf(25544),
amSatLinear = setOf(7530),
sstvIds = listOf(43803)
)
repo.setTypes(listOf("AMSAT Live FM", "Live SSTV"))
val results = repo.getEntriesFlow().first()
assertEquals(setOf(25544, 43803), results.map { it.catnum }.toSet())
}
@Test
fun `clearing types restores full list`() = runTest {
val repo = createRepo(items = sampleItems, amSatFm = setOf(25544))
repo.setTypes(listOf("AMSAT Live FM"))
assertTrue(repo.getEntriesFlow().first().isNotEmpty())
repo.setTypes(emptyList())
assertEquals(sampleItems.size, repo.getEntriesFlow().first().size)
}
@Test
fun `types list has virtual transponder types first and keeps All`() {
val repo = createRepo(sampleItems)
val types = repo.getTypesList()
assertTrue(types.size >= 4)
assertEquals("AMSAT Live FM", types[0])
assertEquals("AMSAT Live Linear", types[1])
assertEquals("Live SSTV", types[2])
// "All" must be kept in the list (first original type shown after virtual ones).
assertTrue("All" in types)
}
private fun createRepo(
items: List<SatItem>,
amSatFm: Set<Int> = emptySet(),
amSatLinear: Set<Int> = emptySet(),
sstvIds: List<Int> = emptyList()
): ISelectionRepo {
return SelectionRepo( return SelectionRepo(
dispatcher = Dispatchers.Unconfined, dispatcher = Dispatchers.Unconfined,
localSource = FakeLocalSourceForSearch(items), localSource = FakeLocalSourceForSearch(items, sstvIds),
settingsRepo = FakeSettingsRepoForSearch() settingsRepo = FakeSettingsRepoForSearch(amSatFm, amSatLinear)
) )
} }
} }
private class FakeLocalSourceForSearch(private val items: List<SatItem>) : ILocalSource { private class FakeLocalSourceForSearch(
private val items: List<SatItem>,
private val sstvIds: List<Int> = emptyList()
) : ILocalSource {
override suspend fun getEntriesTotal(): Int = items.size override suspend fun getEntriesTotal(): Int = items.size
override suspend fun getEntriesList(): List<SatItem> = items override suspend fun getEntriesList(): List<SatItem> = items
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList() override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
override suspend fun insertEntries(entries: List<OrbitalData>) = Unit override suspend fun insertEntries(entries: List<OrbitalData>) = Unit
override suspend fun deleteEntries() = Unit override suspend fun deleteEntries() = Unit
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList() override suspend fun getIdsWithModes(modes: List<String>): List<Int> = sstvIds
override suspend fun getRadiosTotal(): Int = 0 override suspend fun getRadiosTotal(): Int = 0
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList() override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) = Unit override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) = Unit
@@ -135,7 +199,10 @@ private class FakeLocalSourceForSearch(private val items: List<SatItem>) : ILoca
override suspend fun deleteRadios() = Unit override suspend fun deleteRadios() = Unit
} }
private class FakeSettingsRepoForSearch : ISettingsRepo { private class FakeSettingsRepoForSearch(
private val amSatFm: Set<Int> = emptySet(),
private val amSatLinear: Set<Int> = emptySet()
) : ISettingsRepo {
override val appVersionName: String = "test" override val appVersionName: String = "test"
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList()) override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList()) override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
@@ -177,6 +244,9 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
override fun updateDataSourcesStatus(status: Map<String, Int>) = Unit override fun updateDataSourcesStatus(status: Map<String, Int>) = Unit
override fun getAmSatFmCatnums(): Set<Int> = amSatFm
override fun getAmSatLinearCatnums(): Set<Int> = amSatLinear
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) = Unit
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = "" override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
@@ -177,6 +177,7 @@ private fun SatellitesScreen(
) )
} }
} }
// 转发器/活动筛选已在"卫星类型"对话框前三个选项中体现(AMSAT Live FM/Linear, Live SSTV).
ElevatedCard(modifier = Modifier.fillMaxSize()) { ElevatedCard(modifier = Modifier.fillMaxSize()) {
val emptyMessage = stringResource(R.string.sat_empty_list_message) val emptyMessage = stringResource(R.string.sat_empty_list_message)
when { when {