Added AOS/LOS timer to each pass and a SearchBar filter

This commit is contained in:
Arty Bishop committed 2026-10-04 20:19:48 +01:00
1 parent 782b438a07
commit 83ce7229f6
9 files changed
+396 -249

No files matched your search

@@ -31,4 +31,15 @@ data class OrbitalPass(
val catNum: Int = orbitalObject.data.catnum
val name: String = if (hasDecayed) "${orbitalObject.data.name} (decayed?)" else orbitalObject.data.name
val isDeepSpace: Boolean = orbitalObject.data.isDeepSpace
/**
* Fraction of the pass elapsed at [timeMillis], derived on demand so that a ticking clock
* never has to rewrite the pass list. A DeepSpace object is always in view, so it reads 1.
*/
fun progressAt(timeMillis: Long): Float {
if (isDeepSpace) return 1f
val total = (losTime - aosTime).toFloat()
if (total <= 0f) return 0f
return ((timeMillis - aosTime) / total).coerceIn(0f, 1f)
}
}
@@ -39,6 +39,7 @@ import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
@@ -46,21 +47,32 @@ import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.addOutline
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
@@ -428,6 +440,55 @@ private fun DialogShell(
}
}
@Composable
fun SearchBar(onQueryChange: (String) -> Unit, modifier: Modifier = Modifier) {
val currentQuery = rememberSaveable { mutableStateOf("") }
val updateQuery = { newValue: String ->
currentQuery.value = newValue
onQueryChange(newValue)
}
ElevatedCard(modifier = modifier.height(48.dp)) {
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 12.dp)
) {
Icon(painter = painterResource(id = R.drawable.ic_search), contentDescription = null)
BasicTextField(
value = currentQuery.value,
onValueChange = updateQuery,
singleLine = true,
modifier = Modifier
.weight(1f)
.padding(start = 12.dp),
textStyle = TextStyle(
fontSize = 16.sp,
lineHeight = 20.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
),
decorationBox = { innerTextField ->
if (currentQuery.value.isEmpty()) {
Text(
text = stringResource(id = R.string.sat_search_hint),
fontSize = 16.sp,
lineHeight = 20.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
}
innerTextField()
},
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface)
)
IconButton(onClick = { updateQuery("") }) {
val clearCd = stringResource(R.string.sat_search_clear)
Icon(painter = painterResource(id = R.drawable.ic_close), contentDescription = clearCd)
}
}
}
}
@Composable
fun hasEnoughHeight(): Boolean =
currentWindowAdaptiveInfoV2().windowSizeClass.isHeightAtLeastBreakpoint(480)
@@ -580,3 +641,30 @@ fun formatFrequency(frequencyHz: Long): String {
val hz = frequencyHz % 1_000
return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz)
}
/**
* Strokes the [shape] outline as a track, then reveals it left to right up to the [progress]
* fraction of the width, so the top and bottom edges fill in sync.
*/
@Composable
fun Modifier.progressOutline(
progress: Float,
shape: Shape = RoundedCornerShape(12.dp),
width: Dp = 0.5.dp,
trackColor: Color = ProgressIndicatorDefaults.linearTrackColor,
progressColor: Color = ProgressIndicatorDefaults.linearColor
) = this.drawWithCache {
val widthPx = width.toPx()
val outline = shape.createOutline(Size(size.width - widthPx, size.height - widthPx), layoutDirection, this)
val outlinePath = Path().apply {
addOutline(outline)
translate(Offset(widthPx / 2f, widthPx / 2f))
}
val stroke = Stroke(width = widthPx)
val revealWidth = size.width * progress.coerceIn(0f, 1f)
onDrawWithContent {
drawContent()
drawPath(outlinePath, trackColor, style = stroke)
clipRect(right = revealWidth) { drawPath(outlinePath, progressColor, style = stroke) }
}
}
@@ -39,6 +39,7 @@ fun SwipeableItem(onSwipeRight: () -> Unit, onSwipeLeft: () -> Unit, content: @C
val dismissThresholdPx = with(LocalDensity.current) { 120.dp.toPx() }
val dismissState = rememberSwipeToDismissBoxState { dismissThresholdPx }
val willTrigger by remember { derivedStateOf { dismissState.targetValue != SwipeToDismissBoxValue.Settled } }
val isSwiping by remember { derivedStateOf { dismissState.dismissDirection != SwipeToDismissBoxValue.Settled } }
val hapticFeedback = LocalHapticFeedback.current
LaunchedEffect(willTrigger) {
val feedbackType = if (willTrigger) HapticFeedbackType.LongPress else HapticFeedbackType.SegmentTick
@@ -49,8 +50,10 @@ fun SwipeableItem(onSwipeRight: () -> Unit, onSwipeLeft: () -> Unit, content: @C
enableDismissFromStartToEnd = true,
enableDismissFromEndToStart = true,
backgroundContent = {
val isSwipeRight = dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd
SwipeBackground(isSwipeRight, willTrigger, MaterialTheme.colorScheme.primary)
if (isSwiping) {
val isSwipeRight = dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd
SwipeBackground(isSwipeRight, willTrigger, MaterialTheme.colorScheme.primary)
}
},
content = { content() },
onDismiss = {
@@ -47,8 +47,13 @@
<string name="pass_deep_space" translatable="false">DeepSpace</string>
<string name="pass_altitude">Altitude: %d km</string>
<string name="pass_aosLos" translatable="false">%03d° - %03d°</string>
<string name="pass_azimuth" translatable="false">%03d°</string>
<string name="pass_duration_placeholder" translatable="false">--m --s</string>
<string name="pass_timer_aos" translatable="false">AOS %s</string>
<string name="pass_timer_los" translatable="false">LOS %s</string>
<string name="pass_empty_list_message">
Make sure your filters are correct and you have selected satellites</string>
<string name="pass_empty_search_message">No upcoming passes match your search</string>
<string name="pass_error_message">There are no upcoming satellite passes to display</string>
<string name="pass_welcome_title">Welcome to Look4Sat\!</string>
<string name="pass_welcome_message">
@@ -106,7 +106,7 @@ class MapViewModel(
private fun selectDefaultSatellite(catnum: Int) {
if (allSatellites.isNotEmpty()) {
if (catnum == -1) {
allPasses.find { pass -> pass.progress < 100 && !pass.isDeepSpace }
allPasses.find { pass -> pass.progress < 1 && !pass.isDeepSpace }
?.let { pass -> selectSatellite(pass.orbitalObject) }
} else {
allSatellites.find { it.data.catnum == catnum }?.let { selectSatellite(it) }
@@ -189,8 +189,8 @@ class MapViewModel(
}
}
for ((localPositions, localSelectedPos) in deferreds.awaitAll()) {
for (pair in localPositions) {
positionsMap[pair.first] = pair.second
for ((first, second) in localPositions) {
positionsMap[first] = second
}
if (localSelectedPos != null) {
selectedSatPos = localSelectedPos
@@ -17,11 +17,6 @@
*/
package com.rtbishop.look4sat.feature.passes
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -37,16 +32,18 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -54,6 +51,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -65,19 +64,21 @@ import com.rtbishop.look4sat.core.domain.predict.NearEarthObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.utility.toTimerString
import com.rtbishop.look4sat.core.presentation.EmptyListCard
import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.NextPassRow
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.ScreenColumn
import com.rtbishop.look4sat.core.presentation.SwipeableItem
import com.rtbishop.look4sat.core.presentation.TimerRow
import com.rtbishop.look4sat.core.presentation.SearchBar
import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.WhatsNewDialog
import com.rtbishop.look4sat.core.presentation.elevationColor
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -89,12 +90,13 @@ fun PassesDestination(navigateToRadar: (Int, Long) -> Unit) {
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: PassesViewModel = viewModel(factory = PassesViewModel.factory(container))
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
PassesScreen(uiState, viewModel::onAction, navigateToRadar)
PassesScreen(uiState, viewModel.timeNow, viewModel::onAction, navigateToRadar)
}
@Composable
private fun PassesScreen(
uiState: PassesState,
timeNow: StateFlow<Long>,
onAction: (PassesAction) -> Unit,
navigateToRadar: (Int, Long) -> Unit
) {
@@ -145,7 +147,10 @@ private fun PassesScreen(
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
},
topInfo = {
TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos)
SearchBar(
onQueryChange = { onAction(PassesAction.SearchFor(it)) },
modifier = Modifier.weight(1f)
)
},
bottomInfo = {
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
@@ -162,14 +167,14 @@ private fun PassesScreen(
passes = uiState.itemsList,
groupedPasses = uiState.groupedPasses,
sunTimes = uiState.sunTimes,
focusedCatNum = uiState.focusedCatNum,
isSearching = uiState.searchQuery.isNotBlank(),
timeNow = timeNow,
navigateToRadar = navigateToRadar,
onAction = onAction
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PassesList(
isRefreshing: Boolean,
@@ -177,7 +182,8 @@ private fun PassesList(
passes: List<OrbitalPass>,
groupedPasses: Map<String, List<OrbitalPass>>,
sunTimes: Map<String, Pair<String, String>>,
focusedCatNum: Int?,
isSearching: Boolean,
timeNow: StateFlow<Long>,
navigateToRadar: (Int, Long) -> Unit,
onAction: (PassesAction) -> Unit
) {
@@ -199,38 +205,28 @@ private fun PassesList(
}
) {
if (passes.isEmpty()) {
EmptyListCard(message = stringResource(R.string.pass_empty_list_message))
val emptyMessage = if (isSearching) {
stringResource(R.string.pass_empty_search_message)
} else {
stringResource(R.string.pass_empty_list_message)
}
EmptyListCard(message = emptyMessage)
} else {
AnimatedContent(
targetState = focusedCatNum,
transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) },
label = "PassesFocusTransition"
) { targetFocus ->
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp), modifier = Modifier.fillMaxSize()) {
for ((dateLabel, dayPasses) in groupedPasses) {
val headerVisible = targetFocus == null || dayPasses.any { it.catNum == targetFocus }
if (headerVisible) {
stickyHeader(key = "header_$dateLabel") {
val (rise, set) = sunTimes[dateLabel] ?: ("--:--" to "--:--")
StickyDateHeader(label = dateLabel, sunriseTime = rise, sunsetTime = set)
}
}
val visiblePasses = if (targetFocus == null) dayPasses
else dayPasses.filter { it.catNum == targetFocus }
items(items = visiblePasses, key = { item -> item.catNum + item.aosTime }) { pass ->
SwipeableItem(
onSwipeRight = { onAction(PassesAction.FocusCatNum(pass.catNum)) },
onSwipeLeft = { onAction(PassesAction.ClearFocus) }
) {
PassItem(
pass = pass,
navigateToRadar = navigateToRadar,
modifier = Modifier.animateItem(),
isVerticalLayout = isVerticalLayout,
isUtc = isUtc
)
}
}
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp), modifier = Modifier.fillMaxSize()) {
for ((dateLabel, dayPasses) in groupedPasses) {
stickyHeader(key = "header_$dateLabel") {
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,
timeNow = timeNow,
modifier = Modifier.animateItem(),
isVerticalLayout = isVerticalLayout,
isUtc = isUtc
)
}
}
}
@@ -278,7 +274,7 @@ private fun StickyDateHeader(label: String, sunriseTime: String, sunsetTime: Str
}
}
private fun displayLocale(): Locale {
internal fun displayLocale(): Locale {
val locale = Locale.getDefault()
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
}
@@ -288,51 +284,127 @@ private fun displayLocale(): Locale {
private fun DeepSpacePassPreview() {
val data = OrbitalData("Satellite", 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 45000, 0.0)
val satellite = DeepSpaceObject(data)
val pass = OrbitalPass(1L, 180.0, 10L, 360.0, 36650, 45.0, satellite, 0.5f)
MainTheme { PassItem(pass = pass, { _, _ -> }) }
val pass = OrbitalPass(1L, 180.0, 10L, 180.0, 36650, 45.0, satellite, 0.5f)
MainTheme { PassItem(pass = pass, { _, _ -> }, MutableStateFlow(0L)) }
}
@Preview(showBackground = true)
@Composable
private fun NearEarthPassPreview() {
private fun UpcomingPassPreview() {
val data = OrbitalData("Satellite", 0.0, 15.0, 0.0, 0.0, 0.0, 0.0, 0.0, 45000, 0.0)
val satellite = NearEarthObject(data)
val pass = OrbitalPass(1L, 180.0, 10L, 360.0, 36650, 45.0, satellite, 0.5f)
MainTheme { PassItem(pass = pass, { _, _ -> }) }
val aosTime = 1767225600000L
val pass = OrbitalPass(aosTime, 180.0, aosTime + 702000L, 360.0, 36650, 45.0, NearEarthObject(data), 0f)
MainTheme { PassItem(pass = pass, { _, _ -> }, MutableStateFlow(aosTime - 866000L)) }
}
@Preview(showBackground = true)
@Composable
private fun ActivePassPreview() {
val data = OrbitalData("Satellite", 0.0, 15.0, 0.0, 0.0, 0.0, 0.0, 0.0, 45000, 0.0)
val aosTime = 1767225600000L
val pass = OrbitalPass(aosTime, 180.0, aosTime + 702000L, 360.0, 36650, 45.0, NearEarthObject(data), 0.4f)
MainTheme { PassItem(pass = pass, { _, _ -> }, MutableStateFlow(aosTime + 280000L)) }
}
/**
* Countdown to AOS while the pass is pending, then to LOS while it is active.
* Gray denotes a pending pass, primary an active one. Collects the clock itself so a
* tick recomposes only this chip.
*/
@Composable
private fun PassTimerChip(pass: OrbitalPass, timeNow: StateFlow<Long>) {
val now by timeNow.collectAsStateWithLifecycle()
// A DeepSpace object is permanently in view, so it gets a neutral label instead of a countdown
val isActive = !pass.isDeepSpace && now >= pass.aosTime
val text = when {
pass.isDeepSpace -> stringResource(R.string.pass_deep_space)
isActive -> stringResource(R.string.pass_timer_los, (pass.losTime - now).toTimerString())
else -> stringResource(R.string.pass_timer_aos, (pass.aosTime - now).toTimerString())
}
Surface(
shape = MaterialTheme.shapes.small,
color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
contentColor = if (isActive) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
) {
Text(
text = text,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
style = LocalTextStyle.current.copy(
// Tabular figures stop the chip reflowing on every tick, while the trimmed
// line height keeps it short without shrinking the timer text
fontFeatureSettings = "tnum",
lineHeight = 16.sp,
lineHeightStyle = LineHeightStyle(
alignment = LineHeightStyle.Alignment.Center,
trim = LineHeightStyle.Trim.Both
)
),
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
/** Elapsed fraction of the pass. Collects the clock itself to keep ticks off the text rows. */
@Composable
private fun PassProgressBar(pass: OrbitalPass, timeNow: StateFlow<Long>, modifier: Modifier = Modifier) {
val now by timeNow.collectAsStateWithLifecycle()
LinearProgressIndicator(
progress = { pass.progressAt(now) },
drawStopIndicator = {},
modifier = modifier
)
}
@Composable
private fun PassItem(
pass: OrbitalPass,
navigateToRadar: (Int, Long) -> Unit,
timeNow: StateFlow<Long>,
modifier: Modifier = Modifier,
isVerticalLayout: Boolean = true,
isUtc: Boolean = false
) {
val passSatId = stringResource(id = R.string.pass_satId, pass.catNum)
val horizontalPadding = if (isVerticalLayout) 6.dp else 10.dp
val horizontalPadding = if (isVerticalLayout) 6.dp else 12.dp
val timeZone = remember(isUtc) {
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
}
val sdfTime = remember(isUtc) {
SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
}
val azimuthFormat = stringResource(id = R.string.pass_azimuth)
val aosTimeStr = remember(pass.aosTime, isUtc) { sdfTime.format(Date(pass.aosTime)) }
val losTimeStr = remember(pass.losTime, isUtc) { sdfTime.format(Date(pass.losTime)) }
val aosAzStr = remember(pass.aosAzimuth, azimuthFormat) {
String.format(displayLocale(), azimuthFormat, pass.aosAzimuth.toInt() % 360)
}
val losAzStr = remember(pass.losAzimuth, azimuthFormat) {
String.format(displayLocale(), azimuthFormat, pass.losAzimuth.toInt() % 360)
}
val durationStr = remember(pass.aosTime, pass.losTime) {
val seconds = (pass.losTime - pass.aosTime) / 1000
"${seconds / 60}m ${seconds % 60}s"
}
// do not delete
// val progressTint = MaterialTheme.colorScheme.secondary.copy(alpha = 0.08f)
// val progressFraction = if (pass.isDeepSpace) 0f else pass.progress.coerceIn(0f, 1f)
Column(
modifier = modifier.clickable { navigateToRadar(pass.catNum, pass.aosTime) }
) {
Column(
verticalArrangement = Arrangement.spacedBy(1.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
.fillMaxWidth()
.background(color = MaterialTheme.colorScheme.surface)
.padding(horizontal = horizontalPadding, vertical = 4.dp)
// do not delete
// .drawBehind { drawRect(progressTint, size = Size(size.width * progressFraction, size.height)) }
.padding(horizontal = horizontalPadding, vertical = 6.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
@@ -350,84 +422,88 @@ private fun PassItem(
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface
)
val elevColor = elevationColor(pass.maxElevation)
Icon(
painter = painterResource(id = R.drawable.ic_elevation),
contentDescription = null,
tint = elevColor,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "${pass.maxElevation}°",
color = elevColor
)
PassTimerChip(pass = pass, timeNow = timeNow)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
if (pass.isDeepSpace) {
Text(
text = stringResource(R.string.pass_deep_space),
fontSize = 15.sp,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = if (pass.isDeepSpace) {
stringResource(R.string.pass_duration_placeholder)
} else {
Text(text = durationStr, fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface)
}
}
durationStr
},
fontSize = 15.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f)
)
// AOS azimuth, peak elevation and LOS azimuth read as the arc of the pass
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = "${pass.altitude} km", fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface)
}
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.End,
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(
id = R.string.pass_aosLos,
pass.aosAzimuth.toInt(),
pass.losAzimuth.toInt()
),
text = aosAzStr,
fontSize = 15.sp,
color = MaterialTheme.colorScheme.onSurface
)
Row(verticalAlignment = Alignment.CenterVertically) {
val elevColor = elevationColor(pass.maxElevation)
Icon(
painter = painterResource(id = R.drawable.ic_elevation),
contentDescription = null,
tint = elevColor,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "${pass.maxElevation}°",
color = elevColor
)
}
Text(
text = losAzStr,
fontSize = 15.sp,
color = MaterialTheme.colorScheme.onSurface
)
}
Text(
text = "${pass.altitude} km",
fontSize = 15.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.End,
modifier = Modifier.weight(1f)
)
}
// A DeepSpace object has no AOS/LOS to count between, so it drops the progress row
if (!pass.isDeepSpace) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = aosTimeStr,
fontSize = 15.sp,
color = MaterialTheme.colorScheme.onSurface
)
PassProgressBar(
pass = pass,
timeNow = timeNow,
modifier = Modifier.fillMaxWidth(0.75f)
)
Text(
text = losTimeStr,
fontSize = 15.sp,
color = MaterialTheme.colorScheme.onSurface
)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
val defaultTime = " - - : - - "
Text(
text = if (pass.isDeepSpace) defaultTime else aosTimeStr,
fontSize = 15.sp,
color = MaterialTheme.colorScheme.onSurface
)
LinearProgressIndicator(
progress = { if (pass.isDeepSpace) 100f else pass.progress },
drawStopIndicator = {},
modifier = Modifier.fillMaxWidth(0.75f)
)
Text(
text = if (pass.isDeepSpace) defaultTime else losTimeStr,
fontSize = 15.sp,
color = MaterialTheme.colorScheme.onSurface
)
}
}
HorizontalDivider(thickness = 2.dp, color = MaterialTheme.colorScheme.background)
}
@@ -25,8 +25,7 @@ data class PassesState(
val isRefreshing: Boolean = true,
val isUtc: Boolean = false,
val nextPass: OrbitalPass,
val nextTime: String = "00:00:00",
val isNextTimeAos: Boolean = true,
val searchQuery: String = "",
val hours: Int = 24,
val elevation: Double = 16.0,
val lowElevation: Double = 16.0,
@@ -39,8 +38,7 @@ data class PassesState(
val itemsList: List<OrbitalPass> = emptyList(),
val groupedPasses: Map<String, List<OrbitalPass>> = emptyMap(),
val shouldSeeWhatsNew: Boolean = false,
val sunTimes: Map<String, Pair<String, String>> = emptyMap(),
val focusedCatNum: Int? = null
val sunTimes: Map<String, Pair<String, String>> = emptyMap()
)
sealed interface PassesAction {
@@ -59,6 +57,5 @@ sealed interface PassesAction {
data object RefreshPasses : PassesAction
data object TogglePassesDialog : PassesAction
data object ToggleRadiosDialog : PassesAction
data class FocusCatNum(val catNum: Int) : PassesAction
data object ClearFocus : PassesAction
data class SearchFor(val query: String) : PassesAction
}
@@ -27,8 +27,6 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.utility.round
import com.rtbishop.look4sat.core.domain.utility.toTimerString
import com.rtbishop.look4sat.core.presentation.getDefaultPass
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -46,6 +44,14 @@ import java.util.Locale
import java.util.TimeZone
import kotlin.time.Duration.Companion.milliseconds
/** Inputs that together determine the visible pass list; a change in any one regroups it. */
private data class PassesInput(
val passes: List<OrbitalPass>,
val isUtc: Boolean,
val showDeepSpace: Boolean,
val query: String
)
class PassesViewModel(
private val satelliteRepo: ISatelliteRepo,
private val settingsRepo: ISettingsRepo
@@ -70,6 +76,16 @@ class PassesViewModel(
)
val uiState: StateFlow<PassesState> = _uiState
private val _timeNow = MutableStateFlow(System.currentTimeMillis())
private val searchQuery = MutableStateFlow("")
/**
* Ticks once per second, kept separate from [uiState] so that a clock update only
* recomposes the countdown chips and progress bars instead of the whole screen.
*/
val timeNow: StateFlow<Long> = _timeNow
init {
// Refresh indicator: mirrors the repo's isCalculating state
viewModelScope.launch {
@@ -95,36 +111,50 @@ class PassesViewModel(
_uiState.update { it.copy(modes = modes) }
}
}
// Tick loop: restarts on passes change, UTC/DeepSpace changes. Grouping/sun-time
// computations run once per restart, progress/countdown are calculated every second.
// Tick loop, gated on having an observer so it stops while the screen is in the
// background. Restarts on passes, UTC or DeepSpace changes. Sun times and grouping are
// computed only when the pass list itself changes; the clock alone never rebuilds them.
viewModelScope.launch {
combine(
satelliteRepo.passes,
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged(),
settingsRepo.passesSettings.map { it.showDeepSpace }.distinctUntilChanged()
) { passes, isUtc, showDeepSpace -> Triple(passes, isUtc, showDeepSpace) }
.collectLatest { (allPasses, isUtc, showDeepSpace) ->
val filtered = if (showDeepSpace) allPasses
else allPasses.filter { !it.isDeepSpace }
// Expensive: recompute sun times once per items/UTC change, not every second
val sunTimes = computeSunTimes(filtered, isUtc)
_uiState.update { it.copy(sunTimes = sunTimes) }
while (isActive) {
val timeNow = System.currentTimeMillis()
val processed = computePassProgress(filtered, timeNow)
val grouped = groupPasses(processed, isUtc)
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
_uiState.update {
it.copy(
itemsList = processed,
groupedPasses = grouped,
nextPass = nextPass,
nextTime = nextTime,
isNextTimeAos = isAos
)
}
delay(1000.milliseconds)
_uiState.subscriptionCount
.map { count -> count > 0 }
.distinctUntilChanged()
.collectLatest { isObserved ->
if (!isObserved) return@collectLatest
combine(
satelliteRepo.passes,
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged(),
settingsRepo.passesSettings.map { it.showDeepSpace }.distinctUntilChanged(),
searchQuery
) { passes, isUtc, showDeepSpace, query ->
PassesInput(passes, isUtc, showDeepSpace, query)
}
.collectLatest { (allPasses, isUtc, showDeepSpace, query) ->
val filtered = filterByQuery(
if (showDeepSpace) allPasses else allPasses.filter { !it.isDeepSpace },
query
)
val sunTimes = computeSunTimes(filtered, isUtc)
var live: List<OrbitalPass>? = null
while (isActive) {
val timeNow = System.currentTimeMillis()
_timeNow.value = timeNow
val current = filtered.filter { it.isDeepSpace || timeNow < it.losTime }
// Only a pass dropping off the list warrants a regroup
if (live == null || current.size != live.size) {
live = current
_uiState.update {
it.copy(
itemsList = current,
groupedPasses = groupPasses(current, isUtc),
sunTimes = sunTimes
)
}
}
val nextPass = resolveNextPass(current, timeNow)
_uiState.update { it.copy(nextPass = nextPass) }
delay(1000.milliseconds)
}
}
}
}
}
@@ -149,8 +179,10 @@ class PassesViewModel(
_uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) }
PassesAction.ToggleRadiosDialog ->
_uiState.update { it.copy(isRadiosDialogShown = !it.isRadiosDialogShown) }
is PassesAction.FocusCatNum -> _uiState.update { it.copy(focusedCatNum = action.catNum) }
PassesAction.ClearFocus -> _uiState.update { it.copy(focusedCatNum = null) }
is PassesAction.SearchFor -> {
searchQuery.value = action.query
_uiState.update { it.copy(searchQuery = action.query) }
}
}
}
@@ -208,43 +240,34 @@ class PassesViewModel(
return ordered
}
/** 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)
for (pass in passList) {
if (!pass.isDeepSpace && time > pass.aosTime) {
val deltaNow = time.minus(pass.aosTime).toFloat()
val deltaTotal = pass.losTime.minus(pass.aosTime).toFloat()
val newProgress = (deltaNow / deltaTotal).round(2)
if (newProgress >= 1.0f) continue
if (newProgress != pass.progress) {
result.add(pass.copy(progress = newProgress))
} else {
result.add(pass)
}
} else {
result.add(pass)
}
}
return result
/** Resolves the next upcoming pass, falling back to the one currently in progress. */
private fun resolveNextPass(passes: List<OrbitalPass>, timeNow: Long): OrbitalPass {
val upcoming = passes.firstOrNull { it.aosTime > timeNow }
if (upcoming != null) return upcoming
return passes.lastOrNull() ?: defaultPass
}
/** Resolves the next upcoming or active pass and its countdown timer. */
private fun resolveNextPass(
passes: List<OrbitalPass>,
timeNow: Long
): Triple<OrbitalPass, String, Boolean> {
val upcoming = passes.firstOrNull { it.aosTime > timeNow }
if (upcoming != null) {
return Triple(upcoming, (upcoming.aosTime - timeNow).toTimerString(), true)
/**
* Filters passes by query, mirroring the satellite search: a numeric query matches the
* catalog number, otherwise every space-separated token must appear in the name once both
* sides are normalized, so "ao7" matches "AO-7 (AMSAT-OSCAR 7)".
*/
private fun filterByQuery(passes: List<OrbitalPass>, query: String): List<OrbitalPass> {
if (query.isBlank()) return passes
val catNum = query.trim().toIntOrNull()
if (catNum != null) return passes.filter { it.catNum == catNum }
val tokens = query.split(' ').map { normalizeForSearch(it) }.filter { it.isNotEmpty() }
if (tokens.isEmpty()) return passes
return passes.filter { pass ->
val normalizedName = normalizeForSearch(pass.name)
tokens.all { normalizedName.contains(it) }
}
if (passes.isNotEmpty()) {
val lastPass = passes.last()
return Triple(lastPass, (lastPass.losTime - timeNow).toTimerString(), false)
}
return Triple(defaultPass, "00:00:00", true)
}
/** Lowercases and strips all non-alphanumeric chars for fuzzy matching. */
private fun normalizeForSearch(text: String): String =
text.lowercase().filter { it.isLetterOrDigit() }
private fun applyFilter(
hoursAhead: Int,
minElevation: Double,
@@ -30,26 +30,20 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
@@ -67,6 +61,7 @@ import com.rtbishop.look4sat.core.presentation.LocalSpacing
import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SearchBar
import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
@@ -191,55 +186,6 @@ private fun SatellitesScreen(
}
}
@Composable
private fun SearchBar(onQueryChange: (String) -> Unit, modifier: Modifier = Modifier) {
val currentQuery = rememberSaveable { mutableStateOf("") }
val updateQuery = { newValue: String ->
currentQuery.value = newValue
onQueryChange(newValue)
}
ElevatedCard(modifier = modifier.height(48.dp)) {
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 12.dp)
) {
Icon(painter = painterResource(id = R.drawable.ic_search), contentDescription = null)
BasicTextField(
value = currentQuery.value,
onValueChange = updateQuery,
singleLine = true,
modifier = Modifier
.weight(1f)
.padding(start = 12.dp),
textStyle = TextStyle(
fontSize = 16.sp,
lineHeight = 20.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
),
decorationBox = { innerTextField ->
if (currentQuery.value.isEmpty()) {
Text(
text = stringResource(id = R.string.sat_search_hint),
fontSize = 16.sp,
lineHeight = 20.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
}
innerTextField()
},
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface)
)
IconButton(onClick = { updateQuery("") }) {
val clearCd = stringResource(R.string.sat_search_clear)
Icon(painter = painterResource(id = R.drawable.ic_close), contentDescription = clearCd)
}
}
}
}
@Composable
private fun ModeCard(modes: List<String>, onClick: () -> Unit, modifier: Modifier = Modifier) {
val modesText = if (modes.isEmpty()) "All" else modes.joinToString(", ")
@@ -372,5 +318,3 @@ private fun SummaryHeader(selectedText: String, availableText: String) {
)
}
}