mirror of
https://github.com/rt-bishop/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Added sticky header with a date and sunrise/sunset time
This commit is contained in:
1 parent
31d329a19b
commit
6e5a3cb0bb
5 files changed
+164
-23
No files matched your search
@@ -0,0 +1,9 @@
|
||||
<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="M12.34,2.02C6.59,1.82 2,6.42 2,12c0,5.52 4.48,10 10,10c3.71,0 6.93,-2.02 8.66,-5.02C13.15,16.73 8.57,8.55 12.34,2.02z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<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="M11,4V2c0,-0.55 0.45,-1 1,-1s1,0.45 1,1v2c0,0.55 -0.45,1 -1,1S11,4.55 11,4zM18.36,7.05l1.41,-1.42c0.39,-0.39 0.39,-1.02 0,-1.41c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.41,1.42c-0.39,0.39 -0.39,1.02 0,1.41C17.34,7.44 17.97,7.44 18.36,7.05zM22,11h-2c-0.55,0 -1,0.45 -1,1s0.45,1 1,1h2c0.55,0 1,-0.45 1,-1S22.55,11 22,11zM12,19c-0.55,0 -1,0.45 -1,1v2c0,0.55 0.45,1 1,1s1,-0.45 1,-1v-2C13,19.45 12.55,19 12,19zM5.64,7.05L4.22,5.64c-0.39,-0.39 -0.39,-1.03 0,-1.41s1.03,-0.39 1.41,0l1.41,1.41c0.39,0.39 0.39,1.03 0,1.41S6.02,7.44 5.64,7.05zM16.95,16.95c-0.39,0.39 -0.39,1.03 0,1.41l1.41,1.41c0.39,0.39 1.03,0.39 1.41,0c0.39,-0.39 0.39,-1.03 0,-1.41l-1.41,-1.41C17.98,16.56 17.34,16.56 16.95,16.95zM2,13h2c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1H2c-0.55,0 -1,0.45 -1,1S1.45,13 2,13zM5.64,19.78l1.41,-1.41c0.39,-0.39 0.39,-1.03 0,-1.41s-1.03,-0.39 -1.41,0l-1.41,1.41c-0.39,0.39 -0.39,1.03 0,1.41C4.61,20.17 5.25,20.17 5.64,19.78zM12,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6s6,-2.69 6,-6S15.31,6 12,6z" />
|
||||
</vector>
|
||||
@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
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.items
|
||||
@@ -44,6 +45,8 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
|
||||
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -144,7 +147,8 @@ private fun PassesScreen(
|
||||
passes = uiState.itemsList,
|
||||
navigateToRadar = navigateToRadar,
|
||||
refreshPasses = { onAction(PassesAction.RefreshPasses) },
|
||||
gridState = gridState
|
||||
gridState = gridState,
|
||||
sunTimes = uiState.sunTimes
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -157,10 +161,43 @@ private fun PassesList(
|
||||
passes: List<OrbitalPass>,
|
||||
navigateToRadar: (Int, Long) -> Unit,
|
||||
refreshPasses: () -> Unit,
|
||||
gridState: LazyGridState
|
||||
gridState: LazyGridState,
|
||||
sunTimes: Map<String, Pair<String, String>>
|
||||
) {
|
||||
val isVerticalLayout = isVerticalLayout()
|
||||
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()) {
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isRefreshing,
|
||||
@@ -179,12 +216,24 @@ private fun PassesList(
|
||||
if (passes.isEmpty()) {
|
||||
EmptyListCard(message = stringResource(R.string.pass_empty_list_message))
|
||||
} else {
|
||||
Column {
|
||||
// Sticky header overlay — only visible once the in-list header scrolls away
|
||||
if (showStickyOverlay) {
|
||||
val (rise, set) = sunTimes[stickyHeader] ?: ("--:--" to "--:--")
|
||||
StickyDateHeader(label = stickyHeader, sunriseTime = rise, sunsetTime = set)
|
||||
}
|
||||
LazyVerticalGrid(
|
||||
state = gridState,
|
||||
columns = GridCells.Adaptive(320.dp),
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
items(items = passes, key = { item -> item.catNum + item.aosTime }) { pass ->
|
||||
for ((dateLabel, dayPasses) in groupedPasses) {
|
||||
// In-list section header (scrolls away, drives the sticky overlay)
|
||||
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,
|
||||
@@ -197,6 +246,47 @@ private fun PassesList(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StickyDateHeader(label: String, sunriseTime: String, sunsetTime: String) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_sun),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Text(text = sunriseTime, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_moon),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Text(text = sunsetTime, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@@ -230,15 +320,15 @@ private fun PassItem(
|
||||
val timeZone = remember(isUtc) {
|
||||
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||
}
|
||||
val sdfDate = remember(isUtc) {
|
||||
SimpleDateFormat("EEE dd MMM", Locale.ENGLISH).also { it.timeZone = timeZone }
|
||||
}
|
||||
val sdfTime = remember(isUtc) {
|
||||
SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone }
|
||||
}
|
||||
val aosDateStr = remember(pass.aosTime, isUtc) { sdfDate.format(Date(pass.aosTime)) }
|
||||
val aosTimeStr = remember(pass.aosTime, isUtc) { sdfTime.format(Date(pass.aosTime)) }
|
||||
val losTimeStr = remember(pass.losTime, isUtc) { sdfTime.format(Date(pass.losTime)) }
|
||||
val durationStr = remember(pass.aosTime, pass.losTime) {
|
||||
val seconds = (pass.losTime - pass.aosTime) / 1000
|
||||
"${seconds / 60}m ${seconds % 60}s"
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier.clickable { navigateToRadar(pass.catNum, pass.aosTime) }
|
||||
@@ -295,7 +385,7 @@ private fun PassItem(
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
} else {
|
||||
Text(text = aosDateStr, fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface)
|
||||
Text(text = durationStr, fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
|
||||
@@ -32,7 +32,9 @@ data class PassesState(
|
||||
val showDeepSpace: Boolean = true,
|
||||
val modes: List<String> = emptyList(),
|
||||
val itemsList: List<OrbitalPass> = emptyList(),
|
||||
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()
|
||||
)
|
||||
|
||||
sealed interface PassesAction {
|
||||
|
||||
+45
-14
@@ -23,6 +23,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||
@@ -37,6 +38,10 @@ import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
class PassesViewModel(
|
||||
private val satelliteRepo: ISatelliteRepo,
|
||||
@@ -64,36 +69,45 @@ class PassesViewModel(
|
||||
_uiState.update { it.copy(isRefreshing = calculating) }
|
||||
}
|
||||
}
|
||||
// Local tick loop — computes pass progress and countdown timer every second
|
||||
// React to settings changes: update UTC flag and whatsNew
|
||||
viewModelScope.launch {
|
||||
settingsRepo.otherSettings.collectLatest { settings ->
|
||||
_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
|
||||
viewModelScope.launch {
|
||||
var lastSunTimesKey = "" // track when we need to recompute sun times
|
||||
while (isActive) {
|
||||
val timeNow = System.currentTimeMillis()
|
||||
val isUtc = _uiState.value.isUtc
|
||||
val showDeepSpace = _uiState.value.showDeepSpace
|
||||
val allPasses = satelliteRepo.passes.value
|
||||
val filtered = if (showDeepSpace) allPasses else allPasses.filter { !it.isDeepSpace }
|
||||
val processed = computePassProgress(filtered, timeNow)
|
||||
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
|
||||
|
||||
// Recompute per-day sun times only when passes list or UTC setting changes
|
||||
val sunTimesKey = "${processed.firstOrNull()?.aosTime}-${processed.lastOrNull()?.aosTime}-$isUtc"
|
||||
val sunTimes = if (sunTimesKey != lastSunTimesKey) {
|
||||
lastSunTimesKey = sunTimesKey
|
||||
computeSunTimes(processed, isUtc)
|
||||
} else {
|
||||
_uiState.value.sunTimes
|
||||
}
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
itemsList = processed,
|
||||
nextPass = nextPass,
|
||||
nextTime = nextTime,
|
||||
isNextTimeAos = isAos
|
||||
isNextTimeAos = isAos,
|
||||
sunTimes = sunTimes
|
||||
)
|
||||
}
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
settingsRepo.otherSettings.collectLatest { settings ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isUtc = settings.stateOfUtc,
|
||||
shouldSeeWhatsNew = settings.shouldSeeWhatsNew
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onAction(action: PassesAction) {
|
||||
@@ -101,18 +115,35 @@ class PassesViewModel(
|
||||
PassesAction.DismissWhatsNew -> settingsRepo.setWhatsNewDismissed()
|
||||
is PassesAction.FilterPasses ->
|
||||
applyFilter(action.hoursAhead, action.minElevation, action.showDeepSpace, _uiState.value.modes)
|
||||
|
||||
is PassesAction.FilterRadios ->
|
||||
applyFilter(_uiState.value.hours, _uiState.value.elevation, _uiState.value.showDeepSpace, action.modes)
|
||||
PassesAction.RefreshPasses -> refreshPasses()
|
||||
PassesAction.TogglePassesDialog ->
|
||||
_uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) }
|
||||
|
||||
PassesAction.ToggleRadiosDialog ->
|
||||
_uiState.update { it.copy(isRadiosDialogShown = !it.isRadiosDialogShown) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Computes sunrise/sunset strings for each unique calendar day in the pass list. */
|
||||
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
|
||||
val stationPos = settingsRepo.stationPosition.value
|
||||
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||
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 result = LinkedHashMap<String, Pair<String, String>>()
|
||||
for (pass in passes) {
|
||||
if (pass.isDeepSpace) continue
|
||||
val label = sdfDate.format(Date(pass.aosTime))
|
||||
if (label in result) continue
|
||||
val riseSet = CelestialComputer.findSunRiseSet(stationPos, pass.aosTime)
|
||||
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[label] = rise to set
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Computes live progress for each pass, filtering out expired ones. */
|
||||
private fun computePassProgress(passList: List<OrbitalPass>, time: Long): List<OrbitalPass> {
|
||||
val result = ArrayList<OrbitalPass>(passList.size)
|
||||
|
||||
Reference in new issue
Block a user