mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-04 19:47:22 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8138c948b | ||
|
|
0e28231c4a | ||
|
|
d6bdf5ecb6 |
No files matched your search
@@ -177,6 +177,28 @@ fun MainScreen(
|
||||
factory = MapFilterViewModel.factory()
|
||||
)
|
||||
|
||||
// Map grid-QSO dialog "Match" button: pre-fill the match page for that
|
||||
// grid and run the query BEFORE navigating, so the page's first frame
|
||||
// already contains the results (time-range card at the top, pass curves
|
||||
// right below — no scroll flicker). Navigation happens once the query
|
||||
// finishes (see the collector below).
|
||||
val mutualState by mutualViewModel.uiState.collectAsStateWithLifecycle()
|
||||
val matchCalculating = mutualState.pendingNavigation && mutualState.isCalculating
|
||||
|
||||
LaunchedEffect(mutualViewModel) {
|
||||
mutualViewModel.uiState.collect { state ->
|
||||
if (state.pendingNavigation && !state.isCalculating) {
|
||||
mutualViewModel.consumePendingNavigation()
|
||||
// Push Mutual on top of the Map entry instead of replacing the
|
||||
// stack (bottom-nav style): the system back gesture then pops
|
||||
// back to the map page, which is the page the user came from.
|
||||
if (backStack.lastOrNull() !is Screen.Mutual) {
|
||||
backStack.add(Screen.Mutual)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalElevationThresholds provides ElevationThresholds(
|
||||
low = otherSettings.lowElevation,
|
||||
@@ -246,17 +268,14 @@ fun MainScreen(
|
||||
entry<Screen.Map> {
|
||||
MapDestination(
|
||||
mapFilterViewModel = mapFilterViewModel,
|
||||
matchCalculating = matchCalculating,
|
||||
onMatchGrid = { grid ->
|
||||
// Grid-QSO dialog "Match" button: pre-fill the
|
||||
// match page for that grid and open it.
|
||||
// Grid-QSO dialog "Match" button: pre-fill
|
||||
// the match page for that grid and start the
|
||||
// query. Navigation to the match page is
|
||||
// handled by the uiState collector above,
|
||||
// once the query finishes.
|
||||
mutualViewModel.prefillMatchFromGrid(grid)
|
||||
// Push Mutual on top of the Map entry instead of
|
||||
// replacing the stack (bottom-nav style): the
|
||||
// system back gesture then pops back to the map
|
||||
// page, which is the page the user came from.
|
||||
if (backStack.lastOrNull() !is Screen.Mutual) {
|
||||
backStack.add(Screen.Mutual)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -153,7 +154,10 @@ fun MapDestination(
|
||||
mapFilterViewModel: MapFilterViewModel,
|
||||
// Invoked with the tapped grid when the QSO dialog's "Match" button is
|
||||
// pressed: navigate to the match page pre-filled for that grid.
|
||||
onMatchGrid: (String) -> Unit = {}
|
||||
onMatchGrid: (String) -> Unit = {},
|
||||
// True while the map "Match" query is running (the page only navigates
|
||||
// once the query finished, so the match page opens with results ready).
|
||||
matchCalculating: Boolean = false
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
@@ -190,7 +194,7 @@ fun MapDestination(
|
||||
viewModel.onAction(MapAction.SetVisible(false))
|
||||
}
|
||||
}
|
||||
MapScreen(uiState, viewModel::onAction, mapView, mapFilterViewModel, onMatchGrid)
|
||||
MapScreen(uiState, viewModel::onAction, mapView, mapFilterViewModel, onMatchGrid, matchCalculating)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -199,7 +203,8 @@ private fun MapScreen(
|
||||
onAction: (MapAction) -> Unit,
|
||||
mapView: MapView,
|
||||
mapFilterViewModel: MapFilterViewModel,
|
||||
onMatchGrid: (String) -> Unit
|
||||
onMatchGrid: (String) -> Unit,
|
||||
matchCalculating: Boolean
|
||||
) {
|
||||
val rotateMod = Modifier.rotate(180f)
|
||||
val timeString = uiState.mapData?.aosTime ?: "00:00:00"
|
||||
@@ -423,6 +428,7 @@ private fun MapScreen(
|
||||
grid = grid,
|
||||
qsos = uiState.workedGridQsos[grid].orEmpty().sortedBy { it.epochMs },
|
||||
isUtc = uiState.isUtc,
|
||||
matchCalculating = matchCalculating,
|
||||
onDismiss = { selectedGrid = null },
|
||||
onMatch = { onMatchGrid(grid) }
|
||||
)
|
||||
@@ -436,6 +442,7 @@ private fun WorkedGridQsoDialog(
|
||||
grid: String,
|
||||
qsos: List<com.rtbishop.look4sat.core.domain.model.GridQso>,
|
||||
isUtc: Boolean,
|
||||
matchCalculating: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onMatch: () -> Unit
|
||||
) {
|
||||
@@ -467,16 +474,24 @@ private fun WorkedGridQsoDialog(
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
enabled = !matchCalculating,
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onMatch()
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_radio_tower),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
if (matchCalculating) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_radio_tower),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(stringResource(R.string.grid_qso_match))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+42
-12
@@ -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()) {
|
||||
|
||||
+7
@@ -146,6 +146,13 @@ class MutualMatchPrefillScrollTest {
|
||||
// ...and the target grid + 24h range pre-filled.
|
||||
org.junit.Assert.assertEquals("OL62", s.stationBGrid)
|
||||
org.junit.Assert.assertEquals(24, s.hoursAhead)
|
||||
// ...and navigation to the match page is pending until the query done.
|
||||
assertTrue("navigation should be pending", s.pendingNavigation)
|
||||
// The early-return guard means the query finished immediately (no
|
||||
// satellites): the map page must now be free to navigate.
|
||||
org.junit.Assert.assertFalse("query finished -> not calculating", s.isCalculating)
|
||||
vm.consumePendingNavigation()
|
||||
org.junit.Assert.assertFalse("consume clears pending navigation", vm.uiState.value.pendingNavigation)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+35
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "492"
|
||||
appVersionCode = "493"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.7-ba7opf.12.6"
|
||||
appVersionName = "4.4.7-ba7opf.13"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
|
||||
Reference in new issue
Block a user