mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-05 20:17:29 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1277763de |
No files matched your search
@@ -27,8 +27,6 @@ import android.view.View
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
@@ -50,10 +48,8 @@ class MainActivity : ComponentActivity() {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
observeNightFilterState()
|
||||
val settingsRepo = (applicationContext as IContainerProvider).getMainContainer().settingsRepo
|
||||
setContent {
|
||||
val otherSettings by settingsRepo.otherSettings.collectAsState()
|
||||
MainTheme(isDarkTheme = !otherSettings.stateOfLightTheme) { NavRoot() }
|
||||
MainTheme(isDarkTheme = true) { NavRoot() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-4
@@ -330,13 +330,12 @@ object IcomCivProtocol {
|
||||
|
||||
/**
|
||||
* Parse frequency + mode from a CMD_READ_FREQ reply payload.
|
||||
* Payload layout after stripping command byte: [5 freq bytes]
|
||||
* The IC-705 does not include a mode byte; if one is present it is parsed, otherwise mode is empty.
|
||||
* Payload layout after stripping command byte: [5 freq bytes] [mode byte] [filter byte]
|
||||
*/
|
||||
fun parseFreqModePayload(payload: ByteArray): Pair<Long, String>? {
|
||||
if (payload.size < 5) return null
|
||||
if (payload.size < 6) return null
|
||||
val freqHz = decodeFrequencyBcd(payload.copyOfRange(0, 5))
|
||||
val mode = if (payload.size > 5) BYTE_TO_MODE[payload[5]] ?: return null else ""
|
||||
val mode = BYTE_TO_MODE[payload[5]] ?: return null
|
||||
return freqHz to mode
|
||||
}
|
||||
|
||||
|
||||
+3
-12
@@ -35,8 +35,6 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.TimeZone
|
||||
@@ -69,9 +67,8 @@ class SatelliteRepo(
|
||||
override suspend fun initRepository() = withContext(dispatcher) {
|
||||
combine(
|
||||
settingsRepo.selectedIds,
|
||||
settingsRepo.stationPosition,
|
||||
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged()
|
||||
) { selectedIds, _, _ -> selectedIds }
|
||||
settingsRepo.stationPosition
|
||||
) { selectedIds, _ -> selectedIds }
|
||||
.collect { selectedIds ->
|
||||
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
||||
val settings = settingsRepo.passesSettings.value
|
||||
@@ -179,13 +176,7 @@ class SatelliteRepo(
|
||||
aosEndMinute: Int,
|
||||
invertAosTimeWindow: Boolean
|
||||
): Boolean {
|
||||
// Must match the timezone used to display pass times (UTC toggle vs device local)
|
||||
val tz = if (settingsRepo.otherSettings.value.stateOfUtc) {
|
||||
TimeZone.getTimeZone("UTC")
|
||||
} else {
|
||||
TimeZone.getDefault()
|
||||
}
|
||||
val offsetMillis = tz.getOffset(aosTime).toLong()
|
||||
val offsetMillis = TimeZone.getDefault().getOffset(aosTime).toLong()
|
||||
val localMillis = Math.floorMod(aosTime + offsetMillis, 24L * 60L * 60L * 1000L)
|
||||
val aosMinute = (localMillis / 60_000L).toInt()
|
||||
val inRange = if (aosStartMinute <= aosEndMinute) {
|
||||
|
||||
+2
-19
@@ -114,29 +114,12 @@ class SelectionRepo(
|
||||
/**
|
||||
* Filters items by query. Uses toIntOrNull() instead of exception-based flow,
|
||||
* and lowercases the query once up front instead of per-item.
|
||||
*
|
||||
* Fuzzy search: the query is split into space-separated tokens and every
|
||||
* token must appear in the satellite name after both sides are normalized
|
||||
* (lowercased, non-alphanumeric separators such as dashes, spaces, brackets
|
||||
* and dots stripped). This makes "ao7" match "AO-7 (AMSAT-OSCAR 7)" and
|
||||
* "iss zarya" match "ISS (ZARYA)" — exact continuous-substring matching
|
||||
* previously failed whenever the name contained a separator the query lacked.
|
||||
*/
|
||||
private fun filterByQuery(items: List<SatItem>, query: String): List<SatItem> {
|
||||
if (query.isBlank()) return items
|
||||
val catnum = query.toIntOrNull()
|
||||
if (catnum != null) return items.filter { it.catnum == catnum }
|
||||
val tokens = query.split(' ')
|
||||
.map { normalizeForSearch(it) }
|
||||
.filter { it.isNotEmpty() }
|
||||
if (tokens.isEmpty()) return items
|
||||
return items.filter { item ->
|
||||
val normalizedName = normalizeForSearch(item.name)
|
||||
tokens.all { normalizedName.contains(it) }
|
||||
}
|
||||
val lowerQuery = query.lowercase()
|
||||
return items.filter { it.name.lowercase().contains(lowerQuery) }
|
||||
}
|
||||
|
||||
/** Lowercases and strips all non-alphanumeric chars for fuzzy matching. */
|
||||
private fun normalizeForSearch(text: String): String =
|
||||
text.lowercase().filter { it.isLetterOrDigit() }
|
||||
}
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
package com.rtbishop.look4sat.core.data.framework
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class IcomCivProtocolTest {
|
||||
|
||||
private fun bytes(vararg values: Int) = ByteArray(values.size) { values[it].toByte() }
|
||||
|
||||
@Test
|
||||
fun parseFreqModePayload_ic705ReadFreqReply() {
|
||||
// Reply captured from an IC-705 to CMD 0x03: frequency only, no mode byte
|
||||
val reply = bytes(0xFE, 0xFE, 0xE0, 0xA4, 0x03, 0x60, 0x74, 0x95, 0x45, 0x01, 0xFD)
|
||||
val response = IcomCivProtocol.parseResponse(reply, IcomCivProtocol.CMD_READ_FREQ)
|
||||
assertNotNull(response)
|
||||
assertEquals(145957460L to "", IcomCivProtocol.parseFreqModePayload(response!!.payload))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseFreqModePayload_withModeByte() {
|
||||
assertEquals(
|
||||
435611000L to "USB",
|
||||
IcomCivProtocol.parseFreqModePayload(bytes(0x00, 0x10, 0x61, 0x35, 0x04, 0x01, 0x01))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseFreqModePayload_tooShort() {
|
||||
assertNull(IcomCivProtocol.parseFreqModePayload(bytes(0x60, 0x74, 0x95, 0x45)))
|
||||
}
|
||||
}
|
||||
-59
@@ -84,65 +84,6 @@ class SelectionRepoTest {
|
||||
assertEquals(listOf(true, false, false), items.map { it.isSelected })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query without separators matches name with dashes spaces brackets`() = runTest(dispatcher) {
|
||||
val repository = repoWithSearchEntries()
|
||||
val flow = repository.getEntriesFlow()
|
||||
repository.setQuery("ao7")
|
||||
val items = flow.first()
|
||||
// "ao7" is a substring of the normalized "AO-73 (FUNcube-1)" too, so a
|
||||
// fuzzy search legitimately returns both AO-7 (first) and AO-73. The key
|
||||
// guarantee is that AO-7 — unreachable before because of dashes/brackets
|
||||
// — is now found.
|
||||
assertEquals(7530, items.first().catnum)
|
||||
assertEquals(listOf(7530, 39444), items.map { it.catnum })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query without separators matches name with only dashes`() = runTest(dispatcher) {
|
||||
val repository = repoWithSearchEntries()
|
||||
val flow = repository.getEntriesFlow()
|
||||
repository.setQuery("fo29")
|
||||
assertEquals(listOf(99999), flow.first().map { it.catnum })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `space-separated tokens all must match in any order`() = runTest(dispatcher) {
|
||||
val repository = repoWithSearchEntries()
|
||||
val flow = repository.getEntriesFlow()
|
||||
repository.setQuery("zarya iss")
|
||||
assertEquals(listOf(25544), flow.first().map { it.catnum })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `partial token query matches substring`() = runTest(dispatcher) {
|
||||
val repository = repoWithSearchEntries()
|
||||
val flow = repository.getEntriesFlow()
|
||||
repository.setQuery("funcube")
|
||||
assertEquals(listOf(39444), flow.first().map { it.catnum })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unmatched query returns nothing`() = runTest(dispatcher) {
|
||||
val repository = repoWithSearchEntries()
|
||||
val flow = repository.getEntriesFlow()
|
||||
repository.setQuery("zzzznomatch")
|
||||
assertEquals(emptyList<Int>(), flow.first().map { it.catnum })
|
||||
}
|
||||
|
||||
private fun repoWithSearchEntries(): SelectionRepo {
|
||||
val localSource = FakeLocalSource(
|
||||
entries = listOf(
|
||||
SatItem(25544, "ISS (ZARYA)", false),
|
||||
SatItem(7530, "AO-7 (AMSAT-OSCAR 7)", false),
|
||||
SatItem(39444, "AO-73 (FUNcube-1)", false),
|
||||
SatItem(43803, "JO-97 (BIRDS-3)", false),
|
||||
SatItem(99999, "FO-29", false)
|
||||
)
|
||||
)
|
||||
return SelectionRepo(dispatcher, localSource, FakeSettingsRepo(selectedModes = emptyList()))
|
||||
}
|
||||
|
||||
private class FakeLocalSource(
|
||||
private val entries: List<SatItem>
|
||||
) : ILocalSource {
|
||||
|
||||
@@ -31,15 +31,4 @@ 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)
|
||||
}
|
||||
}
|
||||
+24
-59
@@ -16,31 +16,18 @@ import java.util.Locale
|
||||
/**
|
||||
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
|
||||
*
|
||||
* The full physical path:
|
||||
* 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:
|
||||
*
|
||||
* 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).
|
||||
* downlink → uplink: mapDownlinkToUplink (passband) → getUplinkFreq (Doppler)
|
||||
* uplink → downlink: mapUplinkToDownlink (passband) → getDownlinkFreq (Doppler)
|
||||
*/
|
||||
object DopplerFrequencyCalculator {
|
||||
|
||||
/**
|
||||
* Given a downlink frequency (what the user hears), compute the
|
||||
* uplink frequency the user should transmit.
|
||||
* Full path: 4 -> 3 -> 2 -> 1
|
||||
* Given a downlink frequency, compute the Doppler-corrected uplink frequency.
|
||||
* Returns null if the transponder is not a linear passband type.
|
||||
*/
|
||||
fun computeUplinkFromDownlink(
|
||||
downlinkHz: Long,
|
||||
@@ -48,23 +35,17 @@ object DopplerFrequencyCalculator {
|
||||
orbitalPos: OrbitalPos
|
||||
): Long? {
|
||||
if (!isLinearTransponder(transponder)) return null
|
||||
// 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)
|
||||
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz, transponder) ?: return null
|
||||
return orbitalPos.getUplinkFreq(baseUplink)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
*
|
||||
* The user-entered downlink frequency already includes the offset, so subtract
|
||||
* it before the inverse passband mapping. The offset lives in the satellite
|
||||
* frequency domain, hence it is removed after undoing the downlink Doppler.
|
||||
* it before mapping the downlink passband position back to the uplink.
|
||||
*/
|
||||
fun computeUplinkFromDownlinkWithOffset(
|
||||
downlinkHz: Long,
|
||||
@@ -73,20 +54,13 @@ object DopplerFrequencyCalculator {
|
||||
offsetHz: Long
|
||||
): Long? {
|
||||
if (!isLinearTransponder(transponder)) return null
|
||||
// 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)
|
||||
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz - offsetHz, transponder) ?: return null
|
||||
return orbitalPos.getUplinkFreq(baseUplink)
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an uplink frequency (what the user transmits), compute the
|
||||
* downlink frequency the user will hear.
|
||||
* Full path: 1 -> 2 -> 3 -> 4
|
||||
* Given an uplink frequency, compute the Doppler-corrected downlink frequency.
|
||||
* Returns null if the transponder is not a linear passband type.
|
||||
*/
|
||||
fun computeDownlinkFromUplink(
|
||||
uplinkHz: Long,
|
||||
@@ -94,19 +68,14 @@ object DopplerFrequencyCalculator {
|
||||
orbitalPos: OrbitalPos
|
||||
): Long? {
|
||||
if (!isLinearTransponder(transponder)) return null
|
||||
// 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)
|
||||
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null
|
||||
return orbitalPos.getDownlinkFreq(baseDownlink)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
fun computeDownlinkFromUplinkWithOffset(
|
||||
uplinkHz: Long,
|
||||
@@ -115,12 +84,8 @@ object DopplerFrequencyCalculator {
|
||||
offsetHz: Long
|
||||
): Long? {
|
||||
if (!isLinearTransponder(transponder)) return null
|
||||
// 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)
|
||||
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null
|
||||
return orbitalPos.getDownlinkFreq(baseDownlink + offsetHz)
|
||||
}
|
||||
|
||||
/** True if this transponder supports linear passband mapping. */
|
||||
|
||||
+1
-66
@@ -268,71 +268,6 @@ class DopplerFrequencyCalculatorTest {
|
||||
val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos)
|
||||
assertNotNull(roundTripDownlink)
|
||||
val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink)
|
||||
// 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)
|
||||
assertTrue("Round-trip error too large: $error", error < 10000)
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,6 @@ 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
|
||||
@@ -47,32 +46,21 @@ 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.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
|
||||
@@ -440,55 +428,6 @@ 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)
|
||||
@@ -641,30 +580,3 @@ 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) }
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -61,8 +61,8 @@ fun MainTheme(isDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable
|
||||
SideEffect {
|
||||
val window = (view.context as ComponentActivity).window
|
||||
val insetsController = WindowCompat.getInsetsController(window, view)
|
||||
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
||||
insetsController.isAppearanceLightNavigationBars = !isDarkTheme
|
||||
insetsController.isAppearanceLightStatusBars = false
|
||||
insetsController.isAppearanceLightNavigationBars = false
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
}
|
||||
|
||||
+2
-5
@@ -39,7 +39,6 @@ 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
|
||||
@@ -50,10 +49,8 @@ fun SwipeableItem(onSwipeRight: () -> Unit, onSwipeLeft: () -> Unit, content: @C
|
||||
enableDismissFromStartToEnd = true,
|
||||
enableDismissFromEndToStart = true,
|
||||
backgroundContent = {
|
||||
if (isSwiping) {
|
||||
val isSwipeRight = dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd
|
||||
SwipeBackground(isSwipeRight, willTrigger, MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
val isSwipeRight = dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd
|
||||
SwipeBackground(isSwipeRight, willTrigger, MaterialTheme.colorScheme.primary)
|
||||
},
|
||||
content = { content() },
|
||||
onDismiss = {
|
||||
|
||||
@@ -161,13 +161,10 @@
|
||||
|
||||
<string name="prefs_other_title">Otros</string>
|
||||
<string name="prefs_other_switch_utc">Hora UTC</string>
|
||||
<string name="prefs_other_switch_update">Auto-actualizar</string>
|
||||
<string name="prefs_other_switch_update">Actualización automática</string>
|
||||
<string name="prefs_other_switch_sweep">Animación radar</string>
|
||||
<string name="prefs_other_switch_sensors">Sensores</string>
|
||||
<string name="prefs_other_switch_night_mode">Filtro nocturno</string>
|
||||
<string name="prefs_other_switch_light_theme">Tema claro</string>
|
||||
<string name="prefs_other_compass_offset_az">Desfase de brújula (ácimut)</string>
|
||||
<string name="prefs_other_compass_offset_elev">Desfase de brújula (elevación)</string>
|
||||
|
||||
<string name="prefs_highlight_title">Resaltado de elevación</string>
|
||||
<string name="prefs_highlight_low">Baja < %1$d°</string>
|
||||
|
||||
@@ -161,13 +161,10 @@
|
||||
|
||||
<string name="prefs_other_title">Прочее</string>
|
||||
<string name="prefs_other_switch_utc">Время в UTC</string>
|
||||
<string name="prefs_other_switch_update">Автообновление</string>
|
||||
<string name="prefs_other_switch_update">Автообновление данных</string>
|
||||
<string name="prefs_other_switch_sweep">Анимация радара</string>
|
||||
<string name="prefs_other_switch_sensors">Сенсоры</string>
|
||||
<string name="prefs_other_switch_night_mode">Ночной фильтр</string>
|
||||
<string name="prefs_other_switch_light_theme">Светлая тема</string>
|
||||
<string name="prefs_other_compass_offset_az">Смещение компаса (азимут)</string>
|
||||
<string name="prefs_other_compass_offset_elev">Смещение компаса (элевация)</string>
|
||||
|
||||
<string name="prefs_highlight_title">Подсветка высоты</string>
|
||||
<string name="prefs_highlight_low">Низко < %1$d°</string>
|
||||
|
||||
@@ -161,13 +161,10 @@
|
||||
|
||||
<string name="prefs_other_title">වෙනත්</string>
|
||||
<string name="prefs_other_switch_utc">UTC වේලාව</string>
|
||||
<string name="prefs_other_switch_update">ස්වයං යාවත්කාලීන</string>
|
||||
<string name="prefs_other_switch_sweep">රේඩාර් පරිලෝකනය</string>
|
||||
<string name="prefs_other_switch_update">දත්ත ස්වයං යාවත්කාලීන</string>
|
||||
<string name="prefs_other_switch_sweep">Radar sweep</string>
|
||||
<string name="prefs_other_switch_sensors">සංවේදක</string>
|
||||
<string name="prefs_other_switch_night_mode">රාත්රී පෙරහන</string>
|
||||
<string name="prefs_other_switch_light_theme">ආලෝක තේමාව</string>
|
||||
<string name="prefs_other_compass_offset_az">මාලිමා අපගමනය (දිගංශය)</string>
|
||||
<string name="prefs_other_compass_offset_elev">මාලිමා අපගමනය (උත්තෝලනය)</string>
|
||||
|
||||
<string name="prefs_highlight_title">උස් උද්දීපනය</string>
|
||||
<string name="prefs_highlight_low">අඩු < %1$d°</string>
|
||||
|
||||
@@ -193,13 +193,10 @@
|
||||
|
||||
<string name="prefs_other_title">Diğer</string>
|
||||
<string name="prefs_other_switch_utc">UTC saati</string>
|
||||
<string name="prefs_other_switch_update">Otomatik güncelle</string>
|
||||
<string name="prefs_other_switch_update">Veriyi otomatik güncelle</string>
|
||||
<string name="prefs_other_switch_sweep">Radar taraması</string>
|
||||
<string name="prefs_other_switch_sensors">Sensör</string>
|
||||
<string name="prefs_other_switch_night_mode">Gece filtresi</string>
|
||||
<string name="prefs_other_switch_light_theme">Açık tema</string>
|
||||
<string name="prefs_other_compass_offset_az">Pusula sapması (azimut)</string>
|
||||
<string name="prefs_other_compass_offset_elev">Pusula sapması (yükseklik açısı)</string>
|
||||
|
||||
<string name="prefs_highlight_title">Elevasyon vurgusu</string>
|
||||
<string name="prefs_highlight_low">Düşük < %1$d°</string>
|
||||
|
||||
@@ -161,13 +161,10 @@
|
||||
|
||||
<string name="prefs_other_title">Інше</string>
|
||||
<string name="prefs_other_switch_utc">Час в UTC</string>
|
||||
<string name="prefs_other_switch_update">Автооновлення</string>
|
||||
<string name="prefs_other_switch_update">Автооновлення даних</string>
|
||||
<string name="prefs_other_switch_sweep">Анімація радара</string>
|
||||
<string name="prefs_other_switch_sensors">Сенсори</string>
|
||||
<string name="prefs_other_switch_night_mode">Нічний фільтр</string>
|
||||
<string name="prefs_other_switch_light_theme">Світла тема</string>
|
||||
<string name="prefs_other_compass_offset_az">Зміщення компаса (азимут)</string>
|
||||
<string name="prefs_other_compass_offset_elev">Зміщення компаса (піднесення)</string>
|
||||
|
||||
<string name="prefs_highlight_title">Підсвічування висоти</string>
|
||||
<string name="prefs_highlight_low">Низько < %1$d°</string>
|
||||
|
||||
@@ -219,13 +219,12 @@
|
||||
|
||||
<string name="prefs_other_title">其他</string>
|
||||
<string name="prefs_other_switch_utc">UTC 时间</string>
|
||||
<string name="prefs_other_switch_update">自动更新</string>
|
||||
<string name="prefs_other_switch_sweep">雷达扫描</string>
|
||||
<string name="prefs_other_switch_sensors">传感器</string>
|
||||
<string name="prefs_other_switch_update">自动更新数据</string>
|
||||
<string name="prefs_other_switch_sweep">雷达扫描动画</string>
|
||||
<string name="prefs_other_switch_sensors">传感器控制雷达</string>
|
||||
<string name="prefs_other_switch_night_mode">红色夜间模式</string>
|
||||
<string name="prefs_other_switch_light_theme">浅色主题</string>
|
||||
<string name="prefs_other_compass_offset_az">罗盘偏移量(方位角)</string>
|
||||
<string name="prefs_other_compass_offset_elev">罗盘偏移量(仰角)</string>
|
||||
<string name="prefs_other_compass_offset">雷达罗盘水平偏移量</string>
|
||||
<string name="prefs_other_compass_offset_elev">雷达罗盘俯仰偏移量</string>
|
||||
|
||||
<string name="prefs_highlight_title">仰角高亮</string>
|
||||
<string name="prefs_highlight_low">低 < %1$d°</string>
|
||||
|
||||
@@ -47,13 +47,8 @@
|
||||
<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_azimuth" translatable="false">%03d°</string>
|
||||
<string name="pass_duration_placeholder" translatable="false">--m --s</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">
|
||||
@@ -61,12 +56,14 @@
|
||||
\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
|
||||
* Tweaked sunrise/sunset calculations, added tests by atsunatsu (#241)
|
||||
\n\n* Overhauled sources import, added HTTP status by wty2019wty (#242)
|
||||
\nSources now have priority - the top ones define satellite names and transceiver data.
|
||||
\nYou can enable/disable sources and restore defaults via the Satellite data import dialog.
|
||||
\n\n* Localized AMSAT status screen for Chinese by atsunatsu (#245)
|
||||
\n\n* Extracted hardcoded UI strings to resources by TianhengZhuang (#247)
|
||||
\n\n* Added AMSAT satellite status reports submission by atsunatsu (#248)
|
||||
\n\n* Slightly optimized map performance to reduce ANRs and memory consumption. Zoom in to see satellite labels.
|
||||
</string>
|
||||
|
||||
<!-- AMSAT Status screen -->
|
||||
@@ -265,13 +262,12 @@
|
||||
|
||||
<string name="prefs_other_title">Other</string>
|
||||
<string name="prefs_other_switch_utc">UTC time</string>
|
||||
<string name="prefs_other_switch_update">Auto-update</string>
|
||||
<string name="prefs_other_switch_update">Auto-update data</string>
|
||||
<string name="prefs_other_switch_sweep">Radar sweep</string>
|
||||
<string name="prefs_other_switch_sensors">Sensors</string>
|
||||
<string name="prefs_other_switch_sensors">Use sensors</string>
|
||||
<string name="prefs_other_switch_night_mode">Night filter</string>
|
||||
<string name="prefs_other_switch_light_theme">Light theme</string>
|
||||
<string name="prefs_other_compass_offset_az">Compass offset (azimuth)</string>
|
||||
<string name="prefs_other_compass_offset_elev">Compass offset (elevation)</string>
|
||||
<string name="prefs_other_compass_offset">Radar compass offset</string>
|
||||
<string name="prefs_other_compass_offset_elev">Radar compass offset (elev)</string>
|
||||
|
||||
<string name="prefs_highlight_title">Elevation highlight</string>
|
||||
<string name="prefs_highlight_low">Low < %1$d°</string>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
* 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
|
||||
* Tweaked sun calculations, added tests by atsunatsu (#241)
|
||||
* Overhauled sources import, added HTTP status by wty2019wty (#242)
|
||||
* Localized AMSAT status screen for Chinese by atsunatsu (#245)
|
||||
* Extracted hardcoded UI strings to resources by TianhengZhuang (#247)
|
||||
* Added AMSAT satellite status reports submission by atsunatsu (#248)
|
||||
@@ -116,9 +116,8 @@ class MapNightOverlay : Overlay() {
|
||||
|
||||
else -> {
|
||||
// Terminator crosses this column — find the crossing pixel by binary search
|
||||
val isNightAtTop = dotTop < 0
|
||||
val crossY = findCrossingY(proj, x, 0, h - 1, sinSunLat, cosSunLat, cosLonDiff, isNightAtTop)
|
||||
if (isNightAtTop) {
|
||||
val crossY = findCrossingY(proj, x, 0, h - 1, sinSunLat, cosSunLat, cosLonDiff)
|
||||
if (dotTop < 0) {
|
||||
// Night at top, day at bottom
|
||||
rect.set(x.toFloat(), 0f, (x + stepPx).toFloat(), crossY.toFloat())
|
||||
canvas.drawRect(rect, nightPaint)
|
||||
@@ -135,11 +134,7 @@ class MapNightOverlay : Overlay() {
|
||||
|
||||
/**
|
||||
* Binary-search for the pixel row where the day/night boundary crosses column [x].
|
||||
*
|
||||
* [isNightAtTop] states which side [yTop] is on. Both polarities occur: the pole nearest
|
||||
* the sub-solar point is lit, so the terminator runs day-over-night while the sub-solar
|
||||
* latitude is positive and night-over-day once it turns negative after the September
|
||||
* equinox. Anchoring lo to the top pixel's side keeps the search valid either way.
|
||||
* [yTop] is in day, [yBot] is in night (or vice versa).
|
||||
*/
|
||||
private fun findCrossingY(
|
||||
proj: org.osmdroid.views.Projection,
|
||||
@@ -148,8 +143,7 @@ class MapNightOverlay : Overlay() {
|
||||
yBot: Int,
|
||||
sinSunLat: Double,
|
||||
cosSunLat: Double,
|
||||
cosLonDiff: Double,
|
||||
isNightAtTop: Boolean
|
||||
cosLonDiff: Double
|
||||
): Int {
|
||||
var lo = yTop
|
||||
var hi = yBot
|
||||
@@ -158,8 +152,7 @@ class MapNightOverlay : Overlay() {
|
||||
val geo = proj.fromPixels(x, mid, reusableGeoPoint) ?: return mid
|
||||
val latRad = Math.toRadians(geo.latitude)
|
||||
val dot = sin(latRad) * sinSunLat + cos(latRad) * cosSunLat * cosLonDiff
|
||||
// Keep lo on the top pixel's side of the terminator and hi on the opposite side
|
||||
if ((dot < 0) == isNightAtTop) lo = mid else hi = mid
|
||||
if (dot < 0) hi = mid else lo = mid
|
||||
}
|
||||
return (lo + hi) / 2
|
||||
}
|
||||
|
||||
@@ -122,39 +122,6 @@ private val moonIconPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
android.graphics.PorterDuffColorFilter("#E0E0E0".toColorInt(), android.graphics.PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
|
||||
/** Accent used for the station marker; recolored per theme so it stays visible on a light map. */
|
||||
private var mapAccentColor = "#FFE082".toColorInt()
|
||||
|
||||
/** The dark-map filter (grayscale + invert) is only applied when NOT in light theme; cached to avoid rebuilds. */
|
||||
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.
|
||||
* Must run before the set*() overlay builders on each frame, as several icons cache the paint color.
|
||||
*/
|
||||
private fun applyMapColors(isLightUi: Boolean) {
|
||||
if (isLightUi) {
|
||||
mapAccentColor = "#715C0C".toColorInt() // dark amber (matches app lightScheme primary)
|
||||
footprintPaint.color = mapAccentColor
|
||||
textPaint.color = "#1E1B13".toColorInt()
|
||||
textPaint.setShadowLayer(3f, 3f, 3f, Color.WHITE)
|
||||
sunIconPaint.colorFilter =
|
||||
android.graphics.PorterDuffColorFilter(mapAccentColor, android.graphics.PorterDuff.Mode.SRC_IN)
|
||||
moonIconPaint.colorFilter =
|
||||
android.graphics.PorterDuffColorFilter("#4C4639".toColorInt(), android.graphics.PorterDuff.Mode.SRC_IN)
|
||||
} else {
|
||||
mapAccentColor = "#FFE082".toColorInt()
|
||||
footprintPaint.color = mapAccentColor
|
||||
textPaint.color = mapAccentColor
|
||||
textPaint.setShadowLayer(3f, 3f, 3f, Color.BLACK)
|
||||
sunIconPaint.colorFilter =
|
||||
android.graphics.PorterDuffColorFilter(mapAccentColor, android.graphics.PorterDuff.Mode.SRC_IN)
|
||||
moonIconPaint.colorFilter =
|
||||
android.graphics.PorterDuffColorFilter("#E0E0E0".toColorInt(), android.graphics.PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MapDestination() {
|
||||
val context = LocalContext.current
|
||||
@@ -210,8 +177,6 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
||||
ElevatedCard(modifier = Modifier.weight(1f)) {
|
||||
Box(contentAlignment = Alignment.BottomCenter) {
|
||||
AndroidView({ mapView }) { view ->
|
||||
applyMapColors(uiState.isLightUi)
|
||||
view.overlayManager.tilesOverlay.setColorFilter(if (uiState.isLightUi) null else darkTileFilter)
|
||||
uiState.stationPosition?.let { setStationPosition(it, view) }
|
||||
uiState.track?.let { setSatelliteTrack(it, view) }
|
||||
uiState.footprint?.let { setFootprint(it, view) }
|
||||
@@ -323,9 +288,7 @@ private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
|
||||
mapView.overlays[OVERLAY_STATION] = Marker(mapView).apply {
|
||||
setInfoWindow(null)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
icon = ContextCompat.getDrawable(mapView.context, R.drawable.ic_position)?.apply {
|
||||
setTint(mapAccentColor)
|
||||
}
|
||||
icon = ContextCompat.getDrawable(mapView.context, R.drawable.ic_position)
|
||||
position = GeoPoint(stationPos.latitude, stationPos.longitude)
|
||||
}
|
||||
}
|
||||
@@ -622,7 +585,7 @@ private fun rememberMapViewWithLifecycle(): MapView {
|
||||
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
|
||||
overlayManager.tilesOverlay.loadingBackgroundColor = Color.TRANSPARENT
|
||||
overlayManager.tilesOverlay.loadingLineColor = Color.TRANSPARENT
|
||||
// Tile color filter (dark inversion vs light) is applied per-theme in the AndroidView update block.
|
||||
overlayManager.tilesOverlay.setColorFilter(createColorFilter())
|
||||
setScrollableAreaLimitLatitude(maxLat, minLat, 0)
|
||||
overlays.addAll(Array(OVERLAY_COUNT) { FolderOverlay() })
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ class MapViewModel(
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
settingsRepo.otherSettings.collectLatest { settings ->
|
||||
_uiState.update { it.copy(isUtc = settings.stateOfUtc, isLightUi = settings.stateOfLightTheme) }
|
||||
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
|
||||
}
|
||||
}
|
||||
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
|
||||
@@ -106,7 +106,7 @@ class MapViewModel(
|
||||
private fun selectDefaultSatellite(catnum: Int) {
|
||||
if (allSatellites.isNotEmpty()) {
|
||||
if (catnum == -1) {
|
||||
allPasses.find { pass -> pass.progress < 1 && !pass.isDeepSpace }
|
||||
allPasses.find { pass -> pass.progress < 100 && !pass.isDeepSpace }
|
||||
?.let { pass -> selectSatellite(pass.orbitalObject) }
|
||||
} else {
|
||||
allSatellites.find { it.data.catnum == catnum }?.let { selectSatellite(it) }
|
||||
@@ -189,8 +189,8 @@ class MapViewModel(
|
||||
}
|
||||
}
|
||||
for ((localPositions, localSelectedPos) in deferreds.awaitAll()) {
|
||||
for ((first, second) in localPositions) {
|
||||
positionsMap[first] = second
|
||||
for (pair in localPositions) {
|
||||
positionsMap[pair.first] = pair.second
|
||||
}
|
||||
if (localSelectedPos != null) {
|
||||
selectedSatPos = localSelectedPos
|
||||
|
||||
+117
-193
@@ -17,6 +17,11 @@
|
||||
*/
|
||||
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
|
||||
@@ -32,18 +37,16 @@ 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
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
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
|
||||
@@ -51,8 +54,6 @@ 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.LineHeightStyle
|
||||
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
|
||||
@@ -64,21 +65,19 @@ 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 com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
||||
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.R
|
||||
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
||||
import com.rtbishop.look4sat.core.presentation.SearchBar
|
||||
import com.rtbishop.look4sat.core.presentation.SwipeableItem
|
||||
import com.rtbishop.look4sat.core.presentation.TimerRow
|
||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||
import com.rtbishop.look4sat.core.presentation.WhatsNewDialog
|
||||
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
|
||||
@@ -90,13 +89,12 @@ 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.timeNow, viewModel::onAction, navigateToRadar)
|
||||
PassesScreen(uiState, viewModel::onAction, navigateToRadar)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PassesScreen(
|
||||
uiState: PassesState,
|
||||
timeNow: StateFlow<Long>,
|
||||
onAction: (PassesAction) -> Unit,
|
||||
navigateToRadar: (Int, Long) -> Unit
|
||||
) {
|
||||
@@ -147,10 +145,7 @@ private fun PassesScreen(
|
||||
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
|
||||
},
|
||||
topInfo = {
|
||||
SearchBar(
|
||||
onQueryChange = { onAction(PassesAction.SearchFor(it)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos)
|
||||
},
|
||||
bottomInfo = {
|
||||
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
|
||||
@@ -167,14 +162,14 @@ private fun PassesScreen(
|
||||
passes = uiState.itemsList,
|
||||
groupedPasses = uiState.groupedPasses,
|
||||
sunTimes = uiState.sunTimes,
|
||||
isSearching = uiState.searchQuery.isNotBlank(),
|
||||
timeNow = timeNow,
|
||||
focusedCatNum = uiState.focusedCatNum,
|
||||
navigateToRadar = navigateToRadar,
|
||||
onAction = onAction
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun PassesList(
|
||||
isRefreshing: Boolean,
|
||||
@@ -182,8 +177,7 @@ private fun PassesList(
|
||||
passes: List<OrbitalPass>,
|
||||
groupedPasses: Map<String, List<OrbitalPass>>,
|
||||
sunTimes: Map<String, Pair<String, String>>,
|
||||
isSearching: Boolean,
|
||||
timeNow: StateFlow<Long>,
|
||||
focusedCatNum: Int?,
|
||||
navigateToRadar: (Int, Long) -> Unit,
|
||||
onAction: (PassesAction) -> Unit
|
||||
) {
|
||||
@@ -205,28 +199,38 @@ private fun PassesList(
|
||||
}
|
||||
) {
|
||||
if (passes.isEmpty()) {
|
||||
val emptyMessage = if (isSearching) {
|
||||
stringResource(R.string.pass_empty_search_message)
|
||||
} else {
|
||||
stringResource(R.string.pass_empty_list_message)
|
||||
}
|
||||
EmptyListCard(message = emptyMessage)
|
||||
EmptyListCard(message = stringResource(R.string.pass_empty_list_message))
|
||||
} else {
|
||||
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
|
||||
)
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -274,7 +278,7 @@ private fun StickyDateHeader(label: String, sunriseTime: String, sunsetTime: Str
|
||||
}
|
||||
}
|
||||
|
||||
internal fun displayLocale(): Locale {
|
||||
private fun displayLocale(): Locale {
|
||||
val locale = Locale.getDefault()
|
||||
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
|
||||
}
|
||||
@@ -284,127 +288,51 @@ internal fun displayLocale(): Locale {
|
||||
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, 180.0, 36650, 45.0, satellite, 0.5f)
|
||||
MainTheme { PassItem(pass = pass, { _, _ -> }, MutableStateFlow(0L)) }
|
||||
val pass = OrbitalPass(1L, 180.0, 10L, 360.0, 36650, 45.0, satellite, 0.5f)
|
||||
MainTheme { PassItem(pass = pass, { _, _ -> }) }
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun UpcomingPassPreview() {
|
||||
private fun NearEarthPassPreview() {
|
||||
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), 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)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
private 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
)
|
||||
val satellite = NearEarthObject(data)
|
||||
val pass = OrbitalPass(1L, 180.0, 10L, 360.0, 36650, 45.0, satellite, 0.5f)
|
||||
MainTheme { PassItem(pass = pass, { _, _ -> }) }
|
||||
}
|
||||
|
||||
@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 12.dp
|
||||
val horizontalPadding = if (isVerticalLayout) 6.dp else 10.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 azimuthFormat = stringResource(id = R.string.pass_azimuth)
|
||||
val aosTimeStr = remember(pass.aosTime, isUtc) { sdfTime.format(Date(pass.aosTime)) }
|
||||
val losTimeStr = remember(pass.losTime, isUtc) { sdfTime.format(Date(pass.losTime)) }
|
||||
val aosAzStr = remember(pass.aosAzimuth, azimuthFormat) {
|
||||
String.format(displayLocale(), azimuthFormat, pass.aosAzimuth.toInt() % 360)
|
||||
}
|
||||
val losAzStr = remember(pass.losAzimuth, azimuthFormat) {
|
||||
String.format(displayLocale(), azimuthFormat, pass.losAzimuth.toInt() % 360)
|
||||
}
|
||||
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(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(color = MaterialTheme.colorScheme.surface)
|
||||
// do not delete
|
||||
// .drawBehind { drawRect(progressTint, size = Size(size.width * progressFraction, size.height)) }
|
||||
.padding(horizontal = horizontalPadding, vertical = 6.dp)
|
||||
.padding(horizontal = horizontalPadding, vertical = 4.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
@@ -422,87 +350,83 @@ private fun PassItem(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
PassTimerChip(pass = pass, timeNow = timeNow)
|
||||
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
|
||||
)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = if (pass.isDeepSpace) {
|
||||
stringResource(R.string.pass_duration_placeholder)
|
||||
} else {
|
||||
durationStr
|
||||
},
|
||||
fontSize = 15.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
// AOS azimuth, peak elevation and LOS azimuth read as the arc of the pass
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
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)
|
||||
}
|
||||
}
|
||||
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
|
||||
) {
|
||||
Text(
|
||||
text = aosAzStr,
|
||||
fontSize = 15.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
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 = losAzStr,
|
||||
text = stringResource(
|
||||
id = R.string.pass_aosLos,
|
||||
pass.aosAzimuth.toInt(),
|
||||
pass.losAzimuth.toInt()
|
||||
),
|
||||
fontSize = 15.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
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(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
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,7 +25,8 @@ data class PassesState(
|
||||
val isRefreshing: Boolean = true,
|
||||
val isUtc: Boolean = false,
|
||||
val nextPass: OrbitalPass,
|
||||
val searchQuery: String = "",
|
||||
val nextTime: String = "00:00:00",
|
||||
val isNextTimeAos: Boolean = true,
|
||||
val hours: Int = 24,
|
||||
val elevation: Double = 16.0,
|
||||
val lowElevation: Double = 16.0,
|
||||
@@ -38,7 +39,8 @@ 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 sunTimes: Map<String, Pair<String, String>> = emptyMap(),
|
||||
val focusedCatNum: Int? = null
|
||||
)
|
||||
|
||||
sealed interface PassesAction {
|
||||
@@ -57,5 +59,6 @@ sealed interface PassesAction {
|
||||
data object RefreshPasses : PassesAction
|
||||
data object TogglePassesDialog : PassesAction
|
||||
data object ToggleRadiosDialog : PassesAction
|
||||
data class SearchFor(val query: String) : PassesAction
|
||||
data class FocusCatNum(val catNum: Int) : PassesAction
|
||||
data object ClearFocus : PassesAction
|
||||
}
|
||||
+64
-87
@@ -27,6 +27,8 @@ 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
|
||||
@@ -44,14 +46,6 @@ 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
|
||||
@@ -76,16 +70,6 @@ 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 {
|
||||
@@ -111,50 +95,36 @@ class PassesViewModel(
|
||||
_uiState.update { it.copy(modes = modes) }
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
// Tick loop: restarts on passes change, UTC/DeepSpace changes. Grouping/sun-time
|
||||
// computations run once per restart, progress/countdown are calculated every second.
|
||||
viewModelScope.launch {
|
||||
_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
|
||||
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
|
||||
)
|
||||
val sunTimes = computeSunTimes(filtered, isUtc)
|
||||
var live: List<OrbitalPass>? = null
|
||||
while (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)
|
||||
}
|
||||
}
|
||||
delay(1000.milliseconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,10 +149,8 @@ class PassesViewModel(
|
||||
_uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) }
|
||||
PassesAction.ToggleRadiosDialog ->
|
||||
_uiState.update { it.copy(isRadiosDialogShown = !it.isRadiosDialogShown) }
|
||||
is PassesAction.SearchFor -> {
|
||||
searchQuery.value = action.query
|
||||
_uiState.update { it.copy(searchQuery = action.query) }
|
||||
}
|
||||
is PassesAction.FocusCatNum -> _uiState.update { it.copy(focusedCatNum = action.catNum) }
|
||||
PassesAction.ClearFocus -> _uiState.update { it.copy(focusedCatNum = null) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,33 +208,42 @@ class PassesViewModel(
|
||||
return ordered
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) }
|
||||
/** 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
|
||||
}
|
||||
|
||||
/** Lowercases and strips all non-alphanumeric chars for fuzzy matching. */
|
||||
private fun normalizeForSearch(text: String): String =
|
||||
text.lowercase().filter { it.isLetterOrDigit() }
|
||||
/** 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)
|
||||
}
|
||||
if (passes.isNotEmpty()) {
|
||||
val lastPass = passes.last()
|
||||
return Triple(lastPass, (lastPass.losTime - timeNow).toTimerString(), false)
|
||||
}
|
||||
return Triple(defaultPass, "00:00:00", true)
|
||||
}
|
||||
|
||||
private fun applyFilter(
|
||||
hoursAhead: Int,
|
||||
|
||||
@@ -101,11 +101,4 @@ sealed interface 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
|
||||
}
|
||||
@@ -70,7 +70,6 @@ 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
|
||||
@@ -261,8 +260,16 @@ 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))
|
||||
it.copy(
|
||||
transceivers = it.transceivers.copy(selectedUuid = newUuid),
|
||||
calculatorOffsetKHz = offsetKHz
|
||||
)
|
||||
}
|
||||
// Only update the tracking service when selecting a different transponder to
|
||||
// avoid resetting a user-adjusted TX base on re-expand
|
||||
@@ -313,9 +320,7 @@ class RadarViewModel(
|
||||
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) }
|
||||
}
|
||||
is RadarAction.ChangeCalculatorOffset -> {
|
||||
// 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 ->
|
||||
val catnum = _uiState.value.transceivers.selectedUuid?.let { uuid ->
|
||||
transponders.find { it.uuid == uuid }?.catnum
|
||||
}
|
||||
if (catnum != null) {
|
||||
@@ -323,11 +328,6 @@ 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -140,13 +140,6 @@ 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
|
||||
|
||||
+57
-1
@@ -30,20 +30,26 @@ 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
|
||||
@@ -61,7 +67,6 @@ 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
|
||||
@@ -186,6 +191,55 @@ 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(", ")
|
||||
@@ -318,3 +372,5 @@ private fun SummaryHeader(selectedText: String, availableText: String) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+25
-38
@@ -495,31 +495,17 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
// Data preferences: auto-update + UTC clock
|
||||
// Display preferences: UTC clock + night filter
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SwitchTile(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
|
||||
onAction(SettingsAction.ToggleUpdate(it))
|
||||
}
|
||||
SwitchTile(R.string.prefs_other_switch_utc, settings.stateOfUtc) {
|
||||
onAction(SettingsAction.ToggleUtc(it))
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
// Appearance: light theme + night filter
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SwitchTile(R.string.prefs_other_switch_light_theme, settings.stateOfLightTheme) {
|
||||
onAction(SettingsAction.ToggleLightTheme(it))
|
||||
}
|
||||
SwitchTile(
|
||||
R.string.prefs_other_switch_night_mode,
|
||||
settings.stateOfNightMode,
|
||||
enabled = !settings.stateOfLightTheme
|
||||
) {
|
||||
SwitchTile(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
|
||||
onAction(SettingsAction.ToggleNightMode(it))
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
// Radar behavior: sweep animation + sensors, followed by the compass offset sliders
|
||||
// Radar behavior: sweep animation + sensor control
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SwitchTile(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
|
||||
onAction(SettingsAction.ToggleSweep(it))
|
||||
@@ -529,9 +515,14 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
// Data management (full width)
|
||||
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
|
||||
onAction(SettingsAction.ToggleUpdate(it))
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
// Compass calibration sliders at the bottom
|
||||
CompassOffsetRow(
|
||||
labelResId = R.string.prefs_other_compass_offset_az,
|
||||
labelResId = R.string.prefs_other_compass_offset,
|
||||
value = settings.radarCompassOffset,
|
||||
range = -180f..180f
|
||||
) { onAction(SettingsAction.SetRadarCompassOffset(it)) }
|
||||
@@ -558,14 +549,7 @@ private fun CompassOffsetRow(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = labelResId),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 4.dp)
|
||||
.infiniteMarquee(),
|
||||
maxLines = 1
|
||||
)
|
||||
Text(text = stringResource(id = labelResId))
|
||||
Text(text = "${value.toInt()}°")
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
@@ -577,12 +561,19 @@ private fun CompassOffsetRow(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowScope.SwitchTile(
|
||||
labelResId: Int,
|
||||
checked: Boolean,
|
||||
enabled: Boolean = true,
|
||||
onCheckedChange: (Boolean) -> Unit
|
||||
) {
|
||||
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(text = stringResource(id = labelResId))
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowScope.SwitchTile(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -590,13 +581,9 @@ private fun RowScope.SwitchTile(
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = labelResId),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 4.dp)
|
||||
.infiniteMarquee(),
|
||||
maxLines = 1
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Switch(checked = checked, enabled = enabled, onCheckedChange = onCheckedChange)
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-4
@@ -127,10 +127,7 @@ class SettingsViewModel(
|
||||
is SettingsAction.ToggleUpdate -> settingsRepo.updateOtherSettings { it.copy(stateOfAutoUpdate = action.value) }
|
||||
is SettingsAction.ToggleSweep -> settingsRepo.updateOtherSettings { it.copy(stateOfSweep = action.value) }
|
||||
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
|
||||
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings {
|
||||
// Night filter (red-only overlay) is incompatible with the light theme, so turn it off.
|
||||
it.copy(stateOfLightTheme = action.value, stateOfNightMode = if (action.value) false else it.stateOfNightMode)
|
||||
}
|
||||
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
|
||||
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
|
||||
is SettingsAction.SetRadarCompassOffset -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffset = action.value) }
|
||||
is SettingsAction.SetRadarCompassOffsetElev -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffsetElev = action.value) }
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "448"
|
||||
appVersionCode = "447"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.8"
|
||||
appVersionName = "4.4.7"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
@@ -12,16 +12,16 @@ jdkVersion = "21"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
packageName = "com.rtbishop.look4sat"
|
||||
|
||||
android-gradle-plugin = "9.4.1"
|
||||
android-gradle-plugin = "9.4.0"
|
||||
|
||||
androidx-core-ktx = "1.19.1"
|
||||
androidx-core-ktx = "1.19.0"
|
||||
androidx-core-splashscreen = "1.2.0"
|
||||
androidx-room = "2.8.5"
|
||||
|
||||
compose-bom = "2026.09.00"
|
||||
compose-activity = "1.13.0"
|
||||
compose-lifecycle = "2.11.0"
|
||||
compose-navigation3 = "1.2.0"
|
||||
compose-navigation3 = "1.1.7"
|
||||
|
||||
google-ksp = "2.3.11"
|
||||
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
#Sun Sep 27 11:54:01 BST 2026
|
||||
#Sat Sep 12 11:29:59 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.7.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
Reference in new issue
Block a user