feat(roaming): port live GPS tracking and location-disabled hint

Completes the port of the QTH定位器 app's location logic (read from the decompiled MainActivity):

1. Live location updates: a LocationListener registers GPS+NETWORK providers (10s / 10m, matching the reference onResume) while the Roaming page is visible and removes itself on dispose. Every fix is pushed through settingsRepo.setStationPosition, so the shared stationPosition StateFlow updates the Settings page and the map in lockstep — the page now refreshes in real time instead of only showing stale cached coordinates.

2. Provider filtering: only gps/network fixes are accepted, mirroring the reference showLocation() guard that rejects passive fixes.

3. Location-disabled hint: when GPS is off or permission is missing, the header shows a tappable '定位未开启,点击前往系统设置' row that opens ACTION_LOCATION_SOURCE_SETTINGS — the port of the reference btnLocationSettings button. The GPS status dot now has three states: fresh fix (primary), provider on but stale (error), provider off (outline).

4. Periodic recovery: a 30s re-request loop (honoring the 漫游位置实时更新 toggle) re-arms the location request after the chip falls idle.

Not ported (conflict, deliberate): the reference app's Play-store ad banner, 'New! Grid Square with map' promo and GP_IN preferences — commercial advertising does not belong in a GPL satellite tracker.
This commit is contained in:
mckero committed 2026-08-03 11:59:31 +00:00
1 parent f9250ec1f1
commit 44c442ddd3
1 file changed
+104 -11
@@ -17,7 +17,18 @@
*/
package com.rtbishop.look4sat.feature.roaming
import android.Manifest
import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Bundle
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
@@ -36,8 +47,12 @@ import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -48,6 +63,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.utility.positionToQth
@@ -67,24 +83,78 @@ import java.util.Locale
* and row are wider/taller, matching the reference app). Styled with the
* Look4Sat Material 3 theme.
*
* Coordinates come straight from the shared settingsRepo.stationPosition
* StateFlow — the exact same source the Settings page shows — so the two
* pages can never disagree. The red marker is placed at the fractional
* position derived from the 3rd character pair of the locator (ported from
* the QTH定位器 app), scaled to the actual cell size, so it stays accurate
* on any screen (phones, tablets, wide/narrow).
* Live GPS: while the screen is shown (and the "漫游位置实时更新" setting is on)
* a LocationManager listener requests gps+network fixes every 10s / 10m,
* exactly like the reference app; each fix is pushed through the shared
* settingsRepo.stationPosition StateFlow, so the Settings page and every
* other consumer update in lockstep. The red marker is placed at the
* fractional position derived from the 3rd character pair of the locator
* (ported from the QTH定位器 app), scaled to the actual cell size, so it
* stays accurate on any screen (phones, tablets, wide/narrow).
*/
@SuppressLint("MissingPermission")
@Composable
fun RoamingScreen() {
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val stationPos by container.settingsRepo.stationPosition.collectAsStateWithLifecycle()
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
val settingsRepo = container.settingsRepo
val stationPos by settingsRepo.stationPosition.collectAsStateWithLifecycle()
val otherSettings by settingsRepo.otherSettings.collectAsStateWithLifecycle()
val state = remember(stationPos, otherSettings.stateOfRoamingLive) {
RoamingState.fromPosition(stationPos)
}
// Real-time location updates, ported from the reference app's onResume/onPause:
// register gps+network listeners while visible, remove them when leaving.
var gpsAvailable by remember { mutableStateOf(false) }
DisposableEffect(Unit) {
val locationManager = context.getSystemService(LocationManager::class.java)
val hasPermission = ContextCompat.checkSelfPermission(
context, Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
gpsAvailable = hasPermission && locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)
val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
// Accept only real gps/network fixes, like the reference app
if (location.provider != LocationManager.GPS_PROVIDER &&
location.provider != LocationManager.NETWORK_PROVIDER
) return
settingsRepo.setStationPosition(location.latitude, location.longitude, location.altitude)
}
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) = Unit
override fun onProviderEnabled(provider: String) {
if (provider == LocationManager.GPS_PROVIDER) gpsAvailable = true
}
override fun onProviderDisabled(provider: String) {
if (provider == LocationManager.GPS_PROVIDER) gpsAvailable = false
}
}
if (hasPermission) {
// Listen continuously while the page is visible (10s / 10m, like the reference)
locationManager.requestLocationUpdates(
LocationManager.GPS_PROVIDER, 10_000L, 10f, listener
)
locationManager.requestLocationUpdates(
LocationManager.NETWORK_PROVIDER, 10_000L, 10f, listener
)
}
onDispose {
if (hasPermission) locationManager.removeUpdates(listener)
}
}
// Periodic refresh when live updates are enabled: re-request a fix every 30s
// so the page also recovers after the GPS chip falls idle.
LaunchedEffect(otherSettings.stateOfRoamingLive) {
while (otherSettings.stateOfRoamingLive) {
settingsRepo.setStationPosition()
kotlinx.coroutines.delay(30_000L)
}
}
ScreenColumn(
topBar = {
TopBar { Text(text = "漫游", fontSize = 20.sp, fontWeight = FontWeight.Bold) }
@@ -95,15 +165,16 @@ fun RoamingScreen() {
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(spacing.medium)
) {
InfoCard(state = state)
InfoCard(state = state, gpsAvailable = gpsAvailable)
GridCard(state = state, modifier = Modifier.weight(1f))
}
}
}
@Composable
private fun InfoCard(state: RoamingState) {
private fun InfoCard(state: RoamingState, gpsAvailable: Boolean) {
val colorScheme = MaterialTheme.colorScheme
val context = LocalContext.current
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
@@ -124,7 +195,13 @@ private fun InfoCard(state: RoamingState) {
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.background(if (state.gpsEnabled) colorScheme.primary else colorScheme.outline)
.background(
when {
state.gpsEnabled -> colorScheme.primary
gpsAvailable -> colorScheme.error
else -> colorScheme.outline
}
)
)
Spacer(modifier = Modifier.weight(1f))
Text(
@@ -140,6 +217,22 @@ private fun InfoCard(state: RoamingState) {
color = colorScheme.onSurface
)
}
// GPS disabled hint, ported from the reference app's btnLocationSettings:
// opens the system location settings so the user can re-enable GPS.
if (!gpsAvailable) {
Text(
text = "定位未开启,点击前往系统设置",
fontSize = 13.sp,
color = colorScheme.error,
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.small)
.clickable {
context.startActivity(Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS))
}
.padding(vertical = 6.dp)
)
}
// Coordinates: DMS + decimal
CoordinateRow(label = "纬度", value = state.latitude, isLat = true)
CoordinateRow(label = "经度", value = state.longitude, isLat = false)