Implemented Swipe-to-Focus behavior for satellite passes
This commit is contained in:
1 parent
821fa0dfe7
commit
8a2339cfbf
8 files changed
+202
-98
No files matched your search
@@ -178,12 +178,6 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
|||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(
|
|
||||||
painter = painterResource(R.drawable.ic_altitude),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Text(text = "${pass.altitude} km", fontSize = 15.sp)
|
Text(text = "${pass.altitude} km", fontSize = 15.sp)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
package com.rtbishop.look4sat.core.presentation
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SwipeToDismissBox
|
||||||
|
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||||
|
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.dropShadow
|
||||||
|
import androidx.compose.ui.draw.innerShadow
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SwipeableItem(onSwipeRight: () -> Unit, onSwipeLeft: () -> Unit, content: @Composable () -> Unit) {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val dismissThresholdPx = with(LocalDensity.current) { 120.dp.toPx() }
|
||||||
|
val dismissState = rememberSwipeToDismissBoxState { dismissThresholdPx }
|
||||||
|
val willTrigger by remember { derivedStateOf { dismissState.targetValue != SwipeToDismissBoxValue.Settled } }
|
||||||
|
val hapticFeedback = LocalHapticFeedback.current
|
||||||
|
LaunchedEffect(willTrigger) {
|
||||||
|
val feedbackType = if (willTrigger) HapticFeedbackType.LongPress else HapticFeedbackType.SegmentTick
|
||||||
|
if (willTrigger) hapticFeedback.performHapticFeedback(feedbackType)
|
||||||
|
}
|
||||||
|
SwipeToDismissBox(
|
||||||
|
state = dismissState,
|
||||||
|
enableDismissFromStartToEnd = true,
|
||||||
|
enableDismissFromEndToStart = true,
|
||||||
|
backgroundContent = {
|
||||||
|
val isSwipeRight = dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd
|
||||||
|
SwipeBackground(isSwipeRight, willTrigger, MaterialTheme.colorScheme.primary)
|
||||||
|
},
|
||||||
|
content = { content() },
|
||||||
|
onDismiss = {
|
||||||
|
val targetValue = dismissState.targetValue
|
||||||
|
when (targetValue) {
|
||||||
|
SwipeToDismissBoxValue.StartToEnd -> onSwipeRight()
|
||||||
|
SwipeToDismissBoxValue.EndToStart -> onSwipeLeft()
|
||||||
|
SwipeToDismissBoxValue.Settled -> {}
|
||||||
|
}
|
||||||
|
if (targetValue != SwipeToDismissBoxValue.Settled) {
|
||||||
|
coroutineScope.launch { dismissState.snapTo(SwipeToDismissBoxValue.Settled) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SwipeBackground(isSwipeRight: Boolean, willTrigger: Boolean, bgColor: Color = Color(0x00000000)) {
|
||||||
|
val shape = RectangleShape
|
||||||
|
Box(
|
||||||
|
contentAlignment = if (isSwipeRight) Alignment.CenterStart else Alignment.CenterEnd,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.border(width = 1.dp, shape = shape, color = bgColor)
|
||||||
|
.dropShadow(shape = shape) {
|
||||||
|
color = bgColor
|
||||||
|
radius = 40f
|
||||||
|
alpha = if (willTrigger) .2f else 0f
|
||||||
|
}
|
||||||
|
.innerShadow(shape = shape) {
|
||||||
|
color = bgColor
|
||||||
|
radius = 40f
|
||||||
|
alpha = if (willTrigger) 1f else .2f
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
val iconScale by animateFloatAsState(targetValue = if (willTrigger) 1f else .8f)
|
||||||
|
val slide by animateDpAsState(targetValue = if (willTrigger) 32.dp else (12).dp)
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_filter),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(32.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.rotate(if (isSwipeRight) 0f else 180f)
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = iconScale
|
||||||
|
scaleY = iconScale
|
||||||
|
translationX = slide.toPx()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M4,20h16v2L4,22zM4,2h16v2L4,4zM13,9h3l-4,-4 -4,4h3v6L8,15l4,4 4,-4h-3z" />
|
|
||||||
</vector>
|
|
||||||
@@ -17,6 +17,11 @@
|
|||||||
*/
|
*/
|
||||||
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
|
||||||
@@ -67,6 +72,7 @@ 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.TimerRow
|
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.infiniteMarquee
|
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||||
@@ -143,9 +149,11 @@ private fun PassesScreen(
|
|||||||
isRefreshing = uiState.isRefreshing,
|
isRefreshing = uiState.isRefreshing,
|
||||||
isUtc = uiState.isUtc,
|
isUtc = uiState.isUtc,
|
||||||
passes = uiState.itemsList,
|
passes = uiState.itemsList,
|
||||||
|
groupedPasses = uiState.groupedPasses,
|
||||||
|
sunTimes = uiState.sunTimes,
|
||||||
|
focusedCatNum = uiState.focusedCatNum,
|
||||||
navigateToRadar = navigateToRadar,
|
navigateToRadar = navigateToRadar,
|
||||||
refreshPasses = { onAction(PassesAction.RefreshPasses) },
|
onAction = onAction
|
||||||
sunTimes = uiState.sunTimes
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,33 +164,19 @@ private fun PassesList(
|
|||||||
isRefreshing: Boolean,
|
isRefreshing: Boolean,
|
||||||
isUtc: Boolean,
|
isUtc: Boolean,
|
||||||
passes: List<OrbitalPass>,
|
passes: List<OrbitalPass>,
|
||||||
|
groupedPasses: Map<String, List<OrbitalPass>>,
|
||||||
|
sunTimes: Map<String, Pair<String, String>>,
|
||||||
|
focusedCatNum: Int?,
|
||||||
navigateToRadar: (Int, Long) -> Unit,
|
navigateToRadar: (Int, Long) -> Unit,
|
||||||
refreshPasses: () -> Unit,
|
onAction: (PassesAction) -> Unit
|
||||||
sunTimes: Map<String, Pair<String, String>>
|
|
||||||
) {
|
) {
|
||||||
val isVerticalLayout = isVerticalLayout()
|
val isVerticalLayout = isVerticalLayout()
|
||||||
val refreshState = rememberPullToRefreshState()
|
val refreshState = rememberPullToRefreshState()
|
||||||
val timeZone = remember(isUtc) { if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault() }
|
|
||||||
val sdfDate = remember(isUtc) {
|
|
||||||
SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = timeZone }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deep Space first, then date-grouped timed passes
|
|
||||||
val groupedPasses = remember(passes, isUtc) {
|
|
||||||
val ordered = LinkedHashMap<String, List<OrbitalPass>>()
|
|
||||||
val deepSpace = passes.filter { it.isDeepSpace }
|
|
||||||
if (deepSpace.isNotEmpty()) ordered["Deep Space"] = deepSpace
|
|
||||||
passes.filter { !it.isDeepSpace }
|
|
||||||
.groupByTo(LinkedHashMap()) { sdfDate.format(Date(it.aosTime)) }
|
|
||||||
.forEach { (k, v) -> ordered[k] = v }
|
|
||||||
ordered
|
|
||||||
}
|
|
||||||
|
|
||||||
ElevatedCard(modifier = Modifier.fillMaxSize()) {
|
ElevatedCard(modifier = Modifier.fillMaxSize()) {
|
||||||
PullToRefreshBox(
|
PullToRefreshBox(
|
||||||
isRefreshing = isRefreshing,
|
isRefreshing = isRefreshing,
|
||||||
state = refreshState,
|
state = refreshState,
|
||||||
onRefresh = refreshPasses,
|
onRefresh = { onAction(PassesAction.RefreshPasses) },
|
||||||
indicator = {
|
indicator = {
|
||||||
PullToRefreshDefaults.Indicator(
|
PullToRefreshDefaults.Indicator(
|
||||||
state = refreshState,
|
state = refreshState,
|
||||||
@@ -196,20 +190,36 @@ private fun PassesList(
|
|||||||
if (passes.isEmpty()) {
|
if (passes.isEmpty()) {
|
||||||
EmptyListCard(message = stringResource(R.string.pass_empty_list_message))
|
EmptyListCard(message = stringResource(R.string.pass_empty_list_message))
|
||||||
} else {
|
} else {
|
||||||
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp), modifier = Modifier.fillMaxSize()) {
|
AnimatedContent(
|
||||||
for ((dateLabel, dayPasses) in groupedPasses) {
|
targetState = focusedCatNum,
|
||||||
stickyHeader(key = "header_$dateLabel") {
|
transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) },
|
||||||
val (rise, set) = sunTimes[dateLabel] ?: ("--:--" to "--:--")
|
label = "PassesFocusTransition"
|
||||||
StickyDateHeader(label = dateLabel, sunriseTime = rise, sunsetTime = set)
|
) { targetFocus ->
|
||||||
}
|
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp), modifier = Modifier.fillMaxSize()) {
|
||||||
items(items = dayPasses, key = { item -> item.catNum + item.aosTime }) { pass ->
|
for ((dateLabel, dayPasses) in groupedPasses) {
|
||||||
PassItem(
|
val headerVisible = targetFocus == null || dayPasses.any { it.catNum == targetFocus }
|
||||||
pass = pass,
|
if (headerVisible) {
|
||||||
navigateToRadar = navigateToRadar,
|
stickyHeader(key = "header_$dateLabel") {
|
||||||
modifier = Modifier.animateItem(),
|
val (rise, set) = sunTimes[dateLabel] ?: ("--:--" to "--:--")
|
||||||
isVerticalLayout = isVerticalLayout,
|
StickyDateHeader(label = dateLabel, sunriseTime = rise, sunsetTime = set)
|
||||||
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -361,13 +371,6 @@ private fun PassItem(
|
|||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.drawable.ic_altitude),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Text(text = "${pass.altitude} km", fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface)
|
Text(text = "${pass.altitude} km", fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface)
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -32,9 +32,10 @@ data class PassesState(
|
|||||||
val showDeepSpace: Boolean = true,
|
val showDeepSpace: Boolean = true,
|
||||||
val modes: List<String> = emptyList(),
|
val modes: List<String> = emptyList(),
|
||||||
val itemsList: List<OrbitalPass> = emptyList(),
|
val itemsList: List<OrbitalPass> = emptyList(),
|
||||||
|
val groupedPasses: Map<String, List<OrbitalPass>> = emptyMap(),
|
||||||
val shouldSeeWhatsNew: Boolean = false,
|
val shouldSeeWhatsNew: Boolean = false,
|
||||||
// Map of dateLabel -> Pair(sunriseTime, sunsetTime) for each day group
|
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 {
|
||||||
@@ -44,4 +45,6 @@ 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 object ClearFocus : PassesAction
|
||||||
}
|
}
|
||||||
+40
-32
@@ -74,37 +74,31 @@ class PassesViewModel(
|
|||||||
_uiState.update { it.copy(isUtc = settings.stateOfUtc, shouldSeeWhatsNew = settings.shouldSeeWhatsNew) }
|
_uiState.update { it.copy(isUtc = settings.stateOfUtc, shouldSeeWhatsNew = settings.shouldSeeWhatsNew) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Local tick loop — computes pass progress, countdown timer, and per-day sun times every second
|
// Main tick loop: reacts to new passes, then ticks every second
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
var lastSunTimesKey = "" // track when we need to recompute sun times
|
satelliteRepo.passes.collectLatest { allPasses ->
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
val timeNow = System.currentTimeMillis()
|
val timeNow = System.currentTimeMillis()
|
||||||
val isUtc = _uiState.value.isUtc
|
val isUtc = _uiState.value.isUtc
|
||||||
val showDeepSpace = _uiState.value.showDeepSpace
|
val showDeepSpace = _uiState.value.showDeepSpace
|
||||||
val allPasses = satelliteRepo.passes.value
|
val filtered = allPasses
|
||||||
val filtered = if (showDeepSpace) allPasses else allPasses.filter { !it.isDeepSpace }
|
.let { if (showDeepSpace) it else it.filter { pass -> !pass.isDeepSpace } }
|
||||||
val processed = computePassProgress(filtered, timeNow)
|
val processed = computePassProgress(filtered, timeNow)
|
||||||
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
|
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
|
||||||
|
val sunTimes = computeSunTimes(processed, isUtc)
|
||||||
// Recompute per-day sun times only when passes list or UTC setting changes
|
val grouped = groupPasses(processed, isUtc)
|
||||||
val sunTimesKey = "${processed.firstOrNull()?.aosTime}-${processed.lastOrNull()?.aosTime}-$isUtc"
|
_uiState.update {
|
||||||
val sunTimes = if (sunTimesKey != lastSunTimesKey) {
|
it.copy(
|
||||||
lastSunTimesKey = sunTimesKey
|
itemsList = processed,
|
||||||
computeSunTimes(processed, isUtc)
|
groupedPasses = grouped,
|
||||||
} else {
|
sunTimes = sunTimes,
|
||||||
_uiState.value.sunTimes
|
nextPass = nextPass,
|
||||||
|
nextTime = nextTime,
|
||||||
|
isNextTimeAos = isAos
|
||||||
|
)
|
||||||
|
}
|
||||||
|
delay(1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
_uiState.update {
|
|
||||||
it.copy(
|
|
||||||
itemsList = processed,
|
|
||||||
nextPass = nextPass,
|
|
||||||
nextTime = nextTime,
|
|
||||||
isNextTimeAos = isAos,
|
|
||||||
sunTimes = sunTimes
|
|
||||||
)
|
|
||||||
}
|
|
||||||
delay(1000)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,22 +115,24 @@ 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) }
|
||||||
|
PassesAction.ClearFocus -> _uiState.update { it.copy(focusedCatNum = null) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for Deep Space
|
// Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace
|
||||||
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
|
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
|
||||||
val stationPos = settingsRepo.stationPosition.value
|
val stationPos = settingsRepo.stationPosition.value
|
||||||
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz }
|
val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz }
|
||||||
val sdfTime = SimpleDateFormat("HH:mm", Locale.ENGLISH).also { it.timeZone = tz }
|
val sdfTime = SimpleDateFormat("HH:mm", Locale.ENGLISH).also { it.timeZone = tz }
|
||||||
val result = LinkedHashMap<String, Pair<String, String>>()
|
val result = LinkedHashMap<String, Pair<String, String>>()
|
||||||
// Deep Space group always shows today's sun times
|
// DeepSpace group always shows today's sun times
|
||||||
if (passes.any { it.isDeepSpace }) {
|
if (passes.any { it.isDeepSpace }) {
|
||||||
val riseSet = CelestialComputer.findSunRiseSet(stationPos, System.currentTimeMillis())
|
val riseSet = CelestialComputer.findSunRiseSet(stationPos, System.currentTimeMillis())
|
||||||
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
|
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
|
||||||
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
|
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
|
||||||
result["Deep Space"] = rise to set
|
result["DeepSpace (period >225min)"] = rise to set
|
||||||
}
|
}
|
||||||
for (pass in passes) {
|
for (pass in passes) {
|
||||||
if (pass.isDeepSpace) continue
|
if (pass.isDeepSpace) continue
|
||||||
@@ -150,6 +146,18 @@ class PassesViewModel(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun groupPasses(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, List<OrbitalPass>> {
|
||||||
|
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
|
val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz }
|
||||||
|
val ordered = LinkedHashMap<String, List<OrbitalPass>>()
|
||||||
|
val deepSpace = passes.filter { it.isDeepSpace }
|
||||||
|
if (deepSpace.isNotEmpty()) ordered["DeepSpace (period >225min)"] = deepSpace
|
||||||
|
passes.filter { !it.isDeepSpace }
|
||||||
|
.groupByTo(LinkedHashMap()) { sdfDate.format(Date(it.aosTime)) }
|
||||||
|
.forEach { (k, v) -> ordered[k] = v }
|
||||||
|
return ordered
|
||||||
|
}
|
||||||
|
|
||||||
/** Computes live progress for each pass, filtering out expired ones. */
|
/** Computes live progress for each pass, filtering out expired ones. */
|
||||||
private fun computePassProgress(passList: List<OrbitalPass>, time: Long): List<OrbitalPass> {
|
private fun computePassProgress(passList: List<OrbitalPass>, time: Long): List<OrbitalPass> {
|
||||||
val result = ArrayList<OrbitalPass>(passList.size)
|
val result = ArrayList<OrbitalPass>(passList.size)
|
||||||
|
|||||||
@@ -12,21 +12,21 @@ jdkVersion = "17"
|
|||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
packageName = "com.rtbishop.look4sat"
|
packageName = "com.rtbishop.look4sat"
|
||||||
|
|
||||||
android-gradle-plugin = "9.2.0"
|
android-gradle-plugin = "9.2.1"
|
||||||
|
|
||||||
androidx-core-ktx = "1.18.0"
|
androidx-core-ktx = "1.18.0"
|
||||||
androidx-core-splashscreen = "1.2.0"
|
androidx-core-splashscreen = "1.2.0"
|
||||||
androidx-room = "2.8.4"
|
androidx-room = "2.8.4"
|
||||||
|
|
||||||
compose-bom = "2026.04.01"
|
compose-bom = "2026.05.01"
|
||||||
compose-activity = "1.13.0"
|
compose-activity = "1.13.0"
|
||||||
compose-lifecycle = "2.10.0"
|
compose-lifecycle = "2.10.0"
|
||||||
compose-navigation3 = "1.1.1"
|
compose-navigation3 = "1.1.2"
|
||||||
|
|
||||||
google-ksp = "2.3.6"
|
google-ksp = "2.3.7"
|
||||||
|
|
||||||
kotlin = "2.3.21"
|
kotlin = "2.3.21"
|
||||||
kotlin-coroutines = "1.10.2"
|
kotlin-coroutines = "1.11.0"
|
||||||
kotlin-serialization = "1.11.0"
|
kotlin-serialization = "1.11.0"
|
||||||
|
|
||||||
other-okhttp = "5.3.2"
|
other-okhttp = "5.3.2"
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
#Tue Apr 28 15:46:23 BST 2026
|
#Mon May 25 11:20:48 BST 2026
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
Reference in new issue
Block a user