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

@@ -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) }
}
}
}