Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
deec5581ae | ||
|
|
1de5632428 | ||
|
|
2dfd76825b | ||
|
|
06fc8bc2fb | ||
|
|
75322390b8 | ||
|
|
d1312f1f09 | ||
|
|
75acbbd633 | ||
|
|
a37fb8f773 | ||
|
|
9d0d971500 | ||
|
|
1813e1bfa4 |
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
|
||||
@@ -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>
|
||||
@@ -72,6 +72,7 @@ import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
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.ElevationThresholds
|
||||
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
|
||||
@@ -200,6 +201,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
}
|
||||
entry<Screen.Passes> {
|
||||
PassesDestination { catNum, aosTime ->
|
||||
container.setMutualPassData(MutualPassData())
|
||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||
backStack.add(Screen.Radar)
|
||||
// navigateToRadar()
|
||||
@@ -216,7 +218,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
viewModel = mutualViewModel,
|
||||
navigateUp = navigateBack,
|
||||
navigateToRadar = { catNum, aosTime, pass ->
|
||||
pass?.let { container.setMutualPassData(it) }
|
||||
container.setMutualPassData(pass ?: MutualPassData())
|
||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||
backStack.add(Screen.Radar)
|
||||
}
|
||||
@@ -245,6 +247,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
.clickable {
|
||||
val pass = trackingState.currentPass
|
||||
if (pass != null) {
|
||||
container.setMutualPassData(MutualPassData())
|
||||
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
||||
backStack.add(Screen.Radar)
|
||||
}
|
||||
|
||||
+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>
|
||||
|
||||
+62
-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,5 @@ 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())
|
||||
}
|
||||
@@ -26,21 +26,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 +63,23 @@ 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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -36,7 +36,7 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
||||
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
|
||||
|
||||
@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 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>
|
||||
@@ -9,7 +9,7 @@
|
||||
<string name="nav_sat">卫星</string>
|
||||
<string name="nav_pass">过境</string>
|
||||
<string name="nav_radar">雷达</string>
|
||||
<string name="nav_mutual">对台</string>
|
||||
<string name="nav_mutual">匹配</string>
|
||||
<string name="nav_map">地图</string>
|
||||
<string name="nav_prefs">设置</string>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -10,7 +10,7 @@
|
||||
<string name="nav_sat">Satellites</string>
|
||||
<string name="nav_pass">Passes</string>
|
||||
<string name="nav_radar">Radar</string>
|
||||
<string name="nav_mutual">Mutual</string>
|
||||
<string name="nav_mutual">Match</string>
|
||||
<string name="nav_map">Map</string>
|
||||
<string name="nav_prefs">Settings</string>
|
||||
|
||||
|
||||
+241
-141
@@ -88,7 +88,7 @@ fun MutualScreen(
|
||||
},
|
||||
topInfo = {
|
||||
Text(
|
||||
text = "对台过境",
|
||||
text = "过境匹配",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
@@ -96,7 +96,7 @@ fun MutualScreen(
|
||||
bottomInfo = {
|
||||
if (state.mutualPasses.isNotEmpty()) {
|
||||
Text(
|
||||
text = "找到 ${state.mutualPasses.size} 个过境",
|
||||
text = "找到 ${state.mutualPasses.size} 个匹配",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -183,153 +183,91 @@ private fun MutualContent(
|
||||
|
||||
// Input form
|
||||
item {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "你的位置",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Medium
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
MatchIntroCard()
|
||||
|
||||
if (isVertical) {
|
||||
StationInputCard(
|
||||
title = "你的位置",
|
||||
titleColor = MaterialTheme.colorScheme.primary,
|
||||
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(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.stationALat,
|
||||
onValueChange = onStationALat,
|
||||
label = { Text("纬度") },
|
||||
placeholder = { Text("39.9042") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
singleLine = true,
|
||||
StationInputCard(
|
||||
title = "你的位置",
|
||||
titleColor = MaterialTheme.colorScheme.primary,
|
||||
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,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.stationALon,
|
||||
onValueChange = onStationALon,
|
||||
label = { Text("经度") },
|
||||
placeholder = { Text("116.4074") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
singleLine = true,
|
||||
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,
|
||||
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
|
||||
private fun MutualPassCard(
|
||||
pass: MutualPass,
|
||||
@@ -380,7 +481,6 @@ private fun MutualPassCard(
|
||||
var dragProgress by remember(pass) { mutableFloatStateOf(0.5f) }
|
||||
|
||||
// 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) {
|
||||
pass.elevationSamples.filter { (_, elev) ->
|
||||
elev.first >= minElevA && elev.second >= minElevB
|
||||
@@ -447,7 +547,7 @@ private fun MutualPassCard(
|
||||
// Dual-station radar track (polar plot)
|
||||
if (visibleTracks.isNotEmpty()) {
|
||||
Text(
|
||||
text = "双方轨迹(雷达图)",
|
||||
text = "双方轨迹",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
||||
+20
-15
@@ -352,20 +352,23 @@ class MutualViewModel(
|
||||
tSample += sampleInterval
|
||||
}
|
||||
|
||||
// The pass list already filters by maxElev > minElevation, so we trust
|
||||
// the pass is valid. The mutual pass just needs both stations' curves.
|
||||
results.add(
|
||||
MutualPass(
|
||||
catNum = pass.catNum,
|
||||
name = pass.orbitalObject.data.name,
|
||||
startTime = refinedAos,
|
||||
endTime = refinedLos,
|
||||
maxElevationA = (maxElevA * 10).roundToInt() / 10.0,
|
||||
maxElevationB = (maxElevB * 10).roundToInt() / 10.0,
|
||||
elevationSamples = samples,
|
||||
trackSamples = tracks
|
||||
// Search uses the 0° horizon as the pass boundary, but the user sliders are
|
||||
// a final visibility filter. Both stations must exceed their own threshold;
|
||||
// otherwise the result would expand to an empty filtered chart.
|
||||
if (maxElevA > minElevADeg && maxElevB > minElevBDeg) {
|
||||
results.add(
|
||||
MutualPass(
|
||||
catNum = pass.catNum,
|
||||
name = pass.orbitalObject.data.name,
|
||||
startTime = refinedAos,
|
||||
endTime = refinedLos,
|
||||
maxElevationA = (maxElevA * 10).roundToInt() / 10.0,
|
||||
maxElevationB = (maxElevB * 10).roundToInt() / 10.0,
|
||||
elevationSamples = samples,
|
||||
trackSamples = tracks
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
@@ -413,10 +416,12 @@ class MutualViewModel(
|
||||
|
||||
var tSample = refinedAos
|
||||
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 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 (elevB > maxElevB) maxElevB = 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.layoutPadding
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.PI
|
||||
|
||||
private enum class RadarPage(val title: String) {
|
||||
@@ -87,6 +88,23 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
||||
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
|
||||
val uiState by viewModel.uiState.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
|
||||
val hasPermission = ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.RECORD_AUDIO
|
||||
@@ -101,7 +119,7 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
||||
viewModel.onAction(RadarAction.SstvPermissionResult(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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -139,6 +139,8 @@ class RadarViewModel(
|
||||
val passes = satelliteRepo.passes.value
|
||||
val (catNum, aosTime) = satelliteRepo.selectedPass.value
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -179,7 +181,7 @@ class RadarViewModel(
|
||||
orbitalPos = pos, sunPosition = cachedSunPos, moonPosition = cachedMoonPos
|
||||
)
|
||||
}
|
||||
processRadios(allRadios, pass.orbitalObject, timeNow)
|
||||
processRadios(allRadios, pos)
|
||||
sendPassData(pos)
|
||||
}
|
||||
|
||||
@@ -340,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 }
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "443"
|
||||
appVersionCode = "444"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.3"
|
||||
appVersionName = "4.4.4"
|
||||
#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"
|
||||
|
||||
|
||||
Reference in new issue
Block a user