diff --git a/core/presentation/src/main/res/drawable/ic_moon.xml b/core/presentation/src/main/res/drawable/ic_moon.xml
new file mode 100644
index 00000000..0633e81e
--- /dev/null
+++ b/core/presentation/src/main/res/drawable/ic_moon.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/core/presentation/src/main/res/drawable/ic_sun.xml b/core/presentation/src/main/res/drawable/ic_sun.xml
new file mode 100644
index 00000000..1833bc7f
--- /dev/null
+++ b/core/presentation/src/main/res/drawable/ic_sun.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesScreen.kt b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesScreen.kt
index d0901989..997255c7 100644
--- a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesScreen.kt
+++ b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesScreen.kt
@@ -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,
navigateToRadar: (Int, Long) -> Unit,
refreshPasses: () -> Unit,
- gridState: LazyGridState
+ gridState: LazyGridState,
+ sunTimes: Map>
) {
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,19 +216,33 @@ private fun PassesList(
if (passes.isEmpty()) {
EmptyListCard(message = stringResource(R.string.pass_empty_list_message))
} else {
- LazyVerticalGrid(
- state = gridState,
- columns = GridCells.Adaptive(320.dp),
- modifier = Modifier.fillMaxSize()
- ) {
- items(items = passes, key = { item -> item.catNum + item.aosTime }) { pass ->
- PassItem(
- pass = pass,
- navigateToRadar = navigateToRadar,
- modifier = Modifier.animateItem(),
- isVerticalLayout = isVerticalLayout,
- isUtc = isUtc
- )
+ 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()
+ ) {
+ 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)
@Composable
private fun DeepSpacePassPreview() {
@@ -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(
diff --git a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesState.kt b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesState.kt
index ba5459d1..ff0b1ccc 100644
--- a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesState.kt
+++ b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesState.kt
@@ -32,7 +32,9 @@ data class PassesState(
val showDeepSpace: Boolean = true,
val modes: List = emptyList(),
val itemsList: List = emptyList(),
- val shouldSeeWhatsNew: Boolean = false
+ val shouldSeeWhatsNew: Boolean = false,
+ // Map of dateLabel -> Pair(sunriseTime, sunsetTime) for each day group
+ val sunTimes: Map> = emptyMap()
)
sealed interface PassesAction {
diff --git a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt
index ccc06045..e884be39 100644
--- a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt
+++ b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt
@@ -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, isUtc: Boolean): Map> {
+ 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>()
+ 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, time: Long): List {
val result = ArrayList(passList.size)