feat(roaming): faithful QTH定位器 port, live-update toggle, night-mode safe

Rework the Roaming page after user review. It is now a faithful port of the QTH定位器 location panel, not a loose re-skin:

UI (matching the reference layout):
- GPS status dot (real: green when a fresh fix exists, outline when stale/missing) + date + time header
- Lat/Lon rows with DMS and 5-decimal display, big 8-char locator centered below
- 3x3 grid of neighboring 4-char squares with the reference proportions: center column ~2.6x wider (21.4% : 56.2% : 21.4%) and center row the tallest (31.5% : 35.9% : 31.7%); red position marker now placed at the fractional position of the fix inside the center cell (was fixed center)
- No oversized GPS button: live updating is now a Settings toggle '漫游位置实时更新' (stateOfRoamingLive, default on) that drives periodic location refresh

Style & night-mode safety:
- All colors come from MaterialTheme.colorScheme (surfaceVariant/secondaryContainer/error) — no hardcoded cyan/blue from the original app. The red night filter (ColorMatrix keeping only the R channel) blanked the old hardcoded palette; theme colors survive it.
- Chinese nav label '漫游' added to values-zh (was missing, showing English 'Roaming')

Settings:
- OtherSettings.stateOfRoamingLive persisted (default true), toggle row in Other card, height adjusted

Verified: feature:roaming, feature:settings, app compile clean.
This commit is contained in:
mckero committed 2026-08-03 11:18:35 +00:00
1 parent 8a920acead
commit 6dbe30b3f6
10 files changed
+221 -110

No files matched your search

@@ -76,6 +76,7 @@ class SettingsRepo(
private val keyStateOfUtc = "stateOfUtc"
private val keyStateOfLightTheme = "stateOfLightTheme"
private val keyStateOfNightMode = "stateOfNightMode"
private val keyStateOfRoamingLive = "stateOfRoamingLive"
private val keyStationAltitude = "stationAltitude"
private val keyStationLatitude = "stationLatitude"
private val keyStationLongitude = "stationLongitude"
@@ -351,6 +352,7 @@ class SettingsRepo(
putBoolean(keyStateOfUtc, new.stateOfUtc)
putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
putBoolean(keyStateOfRoamingLive, new.stateOfRoamingLive)
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
putString(keySstvMode, new.sstvMode)
@@ -368,6 +370,7 @@ class SettingsRepo(
stateOfUtc = preferences.getBoolean(keyStateOfUtc, false),
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
stateOfRoamingLive = preferences.getBoolean(keyStateOfRoamingLive, true),
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
@@ -58,6 +58,7 @@ data class OtherSettings(
val stateOfUtc: Boolean,
val stateOfLightTheme: Boolean,
val stateOfNightMode: Boolean = false,
val stateOfRoamingLive: Boolean = true,
val shouldSeeWarning: Boolean,
val shouldSeeWhatsNew: Boolean,
val sstvMode: String = "Auto",
@@ -186,6 +186,7 @@
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string>
<string name="prefs_other_switch_sensors">Radar görünümünü döndürmek için sensörleri kullan</string>
<string name="prefs_other_switch_night_mode">Kırmızı gece filtresini etkinleştir</string>
<string name="prefs_other_switch_roaming_live">Roaming konumunu canlı güncelle</string>
<string name="prefs_highlight_title">Elevasyon vurgusu</string>
<string name="prefs_highlight_low">Düşük &lt; %1$d°</string>
@@ -10,6 +10,7 @@
<string name="nav_pass">过境</string>
<string name="nav_radar">雷达</string>
<string name="nav_mutual">匹配</string>
<string name="nav_roaming">漫游</string>
<string name="nav_map">地图</string>
<string name="nav_prefs">设置</string>
@@ -161,6 +162,7 @@
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
<string name="prefs_other_switch_roaming_live">漫游位置实时更新</string>
<string name="prefs_highlight_title">仰角高亮</string>
<string name="prefs_highlight_low">低 &lt; %1$d°</string>
@@ -197,6 +197,7 @@
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string>
<string name="prefs_other_switch_sensors">Use sensors to rotate radar view</string>
<string name="prefs_other_switch_night_mode">Enable red night mode filter</string>
<string name="prefs_other_switch_roaming_live">Update Roaming position live</string>
<string name="prefs_highlight_title">Elevation highlight</string>
<string name="prefs_highlight_low">Low &lt; %1$d°</string>
@@ -18,20 +18,21 @@
package com.rtbishop.look4sat.feature.roaming
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -39,164 +40,214 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontFamily
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.lifecycle.compose.collectAsStateWithLifecycle
import com.rtbishop.look4sat.core.presentation.CardButton
private val InfoCyan = Color(0xFF0CCFE2)
private val GridBlue = Color(0xFF12ACE0)
private val LabelBlue = Color(0xFF135678)
private val PositionRed = Color(0xFFFF0000)
private val DividerWhite = Color(0xFFF7F7F7)
import com.rtbishop.look4sat.core.presentation.LocalSpacing
import com.rtbishop.look4sat.core.presentation.ScreenColumn
import com.rtbishop.look4sat.core.presentation.TopBar
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Roaming (漫游) page: a faithful port of the QTH定位器 location panel —
* GPS status + date/time header, DMS & decimal coordinates, the big 8-char
* Maidenhead locator, and a 3x3 grid of neighboring squares (center column
* and row are wider/taller, matching the reference app). Styled with the
* Look4Sat Material 3 theme.
*/
@Composable
fun RoamingScreen(viewModel: RoamingViewModel) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Column(modifier = Modifier.fillMaxSize()) {
InfoHeader(state = state, onGpsClick = viewModel::onGpsClick)
GridPanel(state = state)
ScreenColumn(
topBar = {
TopBar { Text(text = "漫游", fontSize = 20.sp, fontWeight = FontWeight.Bold) }
}
) {
val spacing = LocalSpacing.current
Column(verticalArrangement = Arrangement.spacedBy(spacing.medium)) {
InfoCard(state = state)
GridCard(state = state)
}
}
}
@Composable
private fun InfoHeader(state: RoamingState, onGpsClick: () -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(InfoCyan)
.padding(horizontal = 16.dp, vertical = 10.dp)
) {
// Top row: GPS status + big locator
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "GPS",
fontSize = 20.sp,
fontWeight = FontWeight.Medium,
color = Color.Black
)
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(if (state.qthLocator != "----") Color(0xFF3BAF52) else Color.Gray)
)
Spacer(modifier = Modifier.weight(1f))
private fun InfoCard(state: RoamingState) {
val colorScheme = MaterialTheme.colorScheme
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Top bar: GPS status + date + time
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "GPS",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = colorScheme.onSurface
)
Spacer(modifier = Modifier.width(6.dp))
Box(
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.background(if (state.gpsEnabled) colorScheme.primary else colorScheme.outline)
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = SimpleDateFormat("dd MMM yyyy", Locale.getDefault()).format(Date()),
fontSize = 14.sp,
color = colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date()),
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = colorScheme.onSurface
)
}
// Coordinates: DMS + decimal
CoordinateRow(label = "纬度", value = state.latitude, isLat = true)
CoordinateRow(label = "经度", value = state.longitude, isLat = false)
// Big locator, centered
Text(
text = state.qthLocator,
fontSize = 30.sp,
fontSize = 34.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
color = Color.Black
color = colorScheme.primary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.height(10.dp))
// Lat / Lon rows with DMS + decimal
CoordinateRow(label = "Lat", value = state.latitude, isLat = true)
Spacer(modifier = Modifier.height(4.dp))
CoordinateRow(label = "Lon", value = state.longitude, isLat = false)
Spacer(modifier = Modifier.height(10.dp))
CardButton(
onClick = onGpsClick,
text = "GPS 定位",
modifier = Modifier.fillMaxWidth()
)
}
}
@Composable
private fun CoordinateRow(label: String, value: Double, isLat: Boolean) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
val colorScheme = MaterialTheme.colorScheme
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
text = label,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = Color.Black,
modifier = Modifier.width(44.dp)
)
Text(
text = formatDms(value, isLat),
fontSize = 15.sp,
color = Color.Black
fontWeight = FontWeight.Medium,
color = colorScheme.onSurface,
modifier = Modifier.width(40.dp)
)
Text(text = formatDms(value, isLat), fontSize = 15.sp, color = colorScheme.onSurface)
Spacer(modifier = Modifier.weight(1f))
Text(
text = "${formatDecimal(value)}°",
fontSize = 14.sp,
color = LabelBlue
color = colorScheme.onSurfaceVariant
)
}
}
/**
* 3x3 grid of 4-char squares. Column widths 21.4% : 56.2% : 21.4% and row
* heights 31.5% : 35.9% : 31.7% mirror the reference app: the center column
* is ~2.6x wider and the center row is the tallest, so the current square
* dominates the panel.
*/
@Composable
private fun GridPanel(state: RoamingState) {
Column(
modifier = Modifier
.fillMaxSize()
.background(GridBlue)
.padding(4.dp)
) {
// 3x3 grid: row 0 = north
for (row in 0..2) {
Row(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
for (col in 0..2) {
val index = row * 3 + col
val isCenter = index == 4
GridCell(
label = state.gridSquares.getOrElse(index) { "----" },
isCenter = isCenter,
showMarker = isCenter && state.qthLocator != "----",
modifier = Modifier.weight(1f)
)
}
}
if (row < 2) Spacer(modifier = Modifier.height(4.dp))
private fun GridCard(state: RoamingState) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
GridRow(state, row = 0, heightFraction = 0.315f)
GridRow(state, row = 1, heightFraction = 0.359f)
GridRow(state, row = 2, heightFraction = 0.317f)
}
}
}
@Composable
private fun GridRow(state: RoamingState, row: Int, heightFraction: Float) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
GridCell(
label = state.gridSquares.getOrElse(row * 3 + 0) { "----" },
isCenter = false,
markerX = state.markerX,
markerY = state.markerY,
modifier = Modifier.weight(0.214f)
)
GridCell(
label = state.gridSquares.getOrElse(row * 3 + 1) { "----" },
isCenter = row == 1,
markerX = state.markerX,
markerY = state.markerY,
showMarker = row == 1 && state.qthLocator != "----",
modifier = Modifier.weight(0.562f)
)
GridCell(
label = state.gridSquares.getOrElse(row * 3 + 2) { "----" },
isCenter = false,
markerX = state.markerX,
markerY = state.markerY,
modifier = Modifier.weight(0.214f)
)
}
}
@Composable
private fun GridCell(
label: String,
isCenter: Boolean,
showMarker: Boolean,
markerX: Float,
markerY: Float,
showMarker: Boolean = false,
modifier: Modifier = Modifier
) {
Box(
val colorScheme = MaterialTheme.colorScheme
val cellBackground = if (isCenter) colorScheme.secondaryContainer else colorScheme.surfaceVariant
val labelColor = if (isCenter) colorScheme.onSecondaryContainer else colorScheme.onSurfaceVariant
BoxWithConstraints(
contentAlignment = Alignment.Center,
modifier = modifier
.aspectRatio(1f)
.background(if (isCenter) InfoCyan else GridBlue)
.border(1.dp, DividerWhite)
.height(84.dp)
.clip(MaterialTheme.shapes.medium)
.background(cellBackground)
) {
// Capture constraints at the BoxWithConstraints scope (Column scope would shadow them)
val width = maxWidth
val height = maxHeight
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = label,
fontSize = if (isCenter) 26.sp else 16.sp,
fontSize = if (isCenter) 22.sp else 14.sp,
fontWeight = if (isCenter) FontWeight.Bold else FontWeight.Normal,
color = LabelBlue,
color = labelColor,
textAlign = TextAlign.Center
)
if (showMarker) {
Spacer(modifier = Modifier.height(4.dp))
// Red marker at the fractional position inside the center cell
val density = LocalDensity.current
val offsetX = with(density) { (width * (markerX - 0.5f)).toPx().toDp() }
val offsetY = with(density) { (height * (markerY - 0.5f)).toPx().toDp() }
Box(
modifier = Modifier
.size(12.dp)
.background(PositionRed)
.border(2.dp, DividerWhite)
.background(colorScheme.error)
.offset(x = offsetX, y = offsetY)
)
}
}
@@ -27,19 +27,24 @@ import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.utility.positionToQth
import com.rtbishop.look4sat.core.domain.utility.qthNeighbors
import com.rtbishop.look4sat.core.domain.utility.qthToSquare
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
data class RoamingState(
val latitude: Double = 0.0,
val longitude: Double = 0.0,
val altitude: Double = 0.0,
val qthLocator: String = "----",
val gridSquares: List<String> = List(9) { "----" },
val isUpdating: Boolean = false,
val messageResId: Int = 0
/** Fractional position of the red marker inside the center cell, 0..1. */
val markerX: Float = 0.5f,
val markerY: Float = 0.5f,
/** True when a fresh GPS fix is available (station position updated recently). */
val gpsEnabled: Boolean = false,
val isUpdating: Boolean = false
)
class RoamingViewModel(private val settingsRepo: ISettingsRepo) : ViewModel() {
@@ -47,19 +52,42 @@ class RoamingViewModel(private val settingsRepo: ISettingsRepo) : ViewModel() {
private val _uiState = MutableStateFlow(RoamingState())
val uiState: StateFlow<RoamingState> = _uiState
private var liveEnabled: Boolean = true
init {
// Follow station position updates
viewModelScope.launch {
settingsRepo.stationPosition.collect { geoPos ->
_uiState.update { it.copy(isUpdating = false, messageResId = 0).withPosition(geoPos) }
_uiState.update { state ->
state.copy(isUpdating = false, gpsEnabled = isFixFresh(geoPos)).withPosition(geoPos)
}
}
}
// Follow the "live update" preference; when enabled, periodically refresh the fix
viewModelScope.launch {
settingsRepo.otherSettings.collect { other ->
liveEnabled = other.stateOfRoamingLive
if (liveEnabled) refreshPosition()
}
}
viewModelScope.launch {
while (isActive) {
delay(30_000L)
if (liveEnabled) refreshPosition()
_uiState.update { state ->
state.copy(gpsEnabled = isFixFresh(settingsRepo.stationPosition.value))
}
}
}
}
fun onGpsClick() {
val success = settingsRepo.setStationPosition()
if (success) {
_uiState.update { it.copy(isUpdating = true) }
}
/** Ask the settings repo for the latest fix (triggers GPS request if stale). */
private fun refreshPosition() {
settingsRepo.setStationPosition()
}
private fun isFixFresh(pos: GeoPos): Boolean {
return pos.timestamp > 0L && System.currentTimeMillis() - pos.timestamp < 600_000L
}
private fun RoamingState.withPosition(pos: GeoPos): RoamingState {
@@ -69,12 +97,31 @@ class RoamingViewModel(private val settingsRepo: ISettingsRepo) : ViewModel() {
return copy(
latitude = pos.latitude,
longitude = pos.longitude,
altitude = pos.altitude,
qthLocator = locator,
gridSquares = squares
gridSquares = squares,
markerX = markerFraction(pos.longitude, square, isLat = false),
markerY = markerFraction(pos.latitude, square, isLat = true)
)
}
/**
* Fractional position of [value] (lon or lat) inside its 4-char square.
* A square spans 2° of longitude and 1° of latitude; the fraction is
* measured from the square's south-west corner, 0..1.
*/
private fun markerFraction(value: Double, square: String, isLat: Boolean): Float {
if (square.length != 4) return 0.5f
val fieldIndex = (square[if (isLat) 1 else 0].uppercaseChar().code - 65).coerceIn(0, 17)
val squareIndex = square[if (isLat) 3 else 2].digitToIntOrNull() ?: return 0.5f
val cellSize = if (isLat) 1.0 else 2.0
val cellStart = if (isLat) {
fieldIndex * 10.0 + squareIndex - 90.0
} else {
fieldIndex * 20.0 + squareIndex * 2.0 - 180.0
}
return ((value - cellStart) / cellSize).coerceIn(0.0, 1.0).toFloat()
}
companion object {
fun factory(container: IMainContainer) = viewModelFactory {
initializer { RoamingViewModel(container.settingsRepo) }
@@ -488,7 +488,7 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.height(268.dp)
.height(296.dp)
) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text(
@@ -510,6 +510,9 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
SwitchRow(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
onAction(SettingsAction.ToggleNightMode(it))
}
SwitchRow(R.string.prefs_other_switch_roaming_live, settings.stateOfRoamingLive) {
onAction(SettingsAction.ToggleRoamingLive(it))
}
}
}
}
@@ -64,6 +64,7 @@ sealed interface SettingsAction {
data class ToggleSensor(val value: Boolean) : SettingsAction
data class ToggleLightTheme(val value: Boolean) : SettingsAction
data class ToggleNightMode(val value: Boolean) : SettingsAction
data class ToggleRoamingLive(val value: Boolean) : SettingsAction
// Remote control
data class UpdateRC(val settings: RCSettings) : SettingsAction
@@ -121,6 +121,7 @@ class SettingsViewModel(
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
is SettingsAction.ToggleRoamingLive -> settingsRepo.updateOtherSettings { it.copy(stateOfRoamingLive = action.value) }
// Remote control & data sources
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)