From bfa5e5f47464b25eb64a56efa3a08890678ce7b6 Mon Sep 17 00:00:00 2001 From: QIU Date: Mon, 3 Aug 2026 10:46:14 +0000 Subject: [PATCH] feat(roaming): add Roaming page with 3x3 Maidenhead grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../java/com/rtbishop/look4sat/MainScreen.kt | 11 +- .../look4sat/convention/ApplicationPlugin.kt | 1 + .../core/domain/utility/QthConverter.kt | 44 ++++ .../look4sat/core/domain/QthConverterTest.kt | 40 ++++ .../look4sat/core/presentation/Navigation.kt | 3 + .../src/main/res/drawable/ic_roaming.xml | 25 ++ .../look4sat/feature/roaming/RoamingScreen.kt | 216 ++++++++++++++++++ .../feature/roaming/RoamingViewModel.kt | 83 +++++++ settings.gradle.kts | 1 + 9 files changed, 423 insertions(+), 1 deletion(-) create mode 100644 core/presentation/src/main/res/drawable/ic_roaming.xml create mode 100644 feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingScreen.kt create mode 100644 feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingViewModel.kt diff --git a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt index c549f558..30b95f6b 100644 --- a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt +++ b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt @@ -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 { + val roamingViewModel: RoamingViewModel = viewModel( + viewModelStoreOwner = context as ViewModelStoreOwner, + factory = RoamingViewModel.factory(container) + ) + RoamingScreen(viewModel = roamingViewModel) + } entry { SettingsDestination() } diff --git a/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt b/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt index db09bc5f..ba7c0488 100644 --- a/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt +++ b/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt @@ -35,6 +35,7 @@ internal class ApplicationPlugin : Plugin { 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) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/QthConverter.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/QthConverter.kt index efb21735..afea0353 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/QthConverter.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/QthConverter.kt @@ -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 { + 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() + 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() diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/QthConverterTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/QthConverterTest.kt index 9cfecc8f..4b87942a 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/QthConverterTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/QthConverterTest.kt @@ -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!!") == "----") + } } diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt index 3c7672df..26ea0ee6 100644 --- a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt +++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt @@ -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) } diff --git a/core/presentation/src/main/res/drawable/ic_roaming.xml b/core/presentation/src/main/res/drawable/ic_roaming.xml new file mode 100644 index 00000000..240fdaa4 --- /dev/null +++ b/core/presentation/src/main/res/drawable/ic_roaming.xml @@ -0,0 +1,25 @@ + + + + + + + + + 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 new file mode 100644 index 00000000..54cfbedd --- /dev/null +++ b/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingScreen.kt @@ -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 . + */ +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) diff --git a/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingViewModel.kt b/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingViewModel.kt new file mode 100644 index 00000000..70444867 --- /dev/null +++ b/feature/roaming/src/main/java/com/rtbishop/look4sat/feature/roaming/RoamingViewModel.kt @@ -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 . + */ +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 = 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 = _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) } + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 45c81f6b..d35762f1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,6 +25,7 @@ include( ":feature:mutual", ":feature:passes", ":feature:radar", + ":feature:roaming", ":feature:satellites", ":feature:settings" )