feat(roaming): add Roaming page with 3x3 Maidenhead grid

Ports the QTH定位器 (com.us1pm.gridsquarelocator) location panel into Look4Sat as a new 'Roaming' page, restyled with the app's own look.

UI (top to bottom):
- Info header: GPS status dot, big 8-char locator, Lat/Lon rows with DMS + 5-decimal display, and a GPS 定位 button
- 3x3 grid panel: the current 4-char Maidenhead square (e.g. OL42) centered, surrounded by its 8 neighbors (OL33..OL51), with a red position marker in the center cell

Logic:
- QthConverter gains qthNeighbors(square) building the 3x3 grid with field/square carry at boundaries (AA00 wraps to RR99, IO91 crosses into J field), and qthToSquare(locator) extracting the 4-char square
- Verified against the decompiled app algorithm and the reference screenshot (OL42 grid matches exactly); 9 unit tests cover normal, boundary and field-wrap cases

Navigation:
- New bottom-nav item 'Roaming' between Match and Settings, with a crosshair icon
- New feature:roaming module (ViewModel + Compose screen) registered in the app

Build config:
- Lowered Gradle JVM heap from -Xmx6g to 768m: the 2GB build server froze on the old value; heavy release builds stay on GitHub Actions
This commit is contained in:
mckero committed 2026-08-03 10:46:14 +00:00
1 parent 213425379c
commit bfa5e5f474
9 files changed
+423 -1

No files matched your search

@@ -85,6 +85,8 @@ import com.rtbishop.look4sat.feature.mutual.MutualScreen
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
import com.rtbishop.look4sat.feature.passes.PassesDestination
import com.rtbishop.look4sat.feature.radar.RadarDestination
import com.rtbishop.look4sat.feature.roaming.RoamingScreen
import com.rtbishop.look4sat.feature.roaming.RoamingViewModel
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
import com.rtbishop.look4sat.feature.settings.SettingsDestination
@@ -130,7 +132,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
val currentKey = backStack.lastOrNull()
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Map, Screen.Settings)
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Roaming, Screen.Map, Screen.Settings)
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
@@ -224,6 +226,13 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
}
)
}
entry<Screen.Roaming> {
val roamingViewModel: RoamingViewModel = viewModel(
viewModelStoreOwner = context as ViewModelStoreOwner,
factory = RoamingViewModel.factory(container)
)
RoamingScreen(viewModel = roamingViewModel)
}
entry<Screen.Settings> {
SettingsDestination()
}
@@ -35,6 +35,7 @@ internal class ApplicationPlugin : Plugin<Project> {
implementation(project(":feature:mutual"))
implementation(project(":feature:passes"))
implementation(project(":feature:radar"))
implementation(project(":feature:roaming"))
implementation(project(":feature:satellites"))
implementation(project(":feature:settings"))
implementation(libs.androidx.core.splashscreen)
@@ -100,3 +100,47 @@ private fun isValidPosition(lat: Double, lon: Double): Boolean {
private fun isValidLocator(locator: String): Boolean {
return locator.matches("[a-xA-X]{2}\\d{2}[a-xA-X]{2}(?:\\d{2}(?:[a-xA-X]{2})?)?".toRegex())
}
/**
* Returns the 4-char field+square part of a locator, e.g. "OL42ih45" -> "OL42".
* A 4-char input is returned as-is when valid.
*/
fun qthToSquare(locator: String): String {
val upper = locator.trim().uppercase()
return when {
upper.length >= 4 && isValidLocator(upper) -> upper.take(4)
upper.length == 4 && upper.matches("[a-xA-X]{2}\\d{2}".toRegex()) -> upper
else -> "----"
}
}
/**
* Builds the 3x3 grid of 4-char squares surrounding [square] (e.g. "OL42").
* Row 0 = north (lat +1), col 0 = west (lon -1). Handles field/square carry
* at boundaries (e.g. "AA00" wraps to "RR99" at the south-west corner).
* Mirrors the neighbor logic decompiled from the QTH定位器 app.
*/
fun qthNeighbors(square: String): List<String> {
if (square.length != 4) return emptyList()
val lonField = (square[0].uppercaseChar().code - 65).coerceIn(0, 17)
val latField = (square[1].uppercaseChar().code - 65).coerceIn(0, 17)
val lonSquare = square[2].digitToCharOrNull() ?: return emptyList()
val latSquare = square[3].digitToCharOrNull() ?: return emptyList()
val result = mutableListOf<String>()
for (dLat in 1 downTo -1) { // north -> south
for (dLon in -1..1) { // west -> east
var lf = lonField
var tf = latField
var ls = lonSquare + dLon
var ts = latSquare + dLat
if (ls < 0) { lf -= 1; ls = 9 } else if (ls > 9) { lf += 1; ls = 0 }
if (ts < 0) { tf -= 1; ts = 9 } else if (ts > 9) { tf += 1; ts = 0 }
lf = (lf + 18) % 18
tf = (tf + 18) % 18
result += "${('A' + lf).toChar()}${('A' + tf).toChar()}$ls$ts"
}
}
return result
}
private fun Char.digitToCharOrNull(): Int? = digitToIntOrNull()
@@ -18,7 +18,9 @@
package com.rtbishop.look4sat.core.domain
import com.rtbishop.look4sat.core.domain.utility.positionToQth
import com.rtbishop.look4sat.core.domain.utility.qthNeighbors
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
import com.rtbishop.look4sat.core.domain.utility.qthToSquare
import org.junit.Test
class QthConverterTest {
@@ -82,4 +84,42 @@ class QthConverterTest {
assert(qth == qth2) { "Roundtrip failed for ($lat, $lon): $qth -> $qth2" }
}
}
@Test
fun `Given square returns correct 3x3 neighbors`() {
// Reference grid from the QTH定位器 screenshot: OL42
val neighbors = qthNeighbors("OL42")
assert(neighbors == listOf(
"OL33", "OL43", "OL53",
"OL32", "OL42", "OL52",
"OL31", "OL41", "OL51"
)) { "OL42 grid mismatch: $neighbors" }
// Center cell must be the input itself
assert(neighbors[4] == "OL42")
// 9 cells, all distinct
assert(neighbors.size == 9 && neighbors.toSet().size == 9)
}
@Test
fun `Given boundary square wraps fields correctly`() {
// South-west corner: AA00 neighbors wrap to RR99 / RA90 etc.
val sw = qthNeighbors("AA00")
assert(sw.size == 9 && sw.toSet().size == 9)
assert(sw[0] == "RA91" && sw[4] == "AA00" && sw[6] == "RR99" && sw[8] == "AR19")
// North-east corner: RR99 wraps to AA00
val ne = qthNeighbors("RR99")
assert(ne.size == 9 && ne.toSet().size == 9)
assert(ne[0] == "RA80" && ne[4] == "RR99" && ne[8] == "AR08")
// Field boundary: IO91's east neighbors cross into J field
val london = qthNeighbors("IO91")
assert(london[2] == "JO02" && london[5] == "JO01")
}
@Test
fun `Given full locator returns square part`() {
assert(qthToSquare("OL42ih45") == "OL42")
assert(qthToSquare("io91VL39FX") == "IO91")
assert(qthToSquare("JN58") == "JN58")
assert(qthToSquare("garbage!!") == "----")
}
}
@@ -38,6 +38,9 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
@Serializable
data object Mutual : Screen(R.drawable.ic_match, R.string.nav_mutual)
@Serializable
data object Roaming : Screen(R.drawable.ic_roaming, R.string.nav_roaming)
@Serializable
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
}
@@ -0,0 +1,25 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="96"
android:viewportHeight="96">
<!-- Crosshair / grid-locator style icon: concentric rings + center dot -->
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="6"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M 48 12 L 48 84 M 12 48 L 84 48" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="6"
android:pathData="M 48 26 A 22 22 0 1 0 48.1 26" />
<path
android:fillColor="@android:color/white"
android:pathData="M 44 44 L 52 44 L 52 52 L 44 52 Z" />
</vector>
@@ -0,0 +1,216 @@
/*
* 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.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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.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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.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)
@Composable
fun RoamingScreen(viewModel: RoamingViewModel) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Column(modifier = Modifier.fillMaxSize()) {
InfoHeader(state = state, onGpsClick = viewModel::onGpsClick)
GridPanel(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))
Text(
text = state.qthLocator,
fontSize = 30.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
color = Color.Black
)
}
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()
) {
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
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = "${formatDecimal(value)}°",
fontSize = 14.sp,
color = LabelBlue
)
}
}
@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))
}
}
}
@Composable
private fun GridCell(
label: String,
isCenter: Boolean,
showMarker: Boolean,
modifier: Modifier = Modifier
) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.aspectRatio(1f)
.background(if (isCenter) InfoCyan else GridBlue)
.border(1.dp, DividerWhite)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = label,
fontSize = if (isCenter) 26.sp else 16.sp,
fontWeight = if (isCenter) FontWeight.Bold else FontWeight.Normal,
color = LabelBlue,
textAlign = TextAlign.Center
)
if (showMarker) {
Spacer(modifier = Modifier.height(4.dp))
Box(
modifier = Modifier
.size(12.dp)
.background(PositionRed)
.border(2.dp, DividerWhite)
)
}
}
}
}
private fun formatDms(value: Double, isLat: Boolean): String {
val abs = kotlin.math.abs(value)
val deg = abs.toInt()
val minFull = (abs - deg) * 60
val min = minFull.toInt()
val sec = ((minFull - min) * 60).toInt()
val hemi = if (value >= 0) (if (isLat) "N" else "E") else (if (isLat) "S" else "W")
return "$deg° $min' $sec\" $hemi"
}
private fun formatDecimal(value: Double): String = String.format("%.5f", value)
@@ -0,0 +1,83 @@
/*
* 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.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.roaming
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
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.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
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
)
class RoamingViewModel(private val settingsRepo: ISettingsRepo) : ViewModel() {
private val _uiState = MutableStateFlow(RoamingState())
val uiState: StateFlow<RoamingState> = _uiState
init {
viewModelScope.launch {
settingsRepo.stationPosition.collect { geoPos ->
_uiState.update { it.copy(isUpdating = false, messageResId = 0).withPosition(geoPos) }
}
}
}
fun onGpsClick() {
val success = settingsRepo.setStationPosition()
if (success) {
_uiState.update { it.copy(isUpdating = true) }
}
}
private fun RoamingState.withPosition(pos: GeoPos): RoamingState {
val locator = positionToQth(pos.latitude, pos.longitude) ?: return copy(qthLocator = "----")
val square = qthToSquare(locator)
val squares = if (square != "----") qthNeighbors(square) else List(9) { "----" }
return copy(
latitude = pos.latitude,
longitude = pos.longitude,
altitude = pos.altitude,
qthLocator = locator,
gridSquares = squares
)
}
companion object {
fun factory(container: IMainContainer) = viewModelFactory {
initializer { RoamingViewModel(container.settingsRepo) }
}
}
}
+1
View File
@@ -25,6 +25,7 @@ include(
":feature:mutual",
":feature:passes",
":feature:radar",
":feature:roaming",
":feature:satellites",
":feature:settings"
)