Compare commits

...
11 Commits
Author SHA1 Message Date
mckero 929f652b8e chore: bump version to 4.4.6
Next release: versionCode 445 -> 446, versionName 4.4.5 -> 4.4.6. Adds the Roaming page (QTH定位器-style 3x3 Maidenhead grid) between Match and Settings, credits BG7NTA & the original author in the thanks title, and caps local Gradle heap for the 2GB build server.
2026-08-03 10:47:39 +00:00
mckero 610768501e build: add roaming module gradle file, cap local JVM heap
Include feature:roaming/build.gradle.kts (missed from the module commit) and keep the reduced Gradle heap (-Xmx768m) so local light builds don't freeze the 2GB server.
2026-08-03 10:46:32 +00:00
mckero 427e6e1862 i18n: credit BG7NTA and original author in thanks title
The settings outro title now reads 'BG7NTA & the original author would like to thank' (en) / 'BG7NTA 与原作者感谢' (zh) / 'BG7NTA ve orijinal yazar teşekkür eder' (tr), alongside the BA7OPF/BG7NTA entries added to the credits list.
2026-08-03 10:46:20 +00:00
mckero bfa5e5f474 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
2026-08-03 10:46:14 +00:00
mckero 213425379c merge: merge atsunatsu/main (mutual radar overlay arrows)
Merge upstream commit 2298d8ee 'feat: add mutual radar overlay arrows'. Clean auto-merge: upstream changed RadarView.kt arrow drawing, our sweep optimization stayed intact. No conflicts.

First v4.4.5 was built from c3444aed; this release rebuilds from the merged tree.
2026-08-03 10:04:09 +00:00
mckero c3444aed24 chore: bump version to 4.4.5
Next release: versionCode 444 -> 445, versionName 4.4.4 -> 4.4.5, following the upstream scheme without suffix. Includes the station panel layout fix, 5-decimal coordinates, and credits additions already merged on main.
2026-08-03 09:43:10 +00:00
mckero 368bd199e5 fix: station panel layout wraps QTH to its own line
With 5-decimal coordinates the Lat/Lon/Qth row overflowed, pushing the QTH value to a wrapped line. Split into two rows: Lat + Lon on the first line, Qth on the second.

Also append BA7OPF (pass matching feature) and BG7NTA to the credits list in en/zh/tr string resources.
2026-08-03 09:33:40 +00:00
atsunatsu 2298d8eea4 feat: add mutual radar overlay arrows 2026-08-03 17:27:48 +08:00
mckero 7bb0bbbb46 build: load release signing config from local keystore.properties
Conditionally applies the BG7NTA signingConfig when keystore.properties exists (gitignored). CI signs via apksigner with GitHub Secrets, so this only affects local builds.
2026-08-03 09:25:53 +00:00
mckero deec5581ae feat: rebrand to Look4Sat Pro, bump version to 4.4.4
App display name changed from Look4Sat to Look4Sat Pro (fork identity). Version follows upstream scheme without suffix: 4.4.3 -> 4.4.4, versionCode 443 -> 444.
2026-08-03 09:20:41 +00:00
mckero 1de5632428 feat: increase station coordinate precision to 5 decimals
Station lat/lon was stored rounded to 4 decimals (~11 m). Bump to 5 decimals (~1.1 m), slightly better than GPS hardware accuracy without showing noise.

- SettingsRepo.setStationPosition: round(4) -> round(5)
- QthConverter.qthToPosition: round(4) -> round(6) so 10-char locators fully roundtrip
- Update QthConverterTest expected values to 6-decimal precision
2026-08-03 09:14:13 +00:00
19 changed files with 497 additions and 23 deletions

No files matched your search

+26
View File
@@ -1,3 +1,29 @@
import java.util.Properties
plugins {
alias(libs.plugins.convention.applicationPlugin)
}
// Load signing config from keystore.properties (gitignored, never commit credentials)
val keystoreProperties = Properties().apply {
val propsFile = rootProject.file("keystore.properties")
if (propsFile.exists()) propsFile.inputStream().use { load(it) }
}
android {
signingConfigs {
if (keystoreProperties["storeFile"] != null) {
create("release") {
storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
}
}
}
buildTypes {
release {
signingConfig = signingConfigs.findByName("release")
}
}
}
@@ -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)
@@ -213,8 +213,8 @@ class SettingsRepo(
}
private fun setStationPosition(latitude: Double, longitude: Double, altitude: Double, locator: String) {
val newLat = latitude.round(4)
val newLon = longitude.round(4)
val newLat = latitude.round(5)
val newLon = longitude.round(5)
val newAlt = altitude.round(1)
val timestamp = System.currentTimeMillis()
println("Received new Position($newLat, $newLon, $newAlt) & Locator $locator")
@@ -63,7 +63,7 @@ fun qthToPosition(locator: String): GeoPos? {
latitude += 1.0 / 48.0
}
}
return GeoPos(latitude.round(4), longitude.round(4))
return GeoPos(latitude.round(6), longitude.round(6))
}
/**
@@ -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 {
@@ -26,14 +28,14 @@ class QthConverterTest {
@Test
fun `Given valid QTH returns correct POS`() {
var result = qthToPosition("io91VL39FX")
assert(result?.latitude == 51.4999 && result.longitude == -0.2231)
assert(result?.latitude == 51.499913 && result.longitude == -0.22309)
result = qthToPosition("gf15vc")
assert(result?.latitude == -34.8958 && result.longitude == -56.2083)
assert(result?.latitude == -34.895833 && result.longitude == -56.208333)
// 8-char locators: finer 30" x 15" cell center
result = qthToPosition("io91vl47")
assert(result?.latitude == 51.4896 && result.longitude == -0.2125)
assert(result?.latitude == 51.489583 && result.longitude == -0.2125)
result = qthToPosition("jn58td25")
assert(result?.latitude == 48.1479 && result.longitude == 11.6042)
assert(result?.latitude == 48.147917 && result.longitude == 11.604167)
}
@Test
@@ -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>
@@ -192,14 +192,16 @@
<string name="prefs_highlight_mid">Orta %1$d-%2$d°</string>
<string name="prefs_highlight_high">Yüksek &gt; %1$d°</string>
<string name="prefs_outro_title">Teşekkürler</string>
<string name="prefs_outro_title">BG7NTA ve orijinal yazar teşekkür eder</string>
<string name="prefs_outro_thanks" translatable="false">
• Look4Sat users and contributors!
\n• David A. B. Johnson (predict4java)
\n• Dave Moten (predict4java)
\n• Alexandru Csete (Gpredict)
\n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS)</string>
\n• Libre Space Foundation (SatNOGS)
\n• BA7OPF (pass matching feature)
\n• BG7NTA</string>
<string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string>
</resources>
@@ -167,7 +167,7 @@
<string name="prefs_highlight_mid">中 %1$d-%2$d°</string>
<string name="prefs_highlight_high">高 &gt; %1$d°</string>
<string name="prefs_outro_title">我要感谢:</string>
<string name="prefs_outro_title">BG7NTA 与原作者感谢:</string>
<string name="prefs_outro_thanks" translatable="false">
• Look4Sat 所有用户及贡献者!
\n• David A. B. Johnson (predict4java)
@@ -175,7 +175,9 @@
\n• Alexandru Csete (Gpredict)
\n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS)
\n• xdsopl 和 Robot36 贡献者!</string>
\n• xdsopl 和 Robot36 贡献者!
\n• BA7OPF(过境匹配功能)
\n• BG7NTA</string>
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
</resources>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Commmon -->
<string name="app_name" translatable="false">Look4Sat</string>
<string name="app_name" translatable="false">Look4Sat Pro</string>
<string name="btn_accept">Accept</string>
<string name="btn_cancel">Cancel</string>
<string name="empty_list_message">No data to show</string>
@@ -11,6 +11,7 @@
<string name="nav_pass">Passes</string>
<string name="nav_radar">Radar</string>
<string name="nav_mutual">Match</string>
<string name="nav_roaming">Roaming</string>
<string name="nav_map">Map</string>
<string name="nav_prefs">Settings</string>
@@ -202,7 +203,7 @@
<string name="prefs_highlight_mid">Mid %1$d–%2$d°</string>
<string name="prefs_highlight_high">High &gt; %1$d°</string>
<string name="prefs_outro_title">I would like to say thanks to</string>
<string name="prefs_outro_title">BG7NTA &amp; the original author would like to thank</string>
<string name="prefs_outro_thanks" translatable="false">
* Look4Sat users and contributors!
\n* David A. B. Johnson (predict4java)
@@ -211,6 +212,8 @@
\n* Dr T.S. Kelso (Celestrak)
\n* Libre Space Foundation (SatNOGS)
\n* xdsopl and Robot36 contributors!
\n* BA7OPF (pass matching feature)
\n* BG7NTA
</string>
<string name="prefs_outro_license">The app comes with no warranty</string>
@@ -119,9 +119,10 @@ fun RadarViewCompose(
var cachedSweepColor by remember { mutableStateOf(Color.Unspecified) }
var trackPath by remember { mutableStateOf(Path()) }
var trackEffect by remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) }
// Station-B overlay track (dashed)
// Station-B overlay track (dashed, with the same directional arrow stamp as station A)
var cachedTrackBRef by remember { mutableStateOf<List<OrbitalPos>?>(null) }
var trackBPath by remember { mutableStateOf(Path()) }
var trackBEffect by remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) }
// ShaderBrush is cached to avoid allocating a new GPU shader object every frame
var cachedSweepBrush by remember { mutableStateOf<ShaderBrush?>(null) }
@@ -131,7 +132,10 @@ fun RadarViewCompose(
if (radius != cachedRadius || items !== cachedItemsRef || trackB !== cachedTrackBRef) {
trackPath = createTrackPath(items, radius)
trackEffect = createTrackEffect(trackPath)
trackBPath = trackB?.let { createTrackPath(it, radius) } ?: Path()
val newTrackBPath = trackB?.let { createTrackPath(it, radius) } ?: Path()
trackBPath = newTrackBPath
trackBEffect = if (!newTrackBPath.isEmpty) createTrackEffect(newTrackBPath)
else PathEffect.cornerPathEffect(0f)
cachedSweepBrush = makeSweepBrush(center, primaryColor)
cachedRadius = radius
cachedItemsRef = items
@@ -148,13 +152,14 @@ fun RadarViewCompose(
drawElevationLabels(radius, primaryColor, measurer)
translate(center.x, center.y) {
drawTrack(trackPath, trackEffect, aimColor, primaryColor)
// Station-B overlay: full dashed track + live position dot
// (same display mode as the local station: track line + pulsing dot)
// Station-B overlay: full dashed track + directional arrows + live position dot
// (same display mode as the local station: track line/arrow + pulsing dot)
if (trackB != null && trackB.isNotEmpty() && !trackBPath.isEmpty) {
drawPath(
trackBPath, trackBColor,
style = Stroke(STROKE_WIDTH, pathEffect = PathEffect.dashPathEffect(floatArrayOf(18f, 12f)))
)
drawPath(trackBPath, trackBColor, style = Stroke(pathEffect = trackBEffect))
}
trackBPosition?.let { posB ->
if (posB.elevation > 0) {
+11
View File
@@ -0,0 +1,11 @@
plugins {
alias(libs.plugins.convention.featurePlugin)
}
android {
namespace = "com.rtbishop.look4sat.feature.roaming"
}
dependencies {
implementation(project(":core:data"))
}
@@ -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) }
}
}
}
@@ -339,6 +339,8 @@ private fun LocationCard(
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = "Lat: ${settings.stationPos.latitude}°")
Text(text = "Lon: ${settings.stationPos.longitude}°")
}
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = "Qth: ${settings.stationPos.qthLocator}")
}
Spacer(modifier = Modifier.height(1.dp))
+4 -3
View File
@@ -1,5 +1,6 @@
# Configure your build environment: http://www.gradle.org/docs/current/userguide/build_environment.html
org.gradle.caching=true
org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.workers.max=4
# NOTE: lowered from -Xmx6g for the 2GB build server; heavy release builds run on GitHub Actions
org.gradle.jvmargs=-Xmx768m -XX:MaxMetaspaceSize=256m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
org.gradle.parallel=false
org.gradle.workers.max=1
+2 -2
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "443"
appVersionCode = "446"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.3"
appVersionName = "4.4.6"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry
+1
View File
@@ -25,6 +25,7 @@ include(
":feature:mutual",
":feature:passes",
":feature:radar",
":feature:roaming",
":feature:satellites",
":feature:settings"
)