Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
929f652b8e | ||
|
|
610768501e | ||
|
|
427e6e1862 | ||
|
|
bfa5e5f474 | ||
|
|
213425379c | ||
|
|
c3444aed24 | ||
|
|
368bd199e5 | ||
|
|
7bb0bbbb46 | ||
|
|
deec5581ae | ||
|
|
1de5632428 | ||
|
|
2dfd76825b | ||
|
|
06fc8bc2fb | ||
|
|
75322390b8 | ||
|
|
d1312f1f09 | ||
|
|
75acbbd633 | ||
|
|
a37fb8f773 | ||
|
|
9d0d971500 |
No files matched your search
@@ -4,10 +4,15 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- v**
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag_name:
|
||||
description: 'Release tag name (e.g. v4.4.3)'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
env:
|
||||
TAG_NAME: ${{ github.ref_name }}
|
||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
TAG_NAME: ${{ github.event.inputs.tag_name || github.ref_name }}
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@@ -16,19 +21,19 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
- name: Assemble Artifacts
|
||||
run: ./gradlew assembleRelease bundleRelease
|
||||
- name: Assemble APK
|
||||
run: ./gradlew assembleRelease
|
||||
|
||||
- name: Sign APK
|
||||
run: |
|
||||
@@ -44,38 +49,9 @@ jobs:
|
||||
"$APK"
|
||||
rm keystore.jks
|
||||
|
||||
- name: Sign Bundle
|
||||
run: |
|
||||
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
|
||||
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
|
||||
jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \
|
||||
-keystore keystore.jks \
|
||||
-storepass ${{ secrets.KEY_STORE_PASSWORD }} \
|
||||
-keypass ${{ secrets.KEY_PASSWORD }} \
|
||||
"$AAB" ${{ secrets.KEY_ALIAS }}
|
||||
rm keystore.jks
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.4'
|
||||
|
||||
- name: Deploy Bundle to Google Play
|
||||
run: |
|
||||
gem install multi_json
|
||||
gem install fastlane --no-document
|
||||
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
|
||||
echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json
|
||||
fastlane supply \
|
||||
--aab "$AAB" \
|
||||
--json_key service_account.json \
|
||||
--package_name com.rtbishop.look4sat \
|
||||
--track production \
|
||||
--skip_upload_images true \
|
||||
--skip_upload_screenshots true \
|
||||
rm service_account.json
|
||||
|
||||
- name: Create Release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release create $TAG_NAME --title=$TAG_NAME --generate-notes
|
||||
gh release upload $TAG_NAME app/build/outputs/apk/release/look4sat.apk
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@
|
||||
|
||||
<meta-data
|
||||
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
||||
android:value="com.rtbishop.look4sat" />
|
||||
android:value="com.rtbishop.look4sat.bg7nta" />
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+1
@@ -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)
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ internal fun Project.setupAndroidApp() {
|
||||
namespace = libs.versions.packageName.get()
|
||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||
defaultConfig {
|
||||
applicationId = libs.versions.packageName.get()
|
||||
applicationId = libs.versions.applicationId.get()
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
versionCode = libs.versions.appVersionCode.get().toInt()
|
||||
versionName = libs.versions.appVersionName.get()
|
||||
|
||||
+19
-21
@@ -95,14 +95,8 @@ class SatelliteRepo(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getRadios(
|
||||
sat: OrbitalObject,
|
||||
pos: GeoPos,
|
||||
radios: List<SatRadio>,
|
||||
time: Long
|
||||
): List<SatRadio> {
|
||||
override suspend fun getRadios(satPos: OrbitalPos, radios: List<SatRadio>): List<SatRadio> {
|
||||
return withContext(dispatcher) {
|
||||
val satPos = sat.getPosition(pos, time)
|
||||
radios.map { transmitter ->
|
||||
transmitter.copy(
|
||||
downlinkLow = transmitter.downlinkLow?.let { satPos.getDownlinkFreq(it) },
|
||||
@@ -242,13 +236,16 @@ class SatelliteRepo(
|
||||
if (elevation > maxElevation) maxElevation = elevation
|
||||
} while (elevation < 0.0)
|
||||
|
||||
// refine AOS to ~500ms precision
|
||||
calendarTimeMillis -= 60L * 1000L
|
||||
do {
|
||||
calendarTimeMillis += 500L
|
||||
elevation = sat.getElevation(pos, calendarTimeMillis)
|
||||
if (elevation > maxElevation) maxElevation = elevation
|
||||
} while (elevation < 0.0)
|
||||
// refine AOS to ~500ms precision via binary search.
|
||||
// Elevation is monotonic across the horizon crossing, so binary search
|
||||
// finds the crossing in ~8 SGP4 calls instead of up to 120 linear steps.
|
||||
var aosLo = calendarTimeMillis - 60L * 1000L // elevation < 0 (below horizon)
|
||||
var aosHi = calendarTimeMillis // elevation >= 0 (above horizon)
|
||||
while (aosHi - aosLo > 500L) {
|
||||
val mid = (aosLo + aosHi) / 2
|
||||
if (sat.getElevation(pos, mid) < 0.0) aosLo = mid else aosHi = mid
|
||||
}
|
||||
calendarTimeMillis = aosHi
|
||||
|
||||
// Get full position for AOS data (azimuth, altitude)
|
||||
val aosPos = sat.getFullPosition(pos, calendarTimeMillis)
|
||||
@@ -262,13 +259,14 @@ class SatelliteRepo(
|
||||
if (elevation > maxElevation) maxElevation = elevation
|
||||
} while (elevation > 0.0)
|
||||
|
||||
// refine LOS to ~500ms precision
|
||||
calendarTimeMillis -= 30L * 1000L
|
||||
do {
|
||||
calendarTimeMillis += 500L
|
||||
elevation = sat.getElevation(pos, calendarTimeMillis)
|
||||
if (elevation > maxElevation) maxElevation = elevation
|
||||
} while (elevation > 0.0)
|
||||
// refine LOS to ~500ms precision via binary search (same monotonic argument)
|
||||
var losLo = calendarTimeMillis - 30L * 1000L // elevation > 0 (above horizon)
|
||||
var losHi = calendarTimeMillis // elevation <= 0 (below horizon)
|
||||
while (losHi - losLo > 500L) {
|
||||
val mid = (losLo + losHi) / 2
|
||||
if (sat.getElevation(pos, mid) > 0.0) losLo = mid else losHi = mid
|
||||
}
|
||||
calendarTimeMillis = losHi
|
||||
|
||||
// Get full position for LOS data (azimuth, altitude)
|
||||
val losPos = sat.getFullPosition(pos, calendarTimeMillis)
|
||||
|
||||
@@ -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")
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ interface ISatelliteRepo {
|
||||
suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos>
|
||||
|
||||
/** Get Doppler-shifted radio frequencies for a satellite at the given time. */
|
||||
suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List<SatRadio>, time: Long): List<SatRadio>
|
||||
suspend fun getRadios(satPos: OrbitalPos, radios: List<SatRadio>): List<SatRadio>
|
||||
|
||||
/** Fetch radio transceivers for a satellite by its catalog number. */
|
||||
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
||||
|
||||
+106
-15
@@ -19,31 +19,78 @@ package com.rtbishop.look4sat.core.domain.utility
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
|
||||
/**
|
||||
* Converts a Maidenhead locator (QTH grid square) to a GeoPos.
|
||||
* Supports 6-char (3 pair), 8-char (4 pair) and 10-char (5 pair) locators.
|
||||
* The returned position is the center of the finest cell encoded by the locator:
|
||||
* - 6 char: 5' lon x 2.5' lat cell center
|
||||
* - 8 char: 30" lon x 15" lat cell center
|
||||
* - 10 char: 1.25" lon x 0.625" lat cell center
|
||||
*/
|
||||
fun qthToPosition(locator: String): GeoPos? {
|
||||
val trimmedQth = locator.take(6)
|
||||
val trimmedQth = locator.trim().uppercase()
|
||||
if (!isValidLocator(trimmedQth)) return null
|
||||
val lonFirst = (trimmedQth[0].uppercaseChar().code - 65) * 20
|
||||
val latFirst = (trimmedQth[1].uppercaseChar().code - 65) * 10
|
||||
val lonFirst = (trimmedQth[0].code - 65) * 20
|
||||
val latFirst = (trimmedQth[1].code - 65) * 10
|
||||
val lonSecond = trimmedQth[2].toString().toInt() * 2
|
||||
val latSecond = trimmedQth[3].toString().toInt()
|
||||
val lonThird = (((trimmedQth[4].lowercaseChar().code - 97) / 12.0) + (1.0 / 24.0)) - 180
|
||||
val latThird = (((trimmedQth[5].lowercaseChar().code - 97) / 24.0) + (1.0 / 48.0)) - 90
|
||||
val longitude = (lonFirst + lonSecond + lonThird).round(4)
|
||||
val latitude = (latFirst + latSecond + latThird).round(4)
|
||||
return GeoPos(latitude, longitude)
|
||||
val lonThird = (trimmedQth[4].lowercaseChar().code - 97) / 12.0
|
||||
val latThird = (trimmedQth[5].lowercaseChar().code - 97) / 24.0
|
||||
var longitude = lonFirst + lonSecond + lonThird - 180
|
||||
var latitude = latFirst + latSecond + latThird - 90
|
||||
// 8-char extension: 4th pair, digits, 30" lon x 15" lat cells
|
||||
if (trimmedQth.length >= 8) {
|
||||
longitude += trimmedQth[6].toString().toInt() / 120.0
|
||||
latitude += trimmedQth[7].toString().toInt() / 240.0
|
||||
}
|
||||
// 10-char extension: 5th pair, letters, 1.25" lon x 0.625" lat cells
|
||||
if (trimmedQth.length >= 10) {
|
||||
longitude += (trimmedQth[8].lowercaseChar().code - 97) / 2880.0
|
||||
latitude += (trimmedQth[9].lowercaseChar().code - 97) / 5760.0
|
||||
}
|
||||
// Offset to the center of the finest encoded cell
|
||||
when (trimmedQth.length) {
|
||||
8 -> {
|
||||
longitude += 1.0 / 240.0
|
||||
latitude += 1.0 / 480.0
|
||||
}
|
||||
10 -> {
|
||||
longitude += 1.0 / 5760.0
|
||||
latitude += 1.0 / 11520.0
|
||||
}
|
||||
else -> {
|
||||
longitude += 1.0 / 24.0
|
||||
latitude += 1.0 / 48.0
|
||||
}
|
||||
}
|
||||
return GeoPos(latitude.round(6), longitude.round(6))
|
||||
}
|
||||
|
||||
fun positionToQth(latitude: Double, longitude: Double): String? {
|
||||
/**
|
||||
* Converts a GeoPos to a Maidenhead locator (QTH grid square).
|
||||
* Default precision is 8 characters (4 pairs) giving 30" lon x 15" lat resolution,
|
||||
* matching common 8-char grid square tools. Pass precision = 6 for the classic
|
||||
* 5' x 2.5' resolution, or precision = 10 for the finest 1.25" x 0.625" resolution.
|
||||
*/
|
||||
fun positionToQth(latitude: Double, longitude: Double, precision: Int = 8): String? {
|
||||
if (!isValidPosition(latitude, longitude)) return null
|
||||
val newLongitude = if (longitude > 180.0) longitude else longitude + 180
|
||||
val newLongitude = longitude + 180
|
||||
val newLatitude = latitude + 90
|
||||
val lonFirst = (65 + (newLongitude / 20)).toInt().toChar()
|
||||
val latFirst = (65 + (newLatitude / 10)).toInt().toChar()
|
||||
val lonSecond = ((newLongitude / 2) % 10).toInt()
|
||||
val lonFirst = (65 + (newLongitude / 20).toInt().coerceIn(0, 17)).toChar()
|
||||
val latFirst = (65 + (newLatitude / 10).toInt().coerceIn(0, 17)).toChar()
|
||||
val lonSecond = ((newLongitude % 20) / 2).toInt()
|
||||
val latSecond = (newLatitude % 10).toInt()
|
||||
val lonThird = (65 + (newLongitude % 2) * 12).toInt().toChar().lowercaseChar()
|
||||
val latThird = (65 + (newLatitude % 1) * 24).toInt().toChar().lowercaseChar()
|
||||
return "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
|
||||
val qth = "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
|
||||
if (precision < 8) return qth
|
||||
val lonFourth = ((newLongitude % (1.0 / 12.0)) * 120).toInt()
|
||||
val latFourth = ((newLatitude % (1.0 / 24.0)) * 240).toInt()
|
||||
val qth8 = "$qth$lonFourth$latFourth"
|
||||
if (precision < 10) return qth8
|
||||
val lonFifth = (65 + (newLongitude % (1.0 / 120.0)) * 2880).toInt().toChar().lowercaseChar()
|
||||
val latFifth = (65 + (newLatitude % (1.0 / 240.0)) * 5760).toInt().toChar().lowercaseChar()
|
||||
return "$qth8$lonFifth$latFifth"
|
||||
}
|
||||
|
||||
private fun isValidPosition(lat: Double, lon: Double): Boolean {
|
||||
@@ -51,5 +98,49 @@ private fun isValidPosition(lat: Double, lon: Double): Boolean {
|
||||
}
|
||||
|
||||
private fun isValidLocator(locator: String): Boolean {
|
||||
return locator.matches("[a-xA-X][a-xA-X]\\d\\d[a-xA-X][a-xA-X]".toRegex())
|
||||
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,21 +28,36 @@ class QthConverterTest {
|
||||
@Test
|
||||
fun `Given valid QTH returns correct POS`() {
|
||||
var result = qthToPosition("io91VL39FX")
|
||||
assert(result?.latitude == 51.4792 && result.longitude == -0.2083)
|
||||
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.489583 && result.longitude == -0.2125)
|
||||
result = qthToPosition("jn58td25")
|
||||
assert(result?.latitude == 48.147917 && result.longitude == 11.604167)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given invalid QTH returns null`() {
|
||||
assert(qthToPosition("ZZ00zz") == null)
|
||||
assert(qthToPosition("JN58") == null)
|
||||
assert(qthToPosition("io9") == null)
|
||||
assert(qthToPosition("IO91VL7") == null)
|
||||
assert(qthToPosition("IO91VL4X") == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given valid POS returns correct QTH`() {
|
||||
assert(positionToQth(51.4878, -0.2146) == "IO91vl")
|
||||
assert(positionToQth(48.1466, 11.6083) == "JN58td")
|
||||
// default precision is 8 chars
|
||||
assert(positionToQth(51.4878, -0.2146) == "IO91vl47")
|
||||
assert(positionToQth(48.1466, 11.6083) == "JN58td25")
|
||||
// 6-char precision still available for backwards compatibility
|
||||
assert(positionToQth(51.4878, -0.2146, 6) == "IO91vl")
|
||||
assert(positionToQth(48.1466, 11.6083, 6) == "JN58td")
|
||||
// 10-char precision
|
||||
assert(positionToQth(51.4878, -0.2146, 10) == "IO91vl47fb")
|
||||
assert(positionToQth(48.1466, 11.6083, 10) == "JN58td25xe")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -48,4 +65,61 @@ class QthConverterTest {
|
||||
assert(positionToQth(91.0542, -170.1142) == null)
|
||||
assert(positionToQth(89.0542, -240.1142) == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given boundary POS stays in valid grid`() {
|
||||
// antipodal / edge cases must not overflow the A-R / 0-9 / a-x alphabet
|
||||
assert(positionToQth(-90.0, -180.0, 8) == "AA00aa00")
|
||||
assert(positionToQth(90.0, 180.0, 8) == "RR00aa00")
|
||||
assert(positionToQth(0.0, 0.0, 8) == "JJ00aa00")
|
||||
// roundtrip stability: 8-char roundtrip is stable across a sample of positions
|
||||
val positions = listOf(
|
||||
Pair(51.4878, -0.2146), Pair(48.1466, 11.6083), Pair(-33.8688, 151.2093),
|
||||
Pair(39.9042, 116.4074), Pair(35.6895, 139.6917), Pair(41.714, -72.727)
|
||||
)
|
||||
positions.forEach { (lat, lon) ->
|
||||
val qth = positionToQth(lat, lon, 8)
|
||||
val pos = qthToPosition(qth!!)
|
||||
val qth2 = positionToQth(pos!!.latitude, pos.longitude, 8)
|
||||
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 > %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">高 > %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 > %1$d°</string>
|
||||
|
||||
<string name="prefs_outro_title">I would like to say thanks to</string>
|
||||
<string name="prefs_outro_title">BG7NTA & 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>
|
||||
|
||||
|
||||
@@ -94,14 +94,21 @@ fun RadarViewCompose(
|
||||
animationSpec = infiniteRepeatable(tween(1000)),
|
||||
label = "animScale"
|
||||
)
|
||||
// Drive the sweep from the animation framework to eliminate state mutation inside the draw block
|
||||
val sweepTransition = rememberInfiniteTransition(label = "sweep")
|
||||
val sweepDegrees by sweepTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 360f,
|
||||
animationSpec = infiniteRepeatable(tween(SWEEP_DURATION_MS, easing = LinearEasing)),
|
||||
label = "sweepDegrees"
|
||||
)
|
||||
// Drive the sweep from the animation framework to eliminate state mutation inside the draw block.
|
||||
// Only create the infinite transition when the sweep is enabled — otherwise the canvas would
|
||||
// be invalidated every frame for an effect that isn't visible, wasting GPU and battery.
|
||||
val sweepDegrees = if (shouldShowSweep) {
|
||||
val sweepTransition = rememberInfiniteTransition(label = "sweep")
|
||||
val animatedSweep by sweepTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 360f,
|
||||
animationSpec = infiniteRepeatable(tween(SWEEP_DURATION_MS, easing = LinearEasing)),
|
||||
label = "sweepDegrees"
|
||||
)
|
||||
animatedSweep
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
val measurer = rememberTextMeasurer()
|
||||
val sunPainter = painterResource(R.drawable.ic_sun)
|
||||
val moonPainter = painterResource(R.drawable.ic_moon)
|
||||
|
||||
@@ -181,7 +181,7 @@ class RadarViewModel(
|
||||
orbitalPos = pos, sunPosition = cachedSunPos, moonPosition = cachedMoonPos
|
||||
)
|
||||
}
|
||||
processRadios(allRadios, pass.orbitalObject, timeNow)
|
||||
processRadios(allRadios, pos)
|
||||
sendPassData(pos)
|
||||
}
|
||||
|
||||
@@ -342,8 +342,8 @@ class RadarViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun processRadios(radios: List<SatRadio>, orbitalObject: OrbitalObject, time: Long) {
|
||||
val transmitters = satelliteRepo.getRadios(orbitalObject, stationPos, radios, time)
|
||||
private suspend fun processRadios(radios: List<SatRadio>, satPos: OrbitalPos) {
|
||||
val transmitters = satelliteRepo.getRadios(satPos, radios)
|
||||
_uiState.update { state ->
|
||||
val freq = if (state.transceivers.selectedUuid != null) {
|
||||
val selectedRadio = transmitters.firstOrNull { it.uuid == state.transceivers.selectedUuid }
|
||||
|
||||
@@ -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)
|
||||
+83
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -11,6 +11,11 @@ minSdk = "24"
|
||||
jdkVersion = "21"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
packageName = "com.rtbishop.look4sat"
|
||||
# Fork application ID: official app uses com.rtbishop.look4sat with a different
|
||||
# signing cert. Installing a fork with the same applicationId would conflict
|
||||
# with the official build (signature mismatch / cannot overwrite), so the fork
|
||||
# ships under its own applicationId suffix.
|
||||
applicationId = "com.rtbishop.look4sat.bg7nta"
|
||||
|
||||
android-gradle-plugin = "9.3.1"
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ include(
|
||||
":feature:mutual",
|
||||
":feature:passes",
|
||||
":feature:radar",
|
||||
":feature:roaming",
|
||||
":feature:satellites",
|
||||
":feature:settings"
|
||||
)
|
||||
Reference in new issue
Block a user