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