diff --git a/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingScreen.kt b/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingScreen.kt index bcb2b5c3..92fea02e 100644 --- a/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingScreen.kt +++ b/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingScreen.kt @@ -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)