Compare commits

...
6 Commits
26 changed files with 796 additions and 404 deletions

No files matched your search

+5
View File
@@ -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">
+3
View File
@@ -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" }
}
}
}
}
@@ -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 {
@@ -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. */
@@ -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)
}
}
@@ -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
@@ -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) }
}
}
@@ -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
@@ -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
}
@@ -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
@@ -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) {
)
}
}
+3 -3
View File
@@ -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
View File
@@ -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