Compare commits

...
Author SHA1 Message Date
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
atsunatsu c89e79e28f chore: bump version to 4.4.7-ba7opf.8 (versionCode 478) 2026-09-17 11:53:44 +08:00
atsunatsu b40a79e3c6 docs: credit BG5JSU with CW decoding feature 2026-09-17 11:38:19 +08:00
atsunatsu 6c23696e22 docs: add BG5JSU to acknowledgments (after BG7NTA) 2026-09-17 11:25:44 +08:00
9 changed files with 159 additions and 25 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(
@@ -208,7 +208,7 @@
\n• Alexandru Csete (Gpredict)
\n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS)
\n• BG7NTA (CW decoding feature)</string>
\n• BG7NTA BG5JSU (CW decoding feature)</string>
<string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string>
</resources>
@@ -257,7 +257,7 @@
\n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS)
\n• xdsopl 和 Robot36 贡献者!
\n• BG7NTA(CW解码功能)</string>
\n• BG7NTA BG5JSU(CW解码功能)</string>
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
<!-- 匹配页 Mutual match page -->
@@ -291,7 +291,7 @@
\n* Dr T.S. Kelso (Celestrak)
\n* Libre Space Foundation (SatNOGS)
\n* xdsopl and Robot36 contributors!
\n* BG7NTA (CW decoding feature)
\n* BG7NTA BG5JSU (CW decoding feature)
</string>
<string name="prefs_outro_license">The app comes with no warranty</string>
@@ -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 = "477"
appVersionCode = "479"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.7-ba7opf.7"
appVersionName = "4.4.7-ba7opf.9"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry