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) { ) } } - -