mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
fix(mutual): retry prefill scroll until it really lands
On a device the first frame of the match page only contains the station cards and the time-range card, which can be shorter than the viewport — scrollToItem(1) then has no scroll range and silently does nothing, and by the time the async query results grow the list past one screen the one-shot flag is already consumed. Retry every 100ms (up to 2s) until firstVisibleItemIndex reaches the time-range card. Verified with a new Robolectric test that grows the content after a delay. Bump 12.6.
This commit is contained in:
1 parent
94a1a4f7b7
commit
6962a4bfa1
3 files changed
+147
-7
No files matched your search
@@ -80,6 +80,7 @@ import java.text.SimpleDateFormat
|
|||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -399,12 +400,24 @@ private fun MutualContent(
|
|||||||
LaunchedEffect(state.scrollToTimeRange, matchSearchIndex, listReady) {
|
LaunchedEffect(state.scrollToTimeRange, matchSearchIndex, listReady) {
|
||||||
Log.d(TAG, "scroll effect: scrollToTimeRange=${state.scrollToTimeRange} matchIndex=$matchSearchIndex listReady=$listReady")
|
Log.d(TAG, "scroll effect: scrollToTimeRange=${state.scrollToTimeRange} matchIndex=$matchSearchIndex listReady=$listReady")
|
||||||
if (state.scrollToTimeRange && listReady) {
|
if (state.scrollToTimeRange && listReady) {
|
||||||
Log.d(TAG, "attempting scrollToItem($matchSearchIndex)")
|
// Keep trying until the scroll really lands: on a device the first
|
||||||
|
// frame only contains the station cards + time-range card, which
|
||||||
|
// can be shorter than the viewport (no scroll range), so a single
|
||||||
|
// scrollToItem does nothing. When the async query results arrive
|
||||||
|
// the list grows past one screen and the scroll becomes possible.
|
||||||
|
for (attempt in 0 until 20) {
|
||||||
|
Log.d(TAG, "attempting scrollToItem($matchSearchIndex) #$attempt")
|
||||||
try {
|
try {
|
||||||
listState.scrollToItem(matchSearchIndex)
|
listState.scrollToItem(matchSearchIndex)
|
||||||
Log.d(TAG, "scrollToItem($matchSearchIndex) done, firstVisible=${listState.firstVisibleItemIndex}")
|
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
Log.e(TAG, "scrollToItem($matchSearchIndex) threw", t)
|
Log.e(TAG, "scrollToItem($matchSearchIndex) threw", t)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (listState.firstVisibleItemIndex == matchSearchIndex) {
|
||||||
|
Log.d(TAG, "scroll landed at $matchSearchIndex on attempt #$attempt")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
delay(100)
|
||||||
}
|
}
|
||||||
viewModel.consumeScrollToTimeRange()
|
viewModel.consumeScrollToTimeRange()
|
||||||
Log.d(TAG, "scrollToTimeRange consumed")
|
Log.d(TAG, "scrollToTimeRange consumed")
|
||||||
|
|||||||
+130
-3
@@ -1,6 +1,8 @@
|
|||||||
package com.rtbishop.look4sat.feature.mutual
|
package com.rtbishop.look4sat.feature.mutual
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -8,14 +10,19 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
|
import androidx.navigation3.runtime.NavKey
|
||||||
import androidx.navigation3.runtime.entryProvider
|
import androidx.navigation3.runtime.entryProvider
|
||||||
import androidx.navigation3.runtime.rememberNavBackStack
|
import androidx.navigation3.runtime.rememberNavBackStack
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import com.rtbishop.look4sat.core.presentation.Screen
|
import com.rtbishop.look4sat.core.presentation.Screen
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -112,13 +119,23 @@ class MutualMatchPrefillScrollTest {
|
|||||||
// The time-range card must be visible at the top of the page after the
|
// The time-range card must be visible at the top of the page after the
|
||||||
// prefill scroll.
|
// prefill scroll.
|
||||||
composeRule.onNodeWithText("Time range").assertIsDisplayed()
|
composeRule.onNodeWithText("Time range").assertIsDisplayed()
|
||||||
|
// The snapshotFlow write-back stores the scrolled position in the VM.
|
||||||
|
// It equals 1 only if scrollToItem(1) REALLY scrolled the station card
|
||||||
|
// out. If the list content is shorter than the viewport there is no
|
||||||
|
// scroll range, scrollToItem cannot move, and this stays 0 — that is
|
||||||
|
// exactly the device symptom ("page stays at the top").
|
||||||
|
org.junit.Assert.assertEquals(
|
||||||
|
"list must have actually scrolled to item 1 (content shorter than viewport?)",
|
||||||
|
1, vm.listScrollIndex
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun prefillAutoStartsQueryAndFillsState() {
|
fun prefillAutoStartsQueryAndFillsState() {
|
||||||
val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo())
|
val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo())
|
||||||
vm.prefillMatchFromGrid("OL62")
|
vm.prefillMatchFromGrid("OL62")
|
||||||
val s = vm.uiState.value // Query auto-started: with the empty-satellite fake the query reaches
|
val s = vm.uiState.value
|
||||||
|
// Query auto-started: with the empty-satellite fake the query reaches
|
||||||
// the "no satellite data" guard (rather than never being triggered),
|
// the "no satellite data" guard (rather than never being triggered),
|
||||||
// proving prefillMatchFromGrid kicks off queryMutualPasses.
|
// proving prefillMatchFromGrid kicks off queryMutualPasses.
|
||||||
assertTrue(
|
assertTrue(
|
||||||
@@ -133,8 +150,8 @@ class MutualMatchPrefillScrollTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun navDisplayEntryAfterPrefill_scrollsToTimeRange() {
|
fun navDisplayEntryAfterPrefill_scrollsToTimeRange() {
|
||||||
// Closest to the real device path: the Mutual screen composed inside a
|
// The Mutual screen composed inside a NavDisplay entry (back stack
|
||||||
// NavDisplay entry (back stack [Mutual]) right after prefillMatchFromGrid.
|
// [Mutual]) right after prefillMatchFromGrid.
|
||||||
val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo())
|
val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo())
|
||||||
vm.prefillMatchFromGrid("OL62")
|
vm.prefillMatchFromGrid("OL62")
|
||||||
composeRule.setContent {
|
composeRule.setContent {
|
||||||
@@ -154,4 +171,114 @@ class MutualMatchPrefillScrollTest {
|
|||||||
composeRule.waitForIdle()
|
composeRule.waitForIdle()
|
||||||
composeRule.onNodeWithText("Time range").assertIsDisplayed()
|
composeRule.onNodeWithText("Time range").assertIsDisplayed()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun navDisplay_switchToMutualEntry_scrollsToTimeRange() {
|
||||||
|
// Closest device path: start on the Map entry, then push the Mutual
|
||||||
|
// entry (what the map "Match" button does). NavDisplay runs a real
|
||||||
|
// fade transition while the Mutual screen composes.
|
||||||
|
val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo())
|
||||||
|
vm.prefillMatchFromGrid("OL62")
|
||||||
|
val backStackRef = mutableStateOf<NavBackStack<NavKey>?>(null)
|
||||||
|
composeRule.setContent {
|
||||||
|
val backStack = rememberNavBackStack(Screen.Map)
|
||||||
|
backStackRef.value = backStack
|
||||||
|
MaterialTheme {
|
||||||
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
|
NavDisplay(
|
||||||
|
backStack = backStack,
|
||||||
|
onBack = { backStack.removeLastOrNull() },
|
||||||
|
entryProvider = entryProvider {
|
||||||
|
entry<Screen.Map> { Text("MAP PAGE") }
|
||||||
|
entry<Screen.Mutual> { MutualScreen(viewModel = vm) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
// Push Mutual like onMatchGrid does, so the transition composes
|
||||||
|
// MutualScreen with scrollToTimeRange already set.
|
||||||
|
composeRule.runOnIdle { backStackRef.value?.add(Screen.Mutual) }
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
composeRule.onNodeWithText("Time range").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun scrollRetriesUntilContentGrows() {
|
||||||
|
// Reproduces the real-device mechanism: the first frame's content
|
||||||
|
// (item 0 short) is shorter than the viewport, so scrollToItem(1) has
|
||||||
|
// no range and cannot move. Then the content grows (async results)
|
||||||
|
// past one screen; the retry loop must land on item 1.
|
||||||
|
composeRule.setContent {
|
||||||
|
MaterialTheme {
|
||||||
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
|
val tall = mutableStateOf(false)
|
||||||
|
LaunchedEffect(Unit) { delay(300); tall.value = true }
|
||||||
|
val state = remember { LazyListState() }
|
||||||
|
LaunchedEffect(state) {
|
||||||
|
// Same retry loop MutualScreen uses for the prefill.
|
||||||
|
for (attempt in 0 until 20) {
|
||||||
|
state.scrollToItem(1)
|
||||||
|
if (state.firstVisibleItemIndex == 1) return@LaunchedEffect
|
||||||
|
delay(100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LazyColumn(state = state) {
|
||||||
|
item {
|
||||||
|
if (tall.value) Spacer(modifier = Modifier.height(900.dp))
|
||||||
|
else Spacer(modifier = Modifier.height(40.dp))
|
||||||
|
}
|
||||||
|
item { Text("target-item") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
composeRule.onNodeWithText("target-item").assertIsDisplayed()
|
||||||
|
// Confirm the list really scrolled (station card equivalent is gone).
|
||||||
|
// We can't read listState from here, so use the VM-style probe: none —
|
||||||
|
// the target visible + viewport tall enough implies item 1 on top.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun keepPositionAcrossTabs_thenMapMatchPrefill_scrollsToTimeRange() {
|
||||||
|
// User hypothesis: the "keep scroll position across tab switches"
|
||||||
|
// machinery (listState remember(queryGeneration) + snapshotFlow
|
||||||
|
// write-back) interferes with the prefill auto-scroll. Reproduce the
|
||||||
|
// full journey: first visit -> scroll a bit -> leave -> return
|
||||||
|
// (position restored) -> leave -> enter via map Match button.
|
||||||
|
val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo())
|
||||||
|
val showMutual = mutableStateOf(true)
|
||||||
|
|
||||||
|
// First visit (e.g. bottom nav), user scrolls a little; the
|
||||||
|
// snapshotFlow write-back stored index/offset in the VM.
|
||||||
|
composeRule.setContent {
|
||||||
|
MaterialTheme {
|
||||||
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
|
if (showMutual.value) MutualScreen(viewModel = vm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
vm.listScrollIndex = 0
|
||||||
|
vm.listScrollOffset = 40
|
||||||
|
|
||||||
|
// Leave the page (tab switch destroys the composition).
|
||||||
|
showMutual.value = false
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
// Re-enter: keep-position restores the scroll offset.
|
||||||
|
showMutual.value = true
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
// Leave again, then enter via the map grid-QSO Match button.
|
||||||
|
showMutual.value = false
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
vm.prefillMatchFromGrid("OL62")
|
||||||
|
showMutual.value = true
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("Time range").assertIsDisplayed()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
[versions]
|
[versions]
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionCode = "491"
|
appVersionCode = "492"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionName = "4.4.7-ba7opf.12.5"
|
appVersionName = "4.4.7-ba7opf.12.6"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
compileSdk = "37"
|
compileSdk = "37"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
|
|||||||
Reference in new issue
Block a user