Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
929f652b8e | ||
|
|
610768501e | ||
|
|
427e6e1862 | ||
|
|
bfa5e5f474 | ||
|
|
213425379c | ||
|
|
c3444aed24 | ||
|
|
368bd199e5 | ||
|
|
2298d8eea4 | ||
|
|
7bb0bbbb46 | ||
|
|
deec5581ae | ||
|
|
1de5632428 | ||
|
|
2dfd76825b | ||
|
|
06fc8bc2fb | ||
|
|
75322390b8 | ||
|
|
d1312f1f09 | ||
|
|
75acbbd633 | ||
|
|
a37fb8f773 | ||
|
|
9d0d971500 | ||
|
|
1813e1bfa4 |
No files matched your search
@@ -4,10 +4,15 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- v**
|
- v**
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag_name:
|
||||||
|
description: 'Release tag name (e.g. v4.4.3)'
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
|
||||||
env:
|
env:
|
||||||
TAG_NAME: ${{ github.ref_name }}
|
TAG_NAME: ${{ github.event.inputs.tag_name || github.ref_name }}
|
||||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
@@ -16,19 +21,19 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
|
||||||
- name: Setup Gradle
|
- name: Setup Gradle
|
||||||
uses: gradle/actions/setup-gradle@v6
|
uses: gradle/actions/setup-gradle@v4
|
||||||
|
|
||||||
- name: Assemble Artifacts
|
- name: Assemble APK
|
||||||
run: ./gradlew assembleRelease bundleRelease
|
run: ./gradlew assembleRelease
|
||||||
|
|
||||||
- name: Sign APK
|
- name: Sign APK
|
||||||
run: |
|
run: |
|
||||||
@@ -44,38 +49,9 @@ jobs:
|
|||||||
"$APK"
|
"$APK"
|
||||||
rm keystore.jks
|
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
|
- name: Create Release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
gh release create $TAG_NAME --title=$TAG_NAME --generate-notes
|
gh release create $TAG_NAME --title=$TAG_NAME --generate-notes
|
||||||
gh release upload $TAG_NAME app/build/outputs/apk/release/look4sat.apk
|
gh release upload $TAG_NAME app/build/outputs/apk/release/look4sat.apk
|
||||||
@@ -1,3 +1,29 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.convention.applicationPlugin)
|
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
|
<meta-data
|
||||||
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
||||||
android:value="com.rtbishop.look4sat" />
|
android:value="com.rtbishop.look4sat.bg7nta" />
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -72,6 +72,7 @@ import androidx.navigation3.runtime.rememberNavBackStack
|
|||||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||||
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
|
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
|
||||||
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
|
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
|
||||||
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
|
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
|
||||||
@@ -84,6 +85,8 @@ import com.rtbishop.look4sat.feature.mutual.MutualScreen
|
|||||||
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
|
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
|
||||||
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
||||||
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
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.satellites.SatellitesDestination
|
||||||
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
||||||
|
|
||||||
@@ -129,7 +132,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
val currentKey = backStack.lastOrNull()
|
val currentKey = backStack.lastOrNull()
|
||||||
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
||||||
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
|
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 context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
@@ -200,6 +203,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
}
|
}
|
||||||
entry<Screen.Passes> {
|
entry<Screen.Passes> {
|
||||||
PassesDestination { catNum, aosTime ->
|
PassesDestination { catNum, aosTime ->
|
||||||
|
container.setMutualPassData(MutualPassData())
|
||||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||||
backStack.add(Screen.Radar)
|
backStack.add(Screen.Radar)
|
||||||
// navigateToRadar()
|
// navigateToRadar()
|
||||||
@@ -216,12 +220,19 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
viewModel = mutualViewModel,
|
viewModel = mutualViewModel,
|
||||||
navigateUp = navigateBack,
|
navigateUp = navigateBack,
|
||||||
navigateToRadar = { catNum, aosTime, pass ->
|
navigateToRadar = { catNum, aosTime, pass ->
|
||||||
pass?.let { container.setMutualPassData(it) }
|
container.setMutualPassData(pass ?: MutualPassData())
|
||||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||||
backStack.add(Screen.Radar)
|
backStack.add(Screen.Radar)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
entry<Screen.Roaming> {
|
||||||
|
val roamingViewModel: RoamingViewModel = viewModel(
|
||||||
|
viewModelStoreOwner = context as ViewModelStoreOwner,
|
||||||
|
factory = RoamingViewModel.factory(container)
|
||||||
|
)
|
||||||
|
RoamingScreen(viewModel = roamingViewModel)
|
||||||
|
}
|
||||||
entry<Screen.Settings> {
|
entry<Screen.Settings> {
|
||||||
SettingsDestination()
|
SettingsDestination()
|
||||||
}
|
}
|
||||||
@@ -245,6 +256,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
.clickable {
|
.clickable {
|
||||||
val pass = trackingState.currentPass
|
val pass = trackingState.currentPass
|
||||||
if (pass != null) {
|
if (pass != null) {
|
||||||
|
container.setMutualPassData(MutualPassData())
|
||||||
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
||||||
backStack.add(Screen.Radar)
|
backStack.add(Screen.Radar)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -35,6 +35,7 @@ internal class ApplicationPlugin : Plugin<Project> {
|
|||||||
implementation(project(":feature:mutual"))
|
implementation(project(":feature:mutual"))
|
||||||
implementation(project(":feature:passes"))
|
implementation(project(":feature:passes"))
|
||||||
implementation(project(":feature:radar"))
|
implementation(project(":feature:radar"))
|
||||||
|
implementation(project(":feature:roaming"))
|
||||||
implementation(project(":feature:satellites"))
|
implementation(project(":feature:satellites"))
|
||||||
implementation(project(":feature:settings"))
|
implementation(project(":feature:settings"))
|
||||||
implementation(libs.androidx.core.splashscreen)
|
implementation(libs.androidx.core.splashscreen)
|
||||||
|
|||||||
+1
-1
@@ -57,7 +57,7 @@ internal fun Project.setupAndroidApp() {
|
|||||||
namespace = libs.versions.packageName.get()
|
namespace = libs.versions.packageName.get()
|
||||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = libs.versions.packageName.get()
|
applicationId = libs.versions.applicationId.get()
|
||||||
minSdk = libs.versions.minSdk.get().toInt()
|
minSdk = libs.versions.minSdk.get().toInt()
|
||||||
versionCode = libs.versions.appVersionCode.get().toInt()
|
versionCode = libs.versions.appVersionCode.get().toInt()
|
||||||
versionName = libs.versions.appVersionName.get()
|
versionName = libs.versions.appVersionName.get()
|
||||||
|
|||||||
+19
-21
@@ -95,14 +95,8 @@ class SatelliteRepo(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getRadios(
|
override suspend fun getRadios(satPos: OrbitalPos, radios: List<SatRadio>): List<SatRadio> {
|
||||||
sat: OrbitalObject,
|
|
||||||
pos: GeoPos,
|
|
||||||
radios: List<SatRadio>,
|
|
||||||
time: Long
|
|
||||||
): List<SatRadio> {
|
|
||||||
return withContext(dispatcher) {
|
return withContext(dispatcher) {
|
||||||
val satPos = sat.getPosition(pos, time)
|
|
||||||
radios.map { transmitter ->
|
radios.map { transmitter ->
|
||||||
transmitter.copy(
|
transmitter.copy(
|
||||||
downlinkLow = transmitter.downlinkLow?.let { satPos.getDownlinkFreq(it) },
|
downlinkLow = transmitter.downlinkLow?.let { satPos.getDownlinkFreq(it) },
|
||||||
@@ -242,13 +236,16 @@ class SatelliteRepo(
|
|||||||
if (elevation > maxElevation) maxElevation = elevation
|
if (elevation > maxElevation) maxElevation = elevation
|
||||||
} while (elevation < 0.0)
|
} while (elevation < 0.0)
|
||||||
|
|
||||||
// refine AOS to ~500ms precision
|
// refine AOS to ~500ms precision via binary search.
|
||||||
calendarTimeMillis -= 60L * 1000L
|
// Elevation is monotonic across the horizon crossing, so binary search
|
||||||
do {
|
// finds the crossing in ~8 SGP4 calls instead of up to 120 linear steps.
|
||||||
calendarTimeMillis += 500L
|
var aosLo = calendarTimeMillis - 60L * 1000L // elevation < 0 (below horizon)
|
||||||
elevation = sat.getElevation(pos, calendarTimeMillis)
|
var aosHi = calendarTimeMillis // elevation >= 0 (above horizon)
|
||||||
if (elevation > maxElevation) maxElevation = elevation
|
while (aosHi - aosLo > 500L) {
|
||||||
} while (elevation < 0.0)
|
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)
|
// Get full position for AOS data (azimuth, altitude)
|
||||||
val aosPos = sat.getFullPosition(pos, calendarTimeMillis)
|
val aosPos = sat.getFullPosition(pos, calendarTimeMillis)
|
||||||
@@ -262,13 +259,14 @@ class SatelliteRepo(
|
|||||||
if (elevation > maxElevation) maxElevation = elevation
|
if (elevation > maxElevation) maxElevation = elevation
|
||||||
} while (elevation > 0.0)
|
} while (elevation > 0.0)
|
||||||
|
|
||||||
// refine LOS to ~500ms precision
|
// refine LOS to ~500ms precision via binary search (same monotonic argument)
|
||||||
calendarTimeMillis -= 30L * 1000L
|
var losLo = calendarTimeMillis - 30L * 1000L // elevation > 0 (above horizon)
|
||||||
do {
|
var losHi = calendarTimeMillis // elevation <= 0 (below horizon)
|
||||||
calendarTimeMillis += 500L
|
while (losHi - losLo > 500L) {
|
||||||
elevation = sat.getElevation(pos, calendarTimeMillis)
|
val mid = (losLo + losHi) / 2
|
||||||
if (elevation > maxElevation) maxElevation = elevation
|
if (sat.getElevation(pos, mid) > 0.0) losLo = mid else losHi = mid
|
||||||
} while (elevation > 0.0)
|
}
|
||||||
|
calendarTimeMillis = losHi
|
||||||
|
|
||||||
// Get full position for LOS data (azimuth, altitude)
|
// Get full position for LOS data (azimuth, altitude)
|
||||||
val losPos = sat.getFullPosition(pos, calendarTimeMillis)
|
val losPos = sat.getFullPosition(pos, calendarTimeMillis)
|
||||||
|
|||||||
@@ -213,8 +213,8 @@ class SettingsRepo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun setStationPosition(latitude: Double, longitude: Double, altitude: Double, locator: String) {
|
private fun setStationPosition(latitude: Double, longitude: Double, altitude: Double, locator: String) {
|
||||||
val newLat = latitude.round(4)
|
val newLat = latitude.round(5)
|
||||||
val newLon = longitude.round(4)
|
val newLon = longitude.round(5)
|
||||||
val newAlt = altitude.round(1)
|
val newAlt = altitude.round(1)
|
||||||
val timestamp = System.currentTimeMillis()
|
val timestamp = System.currentTimeMillis()
|
||||||
println("Received new Position($newLat, $newLon, $newAlt) & Locator $locator")
|
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>
|
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. */
|
/** 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. */
|
/** Fetch radio transceivers for a satellite by its catalog number. */
|
||||||
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
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
|
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? {
|
fun qthToPosition(locator: String): GeoPos? {
|
||||||
val trimmedQth = locator.take(6)
|
val trimmedQth = locator.trim().uppercase()
|
||||||
if (!isValidLocator(trimmedQth)) return null
|
if (!isValidLocator(trimmedQth)) return null
|
||||||
val lonFirst = (trimmedQth[0].uppercaseChar().code - 65) * 20
|
val lonFirst = (trimmedQth[0].code - 65) * 20
|
||||||
val latFirst = (trimmedQth[1].uppercaseChar().code - 65) * 10
|
val latFirst = (trimmedQth[1].code - 65) * 10
|
||||||
val lonSecond = trimmedQth[2].toString().toInt() * 2
|
val lonSecond = trimmedQth[2].toString().toInt() * 2
|
||||||
val latSecond = trimmedQth[3].toString().toInt()
|
val latSecond = trimmedQth[3].toString().toInt()
|
||||||
val lonThird = (((trimmedQth[4].lowercaseChar().code - 97) / 12.0) + (1.0 / 24.0)) - 180
|
val lonThird = (trimmedQth[4].lowercaseChar().code - 97) / 12.0
|
||||||
val latThird = (((trimmedQth[5].lowercaseChar().code - 97) / 24.0) + (1.0 / 48.0)) - 90
|
val latThird = (trimmedQth[5].lowercaseChar().code - 97) / 24.0
|
||||||
val longitude = (lonFirst + lonSecond + lonThird).round(4)
|
var longitude = lonFirst + lonSecond + lonThird - 180
|
||||||
val latitude = (latFirst + latSecond + latThird).round(4)
|
var latitude = latFirst + latSecond + latThird - 90
|
||||||
return GeoPos(latitude, longitude)
|
// 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
|
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 newLatitude = latitude + 90
|
||||||
val lonFirst = (65 + (newLongitude / 20)).toInt().toChar()
|
val lonFirst = (65 + (newLongitude / 20).toInt().coerceIn(0, 17)).toChar()
|
||||||
val latFirst = (65 + (newLatitude / 10)).toInt().toChar()
|
val latFirst = (65 + (newLatitude / 10).toInt().coerceIn(0, 17)).toChar()
|
||||||
val lonSecond = ((newLongitude / 2) % 10).toInt()
|
val lonSecond = ((newLongitude % 20) / 2).toInt()
|
||||||
val latSecond = (newLatitude % 10).toInt()
|
val latSecond = (newLatitude % 10).toInt()
|
||||||
val lonThird = (65 + (newLongitude % 2) * 12).toInt().toChar().lowercaseChar()
|
val lonThird = (65 + (newLongitude % 2) * 12).toInt().toChar().lowercaseChar()
|
||||||
val latThird = (65 + (newLatitude % 1) * 24).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 {
|
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 {
|
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
|
package com.rtbishop.look4sat.core.domain
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
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.qthToPosition
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.qthToSquare
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class QthConverterTest {
|
class QthConverterTest {
|
||||||
@@ -26,21 +28,36 @@ class QthConverterTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `Given valid QTH returns correct POS`() {
|
fun `Given valid QTH returns correct POS`() {
|
||||||
var result = qthToPosition("io91VL39FX")
|
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")
|
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
|
@Test
|
||||||
fun `Given invalid QTH returns null`() {
|
fun `Given invalid QTH returns null`() {
|
||||||
assert(qthToPosition("ZZ00zz") == null)
|
assert(qthToPosition("ZZ00zz") == null)
|
||||||
assert(qthToPosition("JN58") == null)
|
assert(qthToPosition("JN58") == null)
|
||||||
|
assert(qthToPosition("io9") == null)
|
||||||
|
assert(qthToPosition("IO91VL7") == null)
|
||||||
|
assert(qthToPosition("IO91VL4X") == null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Given valid POS returns correct QTH`() {
|
fun `Given valid POS returns correct QTH`() {
|
||||||
assert(positionToQth(51.4878, -0.2146) == "IO91vl")
|
// default precision is 8 chars
|
||||||
assert(positionToQth(48.1466, 11.6083) == "JN58td")
|
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
|
@Test
|
||||||
@@ -48,4 +65,61 @@ class QthConverterTest {
|
|||||||
assert(positionToQth(91.0542, -170.1142) == null)
|
assert(positionToQth(91.0542, -170.1142) == null)
|
||||||
assert(positionToQth(89.0542, -240.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!!") == "----")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+4
-1
@@ -36,7 +36,10 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
|||||||
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
|
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Mutual : Screen(R.drawable.ic_radar, R.string.nav_mutual)
|
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
|
@Serializable
|
||||||
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
|
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="96"
|
||||||
|
android:viewportHeight="96">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/transparent"
|
||||||
|
android:strokeColor="@android:color/white"
|
||||||
|
android:strokeWidth="6"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M 8 76 C 8 44, 21 24, 39 24 C 57 24, 70 44, 70 76" />
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/transparent"
|
||||||
|
android:strokeColor="@android:color/white"
|
||||||
|
android:strokeWidth="6"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M 30 76 C 30 54, 44 42, 62 42 C 80 42, 94 54, 94 76" />
|
||||||
|
</vector>
|
||||||
@@ -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_mid">Orta %1$d-%2$d°</string>
|
||||||
<string name="prefs_highlight_high">Yüksek > %1$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">
|
<string name="prefs_outro_thanks" translatable="false">
|
||||||
• Look4Sat users and contributors!
|
• Look4Sat users and contributors!
|
||||||
\n• David A. B. Johnson (predict4java)
|
\n• David A. B. Johnson (predict4java)
|
||||||
\n• Dave Moten (predict4java)
|
\n• Dave Moten (predict4java)
|
||||||
\n• Alexandru Csete (Gpredict)
|
\n• Alexandru Csete (Gpredict)
|
||||||
\n• Dr T.S. Kelso (Celestrak)
|
\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>
|
<string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
<string name="nav_sat">卫星</string>
|
<string name="nav_sat">卫星</string>
|
||||||
<string name="nav_pass">过境</string>
|
<string name="nav_pass">过境</string>
|
||||||
<string name="nav_radar">雷达</string>
|
<string name="nav_radar">雷达</string>
|
||||||
<string name="nav_mutual">对台</string>
|
<string name="nav_mutual">匹配</string>
|
||||||
<string name="nav_map">地图</string>
|
<string name="nav_map">地图</string>
|
||||||
<string name="nav_prefs">设置</string>
|
<string name="nav_prefs">设置</string>
|
||||||
|
|
||||||
@@ -167,7 +167,7 @@
|
|||||||
<string name="prefs_highlight_mid">中 %1$d-%2$d°</string>
|
<string name="prefs_highlight_mid">中 %1$d-%2$d°</string>
|
||||||
<string name="prefs_highlight_high">高 > %1$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">
|
<string name="prefs_outro_thanks" translatable="false">
|
||||||
• Look4Sat 所有用户及贡献者!
|
• Look4Sat 所有用户及贡献者!
|
||||||
\n• David A. B. Johnson (predict4java)
|
\n• David A. B. Johnson (predict4java)
|
||||||
@@ -175,7 +175,9 @@
|
|||||||
\n• Alexandru Csete (Gpredict)
|
\n• Alexandru Csete (Gpredict)
|
||||||
\n• Dr T.S. Kelso (Celestrak)
|
\n• Dr T.S. Kelso (Celestrak)
|
||||||
\n• Libre Space Foundation (SatNOGS)
|
\n• Libre Space Foundation (SatNOGS)
|
||||||
\n• xdsopl 和 Robot36 贡献者!</string>
|
\n• xdsopl 和 Robot36 贡献者!
|
||||||
|
\n• BA7OPF(过境匹配功能)
|
||||||
|
\n• BG7NTA</string>
|
||||||
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
|
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<!-- Commmon -->
|
<!-- 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_accept">Accept</string>
|
||||||
<string name="btn_cancel">Cancel</string>
|
<string name="btn_cancel">Cancel</string>
|
||||||
<string name="empty_list_message">No data to show</string>
|
<string name="empty_list_message">No data to show</string>
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
<string name="nav_sat">Satellites</string>
|
<string name="nav_sat">Satellites</string>
|
||||||
<string name="nav_pass">Passes</string>
|
<string name="nav_pass">Passes</string>
|
||||||
<string name="nav_radar">Radar</string>
|
<string name="nav_radar">Radar</string>
|
||||||
<string name="nav_mutual">Mutual</string>
|
<string name="nav_mutual">Match</string>
|
||||||
|
<string name="nav_roaming">Roaming</string>
|
||||||
<string name="nav_map">Map</string>
|
<string name="nav_map">Map</string>
|
||||||
<string name="nav_prefs">Settings</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_mid">Mid %1$d–%2$d°</string>
|
||||||
<string name="prefs_highlight_high">High > %1$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">
|
<string name="prefs_outro_thanks" translatable="false">
|
||||||
* Look4Sat users and contributors!
|
* Look4Sat users and contributors!
|
||||||
\n* David A. B. Johnson (predict4java)
|
\n* David A. B. Johnson (predict4java)
|
||||||
@@ -211,6 +212,8 @@
|
|||||||
\n* Dr T.S. Kelso (Celestrak)
|
\n* Dr T.S. Kelso (Celestrak)
|
||||||
\n* Libre Space Foundation (SatNOGS)
|
\n* Libre Space Foundation (SatNOGS)
|
||||||
\n* xdsopl and Robot36 contributors!
|
\n* xdsopl and Robot36 contributors!
|
||||||
|
\n* BA7OPF (pass matching feature)
|
||||||
|
\n* BG7NTA
|
||||||
</string>
|
</string>
|
||||||
<string name="prefs_outro_license">The app comes with no warranty</string>
|
<string name="prefs_outro_license">The app comes with no warranty</string>
|
||||||
|
|
||||||
|
|||||||
+241
-141
@@ -88,7 +88,7 @@ fun MutualScreen(
|
|||||||
},
|
},
|
||||||
topInfo = {
|
topInfo = {
|
||||||
Text(
|
Text(
|
||||||
text = "对台过境",
|
text = "过境匹配",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
@@ -96,7 +96,7 @@ fun MutualScreen(
|
|||||||
bottomInfo = {
|
bottomInfo = {
|
||||||
if (state.mutualPasses.isNotEmpty()) {
|
if (state.mutualPasses.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = "找到 ${state.mutualPasses.size} 个过境",
|
text = "找到 ${state.mutualPasses.size} 个匹配",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
@@ -183,153 +183,91 @@ private fun MutualContent(
|
|||||||
|
|
||||||
// Input form
|
// Input form
|
||||||
item {
|
item {
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
Column(
|
||||||
Column(
|
modifier = Modifier.fillMaxWidth(),
|
||||||
modifier = Modifier
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
.fillMaxWidth()
|
) {
|
||||||
.padding(12.dp),
|
MatchIntroCard()
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
if (isVertical) {
|
||||||
Text(
|
StationInputCard(
|
||||||
text = "你的位置",
|
title = "你的位置",
|
||||||
style = MaterialTheme.typography.titleSmall,
|
titleColor = MaterialTheme.colorScheme.primary,
|
||||||
fontWeight = FontWeight.Medium
|
lat = state.stationALat,
|
||||||
|
lon = state.stationALon,
|
||||||
|
grid = state.stationAGrid,
|
||||||
|
minElev = state.stationAMinElev,
|
||||||
|
latPlaceholder = "39.9042",
|
||||||
|
lonPlaceholder = "116.4074",
|
||||||
|
gridPlaceholder = "ON79uj",
|
||||||
|
onLatChange = onStationALat,
|
||||||
|
onLonChange = onStationALon,
|
||||||
|
onGridChange = onStationAGrid,
|
||||||
|
onMinElevChange = onStationAMinElev,
|
||||||
|
currentPositionAction = onUseCurrentPosition
|
||||||
)
|
)
|
||||||
|
StationInputCard(
|
||||||
|
title = "友台位置",
|
||||||
|
titleColor = MaterialTheme.colorScheme.tertiary,
|
||||||
|
lat = state.stationBLat,
|
||||||
|
lon = state.stationBLon,
|
||||||
|
grid = state.stationBGrid,
|
||||||
|
minElev = state.stationBMinElev,
|
||||||
|
latPlaceholder = "34.0522",
|
||||||
|
lonPlaceholder = "-118.2437",
|
||||||
|
gridPlaceholder = "PM01tv",
|
||||||
|
onLatChange = onStationBLat,
|
||||||
|
onLonChange = onStationBLon,
|
||||||
|
onGridChange = onStationBGrid,
|
||||||
|
onMinElevChange = onStationBMinElev
|
||||||
|
)
|
||||||
|
} else {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
StationInputCard(
|
||||||
value = state.stationALat,
|
title = "你的位置",
|
||||||
onValueChange = onStationALat,
|
titleColor = MaterialTheme.colorScheme.primary,
|
||||||
label = { Text("纬度") },
|
lat = state.stationALat,
|
||||||
placeholder = { Text("39.9042") },
|
lon = state.stationALon,
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
grid = state.stationAGrid,
|
||||||
singleLine = true,
|
minElev = state.stationAMinElev,
|
||||||
|
latPlaceholder = "39.9042",
|
||||||
|
lonPlaceholder = "116.4074",
|
||||||
|
gridPlaceholder = "ON79uj",
|
||||||
|
onLatChange = onStationALat,
|
||||||
|
onLonChange = onStationALon,
|
||||||
|
onGridChange = onStationAGrid,
|
||||||
|
onMinElevChange = onStationAMinElev,
|
||||||
|
currentPositionAction = onUseCurrentPosition,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
StationInputCard(
|
||||||
value = state.stationALon,
|
title = "友台位置",
|
||||||
onValueChange = onStationALon,
|
titleColor = MaterialTheme.colorScheme.tertiary,
|
||||||
label = { Text("经度") },
|
lat = state.stationBLat,
|
||||||
placeholder = { Text("116.4074") },
|
lon = state.stationBLon,
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
grid = state.stationBGrid,
|
||||||
singleLine = true,
|
minElev = state.stationBMinElev,
|
||||||
|
latPlaceholder = "34.0522",
|
||||||
|
lonPlaceholder = "-118.2437",
|
||||||
|
gridPlaceholder = "PM01tv",
|
||||||
|
onLatChange = onStationBLat,
|
||||||
|
onLonChange = onStationBLon,
|
||||||
|
onGridChange = onStationBGrid,
|
||||||
|
onMinElevChange = onStationBMinElev,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
OutlinedTextField(
|
|
||||||
value = state.stationAGrid,
|
|
||||||
onValueChange = onStationAGrid,
|
|
||||||
label = { Text("网格(4/6/8位,填此可省略经纬度)") },
|
|
||||||
placeholder = { Text("ON79uj") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = onUseCurrentPosition,
|
|
||||||
modifier = Modifier.align(Alignment.End)
|
|
||||||
) {
|
|
||||||
Text("当前精确位置", style = MaterialTheme.typography.bodySmall)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = "最小仰角:${state.stationAMinElev.toInt()}°",
|
|
||||||
style = MaterialTheme.typography.bodySmall
|
|
||||||
)
|
|
||||||
Slider(
|
|
||||||
value = state.stationAMinElev.toFloat(),
|
|
||||||
onValueChange = { onStationAMinElev(it.toDouble()) },
|
|
||||||
valueRange = 0f..90f,
|
|
||||||
steps = 17
|
|
||||||
)
|
|
||||||
|
|
||||||
HorizontalDivider()
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "友台位置",
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.stationBLat,
|
|
||||||
onValueChange = onStationBLat,
|
|
||||||
label = { Text("纬度") },
|
|
||||||
placeholder = { Text("34.0522") },
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.stationBLon,
|
|
||||||
onValueChange = onStationBLon,
|
|
||||||
label = { Text("经度") },
|
|
||||||
placeholder = { Text("-118.2437") },
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.stationBGrid,
|
|
||||||
onValueChange = onStationBGrid,
|
|
||||||
label = { Text("网格(4/6/8位,填此可省略经纬度)") },
|
|
||||||
placeholder = { Text("PM01tv") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "最小仰角:${state.stationBMinElev.toInt()}°",
|
|
||||||
style = MaterialTheme.typography.bodySmall
|
|
||||||
)
|
|
||||||
Slider(
|
|
||||||
value = state.stationBMinElev.toFloat(),
|
|
||||||
onValueChange = { onStationBMinElev(it.toDouble()) },
|
|
||||||
valueRange = 0f..90f,
|
|
||||||
steps = 17
|
|
||||||
)
|
|
||||||
|
|
||||||
HorizontalDivider()
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "时间范围",
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
listOf(6, 12, 24, 48, 72).forEach { hours ->
|
|
||||||
FilterChip(
|
|
||||||
selected = state.hoursAhead == hours,
|
|
||||||
onClick = { onHoursAhead(hours) },
|
|
||||||
label = { Text("${hours}h") }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = onQuery,
|
|
||||||
enabled = !state.isCalculating,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
if (state.isCalculating) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier
|
|
||||||
.height(18.dp)
|
|
||||||
.width(18.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimary
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
}
|
|
||||||
Text(if (state.isCalculating) "计算中..." else "查询过境")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MatchSearchCard(
|
||||||
|
hoursAhead = state.hoursAhead,
|
||||||
|
isCalculating = state.isCalculating,
|
||||||
|
onHoursAhead = onHoursAhead,
|
||||||
|
onQuery = onQuery
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,6 +303,169 @@ private fun MutualContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MatchIntroCard(modifier: Modifier = Modifier) {
|
||||||
|
ElevatedCard(modifier = modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "位置匹配",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "输入双方位置,查找共同可见的卫星过境窗口",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StationInputCard(
|
||||||
|
title: String,
|
||||||
|
titleColor: androidx.compose.ui.graphics.Color,
|
||||||
|
lat: String,
|
||||||
|
lon: String,
|
||||||
|
grid: String,
|
||||||
|
minElev: Double,
|
||||||
|
latPlaceholder: String,
|
||||||
|
lonPlaceholder: String,
|
||||||
|
gridPlaceholder: String,
|
||||||
|
onLatChange: (String) -> Unit,
|
||||||
|
onLonChange: (String) -> Unit,
|
||||||
|
onGridChange: (String) -> Unit,
|
||||||
|
onMinElevChange: (Double) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
currentPositionAction: (() -> Unit)? = null
|
||||||
|
) {
|
||||||
|
ElevatedCard(modifier = modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = titleColor
|
||||||
|
)
|
||||||
|
if (currentPositionAction != null) {
|
||||||
|
OutlinedButton(onClick = currentPositionAction) {
|
||||||
|
Text("填入当前位置", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = lat,
|
||||||
|
onValueChange = onLatChange,
|
||||||
|
label = { Text("纬度") },
|
||||||
|
placeholder = { Text(latPlaceholder) },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = lon,
|
||||||
|
onValueChange = onLonChange,
|
||||||
|
label = { Text("经度") },
|
||||||
|
placeholder = { Text(lonPlaceholder) },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = grid,
|
||||||
|
onValueChange = onGridChange,
|
||||||
|
label = { Text("网格") },
|
||||||
|
placeholder = { Text(gridPlaceholder) },
|
||||||
|
supportingText = { Text("支持 4/6/8 位 Maidenhead 网格;也可直接输入经纬度") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "最小仰角:${minElev.toInt()}°",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Slider(
|
||||||
|
value = minElev.toFloat(),
|
||||||
|
onValueChange = { onMinElevChange(it.toDouble()) },
|
||||||
|
valueRange = 0f..90f,
|
||||||
|
steps = 17
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MatchSearchCard(
|
||||||
|
hoursAhead: Int,
|
||||||
|
isCalculating: Boolean,
|
||||||
|
onHoursAhead: (Int) -> Unit,
|
||||||
|
onQuery: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
ElevatedCard(modifier = modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "时间范围",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
listOf(6, 12, 24, 48, 72).forEach { hours ->
|
||||||
|
FilterChip(
|
||||||
|
selected = hoursAhead == hours,
|
||||||
|
onClick = { onHoursAhead(hours) },
|
||||||
|
label = { Text("${hours}h") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = onQuery,
|
||||||
|
enabled = !isCalculating,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
if (isCalculating) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(18.dp)
|
||||||
|
.width(18.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
Text(if (isCalculating) "计算中..." else "查询匹配")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MutualPassCard(
|
private fun MutualPassCard(
|
||||||
pass: MutualPass,
|
pass: MutualPass,
|
||||||
@@ -380,7 +481,6 @@ private fun MutualPassCard(
|
|||||||
var dragProgress by remember(pass) { mutableFloatStateOf(0.5f) }
|
var dragProgress by remember(pass) { mutableFloatStateOf(0.5f) }
|
||||||
|
|
||||||
// Filter to only show the portion where both stations are above their minElev
|
// Filter to only show the portion where both stations are above their minElev
|
||||||
// (satlover.de style — only the usable common window)
|
|
||||||
val visibleSamples = remember(pass, minElevA, minElevB) {
|
val visibleSamples = remember(pass, minElevA, minElevB) {
|
||||||
pass.elevationSamples.filter { (_, elev) ->
|
pass.elevationSamples.filter { (_, elev) ->
|
||||||
elev.first >= minElevA && elev.second >= minElevB
|
elev.first >= minElevA && elev.second >= minElevB
|
||||||
@@ -447,7 +547,7 @@ private fun MutualPassCard(
|
|||||||
// Dual-station radar track (polar plot)
|
// Dual-station radar track (polar plot)
|
||||||
if (visibleTracks.isNotEmpty()) {
|
if (visibleTracks.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = "双方轨迹(雷达图)",
|
text = "双方轨迹",
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
|||||||
+20
-15
@@ -352,20 +352,23 @@ class MutualViewModel(
|
|||||||
tSample += sampleInterval
|
tSample += sampleInterval
|
||||||
}
|
}
|
||||||
|
|
||||||
// The pass list already filters by maxElev > minElevation, so we trust
|
// Search uses the 0° horizon as the pass boundary, but the user sliders are
|
||||||
// the pass is valid. The mutual pass just needs both stations' curves.
|
// a final visibility filter. Both stations must exceed their own threshold;
|
||||||
results.add(
|
// otherwise the result would expand to an empty filtered chart.
|
||||||
MutualPass(
|
if (maxElevA > minElevADeg && maxElevB > minElevBDeg) {
|
||||||
catNum = pass.catNum,
|
results.add(
|
||||||
name = pass.orbitalObject.data.name,
|
MutualPass(
|
||||||
startTime = refinedAos,
|
catNum = pass.catNum,
|
||||||
endTime = refinedLos,
|
name = pass.orbitalObject.data.name,
|
||||||
maxElevationA = (maxElevA * 10).roundToInt() / 10.0,
|
startTime = refinedAos,
|
||||||
maxElevationB = (maxElevB * 10).roundToInt() / 10.0,
|
endTime = refinedLos,
|
||||||
elevationSamples = samples,
|
maxElevationA = (maxElevA * 10).roundToInt() / 10.0,
|
||||||
trackSamples = tracks
|
maxElevationB = (maxElevB * 10).roundToInt() / 10.0,
|
||||||
|
elevationSamples = samples,
|
||||||
|
trackSamples = tracks
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
@@ -413,10 +416,12 @@ class MutualViewModel(
|
|||||||
|
|
||||||
var tSample = refinedAos
|
var tSample = refinedAos
|
||||||
while (tSample <= refinedLos) {
|
while (tSample <= refinedLos) {
|
||||||
|
// Use getElevation for elevation, matching the main pass predictor; use
|
||||||
|
// getFullPosition only for azimuth needed by the radar plot.
|
||||||
|
val elevA = elevationDeg(sat, posA, tSample)
|
||||||
|
val elevB = elevationDeg(sat, posB, tSample)
|
||||||
val fullA = sat.getFullPosition(posA, tSample)
|
val fullA = sat.getFullPosition(posA, tSample)
|
||||||
val fullB = sat.getFullPosition(posB, tSample)
|
val fullB = sat.getFullPosition(posB, tSample)
|
||||||
val elevA = fullA.elevation * 180.0 / PI
|
|
||||||
val elevB = fullB.elevation * 180.0 / PI
|
|
||||||
if (elevA > maxElevA) maxElevA = elevA
|
if (elevA > maxElevA) maxElevA = elevA
|
||||||
if (elevB > maxElevB) maxElevB = elevB
|
if (elevB > maxElevB) maxElevB = elevB
|
||||||
samples.add(tSample to (elevA to elevB))
|
samples.add(tSample to (elevA to elevB))
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
|||||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlin.math.PI
|
import kotlin.math.PI
|
||||||
|
|
||||||
private enum class RadarPage(val title: String) {
|
private enum class RadarPage(val title: String) {
|
||||||
@@ -87,6 +88,23 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
|||||||
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
|
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val mutualData by container.mutualPassData.collectAsStateWithLifecycle()
|
val mutualData by container.mutualPassData.collectAsStateWithLifecycle()
|
||||||
|
val navigateUpAndClearMutual = {
|
||||||
|
if (container.mutualPassData.value.endTime > 0L) {
|
||||||
|
container.setMutualPassData(MutualPassData())
|
||||||
|
}
|
||||||
|
navigateUp()
|
||||||
|
}
|
||||||
|
LaunchedEffect(mutualData.endTime) {
|
||||||
|
if (mutualData.endTime <= 0L) return@LaunchedEffect
|
||||||
|
while (true) {
|
||||||
|
val remainingMs = mutualData.endTime - System.currentTimeMillis()
|
||||||
|
if (remainingMs <= 0L) {
|
||||||
|
navigateUpAndClearMutual()
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
delay(remainingMs.coerceAtMost(1000L))
|
||||||
|
}
|
||||||
|
}
|
||||||
// Sync actual permission state on every recomposition so it survives screen re-entry
|
// Sync actual permission state on every recomposition so it survives screen re-entry
|
||||||
val hasPermission = ContextCompat.checkSelfPermission(
|
val hasPermission = ContextCompat.checkSelfPermission(
|
||||||
context, Manifest.permission.RECORD_AUDIO
|
context, Manifest.permission.RECORD_AUDIO
|
||||||
@@ -101,7 +119,7 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
|||||||
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
|
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
|
||||||
viewModel.onAction(RadarAction.CwPermissionResult(granted))
|
viewModel.onAction(RadarAction.CwPermissionResult(granted))
|
||||||
}
|
}
|
||||||
RadarScreen(uiState, viewModel::onAction, navigateUp, mutualData, requestMicPermission = {
|
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, requestMicPermission = {
|
||||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,14 +94,21 @@ fun RadarViewCompose(
|
|||||||
animationSpec = infiniteRepeatable(tween(1000)),
|
animationSpec = infiniteRepeatable(tween(1000)),
|
||||||
label = "animScale"
|
label = "animScale"
|
||||||
)
|
)
|
||||||
// Drive the sweep from the animation framework to eliminate state mutation inside the draw block
|
// Drive the sweep from the animation framework to eliminate state mutation inside the draw block.
|
||||||
val sweepTransition = rememberInfiniteTransition(label = "sweep")
|
// Only create the infinite transition when the sweep is enabled — otherwise the canvas would
|
||||||
val sweepDegrees by sweepTransition.animateFloat(
|
// be invalidated every frame for an effect that isn't visible, wasting GPU and battery.
|
||||||
initialValue = 0f,
|
val sweepDegrees = if (shouldShowSweep) {
|
||||||
targetValue = 360f,
|
val sweepTransition = rememberInfiniteTransition(label = "sweep")
|
||||||
animationSpec = infiniteRepeatable(tween(SWEEP_DURATION_MS, easing = LinearEasing)),
|
val animatedSweep by sweepTransition.animateFloat(
|
||||||
label = "sweepDegrees"
|
initialValue = 0f,
|
||||||
)
|
targetValue = 360f,
|
||||||
|
animationSpec = infiniteRepeatable(tween(SWEEP_DURATION_MS, easing = LinearEasing)),
|
||||||
|
label = "sweepDegrees"
|
||||||
|
)
|
||||||
|
animatedSweep
|
||||||
|
} else {
|
||||||
|
0f
|
||||||
|
}
|
||||||
val measurer = rememberTextMeasurer()
|
val measurer = rememberTextMeasurer()
|
||||||
val sunPainter = painterResource(R.drawable.ic_sun)
|
val sunPainter = painterResource(R.drawable.ic_sun)
|
||||||
val moonPainter = painterResource(R.drawable.ic_moon)
|
val moonPainter = painterResource(R.drawable.ic_moon)
|
||||||
@@ -112,9 +119,10 @@ fun RadarViewCompose(
|
|||||||
var cachedSweepColor by remember { mutableStateOf(Color.Unspecified) }
|
var cachedSweepColor by remember { mutableStateOf(Color.Unspecified) }
|
||||||
var trackPath by remember { mutableStateOf(Path()) }
|
var trackPath by remember { mutableStateOf(Path()) }
|
||||||
var trackEffect by remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) }
|
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 cachedTrackBRef by remember { mutableStateOf<List<OrbitalPos>?>(null) }
|
||||||
var trackBPath by remember { mutableStateOf(Path()) }
|
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
|
// ShaderBrush is cached to avoid allocating a new GPU shader object every frame
|
||||||
var cachedSweepBrush by remember { mutableStateOf<ShaderBrush?>(null) }
|
var cachedSweepBrush by remember { mutableStateOf<ShaderBrush?>(null) }
|
||||||
|
|
||||||
@@ -124,7 +132,10 @@ fun RadarViewCompose(
|
|||||||
if (radius != cachedRadius || items !== cachedItemsRef || trackB !== cachedTrackBRef) {
|
if (radius != cachedRadius || items !== cachedItemsRef || trackB !== cachedTrackBRef) {
|
||||||
trackPath = createTrackPath(items, radius)
|
trackPath = createTrackPath(items, radius)
|
||||||
trackEffect = createTrackEffect(trackPath)
|
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)
|
cachedSweepBrush = makeSweepBrush(center, primaryColor)
|
||||||
cachedRadius = radius
|
cachedRadius = radius
|
||||||
cachedItemsRef = items
|
cachedItemsRef = items
|
||||||
@@ -141,13 +152,14 @@ fun RadarViewCompose(
|
|||||||
drawElevationLabels(radius, primaryColor, measurer)
|
drawElevationLabels(radius, primaryColor, measurer)
|
||||||
translate(center.x, center.y) {
|
translate(center.x, center.y) {
|
||||||
drawTrack(trackPath, trackEffect, aimColor, primaryColor)
|
drawTrack(trackPath, trackEffect, aimColor, primaryColor)
|
||||||
// Station-B overlay: full dashed track + live position dot
|
// Station-B overlay: full dashed track + directional arrows + live position dot
|
||||||
// (same display mode as the local station: track line + pulsing dot)
|
// (same display mode as the local station: track line/arrow + pulsing dot)
|
||||||
if (trackB != null && trackB.isNotEmpty() && !trackBPath.isEmpty) {
|
if (trackB != null && trackB.isNotEmpty() && !trackBPath.isEmpty) {
|
||||||
drawPath(
|
drawPath(
|
||||||
trackBPath, trackBColor,
|
trackBPath, trackBColor,
|
||||||
style = Stroke(STROKE_WIDTH, pathEffect = PathEffect.dashPathEffect(floatArrayOf(18f, 12f)))
|
style = Stroke(STROKE_WIDTH, pathEffect = PathEffect.dashPathEffect(floatArrayOf(18f, 12f)))
|
||||||
)
|
)
|
||||||
|
drawPath(trackBPath, trackBColor, style = Stroke(pathEffect = trackBEffect))
|
||||||
}
|
}
|
||||||
trackBPosition?.let { posB ->
|
trackBPosition?.let { posB ->
|
||||||
if (posB.elevation > 0) {
|
if (posB.elevation > 0) {
|
||||||
|
|||||||
@@ -139,6 +139,8 @@ class RadarViewModel(
|
|||||||
val passes = satelliteRepo.passes.value
|
val passes = satelliteRepo.passes.value
|
||||||
val (catNum, aosTime) = satelliteRepo.selectedPass.value
|
val (catNum, aosTime) = satelliteRepo.selectedPass.value
|
||||||
return passes.find { it.catNum == catNum && it.aosTime == aosTime }
|
return passes.find { it.catNum == catNum && it.aosTime == aosTime }
|
||||||
|
?: passes.find { it.catNum == catNum && aosTime in it.aosTime..it.losTime }
|
||||||
|
?: passes.find { it.catNum == catNum }
|
||||||
?: passes.firstOrNull()
|
?: passes.firstOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +181,7 @@ class RadarViewModel(
|
|||||||
orbitalPos = pos, sunPosition = cachedSunPos, moonPosition = cachedMoonPos
|
orbitalPos = pos, sunPosition = cachedSunPos, moonPosition = cachedMoonPos
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
processRadios(allRadios, pass.orbitalObject, timeNow)
|
processRadios(allRadios, pos)
|
||||||
sendPassData(pos)
|
sendPassData(pos)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,8 +342,8 @@ class RadarViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun processRadios(radios: List<SatRadio>, orbitalObject: OrbitalObject, time: Long) {
|
private suspend fun processRadios(radios: List<SatRadio>, satPos: OrbitalPos) {
|
||||||
val transmitters = satelliteRepo.getRadios(orbitalObject, stationPos, radios, time)
|
val transmitters = satelliteRepo.getRadios(satPos, radios)
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
val freq = if (state.transceivers.selectedUuid != null) {
|
val freq = if (state.transceivers.selectedUuid != null) {
|
||||||
val selectedRadio = transmitters.firstOrNull { it.uuid == state.transceivers.selectedUuid }
|
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()) {
|
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||||
Text(text = "Lat: ${settings.stationPos.latitude}°")
|
Text(text = "Lat: ${settings.stationPos.latitude}°")
|
||||||
Text(text = "Lon: ${settings.stationPos.longitude}°")
|
Text(text = "Lon: ${settings.stationPos.longitude}°")
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||||
Text(text = "Qth: ${settings.stationPos.qthLocator}")
|
Text(text = "Qth: ${settings.stationPos.qthLocator}")
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(1.dp))
|
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
|
# Configure your build environment: http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
org.gradle.caching=true
|
org.gradle.caching=true
|
||||||
org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
# NOTE: lowered from -Xmx6g for the 2GB build server; heavy release builds run on GitHub Actions
|
||||||
org.gradle.parallel=true
|
org.gradle.jvmargs=-Xmx768m -XX:MaxMetaspaceSize=256m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
||||||
org.gradle.workers.max=4
|
org.gradle.parallel=false
|
||||||
|
org.gradle.workers.max=1
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
[versions]
|
[versions]
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionCode = "443"
|
appVersionCode = "446"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionName = "4.4.3"
|
appVersionName = "4.4.6"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
compileSdk = "37"
|
compileSdk = "37"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
@@ -11,6 +11,11 @@ minSdk = "24"
|
|||||||
jdkVersion = "21"
|
jdkVersion = "21"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
packageName = "com.rtbishop.look4sat"
|
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"
|
android-gradle-plugin = "9.3.1"
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ include(
|
|||||||
":feature:mutual",
|
":feature:mutual",
|
||||||
":feature:passes",
|
":feature:passes",
|
||||||
":feature:radar",
|
":feature:radar",
|
||||||
|
":feature:roaming",
|
||||||
":feature:satellites",
|
":feature:satellites",
|
||||||
":feature:settings"
|
":feature:settings"
|
||||||
)
|
)
|
||||||
Reference in new issue
Block a user