mirror of
https://github.com/rt-bishop/Look4Sat.git
synced 2026-10-02 19:17:22 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
821fa0dfe7 | ||
|
|
ce8c2a4834 | ||
|
|
8cc4ef5610 | ||
|
|
dde22faf46 |
No files matched your search
+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
|
||||||
}
|
}
|
||||||
+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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
@@ -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,10 @@
|
|||||||
\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)
|
* Added fixes for Chinese translation, by Mubi-Baihua (#216)
|
||||||
\n* Added passes sticky header with sunrise/sunset times
|
\n* Added zipped data sources handling to DatabaseRepo
|
||||||
\n* Added current moon/sun positions to the Radar Screen
|
\n* Tweaked Passes list to show DeepSpace ones at the top
|
||||||
\n* Added current moon/sun positions to the Map Screen
|
\n* Tweaked RadarView to display sun/moon positions correctly
|
||||||
\n* Added red night mode filter overlay for the whole app
|
|
||||||
|
|
||||||
</string>
|
</string>
|
||||||
|
|
||||||
<!-- Radar screen -->
|
<!-- Radar screen -->
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
* Added fixes for Turkish translation, by Emre Can Akdaş (TA3ECR)
|
* Added fixes for Chinese translation, by Mubi-Baihua (#216)
|
||||||
* Added passes sticky header with sunrise/sunset times
|
* Added zipped data sources handling to DatabaseRepo
|
||||||
* Added current moon/sun positions to the Radar Screen
|
* Tweaked Passes list to show DeepSpace ones at the top
|
||||||
* Added current moon/sun positions to the Map Screen
|
* Tweaked RadarView to display sun/moon positions correctly
|
||||||
* Added red night mode filter overlay for the whole app
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
* Added fixes for Turkish translation, by Emre Can Akdaş (TA3ECR)
|
* Added fixes for Chinese translation, by Mubi-Baihua (#216)
|
||||||
* Added passes sticky header with sunrise/sunset times
|
* Added zipped data sources handling to DatabaseRepo
|
||||||
* Added current moon/sun positions to the Radar Screen
|
* Tweaked Passes list to show DeepSpace ones at the top
|
||||||
* Added current moon/sun positions to the Map Screen
|
* Tweaked RadarView to display sun/moon positions correctly
|
||||||
* Added red night mode filter overlay for the whole app
|
|
||||||
@@ -29,11 +29,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 +42,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
|
||||||
@@ -125,7 +120,6 @@ private fun PassesScreen(
|
|||||||
onAction(PassesAction.DismissWhatsNew)
|
onAction(PassesAction.DismissWhatsNew)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val gridState = rememberLazyGridState()
|
|
||||||
ScreenColumn(
|
ScreenColumn(
|
||||||
topBar = { isVerticalLayout ->
|
topBar = { isVerticalLayout ->
|
||||||
TopBar(
|
TopBar(
|
||||||
@@ -151,7 +145,6 @@ private fun PassesScreen(
|
|||||||
passes = uiState.itemsList,
|
passes = uiState.itemsList,
|
||||||
navigateToRadar = navigateToRadar,
|
navigateToRadar = navigateToRadar,
|
||||||
refreshPasses = { onAction(PassesAction.RefreshPasses) },
|
refreshPasses = { onAction(PassesAction.RefreshPasses) },
|
||||||
gridState = gridState,
|
|
||||||
sunTimes = uiState.sunTimes
|
sunTimes = uiState.sunTimes
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -165,7 +158,6 @@ private fun PassesList(
|
|||||||
passes: List<OrbitalPass>,
|
passes: List<OrbitalPass>,
|
||||||
navigateToRadar: (Int, Long) -> Unit,
|
navigateToRadar: (Int, Long) -> Unit,
|
||||||
refreshPasses: () -> Unit,
|
refreshPasses: () -> Unit,
|
||||||
gridState: LazyGridState,
|
|
||||||
sunTimes: Map<String, Pair<String, String>>
|
sunTimes: Map<String, Pair<String, String>>
|
||||||
) {
|
) {
|
||||||
val isVerticalLayout = isVerticalLayout()
|
val isVerticalLayout = isVerticalLayout()
|
||||||
@@ -175,31 +167,15 @@ private fun PassesList(
|
|||||||
SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = timeZone }
|
SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = timeZone }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group passes by calendar day so we know where headers go
|
// Deep Space first, then date-grouped timed passes
|
||||||
val groupedPasses = remember(passes, isUtc) {
|
val groupedPasses = remember(passes, isUtc) {
|
||||||
passes.filter { !it.isDeepSpace }.groupBy { sdfDate.format(Date(it.aosTime)) } +
|
val ordered = LinkedHashMap<String, List<OrbitalPass>>()
|
||||||
if (passes.any { it.isDeepSpace }) mapOf("Deep Space" to passes.filter { it.isDeepSpace }) else emptyMap()
|
val deepSpace = passes.filter { it.isDeepSpace }
|
||||||
}
|
if (deepSpace.isNotEmpty()) ordered["Deep Space"] = deepSpace
|
||||||
|
passes.filter { !it.isDeepSpace }
|
||||||
// Derive the sticky header label from the first visible item index
|
.groupByTo(LinkedHashMap()) { sdfDate.format(Date(it.aosTime)) }
|
||||||
val stickyHeader by remember(gridState, groupedPasses) {
|
.forEach { (k, v) -> ordered[k] = v }
|
||||||
derivedStateOf {
|
ordered
|
||||||
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()) {
|
||||||
@@ -220,32 +196,20 @@ 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 {
|
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp), modifier = Modifier.fillMaxSize()) {
|
||||||
// Sticky header overlay — only visible once the in-list header scrolls away
|
for ((dateLabel, dayPasses) in groupedPasses) {
|
||||||
if (showStickyOverlay) {
|
stickyHeader(key = "header_$dateLabel") {
|
||||||
val (rise, set) = sunTimes[stickyHeader] ?: ("--:--" to "--:--")
|
val (rise, set) = sunTimes[dateLabel] ?: ("--:--" to "--:--")
|
||||||
StickyDateHeader(label = stickyHeader, sunriseTime = rise, sunsetTime = set)
|
StickyDateHeader(label = dateLabel, sunriseTime = rise, sunsetTime = set)
|
||||||
}
|
}
|
||||||
LazyVerticalGrid(
|
items(items = dayPasses, key = { item -> item.catNum + item.aosTime }) { pass ->
|
||||||
state = gridState,
|
PassItem(
|
||||||
columns = GridCells.Adaptive(320.dp),
|
pass = pass,
|
||||||
modifier = Modifier.fillMaxSize()
|
navigateToRadar = navigateToRadar,
|
||||||
) {
|
modifier = Modifier.animateItem(),
|
||||||
for ((dateLabel, dayPasses) in groupedPasses) {
|
isVerticalLayout = isVerticalLayout,
|
||||||
// In-list section header (scrolls away, drives the sticky overlay)
|
isUtc = isUtc
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
)
|
||||||
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,
|
|
||||||
modifier = Modifier.animateItem(),
|
|
||||||
isVerticalLayout = isVerticalLayout,
|
|
||||||
isUtc = isUtc
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -124,13 +124,20 @@ class PassesViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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 Deep Space
|
||||||
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>>()
|
||||||
|
// Deep Space 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["Deep Space"] = 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))
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
[versions]
|
[versions]
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionCode = "430"
|
appVersionCode = "431"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionName = "4.3.0"
|
appVersionName = "4.3.1"
|
||||||
#noinspection GradleDependency,UnusedVersionCatalogEntry
|
#noinspection GradleDependency,UnusedVersionCatalogEntry
|
||||||
compileSdk = "36"
|
compileSdk = "36"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
|
|||||||
Reference in new issue
Block a user