From d6bdf5ecb697775d5aef9bf0088c2e5b54f7ba96 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Tue, 22 Sep 2026 16:41:51 +0800 Subject: [PATCH] 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. --- .../look4sat/feature/mutual/MutualScreen.kt | 2 +- .../feature/mutual/MutualViewModel.kt | 54 ++++++++++++++----- .../feature/mutual/MutualViewModelTest.kt | 35 ++++++++++++ 3 files changed, 78 insertions(+), 13 deletions(-) diff --git a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualScreen.kt b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualScreen.kt index af32d9c7..01570d84 100644 --- a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualScreen.kt +++ b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualScreen.kt @@ -161,7 +161,7 @@ fun MutualScreen( viewModel = viewModel, state = state, isVertical = isVertical, - onQuery = viewModel::queryMutualPasses, + onQuery = { viewModel.queryMutualPasses() }, onSelectPass = viewModel::onSelectPass, onNavigateToRadar = navigateToRadar, onStationALat = viewModel::onStationALat, diff --git a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualViewModel.kt b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualViewModel.kt index 7845757b..331893e8 100644 --- a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualViewModel.kt +++ b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualViewModel.kt @@ -61,7 +61,12 @@ data class MutualUiState( // One-shot flag set by prefillMatchFromGrid() (map grid → Match button): // the page scrolls to the time-range card after the first layout, then // 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( @@ -206,18 +211,33 @@ class MutualViewModel( stationBLat = pos?.let { p -> "%.4f".format(p.latitude) } ?: it.stationBLat, stationBLon = pos?.let { p -> "%.4f".format(p.longitude) } ?: it.stationBLon, hoursAhead = 24, - scrollToTimeRange = true + scrollToTimeRange = true, + pendingNavigation = true ) } queryMutualPasses() } + fun consumePendingNavigation() { + android.util.Log.d(TAG, "consumePendingNavigation") + _uiState.update { it.copy(pendingNavigation = false) } + } + fun consumeScrollToTimeRange() { android.util.Log.d(TAG, "consumeScrollToTimeRange") _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 // Resolve positions from lat/lon or grid @@ -240,16 +260,19 @@ class MutualViewModel( isCalculating = true, hasSearched = true, 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 ) } - // The results list is about to be replaced, so the scroll position must - // not leak from the previous query's list. A prefill from the map - // (grid-QSO dialog "Match" button) instead starts at the time-range - // card so the page opens with it at the top. - listScrollIndex = initialScrollIndex - listScrollOffset = 0 + // Scroll position: an explicit initialScrollIndex overrides (legacy + // callers start at a fixed spot); null keeps the current position so + // re-querying does not jump back to the top. + if (initialScrollIndex != null) { + listScrollIndex = initialScrollIndex + listScrollOffset = 0 + } queryGeneration += 1 viewModelScope.launch { @@ -258,8 +281,15 @@ class MutualViewModel( val minElevB = state.stationBMinElev val hours = state.hoursAhead - val results = withContext(computeDispatcher) { - findMutualPasses(satellites, posA, posB, minElevA, minElevB, time, hours) + val results = try { + withContext(computeDispatcher) { + 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()) { diff --git a/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/MutualViewModelTest.kt b/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/MutualViewModelTest.kt index c9b79ff8..a50db94f 100644 --- a/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/MutualViewModelTest.kt +++ b/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/MutualViewModelTest.kt @@ -285,4 +285,39 @@ class MutualViewModelTest { 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) + } } \ No newline at end of file