Added sticky header with a date and sunrise/sunset time

This commit is contained in:
Arty Bishop committed 2026-04-25 19:20:22 +01:00
1 parent 31d329a19b
commit 6e5a3cb0bb
5 files changed
+176 -35

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.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.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
@@ -44,6 +45,8 @@ 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
@@ -144,7 +147,8 @@ private fun PassesScreen(
passes = uiState.itemsList, passes = uiState.itemsList,
navigateToRadar = navigateToRadar, navigateToRadar = navigateToRadar,
refreshPasses = { onAction(PassesAction.RefreshPasses) }, refreshPasses = { onAction(PassesAction.RefreshPasses) },
gridState = gridState gridState = gridState,
sunTimes = uiState.sunTimes
) )
} }
} }
@@ -157,10 +161,43 @@ private fun PassesList(
passes: List<OrbitalPass>, passes: List<OrbitalPass>,
navigateToRadar: (Int, Long) -> Unit, navigateToRadar: (Int, Long) -> Unit,
refreshPasses: () -> Unit, refreshPasses: () -> Unit,
gridState: LazyGridState 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,
@@ -179,19 +216,33 @@ 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 {
LazyVerticalGrid( Column {
state = gridState, // Sticky header overlay — only visible once the in-list header scrolls away
columns = GridCells.Adaptive(320.dp), if (showStickyOverlay) {
modifier = Modifier.fillMaxSize() val (rise, set) = sunTimes[stickyHeader] ?: ("--:--" to "--:--")
) { StickyDateHeader(label = stickyHeader, sunriseTime = rise, sunsetTime = set)
items(items = passes, key = { item -> item.catNum + item.aosTime }) { pass -> }
PassItem( LazyVerticalGrid(
pass = pass, state = gridState,
navigateToRadar = navigateToRadar, columns = GridCells.Adaptive(320.dp),
modifier = Modifier.animateItem(), modifier = Modifier.fillMaxSize()
isVerticalLayout = isVerticalLayout, ) {
isUtc = isUtc 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,
modifier = Modifier.animateItem(),
isVerticalLayout = isVerticalLayout,
isUtc = isUtc
)
}
}
} }
} }
} }
@@ -199,6 +250,45 @@ 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) @Preview(showBackground = true)
@Composable @Composable
private fun DeepSpacePassPreview() { private fun DeepSpacePassPreview() {
@@ -230,15 +320,15 @@ private fun PassItem(
val timeZone = remember(isUtc) { val timeZone = remember(isUtc) {
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault() 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) { val sdfTime = remember(isUtc) {
SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone } 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 aosTimeStr = remember(pass.aosTime, isUtc) { sdfTime.format(Date(pass.aosTime)) }
val losTimeStr = remember(pass.losTime, isUtc) { sdfTime.format(Date(pass.losTime)) } 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( Column(
modifier = modifier.clickable { navigateToRadar(pass.catNum, pass.aosTime) } modifier = modifier.clickable { navigateToRadar(pass.catNum, pass.aosTime) }
@@ -295,7 +385,7 @@ private fun PassItem(
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
} else { } else {
Text(text = aosDateStr, fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface) Text(text = durationStr, fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface)
} }
} }
Row( Row(
@@ -32,7 +32,9 @@ 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 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 { sealed interface PassesAction {
@@ -23,6 +23,7 @@ import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.model.PassesSettings 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.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo 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.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
class PassesViewModel( class PassesViewModel(
private val satelliteRepo: ISatelliteRepo, private val satelliteRepo: ISatelliteRepo,
@@ -64,36 +69,45 @@ class PassesViewModel(
_uiState.update { it.copy(isRefreshing = calculating) } _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 { 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) { while (isActive) {
val timeNow = System.currentTimeMillis() val timeNow = System.currentTimeMillis()
val isUtc = _uiState.value.isUtc
val showDeepSpace = _uiState.value.showDeepSpace val showDeepSpace = _uiState.value.showDeepSpace
val allPasses = satelliteRepo.passes.value val allPasses = satelliteRepo.passes.value
val filtered = if (showDeepSpace) allPasses else allPasses.filter { !it.isDeepSpace } val filtered = if (showDeepSpace) allPasses else allPasses.filter { !it.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)
// 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 { _uiState.update {
it.copy( it.copy(
itemsList = processed, itemsList = processed,
nextPass = nextPass, nextPass = nextPass,
nextTime = nextTime, nextTime = nextTime,
isNextTimeAos = isAos isNextTimeAos = isAos,
sunTimes = sunTimes
) )
} }
delay(1000) delay(1000)
} }
} }
viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings ->
_uiState.update {
it.copy(
isUtc = settings.stateOfUtc,
shouldSeeWhatsNew = settings.shouldSeeWhatsNew
)
}
}
}
} }
fun onAction(action: PassesAction) { fun onAction(action: PassesAction) {
@@ -101,18 +115,35 @@ class PassesViewModel(
PassesAction.DismissWhatsNew -> settingsRepo.setWhatsNewDismissed() PassesAction.DismissWhatsNew -> settingsRepo.setWhatsNewDismissed()
is PassesAction.FilterPasses -> is PassesAction.FilterPasses ->
applyFilter(action.hoursAhead, action.minElevation, action.showDeepSpace, _uiState.value.modes) applyFilter(action.hoursAhead, action.minElevation, action.showDeepSpace, _uiState.value.modes)
is PassesAction.FilterRadios -> is PassesAction.FilterRadios ->
applyFilter(_uiState.value.hours, _uiState.value.elevation, _uiState.value.showDeepSpace, action.modes) applyFilter(_uiState.value.hours, _uiState.value.elevation, _uiState.value.showDeepSpace, action.modes)
PassesAction.RefreshPasses -> refreshPasses() PassesAction.RefreshPasses -> refreshPasses()
PassesAction.TogglePassesDialog -> PassesAction.TogglePassesDialog ->
_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) }
} }
} }
/** 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. */ /** 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)