mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-07 04:57:23 +00:00
Added AOS/LOS timer to each pass and a SearchBar filter
This commit is contained in:
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 catNum: Int = orbitalObject.data.catnum
|
||||||
val name: String = if (hasDecayed) "${orbitalObject.data.name} (decayed?)" else orbitalObject.data.name
|
val name: String = if (hasDecayed) "${orbitalObject.data.name} (decayed?)" else orbitalObject.data.name
|
||||||
val isDeepSpace: Boolean = orbitalObject.data.isDeepSpace
|
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.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
@@ -46,21 +47,32 @@ import androidx.compose.material3.ElevatedButton
|
|||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LocalTextStyle
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.ProgressIndicatorDefaults
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
|
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawWithCache
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Color
|
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.Stroke
|
||||||
|
import androidx.compose.ui.graphics.drawscope.clipRect
|
||||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
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
|
@Composable
|
||||||
fun hasEnoughHeight(): Boolean =
|
fun hasEnoughHeight(): Boolean =
|
||||||
currentWindowAdaptiveInfoV2().windowSizeClass.isHeightAtLeastBreakpoint(480)
|
currentWindowAdaptiveInfoV2().windowSizeClass.isHeightAtLeastBreakpoint(480)
|
||||||
@@ -580,3 +641,30 @@ fun formatFrequency(frequencyHz: Long): String {
|
|||||||
val hz = frequencyHz % 1_000
|
val hz = frequencyHz % 1_000
|
||||||
return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz)
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-2
@@ -39,6 +39,7 @@ fun SwipeableItem(onSwipeRight: () -> Unit, onSwipeLeft: () -> Unit, content: @C
|
|||||||
val dismissThresholdPx = with(LocalDensity.current) { 120.dp.toPx() }
|
val dismissThresholdPx = with(LocalDensity.current) { 120.dp.toPx() }
|
||||||
val dismissState = rememberSwipeToDismissBoxState { dismissThresholdPx }
|
val dismissState = rememberSwipeToDismissBoxState { dismissThresholdPx }
|
||||||
val willTrigger by remember { derivedStateOf { dismissState.targetValue != SwipeToDismissBoxValue.Settled } }
|
val willTrigger by remember { derivedStateOf { dismissState.targetValue != SwipeToDismissBoxValue.Settled } }
|
||||||
|
val isSwiping by remember { derivedStateOf { dismissState.dismissDirection != SwipeToDismissBoxValue.Settled } }
|
||||||
val hapticFeedback = LocalHapticFeedback.current
|
val hapticFeedback = LocalHapticFeedback.current
|
||||||
LaunchedEffect(willTrigger) {
|
LaunchedEffect(willTrigger) {
|
||||||
val feedbackType = if (willTrigger) HapticFeedbackType.LongPress else HapticFeedbackType.SegmentTick
|
val feedbackType = if (willTrigger) HapticFeedbackType.LongPress else HapticFeedbackType.SegmentTick
|
||||||
@@ -49,8 +50,10 @@ fun SwipeableItem(onSwipeRight: () -> Unit, onSwipeLeft: () -> Unit, content: @C
|
|||||||
enableDismissFromStartToEnd = true,
|
enableDismissFromStartToEnd = true,
|
||||||
enableDismissFromEndToStart = true,
|
enableDismissFromEndToStart = true,
|
||||||
backgroundContent = {
|
backgroundContent = {
|
||||||
val isSwipeRight = dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd
|
if (isSwiping) {
|
||||||
SwipeBackground(isSwipeRight, willTrigger, MaterialTheme.colorScheme.primary)
|
val isSwipeRight = dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd
|
||||||
|
SwipeBackground(isSwipeRight, willTrigger, MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
content = { content() },
|
content = { content() },
|
||||||
onDismiss = {
|
onDismiss = {
|
||||||
|
|||||||
@@ -47,8 +47,13 @@
|
|||||||
<string name="pass_deep_space" translatable="false">DeepSpace</string>
|
<string name="pass_deep_space" translatable="false">DeepSpace</string>
|
||||||
<string name="pass_altitude">Altitude: %d km</string>
|
<string name="pass_altitude">Altitude: %d km</string>
|
||||||
<string name="pass_aosLos" translatable="false">%03d° - %03d°</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">
|
<string name="pass_empty_list_message">
|
||||||
Make sure your filters are correct and you have selected satellites</string>
|
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_error_message">There are no upcoming satellite passes to display</string>
|
||||||
<string name="pass_welcome_title">Welcome to Look4Sat\!</string>
|
<string name="pass_welcome_title">Welcome to Look4Sat\!</string>
|
||||||
<string name="pass_welcome_message">
|
<string name="pass_welcome_message">
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ class MapViewModel(
|
|||||||
private fun selectDefaultSatellite(catnum: Int) {
|
private fun selectDefaultSatellite(catnum: Int) {
|
||||||
if (allSatellites.isNotEmpty()) {
|
if (allSatellites.isNotEmpty()) {
|
||||||
if (catnum == -1) {
|
if (catnum == -1) {
|
||||||
allPasses.find { pass -> pass.progress < 100 && !pass.isDeepSpace }
|
allPasses.find { pass -> pass.progress < 1 && !pass.isDeepSpace }
|
||||||
?.let { pass -> selectSatellite(pass.orbitalObject) }
|
?.let { pass -> selectSatellite(pass.orbitalObject) }
|
||||||
} else {
|
} else {
|
||||||
allSatellites.find { it.data.catnum == catnum }?.let { selectSatellite(it) }
|
allSatellites.find { it.data.catnum == catnum }?.let { selectSatellite(it) }
|
||||||
@@ -189,8 +189,8 @@ class MapViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for ((localPositions, localSelectedPos) in deferreds.awaitAll()) {
|
for ((localPositions, localSelectedPos) in deferreds.awaitAll()) {
|
||||||
for (pair in localPositions) {
|
for ((first, second) in localPositions) {
|
||||||
positionsMap[pair.first] = pair.second
|
positionsMap[first] = second
|
||||||
}
|
}
|
||||||
if (localSelectedPos != null) {
|
if (localSelectedPos != null) {
|
||||||
selectedSatPos = localSelectedPos
|
selectedSatPos = localSelectedPos
|
||||||
|
|||||||
+193
-117
@@ -17,11 +17,6 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.feature.passes
|
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.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
|
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
|
||||||
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
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.OrbitalData
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
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.EmptyListCard
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||||
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
||||||
import com.rtbishop.look4sat.core.presentation.SwipeableItem
|
import com.rtbishop.look4sat.core.presentation.SearchBar
|
||||||
import com.rtbishop.look4sat.core.presentation.TimerRow
|
|
||||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
import com.rtbishop.look4sat.core.presentation.WhatsNewDialog
|
import com.rtbishop.look4sat.core.presentation.WhatsNewDialog
|
||||||
import com.rtbishop.look4sat.core.presentation.elevationColor
|
import com.rtbishop.look4sat.core.presentation.elevationColor
|
||||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -89,12 +90,13 @@ fun PassesDestination(navigateToRadar: (Int, Long) -> Unit) {
|
|||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val viewModel: PassesViewModel = viewModel(factory = PassesViewModel.factory(container))
|
val viewModel: PassesViewModel = viewModel(factory = PassesViewModel.factory(container))
|
||||||
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
|
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
|
||||||
PassesScreen(uiState, viewModel::onAction, navigateToRadar)
|
PassesScreen(uiState, viewModel.timeNow, viewModel::onAction, navigateToRadar)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PassesScreen(
|
private fun PassesScreen(
|
||||||
uiState: PassesState,
|
uiState: PassesState,
|
||||||
|
timeNow: StateFlow<Long>,
|
||||||
onAction: (PassesAction) -> Unit,
|
onAction: (PassesAction) -> Unit,
|
||||||
navigateToRadar: (Int, Long) -> Unit
|
navigateToRadar: (Int, Long) -> Unit
|
||||||
) {
|
) {
|
||||||
@@ -145,7 +147,10 @@ private fun PassesScreen(
|
|||||||
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
|
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
|
||||||
},
|
},
|
||||||
topInfo = {
|
topInfo = {
|
||||||
TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos)
|
SearchBar(
|
||||||
|
onQueryChange = { onAction(PassesAction.SearchFor(it)) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
},
|
},
|
||||||
bottomInfo = {
|
bottomInfo = {
|
||||||
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
|
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
|
||||||
@@ -162,14 +167,14 @@ private fun PassesScreen(
|
|||||||
passes = uiState.itemsList,
|
passes = uiState.itemsList,
|
||||||
groupedPasses = uiState.groupedPasses,
|
groupedPasses = uiState.groupedPasses,
|
||||||
sunTimes = uiState.sunTimes,
|
sunTimes = uiState.sunTimes,
|
||||||
focusedCatNum = uiState.focusedCatNum,
|
isSearching = uiState.searchQuery.isNotBlank(),
|
||||||
|
timeNow = timeNow,
|
||||||
navigateToRadar = navigateToRadar,
|
navigateToRadar = navigateToRadar,
|
||||||
onAction = onAction
|
onAction = onAction
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PassesList(
|
private fun PassesList(
|
||||||
isRefreshing: Boolean,
|
isRefreshing: Boolean,
|
||||||
@@ -177,7 +182,8 @@ private fun PassesList(
|
|||||||
passes: List<OrbitalPass>,
|
passes: List<OrbitalPass>,
|
||||||
groupedPasses: Map<String, List<OrbitalPass>>,
|
groupedPasses: Map<String, List<OrbitalPass>>,
|
||||||
sunTimes: Map<String, Pair<String, String>>,
|
sunTimes: Map<String, Pair<String, String>>,
|
||||||
focusedCatNum: Int?,
|
isSearching: Boolean,
|
||||||
|
timeNow: StateFlow<Long>,
|
||||||
navigateToRadar: (Int, Long) -> Unit,
|
navigateToRadar: (Int, Long) -> Unit,
|
||||||
onAction: (PassesAction) -> Unit
|
onAction: (PassesAction) -> Unit
|
||||||
) {
|
) {
|
||||||
@@ -199,38 +205,28 @@ private fun PassesList(
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
if (passes.isEmpty()) {
|
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 {
|
} else {
|
||||||
AnimatedContent(
|
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp), modifier = Modifier.fillMaxSize()) {
|
||||||
targetState = focusedCatNum,
|
for ((dateLabel, dayPasses) in groupedPasses) {
|
||||||
transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) },
|
stickyHeader(key = "header_$dateLabel") {
|
||||||
label = "PassesFocusTransition"
|
val (rise, set) = sunTimes[dateLabel] ?: ("--:--" to "--:--")
|
||||||
) { targetFocus ->
|
StickyDateHeader(label = dateLabel, sunriseTime = rise, sunsetTime = set)
|
||||||
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp), modifier = Modifier.fillMaxSize()) {
|
}
|
||||||
for ((dateLabel, dayPasses) in groupedPasses) {
|
items(items = dayPasses, key = { item -> "${item.catNum}_${item.aosTime}" }) { pass ->
|
||||||
val headerVisible = targetFocus == null || dayPasses.any { it.catNum == targetFocus }
|
PassItem(
|
||||||
if (headerVisible) {
|
pass = pass,
|
||||||
stickyHeader(key = "header_$dateLabel") {
|
navigateToRadar = navigateToRadar,
|
||||||
val (rise, set) = sunTimes[dateLabel] ?: ("--:--" to "--:--")
|
timeNow = timeNow,
|
||||||
StickyDateHeader(label = dateLabel, sunriseTime = rise, sunsetTime = set)
|
modifier = Modifier.animateItem(),
|
||||||
}
|
isVerticalLayout = isVerticalLayout,
|
||||||
}
|
isUtc = isUtc
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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()
|
val locale = Locale.getDefault()
|
||||||
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
|
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
|
||||||
}
|
}
|
||||||
@@ -288,51 +284,127 @@ private fun displayLocale(): Locale {
|
|||||||
private fun DeepSpacePassPreview() {
|
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 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 satellite = DeepSpaceObject(data)
|
||||||
val pass = OrbitalPass(1L, 180.0, 10L, 360.0, 36650, 45.0, satellite, 0.5f)
|
val pass = OrbitalPass(1L, 180.0, 10L, 180.0, 36650, 45.0, satellite, 0.5f)
|
||||||
MainTheme { PassItem(pass = pass, { _, _ -> }) }
|
MainTheme { PassItem(pass = pass, { _, _ -> }, MutableStateFlow(0L)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@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 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 aosTime = 1767225600000L
|
||||||
val pass = OrbitalPass(1L, 180.0, 10L, 360.0, 36650, 45.0, satellite, 0.5f)
|
val pass = OrbitalPass(aosTime, 180.0, aosTime + 702000L, 360.0, 36650, 45.0, NearEarthObject(data), 0f)
|
||||||
MainTheme { PassItem(pass = pass, { _, _ -> }) }
|
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
|
@Composable
|
||||||
private fun PassItem(
|
private fun PassItem(
|
||||||
pass: OrbitalPass,
|
pass: OrbitalPass,
|
||||||
navigateToRadar: (Int, Long) -> Unit,
|
navigateToRadar: (Int, Long) -> Unit,
|
||||||
|
timeNow: StateFlow<Long>,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
isVerticalLayout: Boolean = true,
|
isVerticalLayout: Boolean = true,
|
||||||
isUtc: Boolean = false
|
isUtc: Boolean = false
|
||||||
) {
|
) {
|
||||||
val passSatId = stringResource(id = R.string.pass_satId, pass.catNum)
|
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) {
|
val timeZone = remember(isUtc) {
|
||||||
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
}
|
}
|
||||||
val sdfTime = remember(isUtc) {
|
val sdfTime = remember(isUtc) {
|
||||||
SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
|
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 aosTimeStr = remember(pass.aosTime, isUtc) { sdfTime.format(Date(pass.aosTime)) }
|
||||||
val losTimeStr = remember(pass.losTime, isUtc) { sdfTime.format(Date(pass.losTime)) }
|
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 durationStr = remember(pass.aosTime, pass.losTime) {
|
||||||
val seconds = (pass.losTime - pass.aosTime) / 1000
|
val seconds = (pass.losTime - pass.aosTime) / 1000
|
||||||
"${seconds / 60}m ${seconds % 60}s"
|
"${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(
|
Column(
|
||||||
modifier = modifier.clickable { navigateToRadar(pass.catNum, pass.aosTime) }
|
modifier = modifier.clickable { navigateToRadar(pass.catNum, pass.aosTime) }
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(1.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(color = MaterialTheme.colorScheme.surface)
|
.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) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
@@ -350,84 +422,88 @@ private fun PassItem(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
val elevColor = elevationColor(pass.maxElevation)
|
PassTimerChip(pass = pass, timeNow = timeNow)
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Row(
|
Text(
|
||||||
modifier = Modifier.weight(1f),
|
text = if (pass.isDeepSpace) {
|
||||||
horizontalArrangement = Arrangement.Start,
|
stringResource(R.string.pass_duration_placeholder)
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
if (pass.isDeepSpace) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.pass_deep_space),
|
|
||||||
fontSize = 15.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
} else {
|
} 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(
|
Row(
|
||||||
modifier = Modifier.weight(1f),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
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,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(
|
text = aosAzStr,
|
||||||
id = R.string.pass_aosLos,
|
fontSize = 15.sp,
|
||||||
pass.aosAzimuth.toInt(),
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
pass.losAzimuth.toInt()
|
)
|
||||||
),
|
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,
|
fontSize = 15.sp,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
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)
|
HorizontalDivider(thickness = 2.dp, color = MaterialTheme.colorScheme.background)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,7 @@ data class PassesState(
|
|||||||
val isRefreshing: Boolean = true,
|
val isRefreshing: Boolean = true,
|
||||||
val isUtc: Boolean = false,
|
val isUtc: Boolean = false,
|
||||||
val nextPass: OrbitalPass,
|
val nextPass: OrbitalPass,
|
||||||
val nextTime: String = "00:00:00",
|
val searchQuery: String = "",
|
||||||
val isNextTimeAos: Boolean = true,
|
|
||||||
val hours: Int = 24,
|
val hours: Int = 24,
|
||||||
val elevation: Double = 16.0,
|
val elevation: Double = 16.0,
|
||||||
val lowElevation: Double = 16.0,
|
val lowElevation: Double = 16.0,
|
||||||
@@ -39,8 +38,7 @@ data class PassesState(
|
|||||||
val itemsList: List<OrbitalPass> = emptyList(),
|
val itemsList: List<OrbitalPass> = emptyList(),
|
||||||
val groupedPasses: Map<String, List<OrbitalPass>> = emptyMap(),
|
val groupedPasses: Map<String, List<OrbitalPass>> = emptyMap(),
|
||||||
val shouldSeeWhatsNew: Boolean = false,
|
val shouldSeeWhatsNew: Boolean = false,
|
||||||
val sunTimes: Map<String, Pair<String, String>> = emptyMap(),
|
val sunTimes: Map<String, Pair<String, String>> = emptyMap()
|
||||||
val focusedCatNum: Int? = null
|
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface PassesAction {
|
sealed interface PassesAction {
|
||||||
@@ -59,6 +57,5 @@ sealed interface PassesAction {
|
|||||||
data object RefreshPasses : PassesAction
|
data object RefreshPasses : PassesAction
|
||||||
data object TogglePassesDialog : PassesAction
|
data object TogglePassesDialog : PassesAction
|
||||||
data object ToggleRadiosDialog : PassesAction
|
data object ToggleRadiosDialog : PassesAction
|
||||||
data class FocusCatNum(val catNum: Int) : PassesAction
|
data class SearchFor(val query: String) : PassesAction
|
||||||
data object ClearFocus : PassesAction
|
|
||||||
}
|
}
|
||||||
+87
-64
@@ -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.IMainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
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 com.rtbishop.look4sat.core.presentation.getDefaultPass
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -46,6 +44,14 @@ import java.util.Locale
|
|||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
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(
|
class PassesViewModel(
|
||||||
private val satelliteRepo: ISatelliteRepo,
|
private val satelliteRepo: ISatelliteRepo,
|
||||||
private val settingsRepo: ISettingsRepo
|
private val settingsRepo: ISettingsRepo
|
||||||
@@ -70,6 +76,16 @@ class PassesViewModel(
|
|||||||
)
|
)
|
||||||
val uiState: StateFlow<PassesState> = _uiState
|
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 {
|
init {
|
||||||
// Refresh indicator: mirrors the repo's isCalculating state
|
// Refresh indicator: mirrors the repo's isCalculating state
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -95,36 +111,50 @@ class PassesViewModel(
|
|||||||
_uiState.update { it.copy(modes = modes) }
|
_uiState.update { it.copy(modes = modes) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Tick loop: restarts on passes change, UTC/DeepSpace changes. Grouping/sun-time
|
// Tick loop, gated on having an observer so it stops while the screen is in the
|
||||||
// computations run once per restart, progress/countdown are calculated every second.
|
// 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 {
|
viewModelScope.launch {
|
||||||
combine(
|
_uiState.subscriptionCount
|
||||||
satelliteRepo.passes,
|
.map { count -> count > 0 }
|
||||||
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged(),
|
.distinctUntilChanged()
|
||||||
settingsRepo.passesSettings.map { it.showDeepSpace }.distinctUntilChanged()
|
.collectLatest { isObserved ->
|
||||||
) { passes, isUtc, showDeepSpace -> Triple(passes, isUtc, showDeepSpace) }
|
if (!isObserved) return@collectLatest
|
||||||
.collectLatest { (allPasses, isUtc, showDeepSpace) ->
|
combine(
|
||||||
val filtered = if (showDeepSpace) allPasses
|
satelliteRepo.passes,
|
||||||
else allPasses.filter { !it.isDeepSpace }
|
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged(),
|
||||||
// Expensive: recompute sun times once per items/UTC change, not every second
|
settingsRepo.passesSettings.map { it.showDeepSpace }.distinctUntilChanged(),
|
||||||
val sunTimes = computeSunTimes(filtered, isUtc)
|
searchQuery
|
||||||
_uiState.update { it.copy(sunTimes = sunTimes) }
|
) { passes, isUtc, showDeepSpace, query ->
|
||||||
while (isActive) {
|
PassesInput(passes, isUtc, showDeepSpace, query)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
.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) }
|
_uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) }
|
||||||
PassesAction.ToggleRadiosDialog ->
|
PassesAction.ToggleRadiosDialog ->
|
||||||
_uiState.update { it.copy(isRadiosDialogShown = !it.isRadiosDialogShown) }
|
_uiState.update { it.copy(isRadiosDialogShown = !it.isRadiosDialogShown) }
|
||||||
is PassesAction.FocusCatNum -> _uiState.update { it.copy(focusedCatNum = action.catNum) }
|
is PassesAction.SearchFor -> {
|
||||||
PassesAction.ClearFocus -> _uiState.update { it.copy(focusedCatNum = null) }
|
searchQuery.value = action.query
|
||||||
|
_uiState.update { it.copy(searchQuery = action.query) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,43 +240,34 @@ class PassesViewModel(
|
|||||||
return ordered
|
return ordered
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Computes live progress for each pass, filtering out expired ones. */
|
/** Resolves the next upcoming pass, falling back to the one currently in progress. */
|
||||||
private fun computePassProgress(passList: List<OrbitalPass>, time: Long): List<OrbitalPass> {
|
private fun resolveNextPass(passes: List<OrbitalPass>, timeNow: Long): OrbitalPass {
|
||||||
val result = ArrayList<OrbitalPass>(passList.size)
|
val upcoming = passes.firstOrNull { it.aosTime > timeNow }
|
||||||
for (pass in passList) {
|
if (upcoming != null) return upcoming
|
||||||
if (!pass.isDeepSpace && time > pass.aosTime) {
|
return passes.lastOrNull() ?: defaultPass
|
||||||
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 or active pass and its countdown timer. */
|
/**
|
||||||
private fun resolveNextPass(
|
* Filters passes by query, mirroring the satellite search: a numeric query matches the
|
||||||
passes: List<OrbitalPass>,
|
* catalog number, otherwise every space-separated token must appear in the name once both
|
||||||
timeNow: Long
|
* sides are normalized, so "ao7" matches "AO-7 (AMSAT-OSCAR 7)".
|
||||||
): Triple<OrbitalPass, String, Boolean> {
|
*/
|
||||||
val upcoming = passes.firstOrNull { it.aosTime > timeNow }
|
private fun filterByQuery(passes: List<OrbitalPass>, query: String): List<OrbitalPass> {
|
||||||
if (upcoming != null) {
|
if (query.isBlank()) return passes
|
||||||
return Triple(upcoming, (upcoming.aosTime - timeNow).toTimerString(), true)
|
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(
|
private fun applyFilter(
|
||||||
hoursAhead: Int,
|
hoursAhead: Int,
|
||||||
minElevation: Double,
|
minElevation: Double,
|
||||||
|
|||||||
+1
-57
@@ -30,26 +30,20 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
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.MainTheme
|
||||||
import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
|
import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
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.TopBar
|
||||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
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
|
@Composable
|
||||||
private fun ModeCard(modes: List<String>, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
private fun ModeCard(modes: List<String>, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
val modesText = if (modes.isEmpty()) "All" else modes.joinToString(", ")
|
val modesText = if (modes.isEmpty()) "All" else modes.joinToString(", ")
|
||||||
@@ -372,5 +318,3 @@ private fun SummaryHeader(selectedText: String, availableText: String) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Reference in new issue
Block a user