mirror of
https://github.com/rt-bishop/Look4Sat.git
synced 2026-10-14 08:17:23 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a471379f07 | ||
|
|
2f3adb4949 | ||
|
|
a751e4af29 | ||
|
|
e335b43c77 | ||
|
|
fa7b8cefe7 | ||
|
|
83ce7229f6 |
No files matched your search
@@ -34,6 +34,11 @@ It is now and always will be completely ad-free and open-source.
|
||||
* Custom TLE satellite data import is available via Three Line Element .txt files
|
||||
* Offline first: calculations are made offline. Weekly TLE data update is recommended.
|
||||
|
||||
## Associated projects:
|
||||
|
||||
- [Look4Sat-rPi](https://github.com/kampji/Look4Sat-rPi) - An independent Raspberry Pi OS port of Look4Sat,
|
||||
bringing satellite tracking to touchscreen devices.
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://star-history.dera.page/#rt-bishop/Look4Sat&type=timeline&legend=top-left">
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
|
||||
@@ -20,10 +20,13 @@ package com.rtbishop.look4sat.core.data.usecase
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.media.MediaScannerConnection
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
@@ -32,23 +35,29 @@ import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
//Saves images to the device gallery using MediaStore (API 29+) or direct file write (API < 29)
|
||||
// Legacy public storage writes require WRITE_EXTERNAL_STORAGE on API 24-28.
|
||||
class SaveImage(private val context: Context) : ISaveImage {
|
||||
|
||||
override suspend fun invoke(pixels: IntArray, width: Int, height: Int, modeName: String): Boolean {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val bitmap = Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888)
|
||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val filename = "SSTV_${modeName}_$timestamp.png"
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
saveWithMediaStore(bitmap, filename)
|
||||
} else {
|
||||
saveToExternalStorage(bitmap, filename)
|
||||
try {
|
||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.US).format(Date())
|
||||
val filename = "SSTV_${modeName}_$timestamp.png"
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
saveWithMediaStore(bitmap, filename)
|
||||
} else {
|
||||
saveToExternalStorage(bitmap, filename)
|
||||
}
|
||||
true
|
||||
} finally {
|
||||
bitmap.recycle()
|
||||
}
|
||||
bitmap.recycle()
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e("SaveImage", "Failed to save SSTV image", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -64,21 +73,42 @@ class SaveImage(private val context: Context) : ISaveImage {
|
||||
val resolver = context.contentResolver
|
||||
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
|
||||
?: throw IllegalStateException("Failed to create MediaStore entry")
|
||||
resolver.openOutputStream(uri)?.use { stream ->
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
|
||||
var saved = false
|
||||
try {
|
||||
val stream = resolver.openOutputStream(uri)
|
||||
?: throw IllegalStateException("Failed to open MediaStore output stream")
|
||||
stream.use {
|
||||
check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)) { "Failed to encode PNG" }
|
||||
}
|
||||
values.clear()
|
||||
values.put(MediaStore.Images.Media.IS_PENDING, 0)
|
||||
check(resolver.update(uri, values, null, null) == 1) { "Failed to publish image" }
|
||||
saved = true
|
||||
} finally {
|
||||
if (!saved) {
|
||||
check(resolver.delete(uri, null, null) == 1) { "Failed to delete incomplete image" }
|
||||
}
|
||||
}
|
||||
values.clear()
|
||||
values.put(MediaStore.Images.Media.IS_PENDING, 0)
|
||||
resolver.update(uri, values, null, null)
|
||||
}
|
||||
|
||||
private fun saveToExternalStorage(bitmap: Bitmap, filename: String) {
|
||||
val picturesDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
|
||||
val dir = File(picturesDir, "Look4Sat")
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
check(dir.isDirectory || dir.mkdirs()) { "Failed to create pictures directory" }
|
||||
val file = File(dir, filename)
|
||||
FileOutputStream(file).use { stream ->
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
|
||||
var saved = false
|
||||
try {
|
||||
FileOutputStream(file).use { stream ->
|
||||
check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)) { "Failed to encode PNG" }
|
||||
}
|
||||
MediaScannerConnection.scanFile(
|
||||
context, arrayOf(file.absolutePath), arrayOf("image/png"), null
|
||||
)
|
||||
saved = true
|
||||
} finally {
|
||||
if (!saved && file.exists()) {
|
||||
check(file.delete()) { "Failed to delete incomplete image" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-6
@@ -62,7 +62,7 @@ object CelestialComputer {
|
||||
/** Moon position as seen from a ground observer. */
|
||||
data class MoonPosition(
|
||||
val azimuth: Double, // degrees, 0=N, 90=E
|
||||
val elevation: Double, // degrees
|
||||
val elevation: Double, // degrees, topocentric (parallax corrected)
|
||||
val rightAscension: Double, // degrees
|
||||
val declination: Double, // degrees
|
||||
val gha: Double, // Greenwich Hour Angle, degrees
|
||||
@@ -232,21 +232,27 @@ object CelestialComputer {
|
||||
val lm = l * DEG2RAD
|
||||
val moonDx = 3.0 / (PI * p)
|
||||
|
||||
// Ecliptic → equatorial
|
||||
// Ecliptic → equatorial. atan2 keeps RA in the right quadrant for any ecliptic longitude:
|
||||
// the periodic terms above can push l slightly outside [0, 360), and an acos based
|
||||
// conversion then mirrors RA around 0h and throws the Moon off by up to 16° in azimuth.
|
||||
val z = (jd - 2415020.5) / 365.2422
|
||||
val ob = (23.452294 - (0.46845 * z + 5.9e-07 * z * z) / 3600.0).toRadians()
|
||||
val dec = asin(sin(bRad) * cos(ob) + cos(bRad) * sin(ob) * sin(lm))
|
||||
var ra = acos(cos(bRad) * cos(lm) / cos(dec)); if (lm > PI) ra = TWO_PI - ra
|
||||
val ra = mod2PI(atan2(sin(lm) * cos(ob) - tan(bRad) * sin(ob), cos(lm)))
|
||||
|
||||
val n = observer.latitude * DEG2RAD
|
||||
t = (jd - 2451545.0) / 36525.0
|
||||
var teg = 280.46061837 + 360.98564736629 * (jd - 2451545.0) + (0.000387933 * t - t * t / 38710000.0) * t
|
||||
while (teg > 360.0) teg -= 360.0
|
||||
val teg = modulus(
|
||||
280.46061837 + 360.98564736629 * (jd - 2451545.0) + (0.000387933 * t - t * t / 38710000.0) * t, 360.0
|
||||
)
|
||||
// LST = GMST + east longitude (positive east convention)
|
||||
val th = mod2PI((teg + observer.longitude) * DEG2RAD)
|
||||
val h = th - ra
|
||||
val azVal = atan2(sin(h), cos(h) * sin(n) - tan(dec) * cos(n)) + PI
|
||||
val el = asin(sin(n) * sin(dec) + cos(n) * cos(dec) * cos(h))
|
||||
// Geocentric → topocentric. Parallax is up to ~1°, two full Moon diameters, and shifts
|
||||
// the Moon along the vertical circle only, so just the elevation needs correcting.
|
||||
val elTopo = el / DEG2RAD - p * cos(el)
|
||||
|
||||
// Moon radial velocity approximation (from "Amateur Radio Software", GM4ANB, RSGB 1985)
|
||||
val mm = fixAngle(1.319238 + daynum * 0.228027135)
|
||||
@@ -266,7 +272,7 @@ object CelestialComputer {
|
||||
|
||||
return MoonPosition(
|
||||
azimuth = azVal / DEG2RAD,
|
||||
elevation = el / DEG2RAD,
|
||||
elevation = elTopo,
|
||||
rightAscension = moonRa,
|
||||
declination = dec / DEG2RAD,
|
||||
gha = moonGha,
|
||||
|
||||
@@ -31,4 +31,15 @@ data class OrbitalPass(
|
||||
val catNum: Int = orbitalObject.data.catnum
|
||||
val name: String = if (hasDecayed) "${orbitalObject.data.name} (decayed?)" else orbitalObject.data.name
|
||||
val isDeepSpace: Boolean = orbitalObject.data.isDeepSpace
|
||||
|
||||
/**
|
||||
* Fraction of the pass elapsed at [timeMillis], derived on demand so that a ticking clock
|
||||
* never has to rewrite the pass list. A DeepSpace object is always in view, so it reads 1.
|
||||
*/
|
||||
fun progressAt(timeMillis: Long): Float {
|
||||
if (isDeepSpace) return 1f
|
||||
val total = (losTime - aosTime).toFloat()
|
||||
if (total <= 0f) return 0f
|
||||
return ((timeMillis - aosTime) / total).coerceIn(0f, 1f)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ package com.rtbishop.look4sat.core.domain.usecase
|
||||
|
||||
/**
|
||||
* Saves a decoded SSTV image to the device gallery.
|
||||
* The caller must obtain storage permission on Android API 24-28 before saving.
|
||||
* @return true if the image was saved successfully
|
||||
*/
|
||||
interface ISaveImage {
|
||||
|
||||
+59
-24
@@ -16,18 +16,31 @@ import java.util.Locale
|
||||
/**
|
||||
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
|
||||
*
|
||||
* For a linear (passband) transponder, uplink and downlink frequencies are
|
||||
* related by a fixed passband offset. When the satellite moves, both are
|
||||
* Doppler-shifted. Given one, this computes the other:
|
||||
* The full physical path:
|
||||
*
|
||||
* downlink → uplink: mapDownlinkToUplink (passband) → getUplinkFreq (Doppler)
|
||||
* uplink → downlink: mapUplinkToDownlink (passband) → getDownlinkFreq (Doppler)
|
||||
* TX to RX (uplink -> downlink):
|
||||
* 1. Ground transmits f_tx
|
||||
* 2. Satellite receives f_tx * (c - v) / c (uplink Doppler)
|
||||
* 3. Satellite transmits the passband mapping of (2) (mapping happens on board)
|
||||
* 4. Ground hears (3) * (c - v) / c (downlink Doppler)
|
||||
*
|
||||
* RX to TX (downlink -> uplink), the same chain in reverse:
|
||||
* 4. Ground hears f_rx
|
||||
* 3. Satellite transmits f_rx * (c + v) / c (undo downlink Doppler)
|
||||
* 2. Satellite receives the inverse passband mapping of (3)
|
||||
* 1. Ground must transmit (2) * (c + v) / c (undo uplink Doppler)
|
||||
*
|
||||
* Both legs of the trip are Doppler shifted and by different amounts (uplink
|
||||
* and downlink frequencies differ), so the mapping must happen between the two
|
||||
* shifts on satellite-received frequencies. Addresses GitHub issue #91
|
||||
* (Custom frequency Doppler correction).
|
||||
*/
|
||||
object DopplerFrequencyCalculator {
|
||||
|
||||
/**
|
||||
* Given a downlink frequency, compute the Doppler-corrected uplink frequency.
|
||||
* Returns null if the transponder is not a linear passband type.
|
||||
* Given a downlink frequency (what the user hears), compute the
|
||||
* uplink frequency the user should transmit.
|
||||
* Full path: 4 -> 3 -> 2 -> 1
|
||||
*/
|
||||
fun computeUplinkFromDownlink(
|
||||
downlinkHz: Long,
|
||||
@@ -35,17 +48,23 @@ object DopplerFrequencyCalculator {
|
||||
orbitalPos: OrbitalPos
|
||||
): Long? {
|
||||
if (!isLinearTransponder(transponder)) return null
|
||||
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz, transponder) ?: return null
|
||||
return orbitalPos.getUplinkFreq(baseUplink)
|
||||
// 4 -> 3 undo the downlink Doppler: the frequency the satellite transmits
|
||||
val satTx = orbitalPos.getUplinkFreq(downlinkHz)
|
||||
// 3 -> 2 inverse passband mapping
|
||||
val satRx = TransponderMapper.mapDownlinkToUplink(satTx, transponder) ?: return null
|
||||
// 2 -> 1 undo the uplink Doppler: the frequency the ground station transmits
|
||||
return orbitalPos.getUplinkFreq(satRx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a downlink frequency, compute the Doppler-corrected uplink frequency
|
||||
* with an offset applied to the downlink (in Hz).
|
||||
* Returns null if the transponder is not a linear passband type.
|
||||
* Given a downlink frequency (what the user hears), compute the
|
||||
* uplink frequency the user should transmit, with an offset applied
|
||||
* to the downlink (in Hz).
|
||||
* Full path: 4 -> 3 -> 2 -> 1
|
||||
*
|
||||
* The user-entered downlink frequency already includes the offset, so subtract
|
||||
* it before mapping the downlink passband position back to the uplink.
|
||||
* it before the inverse passband mapping. The offset lives in the satellite
|
||||
* frequency domain, hence it is removed after undoing the downlink Doppler.
|
||||
*/
|
||||
fun computeUplinkFromDownlinkWithOffset(
|
||||
downlinkHz: Long,
|
||||
@@ -54,13 +73,20 @@ object DopplerFrequencyCalculator {
|
||||
offsetHz: Long
|
||||
): Long? {
|
||||
if (!isLinearTransponder(transponder)) return null
|
||||
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz - offsetHz, transponder) ?: return null
|
||||
return orbitalPos.getUplinkFreq(baseUplink)
|
||||
// 4 -> 3 undo the downlink Doppler (the offset travels with it)
|
||||
val satTxWithOffset = orbitalPos.getUplinkFreq(downlinkHz)
|
||||
// 3 remove the offset (it lives in the satellite frequency domain)
|
||||
val satTx = satTxWithOffset - offsetHz
|
||||
// 3 -> 2 inverse passband mapping
|
||||
val satRx = TransponderMapper.mapDownlinkToUplink(satTx, transponder) ?: return null
|
||||
// 2 -> 1 undo the uplink Doppler
|
||||
return orbitalPos.getUplinkFreq(satRx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an uplink frequency, compute the Doppler-corrected downlink frequency.
|
||||
* Returns null if the transponder is not a linear passband type.
|
||||
* Given an uplink frequency (what the user transmits), compute the
|
||||
* downlink frequency the user will hear.
|
||||
* Full path: 1 -> 2 -> 3 -> 4
|
||||
*/
|
||||
fun computeDownlinkFromUplink(
|
||||
uplinkHz: Long,
|
||||
@@ -68,14 +94,19 @@ object DopplerFrequencyCalculator {
|
||||
orbitalPos: OrbitalPos
|
||||
): Long? {
|
||||
if (!isLinearTransponder(transponder)) return null
|
||||
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null
|
||||
return orbitalPos.getDownlinkFreq(baseDownlink)
|
||||
// 1 -> 2 uplink Doppler: the frequency the satellite receives
|
||||
val satRx = orbitalPos.getDownlinkFreq(uplinkHz)
|
||||
// 2 -> 3 passband mapping
|
||||
val satTx = TransponderMapper.mapUplinkToDownlink(satRx, transponder) ?: return null
|
||||
// 3 -> 4 downlink Doppler: what the ground station hears
|
||||
return orbitalPos.getDownlinkFreq(satTx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an uplink frequency, compute the Doppler-corrected downlink frequency
|
||||
* with an offset applied to the downlink (in Hz).
|
||||
* Returns null if the transponder is not a linear passband type.
|
||||
* Given an uplink frequency (what the user transmits), compute the
|
||||
* downlink frequency the user will hear, with an offset applied
|
||||
* to the downlink (in Hz).
|
||||
* Full path: 1 -> 2 -> 3 -> 4
|
||||
*/
|
||||
fun computeDownlinkFromUplinkWithOffset(
|
||||
uplinkHz: Long,
|
||||
@@ -84,8 +115,12 @@ object DopplerFrequencyCalculator {
|
||||
offsetHz: Long
|
||||
): Long? {
|
||||
if (!isLinearTransponder(transponder)) return null
|
||||
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null
|
||||
return orbitalPos.getDownlinkFreq(baseDownlink + offsetHz)
|
||||
// 1 -> 2 uplink Doppler: the frequency the satellite receives
|
||||
val satRx = orbitalPos.getDownlinkFreq(uplinkHz)
|
||||
// 2 -> 3 passband mapping
|
||||
val satTx = TransponderMapper.mapUplinkToDownlink(satRx, transponder) ?: return null
|
||||
// 3 apply the offset (satellite frequency domain), 3 -> 4 downlink Doppler
|
||||
return orbitalPos.getDownlinkFreq(satTx + offsetHz)
|
||||
}
|
||||
|
||||
/** True if this transponder supports linear passband mapping. */
|
||||
|
||||
+66
-1
@@ -268,6 +268,71 @@ class DopplerFrequencyCalculatorTest {
|
||||
val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos)
|
||||
assertNotNull(roundTripDownlink)
|
||||
val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink)
|
||||
assertTrue("Round-trip error too large: $error", error < 10000)
|
||||
// Both legs of the trip carry their own Doppler shift, so the reverse
|
||||
// computation must undo both. A single-shift implementation leaves an
|
||||
// error of roughly (uplink + downlink) Doppler, ~6.8 kHz at 3.5 km/s.
|
||||
assertTrue("Round-trip error too large: $error", error < 100)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeDownlinkFromUplink_appliesUplinkAndDownlinkDoppler() {
|
||||
// 7 km/s range rate: the uplink is Doppler shifted before the passband
|
||||
// mapping and the downlink after it. Both shifts must be applied - the
|
||||
// mapping happens on the satellite-received frequency, not the ground one.
|
||||
val xpdr = linearTransponder()
|
||||
val orbitalPos = pos(7.0)
|
||||
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_200_000L, xpdr, orbitalPos)
|
||||
assertNotNull(downlink)
|
||||
// A single-shift implementation maps the ground frequency first and
|
||||
// yields 435_189_838 here, missing the uplink shift of 3391 Hz.
|
||||
assertEquals(435_186_447L, downlink)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeDownlinkFromUplink_inverted_appliesUplinkAndDownlinkDoppler() {
|
||||
val xpdr = linearTransponder(inverted = true, downHigh = 435_500_000L)
|
||||
val orbitalPos = pos(7.0)
|
||||
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_200_000L, xpdr, orbitalPos)
|
||||
assertNotNull(downlink)
|
||||
assertEquals(435_293_226L, downlink)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeUplinkFromDownlink_roundTripAtHighRangeRate() {
|
||||
val xpdr = linearTransponder()
|
||||
val orbitalPos = pos(7.0)
|
||||
val originalUplink = 145_200_000L
|
||||
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(originalUplink, xpdr, orbitalPos)
|
||||
assertNotNull(downlink)
|
||||
val roundTripUplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(downlink!!, xpdr, orbitalPos)
|
||||
assertNotNull(roundTripUplink)
|
||||
val error = kotlin.math.abs(roundTripUplink!! - originalUplink)
|
||||
// A single-shift implementation is not its own inverse: the round trip
|
||||
// drifts by ~(uplink + downlink) Doppler, 13.5 kHz at 7 km/s.
|
||||
assertTrue("Round-trip error too large: $error", error < 100)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeOffsetRoundTrip_atHighRangeRate() {
|
||||
val xpdr = linearTransponder()
|
||||
val orbitalPos = pos(7.0)
|
||||
val originalUplink = 145_200_000L
|
||||
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||
uplinkHz = originalUplink,
|
||||
transponder = xpdr,
|
||||
orbitalPos = orbitalPos,
|
||||
offsetHz = 2_500L
|
||||
)
|
||||
assertNotNull(downlink)
|
||||
assertEquals(435_188_947L, downlink)
|
||||
val roundTripUplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||
downlinkHz = downlink!!,
|
||||
transponder = xpdr,
|
||||
orbitalPos = orbitalPos,
|
||||
offsetHz = 2_500L
|
||||
)
|
||||
assertNotNull(roundTripUplink)
|
||||
val error = kotlin.math.abs(roundTripUplink!! - originalUplink)
|
||||
assertTrue("Round-trip error too large: $error", error < 100)
|
||||
}
|
||||
}
|
||||
+35
@@ -71,6 +71,41 @@ class CelestialComputerTest {
|
||||
|
||||
private fun String.toMillis(): Long = Instant.parse(this).toEpochMilli()
|
||||
|
||||
private data class MoonCase(
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val iso: String,
|
||||
val azimuth: Double,
|
||||
val elevation: Double
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `getMoonPosition matches reference topocentric az-el across the ecliptic longitude wrap`() {
|
||||
// Reference values are topocentric, refraction excluded. The first three cases sit on or
|
||||
// near the ecliptic longitude 0h crossing, where an acos based RA conversion mirrors the
|
||||
// Moon around 0h and skews azimuth by up to 16 degrees.
|
||||
val cases = listOf(
|
||||
MoonCase(52.52, 13.405, "2026-01-23T12:12:00Z", 133.317, 28.418),
|
||||
MoonCase(52.52, 13.405, "2026-09-26T02:00:00Z", 239.701, 20.508),
|
||||
MoonCase(52.52, 13.405, "2026-09-26T05:00:00Z", 276.290, -4.496),
|
||||
MoonCase(52.52, 13.405, "2026-10-05T19:00:00Z", 358.283, -19.612),
|
||||
MoonCase(-33.8688, 151.2093, "2026-03-15T11:00:00Z", 184.964, -36.659),
|
||||
MoonCase(40.7128, -74.0060, "2026-07-04T02:00:00Z", 98.385, -10.437)
|
||||
)
|
||||
|
||||
cases.forEach { testCase ->
|
||||
val observer = GeoPos(testCase.latitude, testCase.longitude)
|
||||
val moon = CelestialComputer.getMoonPosition(observer, testCase.iso.toMillis())
|
||||
val azimuthDelta = abs((moon.azimuth - testCase.azimuth + 540.0) % 360.0 - 180.0)
|
||||
|
||||
assertTrue(
|
||||
"${testCase.iso}: azimuth ${moon.azimuth} should be within 0.5 of ${testCase.azimuth}",
|
||||
azimuthDelta < 0.5
|
||||
)
|
||||
assertEquals("${testCase.iso}: elevation", testCase.elevation, moon.elevation, 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val SUNRISE_SET_THRESHOLD = -0.8333
|
||||
private const val HOUR_MILLIS = 60L * 60L * 1000L
|
||||
|
||||
+169
-39
@@ -39,6 +39,7 @@ import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -46,21 +47,33 @@ import androidx.compose.material3.ElevatedButton
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.ProgressIndicatorDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.addOutline
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.clipRect
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
@@ -78,19 +91,21 @@ import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.text.style.LineHeightStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.rtbishop.look4sat.core.domain.predict.NearEarthObject
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
@@ -141,27 +156,21 @@ fun RowScope.TimerRow(timeString: String, isTimeAos: Boolean) {
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
private fun NextPassRowPreview() = MainTheme {
|
||||
TopBar { NextPassRow(pass = getDefaultPass()) }
|
||||
TopBar { NextPassRow(pass = getDefaultPass(), timeNow = MutableStateFlow(System.currentTimeMillis())) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc: Boolean = false) {
|
||||
val timeZone = remember(isUtc) {
|
||||
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||
fun RowScope.NextPassRow(pass: OrbitalPass, timeNow: StateFlow<Long>, modifier: Modifier = Modifier) {
|
||||
val aosLosFormat = stringResource(id = R.string.pass_aosLos)
|
||||
val aosLosStr = remember(pass.aosAzimuth, pass.losAzimuth, aosLosFormat) {
|
||||
String.format(displayLocale(), aosLosFormat, pass.aosAzimuth.toInt() % 360, pass.losAzimuth.toInt() % 360)
|
||||
}
|
||||
val sdfTime = remember(isUtc) {
|
||||
SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
|
||||
}
|
||||
ElevatedCard(
|
||||
modifier = modifier
|
||||
.height(48.dp)
|
||||
.weight(1f)
|
||||
) {
|
||||
ElevatedCard(modifier = modifier.height(48.dp).weight(1f)) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy((-2).dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(start = 6.dp, top = 1.dp, end = 6.dp, bottom = 0.dp)
|
||||
.padding(start = 6.dp, top = 2.dp, end = 6.dp, bottom = 0.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
@@ -176,41 +185,43 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
||||
.infiniteMarquee(),
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
val elevColor = elevationColor(pass.maxElevation)
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_elevation),
|
||||
contentDescription = null,
|
||||
tint = elevColor,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "${pass.maxElevation}°",
|
||||
color = elevColor
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
PassTimerChip(pass = pass, timeNow = timeNow)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = sdfTime.format(Date(pass.aosTime)),
|
||||
text = aosLosStr,
|
||||
fontSize = 15.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(text = "${pass.altitude} km", fontSize = 15.sp)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val elevColor = elevationColor(pass.maxElevation)
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_elevation),
|
||||
contentDescription = null,
|
||||
tint = elevColor,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "${pass.maxElevation}°",
|
||||
color = elevColor
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.pass_aosLos, pass.aosAzimuth.toInt(), pass.losAzimuth.toInt()),
|
||||
text = "${pass.altitude} km",
|
||||
fontSize = 15.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.End,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
@@ -219,7 +230,50 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayLocale(): Locale {
|
||||
/**
|
||||
* Countdown to AOS while the pass is pending, then to LOS while it is active.
|
||||
* Gray denotes a pending pass, primary an active one. Collects the clock itself so a
|
||||
* tick recomposes only this chip.
|
||||
*/
|
||||
@Composable
|
||||
fun PassTimerChip(pass: OrbitalPass, timeNow: StateFlow<Long>) {
|
||||
val now by timeNow.collectAsStateWithLifecycle()
|
||||
// A DeepSpace object is permanently in view, so it gets a neutral label instead of a countdown
|
||||
val isActive = !pass.isDeepSpace && now >= pass.aosTime
|
||||
val text = when {
|
||||
pass.isDeepSpace -> stringResource(R.string.pass_deep_space)
|
||||
isActive -> stringResource(R.string.pass_timer_los, (pass.losTime - now).toTimerString())
|
||||
else -> stringResource(R.string.pass_timer_aos, (pass.aosTime - now).toTimerString())
|
||||
}
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = if (isActive) {
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
style = LocalTextStyle.current.copy(
|
||||
// Tabular figures stop the chip reflowing on every tick, while the trimmed
|
||||
// line height keeps it short without shrinking the timer text
|
||||
fontFeatureSettings = "tnum",
|
||||
lineHeight = 16.sp,
|
||||
lineHeightStyle = LineHeightStyle(
|
||||
alignment = LineHeightStyle.Alignment.Center,
|
||||
trim = LineHeightStyle.Trim.Both
|
||||
)
|
||||
),
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun displayLocale(): Locale {
|
||||
val locale = Locale.getDefault()
|
||||
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
|
||||
}
|
||||
@@ -428,6 +482,55 @@ private fun DialogShell(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SearchBar(onQueryChange: (String) -> Unit, modifier: Modifier = Modifier) {
|
||||
val currentQuery = rememberSaveable { mutableStateOf("") }
|
||||
val updateQuery = { newValue: String ->
|
||||
currentQuery.value = newValue
|
||||
onQueryChange(newValue)
|
||||
}
|
||||
ElevatedCard(modifier = modifier.height(48.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 12.dp)
|
||||
) {
|
||||
Icon(painter = painterResource(id = R.drawable.ic_search), contentDescription = null)
|
||||
BasicTextField(
|
||||
value = currentQuery.value,
|
||||
onValueChange = updateQuery,
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 12.dp),
|
||||
textStyle = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 20.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
decorationBox = { innerTextField ->
|
||||
if (currentQuery.value.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.sat_search_hint),
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 20.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
},
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface)
|
||||
)
|
||||
IconButton(onClick = { updateQuery("") }) {
|
||||
val clearCd = stringResource(R.string.sat_search_clear)
|
||||
Icon(painter = painterResource(id = R.drawable.ic_close), contentDescription = clearCd)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun hasEnoughHeight(): Boolean =
|
||||
currentWindowAdaptiveInfoV2().windowSizeClass.isHeightAtLeastBreakpoint(480)
|
||||
@@ -580,3 +683,30 @@ fun formatFrequency(frequencyHz: Long): String {
|
||||
val hz = frequencyHz % 1_000
|
||||
return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz)
|
||||
}
|
||||
|
||||
/**
|
||||
* Strokes the [shape] outline as a track, then reveals it left to right up to the [progress]
|
||||
* fraction of the width, so the top and bottom edges fill in sync.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.progressOutline(
|
||||
progress: Float,
|
||||
shape: Shape = RoundedCornerShape(12.dp),
|
||||
width: Dp = 0.5.dp,
|
||||
trackColor: Color = ProgressIndicatorDefaults.linearTrackColor,
|
||||
progressColor: Color = ProgressIndicatorDefaults.linearColor
|
||||
) = this.drawWithCache {
|
||||
val widthPx = width.toPx()
|
||||
val outline = shape.createOutline(Size(size.width - widthPx, size.height - widthPx), layoutDirection, this)
|
||||
val outlinePath = Path().apply {
|
||||
addOutline(outline)
|
||||
translate(Offset(widthPx / 2f, widthPx / 2f))
|
||||
}
|
||||
val stroke = Stroke(width = widthPx)
|
||||
val revealWidth = size.width * progress.coerceIn(0f, 1f)
|
||||
onDrawWithContent {
|
||||
drawContent()
|
||||
drawPath(outlinePath, trackColor, style = stroke)
|
||||
clipRect(right = revealWidth) { drawPath(outlinePath, progressColor, style = stroke) }
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -39,6 +39,7 @@ fun SwipeableItem(onSwipeRight: () -> Unit, onSwipeLeft: () -> Unit, content: @C
|
||||
val dismissThresholdPx = with(LocalDensity.current) { 120.dp.toPx() }
|
||||
val dismissState = rememberSwipeToDismissBoxState { dismissThresholdPx }
|
||||
val willTrigger by remember { derivedStateOf { dismissState.targetValue != SwipeToDismissBoxValue.Settled } }
|
||||
val isSwiping by remember { derivedStateOf { dismissState.dismissDirection != SwipeToDismissBoxValue.Settled } }
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
LaunchedEffect(willTrigger) {
|
||||
val feedbackType = if (willTrigger) HapticFeedbackType.LongPress else HapticFeedbackType.SegmentTick
|
||||
@@ -49,8 +50,10 @@ fun SwipeableItem(onSwipeRight: () -> Unit, onSwipeLeft: () -> Unit, content: @C
|
||||
enableDismissFromStartToEnd = true,
|
||||
enableDismissFromEndToStart = true,
|
||||
backgroundContent = {
|
||||
val isSwipeRight = dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd
|
||||
SwipeBackground(isSwipeRight, willTrigger, MaterialTheme.colorScheme.primary)
|
||||
if (isSwiping) {
|
||||
val isSwipeRight = dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd
|
||||
SwipeBackground(isSwipeRight, willTrigger, MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
},
|
||||
content = { content() },
|
||||
onDismiss = {
|
||||
|
||||
@@ -46,9 +46,13 @@
|
||||
<string name="pass_elevation">Elevation: %.1f°</string>
|
||||
<string name="pass_deep_space" translatable="false">DeepSpace</string>
|
||||
<string name="pass_altitude">Altitude: %d km</string>
|
||||
<string name="pass_aosLos" translatable="false">%03d° - %03d°</string>
|
||||
<string name="pass_aosLos" translatable="false">%03d°/%03d°</string>
|
||||
<string name="pass_azimuth" translatable="false">%03d°</string>
|
||||
<string name="pass_timer_aos" translatable="false">AOS %s</string>
|
||||
<string name="pass_timer_los" translatable="false">LOS %s</string>
|
||||
<string name="pass_empty_list_message">
|
||||
Make sure your filters are correct and you have selected satellites</string>
|
||||
<string name="pass_empty_search_message">No upcoming passes match your search</string>
|
||||
<string name="pass_error_message">There are no upcoming satellite passes to display</string>
|
||||
<string name="pass_welcome_title">Welcome to Look4Sat\!</string>
|
||||
<string name="pass_welcome_message">
|
||||
@@ -56,12 +60,12 @@
|
||||
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* Fixed AOS window filter to follow the UTC setting by wty2019wty (#252)
|
||||
\n\n* Applied elevation color thresholds to the Radar by atsunatsu (#253)
|
||||
\n\n* Added fuzzy satellite search ignoring separators by atsunatsu (#255)
|
||||
\n\n* Added light theme suitable for e-ink devices by Ondrej Kolonicny (#256)
|
||||
\n\n* Fixed IC-705 frequency read in dual-radio mode by Michael S (#260)
|
||||
\n\n* Fixed day/night map overlay calculation and display issue by rt-bishop
|
||||
* Added AOS/LOS timers to PassItem and NextPassRow components
|
||||
\n\n* Replaced Swipe-to-Filter functionality with a Search bar
|
||||
\n\n* Tweaked SSTV image saving flow, added permissions #264
|
||||
\n\n* Fixed linear Doppler calculator and offset restore #265
|
||||
\n\n* Corrected CelestialComputer moon calculations error #266
|
||||
\n\n* Added Look4Sat-rPi project ref to app Settings/README #267
|
||||
</string>
|
||||
|
||||
<!-- AMSAT Status screen -->
|
||||
@@ -142,6 +146,9 @@
|
||||
|
||||
<!-- SSTV screen -->
|
||||
<string name="sstv_grant_permission">Grant permission</string>
|
||||
<string name="sstv_image_saved">Image saved</string>
|
||||
<string name="sstv_image_save_failed">Failed to save image</string>
|
||||
<string name="sstv_storage_permission_denied">Storage permission is required to save images. If denied permanently, enable it in app settings.</string>
|
||||
<string name="sstv_listening">Listening…</string>
|
||||
<string name="sstv_mic_permission">Microphone access needed</string>
|
||||
<string name="sstv_mode_format">Mode: %s</string>
|
||||
@@ -276,6 +283,7 @@
|
||||
<string name="prefs_outro_title">I would like to say thanks to</string>
|
||||
<string name="prefs_outro_thanks" translatable="false">
|
||||
* Look4Sat users and contributors!
|
||||
\n* Jill Kamperides (Look4Sat-rPi)
|
||||
\n* David A. B. Johnson (predict4java)
|
||||
\n* Dave Moten (predict4java)
|
||||
\n* Alexandru Csete (Gpredict)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
* Fixed AOS window filter to follow the UTC setting by wty2019wty (#252)
|
||||
* Applied elevation color thresholds to the Radar by atsunatsu (#253)
|
||||
* Added fuzzy satellite search ignoring separators by atsunatsu (#255)
|
||||
* Added light theme suitable for e-ink devices by Ondrej Kolonicny (#256)
|
||||
* Fixed IC-705 frequency read in dual-radio mode by Michael S (#260)
|
||||
* Fixed day/night map overlay calculation and display issue by rt-bishop
|
||||
* Added AOS/LOS timers to PassItem and NextPassRow components
|
||||
* Replaced Swipe-to-Filter functionality with a Search bar
|
||||
* Tweaked SSTV image saving flow, added permissions #264
|
||||
* Fixed linear Doppler calculator and offset restore #265
|
||||
* Corrected CelestialComputer moon calculations error #266
|
||||
* Added Look4Sat-rPi project ref to app Settings/README #267
|
||||
@@ -70,6 +70,7 @@ import com.rtbishop.look4sat.core.presentation.TimerRow
|
||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.osmdroid.events.DelayedMapListener
|
||||
import org.osmdroid.events.MapListener
|
||||
import org.osmdroid.events.ScrollEvent
|
||||
@@ -130,7 +131,7 @@ private val darkTileFilter by lazy { createColorFilter() }
|
||||
|
||||
/**
|
||||
* Recolor the shared overlay paints for the current theme. The light OSM tiles are white, so the
|
||||
* dark-theme amber/grey overlays would vanish; light theme swaps them for dark, high-contrast colors.
|
||||
* dark-theme amber/gray overlays would vanish; light theme swaps them for dark, high-contrast colors.
|
||||
* Must run before the set*() overlay builders on each frame, as several icons cache the paint color.
|
||||
*/
|
||||
private fun applyMapColors(isLightUi: Boolean) {
|
||||
@@ -177,11 +178,16 @@ fun MapDestination() {
|
||||
viewModel.onAction(MapAction.SetVisible(false))
|
||||
}
|
||||
}
|
||||
MapScreen(uiState, viewModel::onAction, mapView)
|
||||
MapScreen(uiState, viewModel.timeNow, viewModel::onAction, mapView)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView: MapView) {
|
||||
private fun MapScreen(
|
||||
uiState: MapState,
|
||||
timeNow: StateFlow<Long>,
|
||||
onAction: (MapAction) -> Unit,
|
||||
mapView: MapView
|
||||
) {
|
||||
val rotateMod = Modifier.rotate(180f)
|
||||
val timeString = uiState.mapData?.aosTime ?: "00:00:00"
|
||||
val isTimeAos = uiState.mapData?.isTimeAos ?: true
|
||||
@@ -198,12 +204,12 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
||||
TimerRow(timeString = timeString, isTimeAos = isTimeAos)
|
||||
IconCard(action = { onAction(MapAction.SelectNext) }, resId = R.drawable.ic_arrow)
|
||||
}
|
||||
TopBar { NextPassRow(pass = uiState.orbitalPass, isUtc = uiState.isUtc) }
|
||||
TopBar { NextPassRow(pass = uiState.orbitalPass, timeNow = timeNow) }
|
||||
} else {
|
||||
TopBar {
|
||||
IconCard(action = { onAction(MapAction.SelectPrev) }, resId = R.drawable.ic_arrow, modifier = rotateMod)
|
||||
TimerRow(timeString = timeString, isTimeAos = isTimeAos)
|
||||
NextPassRow(pass = uiState.orbitalPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
|
||||
NextPassRow(pass = uiState.orbitalPass, timeNow = timeNow, modifier = Modifier.weight(1f))
|
||||
IconCard(action = { onAction(MapAction.SelectNext) }, resId = R.drawable.ic_arrow)
|
||||
}
|
||||
}
|
||||
@@ -408,7 +414,7 @@ private fun setPositions(
|
||||
if (isLonVisible) visibleSats.add(satellite to geoPos)
|
||||
}
|
||||
} else {
|
||||
for (entry in posMap) visibleSats.add(entry.key to entry.value)
|
||||
for ((key, value) in posMap) visibleSats.add(key to value)
|
||||
}
|
||||
|
||||
val showLabels = visibleSats.size <= LABEL_LIMIT
|
||||
@@ -507,7 +513,7 @@ private fun setFootprint(rangeCircle: List<GeoPos>, mapView: MapView) {
|
||||
var pts = footprintPoints
|
||||
if (pts == null || pts.size != rangeCircle.size) {
|
||||
pts = ArrayList(rangeCircle.size)
|
||||
for (gp in rangeCircle) pts.add(GeoPoint(gp.latitude, gp.longitude))
|
||||
for ((latitude, longitude) in rangeCircle) pts.add(GeoPoint(latitude, longitude))
|
||||
footprintPoints = pts
|
||||
} else {
|
||||
for (i in rangeCircle.indices) {
|
||||
|
||||
@@ -77,6 +77,9 @@ class MapViewModel(
|
||||
private val isScreenVisible = MutableStateFlow(true)
|
||||
val uiState: StateFlow<MapState> = _uiState
|
||||
|
||||
private val _timeNow = MutableStateFlow(System.currentTimeMillis())
|
||||
val timeNow: StateFlow<Long> = _timeNow
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
settingsRepo.otherSettings.collectLatest { settings ->
|
||||
@@ -106,7 +109,7 @@ class MapViewModel(
|
||||
private fun selectDefaultSatellite(catnum: Int) {
|
||||
if (allSatellites.isNotEmpty()) {
|
||||
if (catnum == -1) {
|
||||
allPasses.find { pass -> pass.progress < 100 && !pass.isDeepSpace }
|
||||
allPasses.find { pass -> pass.progress < 1 && !pass.isDeepSpace }
|
||||
?.let { pass -> selectSatellite(pass.orbitalObject) }
|
||||
} else {
|
||||
allSatellites.find { it.data.catnum == catnum }?.let { selectSatellite(it) }
|
||||
@@ -148,7 +151,9 @@ class MapViewModel(
|
||||
while (isActive) {
|
||||
// Suspends while the map is off-screen instead of predicting into the void
|
||||
isScreenVisible.first { it }
|
||||
dateNow.time = System.currentTimeMillis()
|
||||
val timeNow = System.currentTimeMillis()
|
||||
_timeNow.value = timeNow
|
||||
dateNow.time = timeNow
|
||||
updateMapState(orbitalObject, allSatellites, stationPos, dateNow)
|
||||
delay(effectiveRate.milliseconds)
|
||||
}
|
||||
@@ -189,8 +194,8 @@ class MapViewModel(
|
||||
}
|
||||
}
|
||||
for ((localPositions, localSelectedPos) in deferreds.awaitAll()) {
|
||||
for (pair in localPositions) {
|
||||
positionsMap[pair.first] = pair.second
|
||||
for ((first, second) in localPositions) {
|
||||
positionsMap[first] = second
|
||||
}
|
||||
if (localSelectedPos != null) {
|
||||
selectedSatPos = localSelectedPos
|
||||
|
||||
+132
-132
@@ -17,11 +17,6 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.passes
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -37,7 +32,6 @@ import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
@@ -47,6 +41,7 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
|
||||
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -54,6 +49,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -69,18 +65,20 @@ import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
||||
import com.rtbishop.look4sat.core.presentation.PassTimerChip
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
||||
import com.rtbishop.look4sat.core.presentation.SwipeableItem
|
||||
import com.rtbishop.look4sat.core.presentation.TimerRow
|
||||
import com.rtbishop.look4sat.core.presentation.SearchBar
|
||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||
import com.rtbishop.look4sat.core.presentation.WhatsNewDialog
|
||||
import com.rtbishop.look4sat.core.presentation.displayLocale
|
||||
import com.rtbishop.look4sat.core.presentation.elevationColor
|
||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
@Composable
|
||||
@@ -89,12 +87,13 @@ fun PassesDestination(navigateToRadar: (Int, Long) -> Unit) {
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val viewModel: PassesViewModel = viewModel(factory = PassesViewModel.factory(container))
|
||||
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
|
||||
PassesScreen(uiState, viewModel::onAction, navigateToRadar)
|
||||
PassesScreen(uiState, viewModel.timeNow, viewModel::onAction, navigateToRadar)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PassesScreen(
|
||||
uiState: PassesState,
|
||||
timeNow: StateFlow<Long>,
|
||||
onAction: (PassesAction) -> Unit,
|
||||
navigateToRadar: (Int, Long) -> Unit
|
||||
) {
|
||||
@@ -145,10 +144,13 @@ private fun PassesScreen(
|
||||
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
|
||||
},
|
||||
topInfo = {
|
||||
TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos)
|
||||
SearchBar(
|
||||
onQueryChange = { onAction(PassesAction.SearchFor(it)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
},
|
||||
bottomInfo = {
|
||||
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
|
||||
NextPassRow(pass = uiState.nextPass, timeNow = timeNow)
|
||||
},
|
||||
endAction = {
|
||||
IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter)
|
||||
@@ -162,14 +164,14 @@ private fun PassesScreen(
|
||||
passes = uiState.itemsList,
|
||||
groupedPasses = uiState.groupedPasses,
|
||||
sunTimes = uiState.sunTimes,
|
||||
focusedCatNum = uiState.focusedCatNum,
|
||||
isSearching = uiState.searchQuery.isNotBlank(),
|
||||
timeNow = timeNow,
|
||||
navigateToRadar = navigateToRadar,
|
||||
onAction = onAction
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun PassesList(
|
||||
isRefreshing: Boolean,
|
||||
@@ -177,7 +179,8 @@ private fun PassesList(
|
||||
passes: List<OrbitalPass>,
|
||||
groupedPasses: Map<String, List<OrbitalPass>>,
|
||||
sunTimes: Map<String, Pair<String, String>>,
|
||||
focusedCatNum: Int?,
|
||||
isSearching: Boolean,
|
||||
timeNow: StateFlow<Long>,
|
||||
navigateToRadar: (Int, Long) -> Unit,
|
||||
onAction: (PassesAction) -> Unit
|
||||
) {
|
||||
@@ -199,38 +202,28 @@ private fun PassesList(
|
||||
}
|
||||
) {
|
||||
if (passes.isEmpty()) {
|
||||
EmptyListCard(message = stringResource(R.string.pass_empty_list_message))
|
||||
val emptyMessage = if (isSearching) {
|
||||
stringResource(R.string.pass_empty_search_message)
|
||||
} else {
|
||||
stringResource(R.string.pass_empty_list_message)
|
||||
}
|
||||
EmptyListCard(message = emptyMessage)
|
||||
} else {
|
||||
AnimatedContent(
|
||||
targetState = focusedCatNum,
|
||||
transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) },
|
||||
label = "PassesFocusTransition"
|
||||
) { targetFocus ->
|
||||
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp), modifier = Modifier.fillMaxSize()) {
|
||||
for ((dateLabel, dayPasses) in groupedPasses) {
|
||||
val headerVisible = targetFocus == null || dayPasses.any { it.catNum == targetFocus }
|
||||
if (headerVisible) {
|
||||
stickyHeader(key = "header_$dateLabel") {
|
||||
val (rise, set) = sunTimes[dateLabel] ?: ("--:--" to "--:--")
|
||||
StickyDateHeader(label = dateLabel, sunriseTime = rise, sunsetTime = set)
|
||||
}
|
||||
}
|
||||
val visiblePasses = if (targetFocus == null) dayPasses
|
||||
else dayPasses.filter { it.catNum == targetFocus }
|
||||
items(items = visiblePasses, key = { item -> item.catNum + item.aosTime }) { pass ->
|
||||
SwipeableItem(
|
||||
onSwipeRight = { onAction(PassesAction.FocusCatNum(pass.catNum)) },
|
||||
onSwipeLeft = { onAction(PassesAction.ClearFocus) }
|
||||
) {
|
||||
PassItem(
|
||||
pass = pass,
|
||||
navigateToRadar = navigateToRadar,
|
||||
modifier = Modifier.animateItem(),
|
||||
isVerticalLayout = isVerticalLayout,
|
||||
isUtc = isUtc
|
||||
)
|
||||
}
|
||||
}
|
||||
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp), modifier = Modifier.fillMaxSize()) {
|
||||
for ((dateLabel, dayPasses) in groupedPasses) {
|
||||
stickyHeader(key = "header_$dateLabel") {
|
||||
val (rise, set) = sunTimes[dateLabel] ?: ("--:--" to "--:--")
|
||||
StickyDateHeader(label = dateLabel, sunriseTime = rise, sunsetTime = set)
|
||||
}
|
||||
items(items = dayPasses, key = { item -> "${item.catNum}_${item.aosTime}" }) { pass ->
|
||||
PassItem(
|
||||
pass = pass,
|
||||
navigateToRadar = navigateToRadar,
|
||||
timeNow = timeNow,
|
||||
modifier = Modifier.animateItem(),
|
||||
isVerticalLayout = isVerticalLayout,
|
||||
isUtc = isUtc
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -278,65 +271,88 @@ private fun StickyDateHeader(label: String, sunriseTime: String, sunsetTime: Str
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayLocale(): Locale {
|
||||
val locale = Locale.getDefault()
|
||||
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun DeepSpacePassPreview() {
|
||||
val data = OrbitalData("Satellite", 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 45000, 0.0)
|
||||
val satellite = DeepSpaceObject(data)
|
||||
val pass = OrbitalPass(1L, 180.0, 10L, 360.0, 36650, 45.0, satellite, 0.5f)
|
||||
MainTheme { PassItem(pass = pass, { _, _ -> }) }
|
||||
val pass = OrbitalPass(1L, 180.0, 10L, 180.0, 36650, 45.0, satellite, 0.5f)
|
||||
MainTheme { PassItem(pass = pass, { _, _ -> }, MutableStateFlow(0L)) }
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun NearEarthPassPreview() {
|
||||
private fun UpcomingPassPreview() {
|
||||
val data = OrbitalData("Satellite", 0.0, 15.0, 0.0, 0.0, 0.0, 0.0, 0.0, 45000, 0.0)
|
||||
val satellite = NearEarthObject(data)
|
||||
val pass = OrbitalPass(1L, 180.0, 10L, 360.0, 36650, 45.0, satellite, 0.5f)
|
||||
MainTheme { PassItem(pass = pass, { _, _ -> }) }
|
||||
val aosTime = 1767225600000L
|
||||
val pass = OrbitalPass(aosTime, 180.0, aosTime + 702000L, 360.0, 36650, 45.0, NearEarthObject(data), 0f)
|
||||
MainTheme { PassItem(pass = pass, { _, _ -> }, MutableStateFlow(aosTime - 866000L)) }
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun ActivePassPreview() {
|
||||
val data = OrbitalData("Satellite", 0.0, 15.0, 0.0, 0.0, 0.0, 0.0, 0.0, 45000, 0.0)
|
||||
val aosTime = 1767225600000L
|
||||
val pass = OrbitalPass(aosTime, 180.0, aosTime + 702000L, 360.0, 36650, 45.0, NearEarthObject(data), 0.4f)
|
||||
MainTheme { PassItem(pass = pass, { _, _ -> }, MutableStateFlow(aosTime + 280000L)) }
|
||||
}
|
||||
|
||||
/** Elapsed fraction of the pass. Collects the clock itself to keep ticks off the text rows. */
|
||||
@Composable
|
||||
private fun PassProgressBar(pass: OrbitalPass, timeNow: StateFlow<Long>, modifier: Modifier = Modifier) {
|
||||
val now by timeNow.collectAsStateWithLifecycle()
|
||||
LinearProgressIndicator(
|
||||
progress = { pass.progressAt(now) },
|
||||
drawStopIndicator = {},
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PassItem(
|
||||
pass: OrbitalPass,
|
||||
navigateToRadar: (Int, Long) -> Unit,
|
||||
timeNow: StateFlow<Long>,
|
||||
modifier: Modifier = Modifier,
|
||||
isVerticalLayout: Boolean = true,
|
||||
isUtc: Boolean = false
|
||||
) {
|
||||
val passSatId = stringResource(id = R.string.pass_satId, pass.catNum)
|
||||
val horizontalPadding = if (isVerticalLayout) 6.dp else 10.dp
|
||||
val horizontalPadding = if (isVerticalLayout) 6.dp else 12.dp
|
||||
val timeZone = remember(isUtc) {
|
||||
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||
}
|
||||
val sdfTime = remember(isUtc) {
|
||||
SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
|
||||
}
|
||||
val aosLosFormat = stringResource(id = R.string.pass_aosLos)
|
||||
val aosTimeStr = remember(pass.aosTime, isUtc) { sdfTime.format(Date(pass.aosTime)) }
|
||||
val losTimeStr = remember(pass.losTime, isUtc) { sdfTime.format(Date(pass.losTime)) }
|
||||
val durationStr = remember(pass.aosTime, pass.losTime) {
|
||||
val seconds = (pass.losTime - pass.aosTime) / 1000
|
||||
"${seconds / 60}m ${seconds % 60}s"
|
||||
val aosLosStr = remember(pass.aosAzimuth, pass.losAzimuth, aosLosFormat) {
|
||||
String.format(displayLocale(), aosLosFormat, pass.aosAzimuth.toInt() % 360, pass.losAzimuth.toInt() % 360)
|
||||
}
|
||||
// do not delete
|
||||
// val durationStr = remember(pass.aosTime, pass.losTime) {
|
||||
// val seconds = (pass.losTime - pass.aosTime) / 1000
|
||||
// "${seconds / 60}m ${seconds % 60}s"
|
||||
// }
|
||||
// do not delete
|
||||
// val progressTint = MaterialTheme.colorScheme.secondary.copy(alpha = 0.08f)
|
||||
// val progressFraction = if (pass.isDeepSpace) 0f else pass.progress.coerceIn(0f, 1f)
|
||||
|
||||
Column(
|
||||
modifier = modifier.clickable { navigateToRadar(pass.catNum, pass.aosTime) }
|
||||
) {
|
||||
Column(modifier = modifier.clickable { navigateToRadar(pass.catNum, pass.aosTime) }) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(color = MaterialTheme.colorScheme.surface)
|
||||
.padding(horizontal = horizontalPadding, vertical = 4.dp)
|
||||
// do not delete
|
||||
// .drawBehind { drawRect(progressTint, size = Size(size.width * progressFraction, size.height)) }
|
||||
.padding(horizontal = horizontalPadding, vertical = 6.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "$passSatId - ",
|
||||
text = "${stringResource(R.string.pass_satId, pass.catNum)} - ",
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
@@ -350,84 +366,68 @@ private fun PassItem(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
val elevColor = elevationColor(pass.maxElevation)
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_elevation),
|
||||
contentDescription = null,
|
||||
tint = elevColor,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "${pass.maxElevation}°",
|
||||
color = elevColor
|
||||
)
|
||||
PassTimerChip(pass = pass, timeNow = timeNow)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (pass.isDeepSpace) {
|
||||
Text(
|
||||
text = stringResource(R.string.pass_deep_space),
|
||||
fontSize = 15.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
} else {
|
||||
Text(text = durationStr, fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
Text(
|
||||
text = aosLosStr,
|
||||
fontSize = 15.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val elevColor = elevationColor(pass.maxElevation)
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_elevation),
|
||||
contentDescription = null,
|
||||
tint = elevColor,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "${pass.maxElevation}°",
|
||||
color = elevColor
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "${pass.altitude} km",
|
||||
fontSize = 15.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.End,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
// A DeepSpace object has no AOS/LOS to count between, so it drops the progress row
|
||||
if (!pass.isDeepSpace) {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(text = "${pass.altitude} km", fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = R.string.pass_aosLos,
|
||||
pass.aosAzimuth.toInt(),
|
||||
pass.losAzimuth.toInt()
|
||||
),
|
||||
text = aosTimeStr,
|
||||
fontSize = 15.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
PassProgressBar(
|
||||
pass = pass,
|
||||
timeNow = timeNow,
|
||||
modifier = Modifier.fillMaxWidth(0.75f)
|
||||
)
|
||||
Text(
|
||||
text = losTimeStr,
|
||||
fontSize = 15.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
val defaultTime = " - - : - - "
|
||||
Text(
|
||||
text = if (pass.isDeepSpace) defaultTime else aosTimeStr,
|
||||
fontSize = 15.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
LinearProgressIndicator(
|
||||
progress = { if (pass.isDeepSpace) 100f else pass.progress },
|
||||
drawStopIndicator = {},
|
||||
modifier = Modifier.fillMaxWidth(0.75f)
|
||||
)
|
||||
Text(
|
||||
text = if (pass.isDeepSpace) defaultTime else losTimeStr,
|
||||
fontSize = 15.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(thickness = 2.dp, color = MaterialTheme.colorScheme.background)
|
||||
}
|
||||
|
||||
@@ -25,8 +25,7 @@ data class PassesState(
|
||||
val isRefreshing: Boolean = true,
|
||||
val isUtc: Boolean = false,
|
||||
val nextPass: OrbitalPass,
|
||||
val nextTime: String = "00:00:00",
|
||||
val isNextTimeAos: Boolean = true,
|
||||
val searchQuery: String = "",
|
||||
val hours: Int = 24,
|
||||
val elevation: Double = 16.0,
|
||||
val lowElevation: Double = 16.0,
|
||||
@@ -39,8 +38,7 @@ data class PassesState(
|
||||
val itemsList: List<OrbitalPass> = emptyList(),
|
||||
val groupedPasses: Map<String, List<OrbitalPass>> = emptyMap(),
|
||||
val shouldSeeWhatsNew: Boolean = false,
|
||||
val sunTimes: Map<String, Pair<String, String>> = emptyMap(),
|
||||
val focusedCatNum: Int? = null
|
||||
val sunTimes: Map<String, Pair<String, String>> = emptyMap()
|
||||
)
|
||||
|
||||
sealed interface PassesAction {
|
||||
@@ -59,6 +57,5 @@ sealed interface PassesAction {
|
||||
data object RefreshPasses : PassesAction
|
||||
data object TogglePassesDialog : PassesAction
|
||||
data object ToggleRadiosDialog : PassesAction
|
||||
data class FocusCatNum(val catNum: Int) : PassesAction
|
||||
data object ClearFocus : PassesAction
|
||||
data class SearchFor(val query: String) : PassesAction
|
||||
}
|
||||
+87
-64
@@ -27,8 +27,6 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.utility.round
|
||||
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
||||
import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -46,6 +44,14 @@ import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/** Inputs that together determine the visible pass list; a change in any one regroups it. */
|
||||
private data class PassesInput(
|
||||
val passes: List<OrbitalPass>,
|
||||
val isUtc: Boolean,
|
||||
val showDeepSpace: Boolean,
|
||||
val query: String
|
||||
)
|
||||
|
||||
class PassesViewModel(
|
||||
private val satelliteRepo: ISatelliteRepo,
|
||||
private val settingsRepo: ISettingsRepo
|
||||
@@ -70,6 +76,16 @@ class PassesViewModel(
|
||||
)
|
||||
val uiState: StateFlow<PassesState> = _uiState
|
||||
|
||||
private val _timeNow = MutableStateFlow(System.currentTimeMillis())
|
||||
|
||||
private val searchQuery = MutableStateFlow("")
|
||||
|
||||
/**
|
||||
* Ticks once per second, kept separate from [uiState] so that a clock update only
|
||||
* recomposes the countdown chips and progress bars instead of the whole screen.
|
||||
*/
|
||||
val timeNow: StateFlow<Long> = _timeNow
|
||||
|
||||
init {
|
||||
// Refresh indicator: mirrors the repo's isCalculating state
|
||||
viewModelScope.launch {
|
||||
@@ -95,36 +111,50 @@ class PassesViewModel(
|
||||
_uiState.update { it.copy(modes = modes) }
|
||||
}
|
||||
}
|
||||
// Tick loop: restarts on passes change, UTC/DeepSpace changes. Grouping/sun-time
|
||||
// computations run once per restart, progress/countdown are calculated every second.
|
||||
// Tick loop, gated on having an observer so it stops while the screen is in the
|
||||
// background. Restarts on passes, UTC or DeepSpace changes. Sun times and grouping are
|
||||
// computed only when the pass list itself changes; the clock alone never rebuilds them.
|
||||
viewModelScope.launch {
|
||||
combine(
|
||||
satelliteRepo.passes,
|
||||
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged(),
|
||||
settingsRepo.passesSettings.map { it.showDeepSpace }.distinctUntilChanged()
|
||||
) { passes, isUtc, showDeepSpace -> Triple(passes, isUtc, showDeepSpace) }
|
||||
.collectLatest { (allPasses, isUtc, showDeepSpace) ->
|
||||
val filtered = if (showDeepSpace) allPasses
|
||||
else allPasses.filter { !it.isDeepSpace }
|
||||
// Expensive: recompute sun times once per items/UTC change, not every second
|
||||
val sunTimes = computeSunTimes(filtered, isUtc)
|
||||
_uiState.update { it.copy(sunTimes = sunTimes) }
|
||||
while (isActive) {
|
||||
val timeNow = System.currentTimeMillis()
|
||||
val processed = computePassProgress(filtered, timeNow)
|
||||
val grouped = groupPasses(processed, isUtc)
|
||||
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
itemsList = processed,
|
||||
groupedPasses = grouped,
|
||||
nextPass = nextPass,
|
||||
nextTime = nextTime,
|
||||
isNextTimeAos = isAos
|
||||
)
|
||||
}
|
||||
delay(1000.milliseconds)
|
||||
_uiState.subscriptionCount
|
||||
.map { count -> count > 0 }
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { isObserved ->
|
||||
if (!isObserved) return@collectLatest
|
||||
combine(
|
||||
satelliteRepo.passes,
|
||||
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged(),
|
||||
settingsRepo.passesSettings.map { it.showDeepSpace }.distinctUntilChanged(),
|
||||
searchQuery
|
||||
) { passes, isUtc, showDeepSpace, query ->
|
||||
PassesInput(passes, isUtc, showDeepSpace, query)
|
||||
}
|
||||
.collectLatest { (allPasses, isUtc, showDeepSpace, query) ->
|
||||
val filtered = filterByQuery(
|
||||
if (showDeepSpace) allPasses else allPasses.filter { !it.isDeepSpace },
|
||||
query
|
||||
)
|
||||
val sunTimes = computeSunTimes(filtered, isUtc)
|
||||
var live: List<OrbitalPass>? = null
|
||||
while (this@launch.isActive) {
|
||||
val timeNow = System.currentTimeMillis()
|
||||
_timeNow.value = timeNow
|
||||
val current = filtered.filter { it.isDeepSpace || timeNow < it.losTime }
|
||||
// Only a pass dropping off the list warrants a regroup
|
||||
if (live == null || current.size != live.size) {
|
||||
live = current
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
itemsList = current,
|
||||
groupedPasses = groupPasses(current, isUtc),
|
||||
sunTimes = sunTimes
|
||||
)
|
||||
}
|
||||
}
|
||||
val nextPass = resolveNextPass(current, timeNow)
|
||||
_uiState.update { it.copy(nextPass = nextPass) }
|
||||
delay(1000.milliseconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,8 +179,10 @@ class PassesViewModel(
|
||||
_uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) }
|
||||
PassesAction.ToggleRadiosDialog ->
|
||||
_uiState.update { it.copy(isRadiosDialogShown = !it.isRadiosDialogShown) }
|
||||
is PassesAction.FocusCatNum -> _uiState.update { it.copy(focusedCatNum = action.catNum) }
|
||||
PassesAction.ClearFocus -> _uiState.update { it.copy(focusedCatNum = null) }
|
||||
is PassesAction.SearchFor -> {
|
||||
searchQuery.value = action.query
|
||||
_uiState.update { it.copy(searchQuery = action.query) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,43 +240,34 @@ class PassesViewModel(
|
||||
return ordered
|
||||
}
|
||||
|
||||
/** Computes live progress for each pass, filtering out expired ones. */
|
||||
private fun computePassProgress(passList: List<OrbitalPass>, time: Long): List<OrbitalPass> {
|
||||
val result = ArrayList<OrbitalPass>(passList.size)
|
||||
for (pass in passList) {
|
||||
if (!pass.isDeepSpace && time > pass.aosTime) {
|
||||
val deltaNow = time.minus(pass.aosTime).toFloat()
|
||||
val deltaTotal = pass.losTime.minus(pass.aosTime).toFloat()
|
||||
val newProgress = (deltaNow / deltaTotal).round(2)
|
||||
if (newProgress >= 1.0f) continue
|
||||
if (newProgress != pass.progress) {
|
||||
result.add(pass.copy(progress = newProgress))
|
||||
} else {
|
||||
result.add(pass)
|
||||
}
|
||||
} else {
|
||||
result.add(pass)
|
||||
}
|
||||
}
|
||||
return result
|
||||
/** Resolves the next upcoming pass, falling back to the one currently in progress. */
|
||||
private fun resolveNextPass(passes: List<OrbitalPass>, timeNow: Long): OrbitalPass {
|
||||
val upcoming = passes.firstOrNull { it.aosTime > timeNow }
|
||||
if (upcoming != null) return upcoming
|
||||
return passes.lastOrNull() ?: defaultPass
|
||||
}
|
||||
|
||||
/** Resolves the next upcoming or active pass and its countdown timer. */
|
||||
private fun resolveNextPass(
|
||||
passes: List<OrbitalPass>,
|
||||
timeNow: Long
|
||||
): Triple<OrbitalPass, String, Boolean> {
|
||||
val upcoming = passes.firstOrNull { it.aosTime > timeNow }
|
||||
if (upcoming != null) {
|
||||
return Triple(upcoming, (upcoming.aosTime - timeNow).toTimerString(), true)
|
||||
/**
|
||||
* Filters passes by query, mirroring the satellite search: a numeric query matches the
|
||||
* catalog number, otherwise every space-separated token must appear in the name once both
|
||||
* sides are normalized, so "ao7" matches "AO-7 (AMSAT-OSCAR 7)".
|
||||
*/
|
||||
private fun filterByQuery(passes: List<OrbitalPass>, query: String): List<OrbitalPass> {
|
||||
if (query.isBlank()) return passes
|
||||
val catNum = query.trim().toIntOrNull()
|
||||
if (catNum != null) return passes.filter { it.catNum == catNum }
|
||||
val tokens = query.split(' ').map { normalizeForSearch(it) }.filter { it.isNotEmpty() }
|
||||
if (tokens.isEmpty()) return passes
|
||||
return passes.filter { pass ->
|
||||
val normalizedName = normalizeForSearch(pass.name)
|
||||
tokens.all { normalizedName.contains(it) }
|
||||
}
|
||||
if (passes.isNotEmpty()) {
|
||||
val lastPass = passes.last()
|
||||
return Triple(lastPass, (lastPass.losTime - timeNow).toTimerString(), false)
|
||||
}
|
||||
return Triple(defaultPass, "00:00:00", true)
|
||||
}
|
||||
|
||||
/** Lowercases and strips all non-alphanumeric chars for fuzzy matching. */
|
||||
private fun normalizeForSearch(text: String): String =
|
||||
text.lowercase().filter { it.isLetterOrDigit() }
|
||||
|
||||
private fun applyFilter(
|
||||
hoursAhead: Int,
|
||||
minElevation: Double,
|
||||
|
||||
@@ -19,6 +19,7 @@ package com.rtbishop.look4sat.feature.radar
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
@@ -75,6 +76,7 @@ import com.rtbishop.look4sat.core.presentation.formatFrequency
|
||||
import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private enum class RadarPage(val title: String) {
|
||||
@@ -99,17 +101,49 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted -> viewModel.onAction(RadarAction.SstvPermissionResult(granted)) }
|
||||
RadarScreen(uiState, viewModel::onAction, navigateUp, requestMicPermission = {
|
||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
})
|
||||
val saveImage = {
|
||||
viewModel.onAction(
|
||||
RadarAction.SstvSaveImage(
|
||||
context.getString(R.string.sstv_image_saved),
|
||||
context.getString(R.string.sstv_image_save_failed)
|
||||
)
|
||||
)
|
||||
}
|
||||
val storagePermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
if (granted) {
|
||||
saveImage()
|
||||
} else {
|
||||
viewModel.onAction(
|
||||
RadarAction.ShowToast(context.getString(R.string.sstv_storage_permission_denied))
|
||||
)
|
||||
}
|
||||
}
|
||||
RadarScreen(
|
||||
uiState, viewModel.timeNow, viewModel::onAction, navigateUp,
|
||||
requestMicPermission = { permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) },
|
||||
requestSaveImage = {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) !=
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
storagePermissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
} else {
|
||||
saveImage()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RadarScreen(
|
||||
uiState: RadarState,
|
||||
timeNow: StateFlow<Long>,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
navigateUp: () -> Unit,
|
||||
requestMicPermission: () -> Unit
|
||||
requestMicPermission: () -> Unit,
|
||||
requestSaveImage: () -> Unit
|
||||
) {
|
||||
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
||||
val addToCalendar: () -> Unit = {
|
||||
@@ -129,22 +163,22 @@ private fun RadarScreen(
|
||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
||||
}
|
||||
TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
|
||||
TopBar { NextPassRow(pass = upcomingPass, timeNow = timeNow) }
|
||||
} else {
|
||||
TopBar {
|
||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
||||
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
|
||||
NextPassRow(pass = upcomingPass, timeNow = timeNow, modifier = Modifier.weight(1f))
|
||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
||||
}
|
||||
}
|
||||
if (isVertical) {
|
||||
RadarCard(uiState, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, requestMicPermission, requestSaveImage, Modifier.weight(1f))
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
RadarCard(uiState, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, requestMicPermission, requestSaveImage, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,6 +189,7 @@ private fun PagerCard(
|
||||
uiState: RadarState,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
requestMicPermission: () -> Unit,
|
||||
requestSaveImage: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val hasCalculatorPage = remember(uiState.transceivers.transmitters) {
|
||||
@@ -209,7 +244,8 @@ private fun PagerCard(
|
||||
sstv = uiState.sstv,
|
||||
dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) },
|
||||
onAction = onAction,
|
||||
requestMicPermission = requestMicPermission
|
||||
requestMicPermission = requestMicPermission,
|
||||
requestSaveImage = requestSaveImage
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,11 +94,19 @@ sealed interface RadarAction {
|
||||
// SSTV actions
|
||||
data object SstvStartRecording : RadarAction
|
||||
data object SstvStopRecording : RadarAction
|
||||
data object SstvSaveImage : RadarAction
|
||||
data class SstvSaveImage(val successMessage: String, val failureMessage: String) : RadarAction
|
||||
data class ShowToast(val message: String) : RadarAction
|
||||
data object SstvReset : RadarAction
|
||||
data class SstvSelectMode(val modeName: String) : RadarAction
|
||||
data class SstvPermissionResult(val granted: Boolean) : RadarAction
|
||||
|
||||
// Calculator actions
|
||||
data class ChangeCalculatorOffset(val offsetKHz: String) : RadarAction
|
||||
|
||||
/**
|
||||
* The transponder currently driving the calculator (null when the satellite has
|
||||
* no linear transponder). The per-satellite offset follows it instead of the
|
||||
* transceiver selection, so it loads and saves even when nothing is selected.
|
||||
*/
|
||||
data class CalculatorTransponderChanged(val catnum: Int?) : RadarAction
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.radar
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
@@ -50,6 +51,10 @@ import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class RadarViewModel(
|
||||
@@ -70,6 +75,7 @@ class RadarViewModel(
|
||||
private var compassOffset = settingsRepo.otherSettings.value.radarCompassOffset
|
||||
private var compassOffsetElev = settingsRepo.otherSettings.value.radarCompassOffsetElev
|
||||
private var transponders: List<SatRadio> = emptyList()
|
||||
private var calculatorCatnum: Int? = null
|
||||
private var sstvDecoder: SstvDecoder? = null
|
||||
private var sstvRecordingJob: Job? = null
|
||||
private var sensorCollectionJob: Job? = null
|
||||
@@ -90,6 +96,9 @@ class RadarViewModel(
|
||||
)
|
||||
val uiState: StateFlow<RadarState> = _uiState
|
||||
|
||||
private val _timeNow = MutableStateFlow(System.currentTimeMillis())
|
||||
val timeNow: StateFlow<Long> = _timeNow
|
||||
|
||||
init {
|
||||
collectSettingsChanges() // also handles initial sensor subscription
|
||||
collectStationPositionChanges()
|
||||
@@ -197,13 +206,16 @@ class RadarViewModel(
|
||||
|
||||
private suspend fun tickPass(pass: OrbitalPass, allRadios: List<SatRadio>) {
|
||||
val timeNow = System.currentTimeMillis()
|
||||
_timeNow.value = timeNow
|
||||
val pos = satelliteRepo.getPosition(pass.orbitalObject, stationPos, timeNow)
|
||||
|
||||
// Recompute celestial positions at most once per minute (they move very slowly)
|
||||
if (timeNow - lastCelestialUpdateMs >= 60_000L) {
|
||||
val isFirstUpdate = lastCelestialUpdateMs == 0L
|
||||
cachedSunPos = CelestialComputer.getSunPosition(stationPos, timeNow)
|
||||
cachedMoonPos = CelestialComputer.getMoonPosition(stationPos, timeNow)
|
||||
lastCelestialUpdateMs = timeNow
|
||||
if (isFirstUpdate) logCelestialPositions(timeNow)
|
||||
}
|
||||
|
||||
val (time, isAos) = computeTimer(pass.isDeepSpace, pass.aosTime, pass.losTime, timeNow)
|
||||
@@ -217,6 +229,23 @@ class RadarViewModel(
|
||||
sendPassData(pos)
|
||||
}
|
||||
|
||||
// Logs the computed celestial positions once when the screen opens, and again whenever the
|
||||
// station position changes, so they can be checked against an external ephemeris.
|
||||
// Values are geometric: no atmospheric refraction is applied, unlike most online tools.
|
||||
private fun logCelestialPositions(timeMillis: Long) {
|
||||
val sun = cachedSunPos ?: return
|
||||
val moon = cachedMoonPos ?: return
|
||||
val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ROOT)
|
||||
.apply { timeZone = TimeZone.getTimeZone("UTC") }
|
||||
val tag = "RadarCelestial"
|
||||
Log.i(tag, "Celestial check at ${formatter.format(Date(timeMillis))} UTC")
|
||||
Log.i(tag, "Observer: ${stationPos.latitude.fmt()}, ${stationPos.longitude.fmt()}, ${stationPos.altitude.fmt()}m")
|
||||
Log.i(tag, "Sun: azimuth=${sun.azimuth.fmt()}° elevation=${sun.elevation.fmt()}°")
|
||||
Log.i(tag, "Moon: azimuth=${moon.azimuth.fmt()}° elevation=${moon.elevation.fmt()}°")
|
||||
}
|
||||
|
||||
private fun Double.fmt(): String = String.format(Locale.ROOT, "%.3f", this)
|
||||
|
||||
private fun collectRadioTrackingState() {
|
||||
viewModelScope.launch {
|
||||
trackingService.state.collect { svc ->
|
||||
@@ -260,16 +289,8 @@ class RadarViewModel(
|
||||
// Compute toggle state before the update so we don't read post-update value
|
||||
val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid
|
||||
val newUuid = if (isTogglingOff) null else action.uuid
|
||||
// Load the saved offset for the newly selected satellite
|
||||
val offsetKHz = if (!isTogglingOff) {
|
||||
transponders.find { it.uuid == action.uuid }
|
||||
?.catnum?.let { settingsRepo.getSatelliteOffset(it) } ?: ""
|
||||
} else ""
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
transceivers = it.transceivers.copy(selectedUuid = newUuid),
|
||||
calculatorOffsetKHz = offsetKHz
|
||||
)
|
||||
it.copy(transceivers = it.transceivers.copy(selectedUuid = newUuid))
|
||||
}
|
||||
// Only update the tracking service when selecting a different transponder to
|
||||
// avoid resetting a user-adjusted TX base on re-expand
|
||||
@@ -300,14 +321,19 @@ class RadarViewModel(
|
||||
}
|
||||
RadarAction.SstvStartRecording -> startSstvRecording()
|
||||
RadarAction.SstvStopRecording -> stopSstvRecording()
|
||||
RadarAction.SstvSaveImage -> {
|
||||
is RadarAction.ShowToast -> showToast(action.message)
|
||||
is RadarAction.SstvSaveImage -> {
|
||||
if (_uiState.value.sstv.isSaving) return
|
||||
val frame = _uiState.value.sstv.currentFrame ?: return
|
||||
val pixels = frame.imagePixels ?: return
|
||||
_uiState.update { it.copy(sstv = it.sstv.copy(isSaving = true)) }
|
||||
viewModelScope.launch {
|
||||
saveImage(pixels, frame.imageWidth, frame.imageHeight, frame.modeName)
|
||||
_uiState.update { it.copy(sstv = it.sstv.copy(isSaving = false)) }
|
||||
showToast("Image saved")
|
||||
try {
|
||||
val saved = saveImage(pixels, frame.imageWidth, frame.imageHeight, frame.modeName)
|
||||
showToast(if (saved) action.successMessage else action.failureMessage)
|
||||
} finally {
|
||||
_uiState.update { it.copy(sstv = it.sstv.copy(isSaving = false)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
is RadarAction.SstvSelectMode -> {
|
||||
@@ -320,7 +346,9 @@ class RadarViewModel(
|
||||
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) }
|
||||
}
|
||||
is RadarAction.ChangeCalculatorOffset -> {
|
||||
val catnum = _uiState.value.transceivers.selectedUuid?.let { uuid ->
|
||||
// Keyed by the transponder driving the calculator, so the offset saves
|
||||
// even when no transceiver is selected (or selection was toggled off)
|
||||
val catnum = calculatorCatnum ?: _uiState.value.transceivers.selectedUuid?.let { uuid ->
|
||||
transponders.find { it.uuid == uuid }?.catnum
|
||||
}
|
||||
if (catnum != null) {
|
||||
@@ -328,6 +356,11 @@ class RadarViewModel(
|
||||
}
|
||||
_uiState.update { it.copy(calculatorOffsetKHz = action.offsetKHz) }
|
||||
}
|
||||
is RadarAction.CalculatorTransponderChanged -> {
|
||||
calculatorCatnum = action.catnum
|
||||
val offsetKHz = action.catnum?.let { settingsRepo.getSatelliteOffset(it) } ?: ""
|
||||
_uiState.update { it.copy(calculatorOffsetKHz = offsetKHz) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,8 @@ internal fun SstvPage(
|
||||
sstv: SstvSubState,
|
||||
dopplerFrequency: String?,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
requestMicPermission: () -> Unit
|
||||
requestMicPermission: () -> Unit,
|
||||
requestSaveImage: () -> Unit
|
||||
) {
|
||||
if (!sstv.hasPermission) {
|
||||
Box(
|
||||
@@ -243,7 +244,7 @@ internal fun SstvPage(
|
||||
)
|
||||
// Save button — always visible, enabled when there are pixels
|
||||
IconCard(
|
||||
action = { onAction(RadarAction.SstvSaveImage) },
|
||||
action = requestSaveImage,
|
||||
resId = R.drawable.ic_save,
|
||||
enabled = sstv.currentFrame?.imagePixels != null && !sstv.isSaving
|
||||
)
|
||||
|
||||
@@ -140,6 +140,13 @@ fun CalculatorPage(
|
||||
?: calculatorTransceivers.firstOrNull()
|
||||
}
|
||||
|
||||
// Keep the per-satellite offset in sync with the transponder driving the
|
||||
// calculator: load the saved value on open/satellite change, and remember
|
||||
// its catnum so editing saves even when no transceiver is selected
|
||||
LaunchedEffect(selectedTransceiver?.uuid) {
|
||||
onAction(RadarAction.CalculatorTransponderChanged(selectedTransceiver?.catnum))
|
||||
}
|
||||
|
||||
if (selectedTransceiver == null) {
|
||||
EmptyTransceiversContent(modifier)
|
||||
return
|
||||
|
||||
+1
-57
@@ -30,26 +30,20 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
@@ -67,6 +61,7 @@ import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||
import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.SearchBar
|
||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||
@@ -191,55 +186,6 @@ private fun SatellitesScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchBar(onQueryChange: (String) -> Unit, modifier: Modifier = Modifier) {
|
||||
val currentQuery = rememberSaveable { mutableStateOf("") }
|
||||
val updateQuery = { newValue: String ->
|
||||
currentQuery.value = newValue
|
||||
onQueryChange(newValue)
|
||||
}
|
||||
ElevatedCard(modifier = modifier.height(48.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 12.dp)
|
||||
) {
|
||||
Icon(painter = painterResource(id = R.drawable.ic_search), contentDescription = null)
|
||||
BasicTextField(
|
||||
value = currentQuery.value,
|
||||
onValueChange = updateQuery,
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 12.dp),
|
||||
textStyle = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 20.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
decorationBox = { innerTextField ->
|
||||
if (currentQuery.value.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.sat_search_hint),
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 20.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
},
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface)
|
||||
)
|
||||
IconButton(onClick = { updateQuery("") }) {
|
||||
val clearCd = stringResource(R.string.sat_search_clear)
|
||||
Icon(painter = painterResource(id = R.drawable.ic_close), contentDescription = clearCd)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModeCard(modes: List<String>, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val modesText = if (modes.isEmpty()) "All" else modes.joinToString(", ")
|
||||
@@ -372,5 +318,3 @@ private fun SummaryHeader(selectedText: String, availableText: String) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "448"
|
||||
appVersionCode = "449"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.8"
|
||||
appVersionName = "4.4.9"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
@@ -25,7 +25,7 @@ compose-navigation3 = "1.2.0"
|
||||
|
||||
google-ksp = "2.3.11"
|
||||
|
||||
kotlin = "2.4.20"
|
||||
kotlin = "2.4.21"
|
||||
kotlin-coroutines = "1.11.0"
|
||||
kotlin-serialization = "1.11.0"
|
||||
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
#Sun Sep 27 11:54:01 BST 2026
|
||||
#Sun Oct 11 12:52:55 BST 2026
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
Reference in new issue
Block a user