mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-03 11:17:25 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22607e7710 | ||
|
|
ad0ff6859e | ||
|
|
a39b1716e7 | ||
|
|
8a2339cfbf | ||
|
|
821fa0dfe7 | ||
|
|
ce8c2a4834 | ||
|
|
8cc4ef5610 | ||
|
|
dde22faf46 |
No files matched your search
@@ -0,0 +1,6 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
@@ -25,50 +25,54 @@ jobs:
|
|||||||
java-version: '17'
|
java-version: '17'
|
||||||
|
|
||||||
- name: Setup Gradle
|
- name: Setup Gradle
|
||||||
uses: gradle/actions/setup-gradle@v5
|
uses: gradle/actions/setup-gradle@v6
|
||||||
|
|
||||||
- name: Assemble Artifacts
|
- name: Assemble Artifacts
|
||||||
run: |
|
run: ./gradlew assembleRelease bundleRelease
|
||||||
./gradlew assembleRelease
|
|
||||||
./gradlew bundleRelease
|
|
||||||
|
|
||||||
- name: Sign APK
|
- name: Sign APK
|
||||||
uses: r0adkll/sign-android-release@v1
|
run: |
|
||||||
id: sign_apk
|
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
|
||||||
with:
|
APK=$(find app/build/outputs/apk/release -name "*.apk" | head -1)
|
||||||
releaseDirectory: app/build/outputs/apk/release
|
BUILD_TOOLS=$(ls -d ${ANDROID_HOME}/build-tools/*/ | sort -V | tail -1)
|
||||||
signingKeyBase64: ${{ secrets.KEY_STORE }}
|
${BUILD_TOOLS}apksigner sign \
|
||||||
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
--ks keystore.jks \
|
||||||
alias: ${{ secrets.KEY_ALIAS }}
|
--ks-pass pass:${{ secrets.KEY_STORE_PASSWORD }} \
|
||||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
--ks-key-alias ${{ secrets.KEY_ALIAS }} \
|
||||||
env:
|
--key-pass pass:${{ secrets.KEY_PASSWORD }} \
|
||||||
BUILD_TOOLS_VERSION: "36.0.0"
|
--out app/build/outputs/apk/release/look4sat.apk \
|
||||||
|
"$APK"
|
||||||
|
rm keystore.jks
|
||||||
|
|
||||||
- name: Sign Bundle
|
- name: Sign Bundle
|
||||||
uses: r0adkll/sign-android-release@v1
|
|
||||||
id: sign_bundle
|
|
||||||
with:
|
|
||||||
releaseDirectory: app/build/outputs/bundle/release
|
|
||||||
signingKeyBase64: ${{ secrets.KEY_STORE }}
|
|
||||||
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
|
||||||
alias: ${{ secrets.KEY_ALIAS }}
|
|
||||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
|
||||||
env:
|
|
||||||
BUILD_TOOLS_VERSION: "36.0.0"
|
|
||||||
|
|
||||||
- name: Rename Artifacts
|
|
||||||
run: |
|
run: |
|
||||||
mv ${{steps.sign_apk.outputs.signedReleaseFile}} app/build/outputs/apk/release/look4sat.apk
|
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
|
||||||
mv ${{steps.sign_bundle.outputs.signedReleaseFile}} app/build/outputs/apk/release/look4sat.aab
|
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
|
||||||
|
jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \
|
||||||
|
-keystore keystore.jks \
|
||||||
|
-storepass ${{ secrets.KEY_STORE_PASSWORD }} \
|
||||||
|
-keypass ${{ secrets.KEY_PASSWORD }} \
|
||||||
|
"$AAB" ${{ secrets.KEY_ALIAS }}
|
||||||
|
rm keystore.jks
|
||||||
|
|
||||||
- name: Deploy Bundle
|
- name: Setup Ruby
|
||||||
uses: r0adkll/upload-google-play@v1
|
uses: ruby/setup-ruby@v1
|
||||||
with:
|
with:
|
||||||
serviceAccountJsonPlainText: ${{secrets.SERVICE_ACCOUNT_JSON}}
|
ruby-version: '3.3'
|
||||||
packageName: com.rtbishop.look4sat
|
|
||||||
releaseFiles: app/build/outputs/apk/release/look4sat.aab
|
- name: Deploy Bundle to Google Play
|
||||||
track: production
|
run: |
|
||||||
whatsNewDirectory: fastlane/metadata/android/en-US/whatsnew
|
gem install fastlane --no-document
|
||||||
|
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
|
||||||
|
echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json
|
||||||
|
fastlane supply \
|
||||||
|
--aab "$AAB" \
|
||||||
|
--json_key service_account.json \
|
||||||
|
--package_name com.rtbishop.look4sat \
|
||||||
|
--track production \
|
||||||
|
--skip_upload_images true \
|
||||||
|
--skip_upload_screenshots true \
|
||||||
|
rm service_account.json
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -14,9 +14,11 @@
|
|||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".MainApplication"
|
android:name=".MainApplication"
|
||||||
|
android:allowBackup="false"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round">
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
|||||||
@@ -76,11 +76,11 @@ import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
|||||||
fun MainScreen() {
|
fun MainScreen() {
|
||||||
val backStack = rememberNavBackStack(Screen.Passes)
|
val backStack = rememberNavBackStack(Screen.Passes)
|
||||||
val currentKey = backStack.lastOrNull()
|
val currentKey = backStack.lastOrNull()
|
||||||
val navigateBack: () -> Unit = { backStack.removeAt(backStack.size - 1) }
|
val navigateBack: () -> Unit = { if (backStack.size > 1) backStack.removeAt(backStack.size - 1) }
|
||||||
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
|
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
|
||||||
// val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
|
// val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
|
||||||
// val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
|
// val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
|
||||||
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar(), Screen.Map, Screen.Settings)
|
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings)
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
@@ -138,25 +138,15 @@ fun MainScreen() {
|
|||||||
}
|
}
|
||||||
entry<Screen.Passes> {
|
entry<Screen.Passes> {
|
||||||
PassesDestination { catNum, aosTime ->
|
PassesDestination { catNum, aosTime ->
|
||||||
backStack.add(Screen.Radar(catNum, aosTime))
|
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||||
|
backStack.add(Screen.Radar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
entry<Screen.Radar> { route ->
|
entry<Screen.Radar> {
|
||||||
RadarDestination(
|
RadarDestination(navigateToRadioControl = { backStack.add(Screen.RadioControl) })
|
||||||
catNum = route.catNum,
|
|
||||||
aosTime = route.aosTime,
|
|
||||||
navigateUp = navigateBack,
|
|
||||||
navigateToRadioControl = { catNum, aosTime ->
|
|
||||||
backStack.add(Screen.RadioControl(catNum, aosTime))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
entry<Screen.RadioControl> { route ->
|
entry<Screen.RadioControl> {
|
||||||
RadioControlDestination(
|
RadioControlDestination(navigateUp = navigateBack)
|
||||||
catNum = route.catNum,
|
|
||||||
aosTime = route.aosTime,
|
|
||||||
navigateUp = navigateBack
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
entry<Screen.Map> {
|
entry<Screen.Map> {
|
||||||
MapDestination()
|
MapDestination()
|
||||||
@@ -184,7 +174,8 @@ fun MainScreen() {
|
|||||||
.clickable {
|
.clickable {
|
||||||
val pass = trackingState.currentPass
|
val pass = trackingState.currentPass
|
||||||
if (pass != null) {
|
if (pass != null) {
|
||||||
backStack.add(Screen.RadioControl(pass.catNum, pass.aosTime))
|
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
||||||
|
backStack.add(Screen.RadioControl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
|
|||||||
+30
-36
@@ -40,28 +40,22 @@ class DatabaseRepo(
|
|||||||
private val settingsRepo: ISettingsRepo
|
private val settingsRepo: ISettingsRepo
|
||||||
) : IDatabaseRepo {
|
) : IDatabaseRepo {
|
||||||
|
|
||||||
private companion object {
|
private val customSourceType = "Other"
|
||||||
val tleTypes = setOf("Amsat", "R4UAB", "Other")
|
|
||||||
val zippedTleTypes = setOf("McCants", "Classified")
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun updateTLEFromFile(uri: String) = withContext(dispatcher) {
|
override suspend fun updateTLEFromFile(uri: String) = withContext(dispatcher) {
|
||||||
remoteSource.getFileStream(uri)?.let { stream ->
|
remoteSource.getFileStream(uri)?.let { stream ->
|
||||||
val entries = dataParser.parseTLEStream(stream)
|
val entries = dataParser.parseTLEStream(unwrapIfZipped(uri, stream))
|
||||||
localSource.insertEntries(entries)
|
localSource.insertEntries(entries)
|
||||||
settingsRepo.setSatelliteTypeIds("Other", entries.map { it.catnum })
|
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
|
||||||
}
|
}
|
||||||
setUpdateSuccessful(System.currentTimeMillis())
|
setUpdateSuccessful(System.currentTimeMillis())
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateTransceiversFromFile(uri: String) = withContext(dispatcher) {
|
override suspend fun updateTransceiversFromFile(uri: String) = withContext(dispatcher) {
|
||||||
remoteSource.getFileStream(uri)
|
remoteSource.getFileStream(uri)?.let { stream ->
|
||||||
?.let { dataParser.parseJSONStream(it) }
|
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
|
||||||
?.takeIf { it.isNotEmpty() }
|
localSource.insertRadios(transceivers)
|
||||||
?.let {
|
}
|
||||||
localSource.deleteRadios()
|
|
||||||
localSource.insertRadios(it)
|
|
||||||
}
|
|
||||||
setUpdateSuccessful(System.currentTimeMillis())
|
setUpdateSuccessful(System.currentTimeMillis())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,28 +63,26 @@ class DatabaseRepo(
|
|||||||
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
||||||
val tleUrls = buildMap {
|
val tleUrls = buildMap {
|
||||||
putAll(Sources.satelliteDataUrls)
|
putAll(Sources.satelliteDataUrls)
|
||||||
if (dataSourcesSettings.useCustomTLE) put("Other", dataSourcesSettings.tleUrl)
|
if (dataSourcesSettings.useCustomTLE) put(customSourceType, dataSourcesSettings.tleUrl)
|
||||||
}.filterValues { it.isNotEmpty() }
|
}.filterValues { it.isNotBlank() }
|
||||||
|
val radioUrls = buildMap {
|
||||||
val radioUrls = buildList {
|
putAll(Sources.transceiversDataUrls)
|
||||||
add(Sources.RADIO_DATA_URL)
|
if (dataSourcesSettings.useCustomTransceivers) put(customSourceType, dataSourcesSettings.transceiversUrl)
|
||||||
if (dataSourcesSettings.useCustomTransceivers) add(dataSourcesSettings.transceiversUrl)
|
}.filterValues { it.isNotBlank() }
|
||||||
}
|
|
||||||
|
|
||||||
// launch all network requests concurrently
|
// launch all network requests concurrently
|
||||||
val tleJobs = tleUrls.map { (type, url) -> async { type to remoteSource.getNetworkStream(url) } }
|
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||||
val radioJobs = radioUrls.map { url -> async { remoteSource.getNetworkStream(url) } }
|
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||||
|
// parse fetched data concurrently and associate with types
|
||||||
// parse satellite data
|
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
|
||||||
val importedEntries = tleJobs.awaitAll().flatMap { (type, stream) ->
|
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
|
||||||
stream?.let { parseSatelliteStream(type, it) }.orEmpty().also { satellites ->
|
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
|
||||||
settingsRepo.setSatelliteTypeIds(type, satellites.map { it.catnum })
|
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
|
||||||
// parse radio data
|
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
||||||
val importedRadios = radioJobs.awaitAll().filterNotNull().flatMap { dataParser.parseJSONStream(it) }
|
}
|
||||||
|
// insert parsed data into the database
|
||||||
localSource.insertEntries(importedEntries)
|
localSource.insertEntries(importedEntries)
|
||||||
localSource.insertRadios(importedRadios)
|
localSource.insertRadios(importedRadios)
|
||||||
setUpdateSuccessful(System.currentTimeMillis())
|
setUpdateSuccessful(System.currentTimeMillis())
|
||||||
@@ -102,10 +94,9 @@ class DatabaseRepo(
|
|||||||
setUpdateSuccessful(0L)
|
setUpdateSuccessful(0L)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun parseSatelliteStream(type: String, stream: InputStream): List<OrbitalData> = when (type) {
|
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> = when {
|
||||||
in tleTypes -> dataParser.parseTLEStream(stream)
|
url.contains("FORMAT=csv", ignoreCase = true) -> dataParser.parseCSVStream(stream)
|
||||||
in zippedTleTypes -> dataParser.parseTLEStream(ZipInputStream(stream).apply { nextEntry })
|
else -> dataParser.parseTLEStream(stream)
|
||||||
else -> dataParser.parseCSVStream(stream)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun setUpdateSuccessful(timestamp: Long) {
|
private suspend fun setUpdateSuccessful(timestamp: Long) {
|
||||||
@@ -113,4 +104,7 @@ class DatabaseRepo(
|
|||||||
DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp)
|
DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun unwrapIfZipped(url: String, stream: InputStream): InputStream =
|
||||||
|
if (url.endsWith(".zip", ignoreCase = true)) ZipInputStream(stream).apply { nextEntry } else stream
|
||||||
}
|
}
|
||||||
@@ -52,6 +52,13 @@ class SatelliteRepo(
|
|||||||
private val _satellites = MutableStateFlow<List<OrbitalObject>>(emptyList())
|
private val _satellites = MutableStateFlow<List<OrbitalObject>>(emptyList())
|
||||||
override val satellites: StateFlow<List<OrbitalObject>> = _satellites
|
override val satellites: StateFlow<List<OrbitalObject>> = _satellites
|
||||||
|
|
||||||
|
private val _selectedPass = MutableStateFlow(0 to 0L)
|
||||||
|
override val selectedPass: StateFlow<Pair<Int, Long>> = _selectedPass
|
||||||
|
|
||||||
|
override fun selectPass(catNum: Int, aosTime: Long) {
|
||||||
|
_selectedPass.value = catNum to aosTime
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
|
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
|
||||||
|
|
||||||
override suspend fun initRepository() = withContext(dispatcher) {
|
override suspend fun initRepository() = withContext(dispatcher) {
|
||||||
|
|||||||
+5
-4
@@ -242,7 +242,8 @@ object CelestialComputer {
|
|||||||
t = (jd - 2451545.0) / 36525.0
|
t = (jd - 2451545.0) / 36525.0
|
||||||
var teg = 280.46061837 + 360.98564736629 * (jd - 2451545.0) + (0.000387933 * t - t * t / 38710000.0) * t
|
var teg = 280.46061837 + 360.98564736629 * (jd - 2451545.0) + (0.000387933 * t - t * t / 38710000.0) * t
|
||||||
while (teg > 360.0) teg -= 360.0
|
while (teg > 360.0) teg -= 360.0
|
||||||
val th = fixAngle((teg - observer.longitude) * DEG2RAD)
|
// LST = GMST + east longitude (positive east convention)
|
||||||
|
val th = mod2PI((teg + observer.longitude) * DEG2RAD)
|
||||||
val h = th - ra
|
val h = th - ra
|
||||||
val azVal = atan2(sin(h), cos(h) * sin(n) - tan(dec) * cos(n)) + PI
|
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))
|
val el = asin(sin(n) * sin(dec) + cos(n) * cos(dec) * cos(h))
|
||||||
@@ -563,9 +564,9 @@ object CelestialComputer {
|
|||||||
// ── Internal helpers ──
|
// ── Internal helpers ──
|
||||||
|
|
||||||
private fun observerGeodetic(pos: GeoPos): DoubleArray {
|
private fun observerGeodetic(pos: GeoPos): DoubleArray {
|
||||||
// [lat_rad, lon_rad, alt_km] — longitude negated so that
|
// [lat_rad, lon_rad, alt_km] — longitude positive east, matching OrbitalObject convention.
|
||||||
// mod2PI(thetaGJD + obsGeo[1]) == mod2PI(thetaGJD + lon_rad)
|
// LST = thetaGJD(julUtc) + obsGeo[1] = GMST + lon_rad (correct).
|
||||||
return doubleArrayOf(pos.latitude * DEG2RAD, -pos.longitude * DEG2RAD, pos.altitude / 1000.0)
|
return doubleArrayOf(pos.latitude * DEG2RAD, pos.longitude * DEG2RAD, pos.altitude / 1000.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+6
@@ -34,6 +34,12 @@ interface ISatelliteRepo {
|
|||||||
/** Whether the repo is currently calculating passes. */
|
/** Whether the repo is currently calculating passes. */
|
||||||
val isCalculating: StateFlow<Boolean>
|
val isCalculating: StateFlow<Boolean>
|
||||||
|
|
||||||
|
/** Currently selected pass (catNum + aosTime), persisted across screen navigations. */
|
||||||
|
val selectedPass: StateFlow<Pair<Int, Long>>
|
||||||
|
|
||||||
|
/** Set the currently selected pass. */
|
||||||
|
fun selectPass(catNum: Int, aosTime: Long)
|
||||||
|
|
||||||
/** Load satellite objects from DB based on the current selection. */
|
/** Load satellite objects from DB based on the current selection. */
|
||||||
suspend fun initRepository()
|
suspend fun initRepository()
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
package com.rtbishop.look4sat.core.domain.source
|
package com.rtbishop.look4sat.core.domain.source
|
||||||
|
|
||||||
object Sources {
|
object Sources {
|
||||||
const val RADIO_DATA_URL = "https://db.satnogs.org/api/transmitters/?format=json&status=active"
|
|
||||||
val satelliteDataUrls = mapOf(
|
val satelliteDataUrls = mapOf(
|
||||||
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||||
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
|
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
|
||||||
@@ -49,4 +48,7 @@ object Sources {
|
|||||||
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
||||||
"Other" to "" // key for sats filter
|
"Other" to "" // key for sats filter
|
||||||
)
|
)
|
||||||
|
val transceiversDataUrls = mapOf(
|
||||||
|
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -58,8 +58,6 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
|||||||
}.getOrDefault(emptyList())
|
}.getOrDefault(emptyList())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isLeapYear(year: Int): Boolean = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
|
||||||
|
|
||||||
private fun parseCSV(values: List<String>): OrbitalData? = runCatching {
|
private fun parseCSV(values: List<String>): OrbitalData? = runCatching {
|
||||||
val name = values[0]
|
val name = values[0]
|
||||||
val timestamp = values[2]
|
val timestamp = values[2]
|
||||||
@@ -106,7 +104,8 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
|||||||
}.onFailure { println("TLE parsing exception: $it") }.getOrNull()
|
}.onFailure { println("TLE parsing exception: $it") }.getOrNull()
|
||||||
|
|
||||||
private fun getDayOfYear(year: Int, month: Int, dayOfMonth: Int): Int {
|
private fun getDayOfYear(year: Int, month: Int, dayOfMonth: Int): Int {
|
||||||
val daysInMonth = intArrayOf(31, if (isLeapYear(year)) 29 else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
|
val isLeapYear = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
||||||
|
val daysInMonth = intArrayOf(31, if (isLeapYear) 29 else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
|
||||||
return daysInMonth.take(month - 1).sum() + dayOfMonth
|
return daysInMonth.take(month - 1).sum() + dayOfMonth
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,11 +95,11 @@ class DataParserTest {
|
|||||||
assert(dataParser.parseCSVStream(validCSVStream) == dataParser.parseTLEStream(validTLEStream))
|
assert(dataParser.parseCSVStream(validCSVStream) == dataParser.parseTLEStream(validTLEStream))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
// @Test
|
||||||
fun `Function isLeapYear returns correct data`() = runTest(testDispatcher) {
|
// fun `Function isLeapYear returns correct data`() = runTest(testDispatcher) {
|
||||||
val years = listOf(1900, 1984, 1994, 2016, 2022, 2024, 2042, 2048)
|
// val years = listOf(1900, 1984, 1994, 2016, 2022, 2024, 2042, 2048)
|
||||||
val answers = listOf(false, true, false, true, false, true, false, true)
|
// val answers = listOf(false, true, false, true, false, true, false, true)
|
||||||
val results = years.map { dataParser.isLeapYear(it) }
|
// val results = years.map { dataParser.isLeapYear(it) }
|
||||||
assert(results == answers)
|
// assert(results == answers)
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
@@ -178,12 +178,6 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
|||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(
|
|
||||||
painter = painterResource(R.drawable.ic_altitude),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Text(text = "${pass.altitude} km", fontSize = 15.sp)
|
Text(text = "${pass.altitude} km", fontSize = 15.sp)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
+2
-2
@@ -30,10 +30,10 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
|||||||
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
|
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Radar(val catNum: Int = 0, val aosTime: Long = 0L) : Screen(R.drawable.ic_radar, R.string.nav_radar)
|
data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class RadioControl(val catNum: Int = 0, val aosTime: Long = 0L) : Screen(0, 0)
|
data object RadioControl : Screen(0, 0)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
|
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
|
||||||
|
|||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
package com.rtbishop.look4sat.core.presentation
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SwipeToDismissBox
|
||||||
|
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||||
|
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.dropShadow
|
||||||
|
import androidx.compose.ui.draw.innerShadow
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SwipeableItem(onSwipeRight: () -> Unit, onSwipeLeft: () -> Unit, content: @Composable () -> Unit) {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val dismissThresholdPx = with(LocalDensity.current) { 120.dp.toPx() }
|
||||||
|
val dismissState = rememberSwipeToDismissBoxState { dismissThresholdPx }
|
||||||
|
val willTrigger by remember { derivedStateOf { dismissState.targetValue != SwipeToDismissBoxValue.Settled } }
|
||||||
|
val hapticFeedback = LocalHapticFeedback.current
|
||||||
|
LaunchedEffect(willTrigger) {
|
||||||
|
val feedbackType = if (willTrigger) HapticFeedbackType.LongPress else HapticFeedbackType.SegmentTick
|
||||||
|
if (willTrigger) hapticFeedback.performHapticFeedback(feedbackType)
|
||||||
|
}
|
||||||
|
SwipeToDismissBox(
|
||||||
|
state = dismissState,
|
||||||
|
enableDismissFromStartToEnd = true,
|
||||||
|
enableDismissFromEndToStart = true,
|
||||||
|
backgroundContent = {
|
||||||
|
val isSwipeRight = dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd
|
||||||
|
SwipeBackground(isSwipeRight, willTrigger, MaterialTheme.colorScheme.primary)
|
||||||
|
},
|
||||||
|
content = { content() },
|
||||||
|
onDismiss = {
|
||||||
|
val targetValue = dismissState.targetValue
|
||||||
|
when (targetValue) {
|
||||||
|
SwipeToDismissBoxValue.StartToEnd -> onSwipeRight()
|
||||||
|
SwipeToDismissBoxValue.EndToStart -> onSwipeLeft()
|
||||||
|
SwipeToDismissBoxValue.Settled -> {}
|
||||||
|
}
|
||||||
|
if (targetValue != SwipeToDismissBoxValue.Settled) {
|
||||||
|
coroutineScope.launch { dismissState.snapTo(SwipeToDismissBoxValue.Settled) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SwipeBackground(isSwipeRight: Boolean, willTrigger: Boolean, bgColor: Color = Color(0x00000000)) {
|
||||||
|
val shape = RectangleShape
|
||||||
|
Box(
|
||||||
|
contentAlignment = if (isSwipeRight) Alignment.CenterStart else Alignment.CenterEnd,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.border(width = 1.dp, shape = shape, color = bgColor)
|
||||||
|
.dropShadow(shape = shape) {
|
||||||
|
color = bgColor
|
||||||
|
radius = 40f
|
||||||
|
alpha = if (willTrigger) .2f else 0f
|
||||||
|
}
|
||||||
|
.innerShadow(shape = shape) {
|
||||||
|
color = bgColor
|
||||||
|
radius = 40f
|
||||||
|
alpha = if (willTrigger) 1f else .2f
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
val iconScale by animateFloatAsState(targetValue = if (willTrigger) 1f else .8f)
|
||||||
|
val slide by animateDpAsState(targetValue = if (willTrigger) 32.dp else (12).dp)
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_filter),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(32.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.rotate(if (isSwipeRight) 0f else 180f)
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = iconScale
|
||||||
|
scaleY = iconScale
|
||||||
|
translationX = slide.toPx()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M4,20h16v2L4,22zM4,2h16v2L4,4zM13,9h3l-4,-4 -4,4h3v6L8,15l4,4 4,-4h-3z" />
|
|
||||||
</vector>
|
|
||||||
@@ -130,8 +130,16 @@
|
|||||||
<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_sweep">启用雷达扫描动画</string>
|
||||||
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
|
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
|
||||||
|
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
|
||||||
|
|
||||||
<string name="prefs_outro_title">我要感谢:</string>
|
<string name="prefs_outro_title">我要感谢:</string>
|
||||||
|
<string name="prefs_outro_thanks" translatable="false">
|
||||||
|
• Look4Sat 所有用户及贡献者!
|
||||||
|
\n• David A. B. Johnson (predict4java)
|
||||||
|
\n• Dave Moten (predict4java)
|
||||||
|
\n• Alexandru Csete (Gpredict)
|
||||||
|
\n• Dr T.S. Kelso (Celestrak)
|
||||||
|
\n• Libre Space Foundation (SatNOGS)</string>
|
||||||
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
|
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
@@ -49,12 +49,9 @@
|
|||||||
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
\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_title" translatable="false">What\'s new in Look4Sat</string>
|
||||||
<string name="pass_whatsnew_message" translatable="false">
|
<string name="pass_whatsnew_message" translatable="false">
|
||||||
* Added fixes for Turkish translation, by Emre Can Akdaş (TA3ECR)
|
* Implemented Swipe-to-Focus behavior for passes - swipe right on a pass to focus it, and left - to unfocus
|
||||||
\n* Added passes sticky header with sunrise/sunset times
|
\n\n* Implemented preserving the pass selection between the Radar and Map screens. Not every satellite has
|
||||||
\n* Added current moon/sun positions to the Radar Screen
|
a pass overhead every day, so this should make it only slightly easier to keep track of certain satellites.
|
||||||
\n* Added current moon/sun positions to the Map Screen
|
|
||||||
\n* Added red night mode filter overlay for the whole app
|
|
||||||
|
|
||||||
</string>
|
</string>
|
||||||
|
|
||||||
<!-- Radar screen -->
|
<!-- Radar screen -->
|
||||||
|
|||||||
@@ -1,5 +1,2 @@
|
|||||||
* Added fixes for Turkish translation, by Emre Can Akdaş (TA3ECR)
|
* Implemented Swipe-to-Focus behavior for passes - swipe right on a pass to focus it, and left - to unfocus
|
||||||
* Added passes sticky header with sunrise/sunset times
|
* Implemented preserving the pass selection between the Radar and Map screens. Not every satellite has a pass overhead every day, so this should make it only slightly easier to keep track of certain satellites.
|
||||||
* Added current moon/sun positions to the Radar Screen
|
|
||||||
* Added current moon/sun positions to the Map Screen
|
|
||||||
* Added red night mode filter overlay for the whole app
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
* Added fixes for Turkish translation, by Emre Can Akdaş (TA3ECR)
|
|
||||||
* Added passes sticky header with sunrise/sunset times
|
|
||||||
* Added current moon/sun positions to the Radar Screen
|
|
||||||
* Added current moon/sun positions to the Map Screen
|
|
||||||
* Added red night mode filter overlay for the whole app
|
|
||||||
@@ -123,10 +123,7 @@ private val moonIconPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|||||||
fun MapDestination() {
|
fun MapDestination() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val viewModel = viewModel(
|
val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container))
|
||||||
modelClass = MapViewModel::class.java,
|
|
||||||
factory = MapViewModel.factory(container)
|
|
||||||
)
|
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val mapView = rememberMapViewWithLifecycle()
|
val mapView = rememberMapViewWithLifecycle()
|
||||||
MapScreen(uiState, viewModel::onAction, mapView)
|
MapScreen(uiState, viewModel::onAction, mapView)
|
||||||
|
|||||||
@@ -76,7 +76,8 @@ class MapViewModel(
|
|||||||
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
|
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
selectDefaultSatellite(-1)
|
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
|
||||||
|
selectDefaultSatellite(if (selectedCatNum != 0) selectedCatNum else -1)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onAction(action: MapAction) {
|
fun onAction(action: MapAction) {
|
||||||
@@ -126,10 +127,16 @@ class MapViewModel(
|
|||||||
val dateNow = Date()
|
val dateNow = Date()
|
||||||
getStationPosition()
|
getStationPosition()
|
||||||
getSatTrack(orbitalObject, stationPos, dateNow)
|
getSatTrack(orbitalObject, stationPos, dateNow)
|
||||||
|
// Scale update interval based on satellite count to avoid excessive CPU usage
|
||||||
|
val effectiveRate = when {
|
||||||
|
allSatellites.size > 5000 -> maxOf(updateFreq, 3000L)
|
||||||
|
allSatellites.size > 1000 -> maxOf(updateFreq, 2000L)
|
||||||
|
else -> updateFreq
|
||||||
|
}
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
dateNow.time = System.currentTimeMillis()
|
dateNow.time = System.currentTimeMillis()
|
||||||
updateMapState(orbitalObject, allSatellites, stationPos, dateNow)
|
updateMapState(orbitalObject, allSatellites, stationPos, dateNow)
|
||||||
delay(updateFreq)
|
delay(effectiveRate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,11 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.feature.passes
|
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.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -29,11 +34,8 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
|
||||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
@@ -45,8 +47,6 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
|||||||
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
|
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
|
||||||
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -72,6 +72,7 @@ import com.rtbishop.look4sat.core.presentation.MainTheme
|
|||||||
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
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.TimerRow
|
||||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||||
@@ -85,10 +86,7 @@ import java.util.TimeZone
|
|||||||
fun PassesDestination(navigateToRadar: (Int, Long) -> Unit) {
|
fun PassesDestination(navigateToRadar: (Int, Long) -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val viewModel = viewModel(
|
val viewModel: PassesViewModel = viewModel(factory = PassesViewModel.factory(container))
|
||||||
modelClass = PassesViewModel::class.java,
|
|
||||||
factory = PassesViewModel.factory(container)
|
|
||||||
)
|
|
||||||
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
|
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
|
||||||
PassesScreen(uiState, viewModel::onAction, navigateToRadar)
|
PassesScreen(uiState, viewModel::onAction, navigateToRadar)
|
||||||
}
|
}
|
||||||
@@ -125,7 +123,6 @@ private fun PassesScreen(
|
|||||||
onAction(PassesAction.DismissWhatsNew)
|
onAction(PassesAction.DismissWhatsNew)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val gridState = rememberLazyGridState()
|
|
||||||
ScreenColumn(
|
ScreenColumn(
|
||||||
topBar = { isVerticalLayout ->
|
topBar = { isVerticalLayout ->
|
||||||
TopBar(
|
TopBar(
|
||||||
@@ -149,10 +146,11 @@ private fun PassesScreen(
|
|||||||
isRefreshing = uiState.isRefreshing,
|
isRefreshing = uiState.isRefreshing,
|
||||||
isUtc = uiState.isUtc,
|
isUtc = uiState.isUtc,
|
||||||
passes = uiState.itemsList,
|
passes = uiState.itemsList,
|
||||||
|
groupedPasses = uiState.groupedPasses,
|
||||||
|
sunTimes = uiState.sunTimes,
|
||||||
|
focusedCatNum = uiState.focusedCatNum,
|
||||||
navigateToRadar = navigateToRadar,
|
navigateToRadar = navigateToRadar,
|
||||||
refreshPasses = { onAction(PassesAction.RefreshPasses) },
|
onAction = onAction
|
||||||
gridState = gridState,
|
|
||||||
sunTimes = uiState.sunTimes
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,50 +161,19 @@ private fun PassesList(
|
|||||||
isRefreshing: Boolean,
|
isRefreshing: Boolean,
|
||||||
isUtc: Boolean,
|
isUtc: Boolean,
|
||||||
passes: List<OrbitalPass>,
|
passes: List<OrbitalPass>,
|
||||||
|
groupedPasses: Map<String, List<OrbitalPass>>,
|
||||||
|
sunTimes: Map<String, Pair<String, String>>,
|
||||||
|
focusedCatNum: Int?,
|
||||||
navigateToRadar: (Int, Long) -> Unit,
|
navigateToRadar: (Int, Long) -> Unit,
|
||||||
refreshPasses: () -> Unit,
|
onAction: (PassesAction) -> Unit
|
||||||
gridState: LazyGridState,
|
|
||||||
sunTimes: Map<String, Pair<String, String>>
|
|
||||||
) {
|
) {
|
||||||
val isVerticalLayout = isVerticalLayout()
|
val isVerticalLayout = isVerticalLayout()
|
||||||
val refreshState = rememberPullToRefreshState()
|
val refreshState = rememberPullToRefreshState()
|
||||||
val timeZone = remember(isUtc) { if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault() }
|
|
||||||
val sdfDate = remember(isUtc) {
|
|
||||||
SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = timeZone }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group passes by calendar day so we know where headers go
|
|
||||||
val groupedPasses = remember(passes, isUtc) {
|
|
||||||
passes.filter { !it.isDeepSpace }.groupBy { sdfDate.format(Date(it.aosTime)) } +
|
|
||||||
if (passes.any { it.isDeepSpace }) mapOf("Deep Space" to passes.filter { it.isDeepSpace }) else emptyMap()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive the sticky header label from the first visible item index
|
|
||||||
val stickyHeader by remember(gridState, groupedPasses) {
|
|
||||||
derivedStateOf {
|
|
||||||
val firstVisible = gridState.firstVisibleItemIndex
|
|
||||||
var itemIndex = 0
|
|
||||||
var header = groupedPasses.keys.firstOrNull() ?: ""
|
|
||||||
for ((key, items) in groupedPasses) {
|
|
||||||
header = key
|
|
||||||
itemIndex += 1 + items.size
|
|
||||||
if (itemIndex > firstVisible) break
|
|
||||||
}
|
|
||||||
header
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The sticky overlay should only show when the first in-list header has scrolled out of view.
|
|
||||||
// firstVisibleItemIndex == 0 means the first header is still visible — hide the overlay.
|
|
||||||
val showStickyOverlay by remember(gridState) {
|
|
||||||
derivedStateOf { gridState.firstVisibleItemIndex > 0 || gridState.firstVisibleItemScrollOffset > 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
ElevatedCard(modifier = Modifier.fillMaxSize()) {
|
ElevatedCard(modifier = Modifier.fillMaxSize()) {
|
||||||
PullToRefreshBox(
|
PullToRefreshBox(
|
||||||
isRefreshing = isRefreshing,
|
isRefreshing = isRefreshing,
|
||||||
state = refreshState,
|
state = refreshState,
|
||||||
onRefresh = refreshPasses,
|
onRefresh = { onAction(PassesAction.RefreshPasses) },
|
||||||
indicator = {
|
indicator = {
|
||||||
PullToRefreshDefaults.Indicator(
|
PullToRefreshDefaults.Indicator(
|
||||||
state = refreshState,
|
state = refreshState,
|
||||||
@@ -220,31 +187,35 @@ private fun PassesList(
|
|||||||
if (passes.isEmpty()) {
|
if (passes.isEmpty()) {
|
||||||
EmptyListCard(message = stringResource(R.string.pass_empty_list_message))
|
EmptyListCard(message = stringResource(R.string.pass_empty_list_message))
|
||||||
} else {
|
} else {
|
||||||
Column {
|
AnimatedContent(
|
||||||
// Sticky header overlay — only visible once the in-list header scrolls away
|
targetState = focusedCatNum,
|
||||||
if (showStickyOverlay) {
|
transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) },
|
||||||
val (rise, set) = sunTimes[stickyHeader] ?: ("--:--" to "--:--")
|
label = "PassesFocusTransition"
|
||||||
StickyDateHeader(label = stickyHeader, sunriseTime = rise, sunsetTime = set)
|
) { targetFocus ->
|
||||||
}
|
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp), modifier = Modifier.fillMaxSize()) {
|
||||||
LazyVerticalGrid(
|
|
||||||
state = gridState,
|
|
||||||
columns = GridCells.Adaptive(320.dp),
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
) {
|
|
||||||
for ((dateLabel, dayPasses) in groupedPasses) {
|
for ((dateLabel, dayPasses) in groupedPasses) {
|
||||||
// In-list section header (scrolls away, drives the sticky overlay)
|
val headerVisible = targetFocus == null || dayPasses.any { it.catNum == targetFocus }
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
if (headerVisible) {
|
||||||
val (rise, set) = sunTimes[dateLabel] ?: ("--:--" to "--:--")
|
stickyHeader(key = "header_$dateLabel") {
|
||||||
StickyDateHeader(label = dateLabel, sunriseTime = rise, sunsetTime = set)
|
val (rise, set) = sunTimes[dateLabel] ?: ("--:--" to "--:--")
|
||||||
|
StickyDateHeader(label = dateLabel, sunriseTime = rise, sunsetTime = set)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
items(items = dayPasses, key = { item -> item.catNum + item.aosTime }) { pass ->
|
val visiblePasses = if (targetFocus == null) dayPasses
|
||||||
PassItem(
|
else dayPasses.filter { it.catNum == targetFocus }
|
||||||
pass = pass,
|
items(items = visiblePasses, key = { item -> item.catNum + item.aosTime }) { pass ->
|
||||||
navigateToRadar = navigateToRadar,
|
SwipeableItem(
|
||||||
modifier = Modifier.animateItem(),
|
onSwipeRight = { onAction(PassesAction.FocusCatNum(pass.catNum)) },
|
||||||
isVerticalLayout = isVerticalLayout,
|
onSwipeLeft = { onAction(PassesAction.ClearFocus) }
|
||||||
isUtc = isUtc
|
) {
|
||||||
)
|
PassItem(
|
||||||
|
pass = pass,
|
||||||
|
navigateToRadar = navigateToRadar,
|
||||||
|
modifier = Modifier.animateItem(),
|
||||||
|
isVerticalLayout = isVerticalLayout,
|
||||||
|
isUtc = isUtc
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -397,13 +368,6 @@ private fun PassItem(
|
|||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.drawable.ic_altitude),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Text(text = "${pass.altitude} km", fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface)
|
Text(text = "${pass.altitude} km", fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface)
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -32,9 +32,10 @@ data class PassesState(
|
|||||||
val showDeepSpace: Boolean = true,
|
val showDeepSpace: Boolean = true,
|
||||||
val modes: List<String> = emptyList(),
|
val modes: List<String> = emptyList(),
|
||||||
val itemsList: List<OrbitalPass> = emptyList(),
|
val itemsList: List<OrbitalPass> = emptyList(),
|
||||||
|
val groupedPasses: Map<String, List<OrbitalPass>> = emptyMap(),
|
||||||
val shouldSeeWhatsNew: Boolean = false,
|
val shouldSeeWhatsNew: Boolean = false,
|
||||||
// Map of dateLabel -> Pair(sunriseTime, sunsetTime) for each day group
|
val sunTimes: Map<String, Pair<String, String>> = emptyMap(),
|
||||||
val sunTimes: Map<String, Pair<String, String>> = emptyMap()
|
val focusedCatNum: Int? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface PassesAction {
|
sealed interface PassesAction {
|
||||||
@@ -44,4 +45,6 @@ sealed interface PassesAction {
|
|||||||
data object RefreshPasses : PassesAction
|
data object RefreshPasses : PassesAction
|
||||||
data object TogglePassesDialog : PassesAction
|
data object TogglePassesDialog : PassesAction
|
||||||
data object ToggleRadiosDialog : PassesAction
|
data object ToggleRadiosDialog : PassesAction
|
||||||
|
data class FocusCatNum(val catNum: Int) : PassesAction
|
||||||
|
data object ClearFocus : PassesAction
|
||||||
}
|
}
|
||||||
+45
-30
@@ -74,37 +74,31 @@ class PassesViewModel(
|
|||||||
_uiState.update { it.copy(isUtc = settings.stateOfUtc, shouldSeeWhatsNew = settings.shouldSeeWhatsNew) }
|
_uiState.update { it.copy(isUtc = settings.stateOfUtc, shouldSeeWhatsNew = settings.shouldSeeWhatsNew) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Local tick loop — computes pass progress, countdown timer, and per-day sun times every second
|
// Main tick loop: reacts to new passes, then ticks every second
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
var lastSunTimesKey = "" // track when we need to recompute sun times
|
satelliteRepo.passes.collectLatest { allPasses ->
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
val timeNow = System.currentTimeMillis()
|
val timeNow = System.currentTimeMillis()
|
||||||
val isUtc = _uiState.value.isUtc
|
val isUtc = _uiState.value.isUtc
|
||||||
val showDeepSpace = _uiState.value.showDeepSpace
|
val showDeepSpace = _uiState.value.showDeepSpace
|
||||||
val allPasses = satelliteRepo.passes.value
|
val filtered = allPasses
|
||||||
val filtered = if (showDeepSpace) allPasses else allPasses.filter { !it.isDeepSpace }
|
.let { if (showDeepSpace) it else it.filter { pass -> !pass.isDeepSpace } }
|
||||||
val processed = computePassProgress(filtered, timeNow)
|
val processed = computePassProgress(filtered, timeNow)
|
||||||
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
|
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
|
||||||
|
val sunTimes = computeSunTimes(processed, isUtc)
|
||||||
// Recompute per-day sun times only when passes list or UTC setting changes
|
val grouped = groupPasses(processed, isUtc)
|
||||||
val sunTimesKey = "${processed.firstOrNull()?.aosTime}-${processed.lastOrNull()?.aosTime}-$isUtc"
|
_uiState.update {
|
||||||
val sunTimes = if (sunTimesKey != lastSunTimesKey) {
|
it.copy(
|
||||||
lastSunTimesKey = sunTimesKey
|
itemsList = processed,
|
||||||
computeSunTimes(processed, isUtc)
|
groupedPasses = grouped,
|
||||||
} else {
|
sunTimes = sunTimes,
|
||||||
_uiState.value.sunTimes
|
nextPass = nextPass,
|
||||||
|
nextTime = nextTime,
|
||||||
|
isNextTimeAos = isAos
|
||||||
|
)
|
||||||
|
}
|
||||||
|
delay(1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
_uiState.update {
|
|
||||||
it.copy(
|
|
||||||
itemsList = processed,
|
|
||||||
nextPass = nextPass,
|
|
||||||
nextTime = nextTime,
|
|
||||||
isNextTimeAos = isAos,
|
|
||||||
sunTimes = sunTimes
|
|
||||||
)
|
|
||||||
}
|
|
||||||
delay(1000)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,16 +115,25 @@ class PassesViewModel(
|
|||||||
_uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) }
|
_uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) }
|
||||||
PassesAction.ToggleRadiosDialog ->
|
PassesAction.ToggleRadiosDialog ->
|
||||||
_uiState.update { it.copy(isRadiosDialogShown = !it.isRadiosDialogShown) }
|
_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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Computes sunrise/sunset strings for each unique calendar day in the pass list. */
|
// Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace
|
||||||
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
|
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
|
||||||
val stationPos = settingsRepo.stationPosition.value
|
val stationPos = settingsRepo.stationPosition.value
|
||||||
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz }
|
val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz }
|
||||||
val sdfTime = SimpleDateFormat("HH:mm", Locale.ENGLISH).also { it.timeZone = tz }
|
val sdfTime = SimpleDateFormat("HH:mm", Locale.ENGLISH).also { it.timeZone = tz }
|
||||||
val result = LinkedHashMap<String, Pair<String, String>>()
|
val result = LinkedHashMap<String, Pair<String, String>>()
|
||||||
|
// DeepSpace group always shows today's sun times
|
||||||
|
if (passes.any { it.isDeepSpace }) {
|
||||||
|
val riseSet = CelestialComputer.findSunRiseSet(stationPos, System.currentTimeMillis())
|
||||||
|
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
|
||||||
|
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
|
||||||
|
result["DeepSpace (period >225min)"] = rise to set
|
||||||
|
}
|
||||||
for (pass in passes) {
|
for (pass in passes) {
|
||||||
if (pass.isDeepSpace) continue
|
if (pass.isDeepSpace) continue
|
||||||
val label = sdfDate.format(Date(pass.aosTime))
|
val label = sdfDate.format(Date(pass.aosTime))
|
||||||
@@ -143,6 +146,18 @@ class PassesViewModel(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun groupPasses(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, List<OrbitalPass>> {
|
||||||
|
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
|
val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz }
|
||||||
|
val ordered = LinkedHashMap<String, List<OrbitalPass>>()
|
||||||
|
val deepSpace = passes.filter { it.isDeepSpace }
|
||||||
|
if (deepSpace.isNotEmpty()) ordered["DeepSpace (period >225min)"] = deepSpace
|
||||||
|
passes.filter { !it.isDeepSpace }
|
||||||
|
.groupByTo(LinkedHashMap()) { sdfDate.format(Date(it.aosTime)) }
|
||||||
|
.forEach { (k, v) -> ordered[k] = v }
|
||||||
|
return ordered
|
||||||
|
}
|
||||||
|
|
||||||
/** Computes live progress for each pass, filtering out expired ones. */
|
/** Computes live progress for each pass, filtering out expired ones. */
|
||||||
private fun computePassProgress(passList: List<OrbitalPass>, time: Long): List<OrbitalPass> {
|
private fun computePassProgress(passList: List<OrbitalPass>, time: Long): List<OrbitalPass> {
|
||||||
val result = ArrayList<OrbitalPass>(passList.size)
|
val result = ArrayList<OrbitalPass>(passList.size)
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -77,42 +76,23 @@ import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
|||||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RadarDestination(
|
fun RadarDestination(navigateToRadioControl: () -> Unit = {}) {
|
||||||
catNum: Int = 0,
|
|
||||||
aosTime: Long = 0L,
|
|
||||||
navigateUp: () -> Unit,
|
|
||||||
navigateToRadioControl: (Int, Long) -> Unit = { _, _ -> }
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val viewModel = viewModel(
|
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
|
||||||
modelClass = RadarViewModel::class.java,
|
|
||||||
key = "$catNum-$aosTime",
|
|
||||||
factory = RadarViewModel.factory(catNum, aosTime, container)
|
|
||||||
)
|
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
RadarScreen(uiState, viewModel::onAction, navigateUp, navigateToRadioControl)
|
RadarScreen(uiState, viewModel::onAction, navigateToRadioControl)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RadarScreen(
|
private fun RadarScreen(
|
||||||
uiState: RadarState,
|
uiState: RadarState,
|
||||||
onAction: (RadarAction) -> Unit,
|
onAction: (RadarAction) -> Unit,
|
||||||
navigateUp: () -> Unit,
|
navigateToRadioControl: () -> Unit
|
||||||
navigateToRadioControl: (Int, Long) -> Unit
|
|
||||||
) {
|
) {
|
||||||
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
||||||
LaunchedEffect(uiState.isLos) { if (uiState.isLos) navigateUp() }
|
|
||||||
|
|
||||||
val addToCalendar: () -> Unit = {
|
val addToCalendar: () -> Unit = {
|
||||||
uiState.currentPass?.let { pass ->
|
uiState.currentPass?.let { onAction(RadarAction.AddToCalendar(it.name, it.aosTime, it.losTime)) }
|
||||||
onAction(RadarAction.AddToCalendar(pass.name, pass.aosTime, pass.losTime))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val openRadioControl: () -> Unit = {
|
|
||||||
uiState.currentPass?.let { pass ->
|
|
||||||
navigateToRadioControl(pass.catNum, pass.aosTime)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -125,7 +105,7 @@ private fun RadarScreen(
|
|||||||
TopBar {
|
TopBar {
|
||||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
||||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
||||||
IconCard(action = openRadioControl, resId = R.drawable.ic_radios)
|
IconCard(action = navigateToRadioControl, resId = R.drawable.ic_radios)
|
||||||
}
|
}
|
||||||
TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
|
TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
|
||||||
} else {
|
} else {
|
||||||
@@ -133,7 +113,7 @@ private fun RadarScreen(
|
|||||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
||||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
||||||
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
|
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
|
||||||
IconCard(action = openRadioControl, resId = R.drawable.ic_radios)
|
IconCard(action = navigateToRadioControl, resId = R.drawable.ic_radios)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isVertical) {
|
if (isVertical) {
|
||||||
|
|||||||
@@ -43,8 +43,6 @@ import kotlinx.coroutines.isActive
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class RadarViewModel(
|
class RadarViewModel(
|
||||||
private val catNum: Int,
|
|
||||||
private val aosTime: Long,
|
|
||||||
private val bluetoothReporter: IReporter,
|
private val bluetoothReporter: IReporter,
|
||||||
private val networkReporter: IReporter,
|
private val networkReporter: IReporter,
|
||||||
private val satelliteRepo: ISatelliteRepo,
|
private val satelliteRepo: ISatelliteRepo,
|
||||||
@@ -85,26 +83,24 @@ class RadarViewModel(
|
|||||||
// Resolve which pass we're tracking and start the tick loop
|
// Resolve which pass we're tracking and start the tick loop
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val passes = satelliteRepo.passes.value
|
val passes = satelliteRepo.passes.value
|
||||||
val currentPass = passes.find { it.catNum == catNum && it.aosTime == aosTime }
|
val (catNum, aosTime) = satelliteRepo.selectedPass.value
|
||||||
?: passes.firstOrNull()
|
val satPass = passes.find { it.catNum == catNum && it.aosTime == aosTime } ?: passes.firstOrNull()
|
||||||
currentPass?.let { satPass ->
|
satPass?.let { pass ->
|
||||||
_uiState.update { it.copy(currentPass = satPass) }
|
_uiState.update { it.copy(currentPass = pass) }
|
||||||
val transmitters = satelliteRepo.getRadiosWithId(satPass.catNum)
|
val transmitters = satelliteRepo.getRadiosWithId(pass.catNum)
|
||||||
// Compute track once (it doesn't change for a given pass)
|
// Compute track once (it doesn't change for a given pass)
|
||||||
if (!satPass.isDeepSpace) {
|
if (!pass.isDeepSpace) {
|
||||||
val track = satelliteRepo.getTrack(
|
val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime)
|
||||||
satPass.orbitalObject, stationPos, satPass.aosTime, satPass.losTime
|
|
||||||
)
|
|
||||||
_uiState.update { it.copy(satTrack = track) }
|
_uiState.update { it.copy(satTrack = track) }
|
||||||
}
|
}
|
||||||
// Tick loop — position, timer, and radio updates every second
|
// Tick loop — position, timer, and radio updates every second
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
val timeNow = System.currentTimeMillis()
|
val timeNow = System.currentTimeMillis()
|
||||||
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow)
|
val pos = satelliteRepo.getPosition(pass.orbitalObject, stationPos, timeNow)
|
||||||
val sunPos = CelestialComputer.getSunPosition(stationPos, timeNow)
|
val sunPos = CelestialComputer.getSunPosition(stationPos, timeNow)
|
||||||
val moonPos = CelestialComputer.getMoonPosition(stationPos, timeNow)
|
val moonPos = CelestialComputer.getMoonPosition(stationPos, timeNow)
|
||||||
val (time, isAos) = computeTimer(satPass.isDeepSpace, satPass.aosTime, satPass.losTime, timeNow)
|
val (time, isAos) = computeTimer(pass.isDeepSpace, pass.aosTime, pass.losTime, timeNow)
|
||||||
val isLos = !satPass.isDeepSpace && timeNow > satPass.losTime
|
val isLos = !pass.isDeepSpace && timeNow > pass.losTime
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
currentTime = time,
|
currentTime = time,
|
||||||
@@ -115,7 +111,7 @@ class RadarViewModel(
|
|||||||
moonPosition = moonPos
|
moonPosition = moonPos
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
processRadios(transmitters, satPass.orbitalObject, timeNow)
|
processRadios(transmitters, pass.orbitalObject, timeNow)
|
||||||
sendPassData(pos)
|
sendPassData(pos)
|
||||||
delay(1000)
|
delay(1000)
|
||||||
}
|
}
|
||||||
@@ -213,11 +209,9 @@ class RadarViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun factory(catNum: Int, aosTime: Long, container: IMainContainer) = viewModelFactory {
|
fun factory(container: IMainContainer) = viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
RadarViewModel(
|
RadarViewModel(
|
||||||
catNum = catNum,
|
|
||||||
aosTime = aosTime,
|
|
||||||
bluetoothReporter = container.provideBluetoothReporter(),
|
bluetoothReporter = container.provideBluetoothReporter(),
|
||||||
networkReporter = container.provideNetworkReporter(),
|
networkReporter = container.provideNetworkReporter(),
|
||||||
satelliteRepo = container.satelliteRepo,
|
satelliteRepo = container.satelliteRepo,
|
||||||
|
|||||||
+2
-6
@@ -66,14 +66,10 @@ import com.rtbishop.look4sat.core.presentation.layoutPadding
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RadioControlDestination(catNum: Int = 0, aosTime: Long = 0L, navigateUp: () -> Unit) {
|
fun RadioControlDestination(navigateUp: () -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val viewModel = viewModel(
|
val viewModel: RadioControlViewModel = viewModel(factory = RadioControlViewModel.factory(container))
|
||||||
modelClass = RadioControlViewModel::class.java,
|
|
||||||
key = "$catNum-$aosTime",
|
|
||||||
factory = RadioControlViewModel.factory(catNum, aosTime, container)
|
|
||||||
)
|
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
RadioControlScreen(uiState, viewModel::onAction, navigateUp)
|
RadioControlScreen(uiState, viewModel::onAction, navigateUp)
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-19
@@ -38,8 +38,6 @@ import kotlinx.coroutines.launch
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
class RadioControlViewModel(
|
class RadioControlViewModel(
|
||||||
private val catNum: Int,
|
|
||||||
private val aosTime: Long,
|
|
||||||
private val trackingService: IRadioTrackingService,
|
private val trackingService: IRadioTrackingService,
|
||||||
private val satelliteRepo: ISatelliteRepo,
|
private val satelliteRepo: ISatelliteRepo,
|
||||||
settingsRepo: ISettingsRepo
|
settingsRepo: ISettingsRepo
|
||||||
@@ -73,27 +71,23 @@ class RadioControlViewModel(
|
|||||||
// Resolve pass and load transponders
|
// Resolve pass and load transponders
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val passes = satelliteRepo.passes.value
|
val passes = satelliteRepo.passes.value
|
||||||
val pass = passes.find { it.catNum == catNum && it.aosTime == aosTime }
|
val (catNum, aosTime) = satelliteRepo.selectedPass.value
|
||||||
?: passes.firstOrNull()
|
val satPass = passes.find { it.catNum == catNum && it.aosTime == aosTime } ?: passes.firstOrNull()
|
||||||
currentPass = pass
|
currentPass = satPass
|
||||||
pass?.let { satPass ->
|
satPass?.let { pass ->
|
||||||
val allRadios = satelliteRepo.getRadiosWithId(satPass.catNum)
|
val allRadios = satelliteRepo.getRadiosWithId(pass.catNum)
|
||||||
transponders = allRadios.filter { it.downlinkLow != null }
|
transponders = allRadios.filter { it.downlinkLow != null }
|
||||||
_uiState.update {
|
_uiState.update { it.copy(currentPass = pass, transponders = transponders) }
|
||||||
it.copy(currentPass = satPass, transponders = transponders)
|
|
||||||
}
|
|
||||||
// If service is already tracking this pass, sync the selected transponder
|
// If service is already tracking this pass, sync the selected transponder
|
||||||
val svcState = trackingService.state.value
|
val svcState = trackingService.state.value
|
||||||
if (svcState.isActive && svcState.currentPass?.catNum == satPass.catNum) {
|
if (svcState.isActive && svcState.currentPass?.catNum == pass.catNum) {
|
||||||
_uiState.update {
|
_uiState.update { it.copy(selectedTransponderUuid = svcState.selectedTransponder?.uuid) }
|
||||||
it.copy(selectedTransponderUuid = svcState.selectedTransponder?.uuid)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Tick loop — timer and satellite position updates every second
|
// Tick loop — timer and satellite position updates every second
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
val timeNow = System.currentTimeMillis()
|
val timeNow = System.currentTimeMillis()
|
||||||
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow)
|
val pos = satelliteRepo.getPosition(pass.orbitalObject, stationPos, timeNow)
|
||||||
val (timeStr, isAos) = computeTimer(satPass.isDeepSpace, satPass.aosTime, satPass.losTime, timeNow)
|
val (timeStr, isAos) = computeTimer(pass.isDeepSpace, pass.aosTime, pass.losTime, timeNow)
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
state.copy(
|
state.copy(
|
||||||
currentTime = timeStr,
|
currentTime = timeStr,
|
||||||
@@ -187,11 +181,9 @@ class RadioControlViewModel(
|
|||||||
return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz)
|
return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun factory(catNum: Int, aosTime: Long, container: IMainContainer) = viewModelFactory {
|
fun factory(container: IMainContainer) = viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
RadioControlViewModel(
|
RadioControlViewModel(
|
||||||
catNum = catNum,
|
|
||||||
aosTime = aosTime,
|
|
||||||
trackingService = container.radioTrackingService,
|
trackingService = container.radioTrackingService,
|
||||||
satelliteRepo = container.satelliteRepo,
|
satelliteRepo = container.satelliteRepo,
|
||||||
settingsRepo = container.settingsRepo
|
settingsRepo = container.settingsRepo
|
||||||
|
|||||||
+1
-4
@@ -75,10 +75,7 @@ import com.rtbishop.look4sat.core.presentation.layoutPadding
|
|||||||
fun SatellitesDestination(navigateUp: () -> Unit) {
|
fun SatellitesDestination(navigateUp: () -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val viewModel = viewModel(
|
val viewModel: SatellitesViewModel = viewModel(factory = SatellitesViewModel.factory(container))
|
||||||
modelClass = SatellitesViewModel::class.java,
|
|
||||||
factory = SatellitesViewModel.factory(container)
|
|
||||||
)
|
|
||||||
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
|
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
|
||||||
SatellitesScreen(uiState, viewModel::onAction, navigateUp)
|
SatellitesScreen(uiState, viewModel::onAction, navigateUp)
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-16
@@ -81,10 +81,7 @@ import java.util.Locale
|
|||||||
fun SettingsDestination() {
|
fun SettingsDestination() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val viewModel = viewModel(
|
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container))
|
||||||
modelClass = SettingsViewModel::class.java,
|
|
||||||
factory = SettingsViewModel.factory(container)
|
|
||||||
)
|
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
SettingsScreen(uiState, viewModel::onAction)
|
SettingsScreen(uiState, viewModel::onAction)
|
||||||
}
|
}
|
||||||
@@ -188,32 +185,63 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
val gitHubUrl = stringResource(R.string.prefs_github_url)
|
val gitHubUrl = stringResource(R.string.prefs_github_url)
|
||||||
val licenseUrl = stringResource(R.string.prefs_license_url)
|
val licenseUrl = stringResource(R.string.prefs_license_url)
|
||||||
val privacyUrl = stringResource(R.string.prefs_privacy_url)
|
val privacyUrl = stringResource(R.string.prefs_privacy_url)
|
||||||
|
val safeOpenUri: (String) -> Unit = { url ->
|
||||||
|
try { uriHandler.openUri(url) } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
ScreenColumn(
|
ScreenColumn(
|
||||||
topBar = { isVerticalLayout ->
|
topBar = { isVerticalLayout ->
|
||||||
if (isVerticalLayout) {
|
if (isVerticalLayout) {
|
||||||
TopBar {
|
TopBar {
|
||||||
TopCard(onClick = { uriHandler.openUri(appUrl) }, version = uiState.appVersionName, modifier = Modifier.weight(1f))
|
TopCard(
|
||||||
PrimaryIconCard(onClick = { uriHandler.openUri(donateUrl) }, resId = R.drawable.ic_pound)
|
onClick = { safeOpenUri(appUrl) },
|
||||||
|
version = uiState.appVersionName,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
|
||||||
}
|
}
|
||||||
TopBar {
|
TopBar {
|
||||||
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
BotCard(onClick = { uriHandler.openUri(fdroidUrl) }, R.drawable.ic_fdroid, fdroidTitle, modifier = Modifier.weight(1f))
|
BotCard(
|
||||||
BotCard(onClick = { uriHandler.openUri(gitHubUrl) }, R.drawable.ic_github, gitHubTitle, modifier = Modifier.weight(1f))
|
onClick = { safeOpenUri(fdroidUrl) },
|
||||||
|
resId = R.drawable.ic_fdroid,
|
||||||
|
text = fdroidTitle,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
BotCard(
|
||||||
|
onClick = { safeOpenUri(gitHubUrl) },
|
||||||
|
resId = R.drawable.ic_github,
|
||||||
|
text = gitHubTitle,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
IconCard(action = { uriHandler.openUri(licenseUrl) }, resId = R.drawable.ic_license)
|
IconCard(action = { safeOpenUri(licenseUrl) }, resId = R.drawable.ic_license)
|
||||||
IconCard(action = { uriHandler.openUri(privacyUrl) }, resId = R.drawable.ic_policy)
|
IconCard(action = { safeOpenUri(privacyUrl) }, resId = R.drawable.ic_policy)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
TopBar {
|
TopBar {
|
||||||
PrimaryIconCard(onClick = { uriHandler.openUri(donateUrl) }, resId = R.drawable.ic_pound)
|
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
|
||||||
TopCard(onClick = { uriHandler.openUri(appUrl) }, version = uiState.appVersionName, modifier = Modifier.weight(1f))
|
TopCard(
|
||||||
|
onClick = { safeOpenUri(appUrl) },
|
||||||
|
version = uiState.appVersionName,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
BotCard(onClick = { uriHandler.openUri(fdroidUrl) }, R.drawable.ic_fdroid, fdroidTitle, modifier = Modifier.weight(1f))
|
BotCard(
|
||||||
BotCard(onClick = { uriHandler.openUri(gitHubUrl) }, R.drawable.ic_github, gitHubTitle, modifier = Modifier.weight(1f))
|
onClick = { safeOpenUri(fdroidUrl) },
|
||||||
|
resId = R.drawable.ic_fdroid,
|
||||||
|
text = fdroidTitle,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
BotCard(
|
||||||
|
onClick = { safeOpenUri(gitHubUrl) },
|
||||||
|
resId = R.drawable.ic_github,
|
||||||
|
text = gitHubTitle,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
IconCard(action = { uriHandler.openUri(licenseUrl) }, resId = R.drawable.ic_license)
|
IconCard(action = { safeOpenUri(licenseUrl) }, resId = R.drawable.ic_license)
|
||||||
IconCard(action = { uriHandler.openUri(privacyUrl) }, resId = R.drawable.ic_policy)
|
IconCard(action = { safeOpenUri(privacyUrl) }, resId = R.drawable.ic_policy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
[versions]
|
[versions]
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionCode = "430"
|
appVersionCode = "432"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionName = "4.3.0"
|
appVersionName = "4.3.2"
|
||||||
#noinspection GradleDependency,UnusedVersionCatalogEntry
|
#noinspection GradleDependency,UnusedVersionCatalogEntry
|
||||||
compileSdk = "36"
|
compileSdk = "36"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
@@ -12,21 +12,21 @@ jdkVersion = "17"
|
|||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
packageName = "com.rtbishop.look4sat"
|
packageName = "com.rtbishop.look4sat"
|
||||||
|
|
||||||
android-gradle-plugin = "9.2.0"
|
android-gradle-plugin = "9.2.1"
|
||||||
|
|
||||||
androidx-core-ktx = "1.18.0"
|
androidx-core-ktx = "1.18.0"
|
||||||
androidx-core-splashscreen = "1.2.0"
|
androidx-core-splashscreen = "1.2.0"
|
||||||
androidx-room = "2.8.4"
|
androidx-room = "2.8.4"
|
||||||
|
|
||||||
compose-bom = "2026.04.01"
|
compose-bom = "2026.05.01"
|
||||||
compose-activity = "1.13.0"
|
compose-activity = "1.13.0"
|
||||||
compose-lifecycle = "2.10.0"
|
compose-lifecycle = "2.10.0"
|
||||||
compose-navigation3 = "1.1.1"
|
compose-navigation3 = "1.1.2"
|
||||||
|
|
||||||
google-ksp = "2.3.6"
|
google-ksp = "2.3.7"
|
||||||
|
|
||||||
kotlin = "2.3.21"
|
kotlin = "2.3.21"
|
||||||
kotlin-coroutines = "1.10.2"
|
kotlin-coroutines = "1.11.0"
|
||||||
kotlin-serialization = "1.11.0"
|
kotlin-serialization = "1.11.0"
|
||||||
|
|
||||||
other-okhttp = "5.3.2"
|
other-okhttp = "5.3.2"
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
#Tue Apr 28 15:46:23 BST 2026
|
#Mon May 25 11:20:48 BST 2026
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
Reference in new issue
Block a user