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)