Compare commits

..
Author SHA1 Message Date
atsunatsu 322becdda8 feat(satellites): fuzzy name search ignoring separators
The satellite search only matched exact continuous substrings, so names with
dashes, spaces or brackets (AO-7, ISS (ZARYA), FO-29) could not be found by
queries without those separators. Now the query is normalized (lowercased,
non-alphanumeric separators stripped) and split into space-separated tokens,
and every token must appear in the normalized satellite name. This makes
'ao7' match 'AO-7 (AMSAT-OSCAR 7)' and 'iss zarya' match 'ISS (ZARYA)'.
Numeric queries still match catnum exactly. Covered by 8 new unit tests.
2026-09-18 14:49:41 +08:00
atsunatsu 7f93a10556 chore: bump version to 4.4.7-ba7opf.9 (versionCode 479) 2026-09-18 02:04:09 +08:00
atsunatsu 3cd5b49f98 feat(mutual): keep pass list scroll position across page switches
The mutual pass list LazyColumn reset to the top on every return to the page.
Scroll position is now stored in the Activity-scoped MutualViewModel and
restored on re-entry; a new query (queryGeneration bump) rebuilds the list at
the top so stale positions never leak into fresh results.
2026-09-18 02:04:04 +08:00
atsunatsu 4dbe896124 feat(map): keep grid-mode viewport across page switches
The osmdroid map was recreated on every return to the Map page, resetting
center/zoom to the default and forcing grid mode back onto the station grid.
Now the viewport (center + zoom) is saved in the Activity-scoped
MapFilterViewModel only in grid mode and restored on re-entry, so the user
keeps exactly where they left off. Satellite mode keeps its original
follow-the-satellite behavior; cold-start entries and in-page grid toggles
still center on the station grid as before.
2026-09-18 02:03:57 +08:00
atsunatsu b47bd78ecd feat(map): keep award filter selection across page switches
The award chip (VUCC/DXCC/WAJA/...) now lives in an Activity-scoped
MapFilterViewModel instead of a per-composition remember, so switching
to another page and back keeps the last selection; it resets to VUCC
only once per process (cold start). Removes the effect that forced
VUCC on every grid-mode entry.
2026-09-17 12:57:36 +08:00
8 changed files with 372 additions and 24 deletions

No files matched your search

@@ -81,6 +81,7 @@ import com.rtbishop.look4sat.core.presentation.Screen
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
import com.rtbishop.look4sat.feature.map.MapDestination
import com.rtbishop.look4sat.feature.map.MapFilterViewModel
import com.rtbishop.look4sat.feature.mutual.MutualScreen
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
import com.rtbishop.look4sat.feature.passes.PassesDestination
@@ -169,6 +170,12 @@ fun MainScreen(
viewModelStoreOwner = context as ViewModelStoreOwner,
factory = MutualViewModel.factory(container)
)
// Activity-scoped so the map's award filter survives page switches;
// resets to VUCC only on cold start (fresh process).
val mapFilterViewModel: MapFilterViewModel = viewModel(
viewModelStoreOwner = context as ViewModelStoreOwner,
factory = MapFilterViewModel.factory()
)
CompositionLocalProvider(
LocalElevationThresholds provides ElevationThresholds(
@@ -237,7 +244,7 @@ fun MainScreen(
)
}
entry<Screen.Map> {
MapDestination()
MapDestination(mapFilterViewModel = mapFilterViewModel)
}
entry<Screen.Mutual> {
MutualScreen(
@@ -111,12 +111,29 @@ class SelectionRepo(
/**
* Filters items by query. Uses toIntOrNull() instead of exception-based flow,
* and lowercases the query once up front instead of per-item.
*
* Fuzzy search: the query is split into space-separated tokens and every
* token must appear in the satellite name after both sides are normalized
* (lowercased, non-alphanumeric separators such as dashes, spaces, brackets
* and dots stripped). This makes "ao7" match "AO-7 (AMSAT-OSCAR 7)" and
* "iss zarya" match "ISS (ZARYA)" — exact continuous-substring matching
* previously failed whenever the name contained a separator the query lacked.
*/
private fun filterByQuery(items: List<SatItem>, query: String): List<SatItem> {
if (query.isBlank()) return items
val catnum = query.toIntOrNull()
if (catnum != null) return items.filter { it.catnum == catnum }
val lowerQuery = query.lowercase()
return items.filter { it.name.lowercase().contains(lowerQuery) }
val tokens = query.split(' ')
.map { normalizeForSearch(it) }
.filter { it.isNotEmpty() }
if (tokens.isEmpty()) return items
return items.filter { item ->
val normalizedName = normalizeForSearch(item.name)
tokens.all { normalizedName.contains(it) }
}
}
/** Lowercases and strips all non-alphanumeric chars for fuzzy matching. */
private fun normalizeForSearch(text: String): String =
text.lowercase().filter { it.isLetterOrDigit() }
}
@@ -0,0 +1,197 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.DatabaseState
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.SatItem
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.source.ILocalSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SelectionRepoSearchTest {
private val sampleItems = listOf(
SatItem(catnum = 25544, name = "ISS (ZARYA)"),
SatItem(catnum = 7530, name = "AO-7 (AMSAT-OSCAR 7)"),
SatItem(catnum = 39444, name = "AO-73 (FUNcube-1)"),
SatItem(catnum = 43803, name = "JO-97 (BIRDS-3)"),
SatItem(catnum = 99999, name = "FO-29"),
)
@Test
fun `query without separators matches name with dashes spaces brackets`() = runTest {
val repo = createRepo(sampleItems)
repo.setQuery("ao7")
val results = repo.getEntriesFlow().first()
// "ao7" is a substring of the normalized "AO-73 (FUNcube-1)" too, so a
// fuzzy search legitimately returns both AO-7 (first) and AO-73. The
// key guarantee is that AO-7 — which was unreachable before because of
// the dashes/brackets — is now found.
assertTrue(results.map { it.catnum }.contains(7530))
assertEquals(7530, results.first().catnum)
}
@Test
fun `query without separators matches name with only dashes`() = runTest {
val repo = createRepo(sampleItems)
repo.setQuery("fo29")
val results = repo.getEntriesFlow().first()
assertEquals(listOf(99999), results.map { it.catnum })
}
@Test
fun `space-separated tokens all must match in any order`() = runTest {
val repo = createRepo(sampleItems)
repo.setQuery("zarya iss")
val results = repo.getEntriesFlow().first()
assertEquals(listOf(25544), results.map { it.catnum })
}
@Test
fun `exact name query still matches`() = runTest {
val repo = createRepo(sampleItems)
repo.setQuery("AO-7 (AMSAT-OSCAR 7)")
val results = repo.getEntriesFlow().first()
assertEquals(listOf(7530), results.map { it.catnum })
}
@Test
fun `partial token query matches substring`() = runTest {
val repo = createRepo(sampleItems)
repo.setQuery("funcube")
val results = repo.getEntriesFlow().first()
assertEquals(listOf(39444), results.map { it.catnum })
}
@Test
fun `numeric query matches catnum exactly`() = runTest {
val repo = createRepo(sampleItems)
repo.setQuery("25544")
val results = repo.getEntriesFlow().first()
assertEquals(listOf(25544), results.map { it.catnum })
}
@Test
fun `no query returns all items`() = runTest {
val repo = createRepo(sampleItems)
repo.setQuery("")
val results = repo.getEntriesFlow().first()
assertEquals(sampleItems.size, results.size)
}
@Test
fun `unmatched query returns nothing`() = runTest {
val repo = createRepo(sampleItems)
repo.setQuery("zzzznomatch")
assertTrue(repo.getEntriesFlow().first().isEmpty())
}
private fun createRepo(items: List<SatItem>): ISelectionRepo {
return SelectionRepo(
dispatcher = Dispatchers.Unconfined,
localSource = FakeLocalSourceForSearch(items),
settingsRepo = FakeSettingsRepoForSearch()
)
}
}
private class FakeLocalSourceForSearch(private val items: List<SatItem>) : ILocalSource {
override suspend fun getEntriesTotal(): Int = items.size
override suspend fun getEntriesList(): List<SatItem> = items
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
override suspend fun insertEntries(entries: List<OrbitalData>) = Unit
override suspend fun deleteEntries() = Unit
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
override suspend fun getRadiosTotal(): Int = 0
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) = Unit
override suspend fun deleteManagedRadios() = Unit
override suspend fun deleteRadios() = Unit
}
private class FakeSettingsRepoForSearch : ISettingsRepo {
override val appVersionName: String = "test"
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
PassesSettings(hoursAhead = 24, minElevation = 0.0, selectedModes = emptyList())
)
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
)
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
OtherSettings(
false, false, false, false, false, false, false,
shouldSeeWarning = false, shouldSeeWhatsNew = false
)
)
override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> =
MutableStateFlow(DataSourcesSettings(satelliteUrls = emptyList(), transceiversUrls = emptyList()))
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
)
override val wavelogSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogSettings> =
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogSettings())
override val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings> =
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.LoTWSettings())
override fun setSelectedIds(ids: List<Int>) = Unit
override fun setSelectedTypes(types: List<String>) = Unit
override fun setPassesSettings(settings: PassesSettings) = Unit
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
override fun setStationPosition(): Boolean = true
override fun setStationPosition(locator: String): Boolean = true
override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = Unit
override fun updateDatabaseState(state: DatabaseState) = Unit
override fun updateRCSettings(settings: RCSettings) = Unit
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
override fun updateDataSourcesStatus(status: Map<String, Int>) = Unit
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
override fun getAmSatCallsign(): String = ""
override fun setAmSatCallsign(callsign: String) = Unit
override fun updateWavelogSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogSettings) = Unit
override fun getWorkedGrids(): Set<String> = emptySet()
override fun setWorkedGrids(grids: Set<String>) = Unit
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap()
override fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>) = Unit
override fun getRoamedGrids(): Set<String> = emptySet()
override fun setRoamedGrids(grids: Set<String>) = Unit
override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) = Unit
override fun getLastLotwSyncDate(): String = ""
override fun setLastLotwSyncDate(date: String) = Unit
override fun getLastLotwSyncCallsign(): String = ""
override fun setLastLotwSyncCallsign(callsign: String) = Unit
}
@@ -0,0 +1,39 @@
package com.rtbishop.look4sat.feature.map
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.model.AwardType
/**
* Session-scoped holder for the map's award filter selection.
*
* Scoped to the Activity (not the navigation entry) so the selection survives
* switching to another page and back; it resets to VUCC only when the process
* starts fresh (cold start), which is what the user expects as the default.
*/
class MapFilterViewModel : ViewModel() {
/** Last chosen award chip; null means "All". Initialized to VUCC once per process. */
val selectedAward: MutableState<AwardType?> = mutableStateOf(AwardType.VUCC)
/** Last map viewport (center + zoom), saved when leaving the page. Null until first visit. */
var mapCenterLat: Double? = null
var mapCenterLon: Double? = null
var mapZoom: Double? = null
/** Persists the current viewport so a later re-entry restores exactly where the user left off. */
fun saveMapViewState(centerLat: Double, centerLon: Double, zoom: Double) {
mapCenterLat = centerLat
mapCenterLon = centerLon
mapZoom = zoom
}
companion object {
fun factory() = viewModelFactory {
initializer { MapFilterViewModel() }
}
}
}
@@ -144,13 +144,30 @@ private val moonIconPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
}
@Composable
fun MapDestination() {
fun MapDestination(
mapFilterViewModel: MapFilterViewModel
) {
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val mapView = rememberMapViewWithLifecycle()
val mapView = rememberMapViewWithLifecycle(mapFilterViewModel, restoreViewport = uiState.isGridMode)
val lifecycle = LocalLifecycleOwner.current.lifecycle
// Save the viewport when leaving the page, but only in grid mode: the
// satellite mode intentionally keeps its original behavior (default center,
// follow the selected satellite), so a satellite-mode exit must not
// overwrite the last grid-mode viewport.
DisposableEffect(mapView, uiState.isGridMode) {
onDispose {
if (uiState.isGridMode) {
mapFilterViewModel.saveMapViewState(
mapView.mapCenter.latitude,
mapView.mapCenter.longitude,
mapView.zoomLevelDouble
)
}
}
}
DisposableEffect(lifecycle) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
@@ -165,11 +182,16 @@ fun MapDestination() {
viewModel.onAction(MapAction.SetVisible(false))
}
}
MapScreen(uiState, viewModel::onAction, mapView)
MapScreen(uiState, viewModel::onAction, mapView, mapFilterViewModel)
}
@Composable
private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView: MapView) {
private fun MapScreen(
uiState: MapState,
onAction: (MapAction) -> Unit,
mapView: MapView,
mapFilterViewModel: MapFilterViewModel
) {
val rotateMod = Modifier.rotate(180f)
val timeString = uiState.mapData?.aosTime ?: "00:00:00"
val isTimeAos = uiState.mapData?.isTimeAos ?: true
@@ -177,9 +199,9 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
// Tapped worked grid -> centered QSO dialog. Local UI state: the map is the
// only consumer and it resets when leaving the page.
var selectedGrid by remember { mutableStateOf<String?>(null) }
// Selected award filter. Entering grid mode defaults to VUCC (the plain
// worked-grid view); the reset effect below re-asserts that on every entry.
var selectedAward by remember { mutableStateOf<AwardType?>(AwardType.VUCC) }
// Selected award filter. Lives in an Activity-scoped ViewModel so it
// survives page switches; defaults to VUCC only once per process (cold start).
var selectedAward by mapFilterViewModel.selectedAward
// Six-award progress derived from the confirmed QSO store; recomputed when
// the store changes (LoTW/Wavelog sync).
// Per operated-grid VUCC breakdown: myGrid -> set of worked grids worked from it.
@@ -215,6 +237,14 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
else vuccByMyGrid[selectedMyGrid] ?: emptySet()
}
val isGridMode = uiState.isGridMode
// True when this composition restored a saved viewport. Only grid mode
// restores the viewport the user left (satellite mode keeps its original
// follow-the-satellite behavior); this implies grid mode was already on
// when re-entering, which is why the grid-mode auto-centering below can
// treat the entry as already centered.
val restoredViewport = remember {
isGridMode && mapFilterViewModel.mapCenterLat != null && mapFilterViewModel.mapZoom != null
}
DisposableEffect(isGridMode, workedGrids) {
val receiver = object : org.osmdroid.events.MapEventsReceiver {
override fun singleTapConfirmedHelper(p: GeoPoint?): Boolean {
@@ -240,12 +270,6 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
(mapView.overlays.getOrNull(OVERLAY_GRID) as? MaidenheadGridOverlay)?.selectedGrid = selectedGrid
mapView.invalidate()
}
// Re-assert the VUCC default each time grid mode is entered; while already
// in grid mode the user's chip choice is preserved.
LaunchedEffect(uiState.isGridMode) {
if (uiState.isGridMode) selectedAward = AwardType.VUCC
}
LaunchedEffect(uiState.track) {
// In grid mode the map is centered on the local grid square; following
// the satellite subpoint here would override that centering on every
@@ -281,9 +305,14 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
}
ElevatedCard(modifier = Modifier.weight(1f)) {
Box(contentAlignment = Alignment.BottomCenter) {
// Track grid-mode transitions; initial value false so that entering
// the map page with grid mode already ON also centers the map.
var prevGridMode by remember { mutableStateOf(false) }
// Track grid-mode transitions. When the map was just restored to
// the viewport the user left (re-entry after a page switch) and
// grid mode is already on, treat the entry as already centered so
// the restored position survives. Cold-start entries and in-page
// toggles still center on the station grid as before.
var prevGridMode by remember {
mutableStateOf(restoredViewport && uiState.isGridMode)
}
AndroidView({ mapView }) { view ->
// Award filter mode: show the selected award's regions with
// worked ones filled green; hide satellite layers like grid mode.
@@ -297,6 +326,10 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
prevGridMode = uiState.isGridMode
} else {
// Center on the station grid whenever entering grid mode.
// (prevGridMode is initialized to "already centered" when
// the map was just restored to the viewport the user left,
// so a re-entry keeps the restored position; in-page
// toggles still center normally.)
// Only mark the transition as consumed once a real position
// was available; otherwise a first frame with a null
// stationPosition would swallow the centering forever.
@@ -1173,7 +1206,10 @@ private fun setMoonPosition(moonLatDeg: Double, moonLonDeg: Double, mapView: Map
// region MapView lifecycle
@Composable
private fun rememberMapViewWithLifecycle(): MapView {
private fun rememberMapViewWithLifecycle(
mapFilterViewModel: MapFilterViewModel,
restoreViewport: Boolean
): MapView {
val tileSource = XYTileSource("tiles", 0, 6, 256, ".webp", emptyArray<String>())
val context = LocalContext.current
val isVertical = isVerticalLayout()
@@ -1189,8 +1225,19 @@ private fun rememberMapViewWithLifecycle(): MapView {
setTileSource(tileSource)
minZoomLevel = getMinZoom(resources.displayMetrics.heightPixels, isVertical)
maxZoomLevel = 7.0
controller.setCenter(GeoPoint(48.8575, 6.3514))
controller.setZoom(minZoomLevel + 2)
// Restore the viewport the user left on the previous visit, but only
// in grid mode: satellite mode intentionally keeps its original
// behavior (default center, then follows the selected satellite).
val savedLat = mapFilterViewModel.mapCenterLat
val savedLon = mapFilterViewModel.mapCenterLon
val savedZoom = mapFilterViewModel.mapZoom
if (restoreViewport && savedLat != null && savedLon != null && savedZoom != null) {
controller.setCenter(GeoPoint(savedLat, savedLon))
controller.setZoom(savedZoom)
} else {
controller.setCenter(GeoPoint(48.8575, 6.3514))
controller.setZoom(minZoomLevel + 2)
}
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
overlayManager.tilesOverlay.loadingBackgroundColor = Color.TRANSPARENT
overlayManager.tilesOverlay.loadingLineColor = Color.TRANSPARENT
@@ -1208,6 +1255,7 @@ private fun rememberMapViewWithLifecycle(): MapView {
}
// The overlay caches below are file-level (shared across MapView instances), so they must be
// released with the MapView or they keep the Activity and its bitmaps alive after disposal.
// (The viewport save lives in MapDestination, where the grid/satellite mode is known.)
DisposableEffect(mapView) {
onDispose { clearMapCaches() }
}
@@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
@@ -49,11 +50,13 @@ import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
@@ -74,6 +77,7 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import kotlinx.coroutines.flow.distinctUntilChanged
@Composable
fun MutualScreen(
@@ -150,6 +154,7 @@ fun MutualScreen(
}
) { isVertical ->
MutualContent(
viewModel = viewModel,
state = state,
isVertical = isVertical,
onQuery = viewModel::queryMutualPasses,
@@ -172,6 +177,7 @@ fun MutualScreen(
@Composable
private fun MutualContent(
viewModel: MutualViewModel,
state: MutualUiState,
isVertical: Boolean,
onQuery: () -> Unit,
@@ -195,8 +201,28 @@ private fun MutualContent(
}
}
// List state survives page switches via the Activity-scoped ViewModel: the
// initial position is restored from the VM, and scrolling writes back to it,
// so returning to this page keeps exactly where the user left off. A new
// query (queryGeneration bump) rebuilds the state at the top.
val listState = remember(viewModel.queryGeneration) {
LazyListState(
firstVisibleItemIndex = viewModel.listScrollIndex,
firstVisibleItemScrollOffset = viewModel.listScrollOffset
)
}
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
.distinctUntilChanged()
.collect { (index, offset) ->
viewModel.listScrollIndex = index
viewModel.listScrollOffset = offset
}
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
// Error message
@@ -69,6 +69,15 @@ class MutualViewModel(
private val _uiState = MutableStateFlow(MutualUiState())
val uiState: StateFlow<MutualUiState> = _uiState.asStateFlow()
// Session-scoped scroll position of the pass list (Activity-scoped VM, so it
// survives switching to another page and back). Reset to the top whenever a
// new query replaces the results.
var listScrollIndex: Int = 0
var listScrollOffset: Int = 0
/** Bumped on every new query so the list scrolls back to the top. */
var queryGeneration: Int = 0
init {
// Pre-fill station A with the user's current station position (as grid),
// and default min elevation to the same value used by the main radar passes
@@ -201,6 +210,11 @@ class MutualViewModel(
selectedPassIndex = -1
)
}
// The results list is about to be replaced, so the scroll position must
// not leak from the previous query's list.
listScrollIndex = 0
listScrollOffset = 0
queryGeneration += 1
viewModelScope.launch {
val time = System.currentTimeMillis()
+2 -2
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "478"
appVersionCode = "480"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.7-ba7opf.8"
appVersionName = "4.4.7-ba7opf.10"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry