diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/OrbitalPass.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/OrbitalPass.kt
index 99575877..739c9edd 100644
--- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/OrbitalPass.kt
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/OrbitalPass.kt
@@ -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)
+ }
}
diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt
index f7f864a4..3c500740 100644
--- a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt
+++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt
@@ -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) }
+ }
+}
diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/SwipeableItem.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/SwipeableItem.kt
index 5e6f448e..ccb87135 100644
--- a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/SwipeableItem.kt
+++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/SwipeableItem.kt
@@ -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 = {
diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml
index 4e1d8714..788716b5 100644
--- a/core/presentation/src/main/res/values/strings.xml
+++ b/core/presentation/src/main/res/values/strings.xml
@@ -47,8 +47,13 @@
DeepSpace
Altitude: %d km
%03d° - %03d°
+ %03d°
+ --m --s
+ AOS %s
+ LOS %s
Make sure your filters are correct and you have selected satellites
+ No upcoming passes match your search
There are no upcoming satellite passes to display
Welcome to Look4Sat\!
diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapViewModel.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapViewModel.kt
index 91524290..41b5f176 100644
--- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapViewModel.kt
+++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapViewModel.kt
@@ -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
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 f9c39dfa..7b32ba71 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
@@ -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,
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,
groupedPasses: Map>,
sunTimes: Map>,
- focusedCatNum: Int?,
+ isSearching: Boolean,
+ timeNow: StateFlow,
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) {
+ 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, 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,
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)
}
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 3aef6726..4e38e99e 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
@@ -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 = emptyList(),
val groupedPasses: Map> = emptyMap(),
val shouldSeeWhatsNew: Boolean = false,
- val sunTimes: Map> = emptyMap(),
- val focusedCatNum: Int? = null
+ val sunTimes: Map> = 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
}
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 08d3d3ac..015b0443 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
@@ -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,
+ 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 = _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 = _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? = 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, time: Long): List {
- val result = ArrayList(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, 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,
- timeNow: Long
- ): Triple {
- 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, query: String): List {
+ 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,
diff --git a/feature/satellites/src/main/java/com/rtbishop/look4sat/feature/satellites/SatellitesScreen.kt b/feature/satellites/src/main/java/com/rtbishop/look4sat/feature/satellites/SatellitesScreen.kt
index 4083dccd..17ceaa84 100644
--- a/feature/satellites/src/main/java/com/rtbishop/look4sat/feature/satellites/SatellitesScreen.kt
+++ b/feature/satellites/src/main/java/com/rtbishop/look4sat/feature/satellites/SatellitesScreen.kt
@@ -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, 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) {
)
}
}
-
-