fix(mutual): keep scroll position when re-querying from the match page

queryMutualPasses() reset listScrollIndex to 0 on every query, so tapping
"query" on the match page jumped the list back to the top. Now the default
keeps the current position; the stale results stay on screen while the new
query runs (clearing them shrank the list below a screen and clamped the
position away) and are replaced when the new results arrive. New VM test
covers position + stale-results behaviour through a settled re-query.
This commit is contained in:
atsunatsu committed 2026-09-22 16:41:51 +08:00
1 parent 6962a4bfa1
commit d6bdf5ecb6
3 files changed
+75 -10

No files matched your search

@@ -161,7 +161,7 @@ fun MutualScreen(
viewModel = viewModel, viewModel = viewModel,
state = state, state = state,
isVertical = isVertical, isVertical = isVertical,
onQuery = viewModel::queryMutualPasses, onQuery = { viewModel.queryMutualPasses() },
onSelectPass = viewModel::onSelectPass, onSelectPass = viewModel::onSelectPass,
onNavigateToRadar = navigateToRadar, onNavigateToRadar = navigateToRadar,
onStationALat = viewModel::onStationALat, onStationALat = viewModel::onStationALat,
@@ -61,7 +61,12 @@ data class MutualUiState(
// One-shot flag set by prefillMatchFromGrid() (map grid → Match button): // One-shot flag set by prefillMatchFromGrid() (map grid → Match button):
// the page scrolls to the time-range card after the first layout, then // the page scrolls to the time-range card after the first layout, then
// consumeScrollToTimeRange() clears it. // consumeScrollToTimeRange() clears it.
val scrollToTimeRange: Boolean = false val scrollToTimeRange: Boolean = false,
// One-shot flag set by prefillMatchFromGrid(): the map page waits for the
// pre-filled query to finish, then navigates to the match page so the very
// first frame already contains the results (time-range card + pass curves,
// no scroll flicker). consumePendingNavigation() clears it.
val pendingNavigation: Boolean = false
) )
class MutualViewModel( class MutualViewModel(
@@ -206,18 +211,33 @@ class MutualViewModel(
stationBLat = pos?.let { p -> "%.4f".format(p.latitude) } ?: it.stationBLat, stationBLat = pos?.let { p -> "%.4f".format(p.latitude) } ?: it.stationBLat,
stationBLon = pos?.let { p -> "%.4f".format(p.longitude) } ?: it.stationBLon, stationBLon = pos?.let { p -> "%.4f".format(p.longitude) } ?: it.stationBLon,
hoursAhead = 24, hoursAhead = 24,
scrollToTimeRange = true scrollToTimeRange = true,
pendingNavigation = true
) )
} }
queryMutualPasses() queryMutualPasses()
} }
fun consumePendingNavigation() {
android.util.Log.d(TAG, "consumePendingNavigation")
_uiState.update { it.copy(pendingNavigation = false) }
}
fun consumeScrollToTimeRange() { fun consumeScrollToTimeRange() {
android.util.Log.d(TAG, "consumeScrollToTimeRange") android.util.Log.d(TAG, "consumeScrollToTimeRange")
_uiState.update { it.copy(scrollToTimeRange = false) } _uiState.update { it.copy(scrollToTimeRange = false) }
} }
fun queryMutualPasses(initialScrollIndex: Int = 0) { /**
* Run a mutual-pass query. `initialScrollIndex` non-null forces the list
* to rebuild at that position (legacy callers); null keeps the current
* scroll position, so re-running a query from the match page does not
* jump back to the top. The previous results stay on screen while the
* query runs (the list keeps its height, so the scroll position is not
* clamped away); they are replaced — possibly with empty — when the new
* results arrive.
*/
fun queryMutualPasses(initialScrollIndex: Int? = null) {
val state = _uiState.value val state = _uiState.value
// Resolve positions from lat/lon or grid // Resolve positions from lat/lon or grid
@@ -240,16 +260,19 @@ class MutualViewModel(
isCalculating = true, isCalculating = true,
hasSearched = true, hasSearched = true,
errorMessage = null, errorMessage = null,
mutualPasses = emptyList(), // Deliberately keep mutualPasses: clearing it would shrink the
// list below one screen and clamp the scroll position away.
// The stale results are replaced when the new ones arrive.
selectedPassIndex = -1 selectedPassIndex = -1
) )
} }
// The results list is about to be replaced, so the scroll position must // Scroll position: an explicit initialScrollIndex overrides (legacy
// not leak from the previous query's list. A prefill from the map // callers start at a fixed spot); null keeps the current position so
// (grid-QSO dialog "Match" button) instead starts at the time-range // re-querying does not jump back to the top.
// card so the page opens with it at the top. if (initialScrollIndex != null) {
listScrollIndex = initialScrollIndex listScrollIndex = initialScrollIndex
listScrollOffset = 0 listScrollOffset = 0
}
queryGeneration += 1 queryGeneration += 1
viewModelScope.launch { viewModelScope.launch {
@@ -258,9 +281,16 @@ class MutualViewModel(
val minElevB = state.stationBMinElev val minElevB = state.stationBMinElev
val hours = state.hoursAhead val hours = state.hoursAhead
val results = withContext(computeDispatcher) { val results = try {
withContext(computeDispatcher) {
findMutualPasses(satellites, posA, posB, minElevA, minElevB, time, hours) findMutualPasses(satellites, posA, posB, minElevA, minElevB, time, hours)
} }
} catch (t: Throwable) {
// Never leave the query stuck in "calculating" (which would also
// block the pending map→match navigation forever).
android.util.Log.e(TAG, "findMutualPasses threw", t)
emptyList()
}
val errorMsg = if (results.isEmpty()) { val errorMsg = if (results.isEmpty()) {
// Debug: check if the main pass list is the culprit // Debug: check if the main pass list is the culprit
@@ -285,4 +285,39 @@ class MutualViewModelTest {
assertNull(vm.uiState.value.errorMessage) assertNull(vm.uiState.value.errorMessage)
} }
@Test
fun `requery keeps scroll position and stale results while running`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
val windows = TestOrbits.findPassWindows()
assertTrue("fixture must produce ISS windows", windows.isNotEmpty())
val vm = createVm(
satellites = listOf(TestOrbits.ISS),
passes = windows
)
vm.onStationBGrid("OL62")
queryAndSettle(vm)
assertTrue("fixture must yield passes", vm.uiState.value.mutualPasses.isNotEmpty())
// The user scrolled into the results; the snapshotFlow write-back has
// stored this position in the VM.
vm.listScrollIndex = 4
vm.listScrollOffset = 137
// Re-run the query from the match page ("query" button).
vm.queryMutualPasses()
// While the query runs, the old results stay on screen (the list keeps
// its height, so the scroll position is not clamped away)...
assertTrue("stale results kept while calculating", vm.uiState.value.mutualPasses.isNotEmpty())
// ...and the scroll position must not be reset to the top.
assertEquals(4, vm.listScrollIndex)
assertEquals(137, vm.listScrollOffset)
advanceUntilIdle()
assertFalse(vm.uiState.value.isCalculating)
assertTrue("fresh results replace the stale ones", vm.uiState.value.mutualPasses.isNotEmpty())
// Position intact after the query settled.
assertEquals(4, vm.listScrollIndex)
assertEquals(137, vm.listScrollOffset)
}
} }