Implemented Swipe-to-Focus behavior for satellite passes

This commit is contained in:
Arty Bishop committed 2026-05-25 18:30:48 +01:00
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,
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(
@@ -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
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
@@ -67,6 +72,7 @@ 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.TopBar
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
@@ -143,9 +149,11 @@ private fun PassesScreen(
isRefreshing = uiState.isRefreshing,
isUtc = uiState.isUtc,
passes = uiState.itemsList,
groupedPasses = uiState.groupedPasses,
sunTimes = uiState.sunTimes,
focusedCatNum = uiState.focusedCatNum,
navigateToRadar = navigateToRadar,
refreshPasses = { onAction(PassesAction.RefreshPasses) },
sunTimes = uiState.sunTimes
onAction = onAction
)
}
}
@@ -156,33 +164,19 @@ private fun PassesList(
isRefreshing: Boolean,
isUtc: Boolean,
passes: List<OrbitalPass>,
groupedPasses: Map<String, List<OrbitalPass>>,
sunTimes: Map<String, Pair<String, String>>,
focusedCatNum: Int?,
navigateToRadar: (Int, Long) -> Unit,
refreshPasses: () -> Unit,
sunTimes: Map<String, Pair<String, String>>
onAction: (PassesAction) -> Unit
) {
val isVerticalLayout = isVerticalLayout()
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()) {
PullToRefreshBox(
isRefreshing = isRefreshing,
state = refreshState,
onRefresh = refreshPasses,
onRefresh = { onAction(PassesAction.RefreshPasses) },
indicator = {
PullToRefreshDefaults.Indicator(
state = refreshState,
@@ -196,20 +190,36 @@ private fun PassesList(
if (passes.isEmpty()) {
EmptyListCard(message = stringResource(R.string.pass_empty_list_message))
} else {
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,
modifier = Modifier.animateItem(),
isVerticalLayout = isVerticalLayout,
isUtc = isUtc
)
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
)
}
}
}
}
}
@@ -361,13 +371,6 @@ private fun PassItem(
horizontalArrangement = Arrangement.Center,
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)
}
Row(
@@ -32,9 +32,10 @@ data class PassesState(
val showDeepSpace: Boolean = true,
val modes: List<String> = emptyList(),
val itemsList: List<OrbitalPass> = emptyList(),
val groupedPasses: Map<String, List<OrbitalPass>> = emptyMap(),
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 {
@@ -44,4 +45,6 @@ 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
}
@@ -74,37 +74,31 @@ class PassesViewModel(
_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 {
var lastSunTimesKey = "" // track when we need to recompute sun times
while (isActive) {
val timeNow = System.currentTimeMillis()
val isUtc = _uiState.value.isUtc
val showDeepSpace = _uiState.value.showDeepSpace
val allPasses = satelliteRepo.passes.value
val filtered = if (showDeepSpace) allPasses else allPasses.filter { !it.isDeepSpace }
val processed = computePassProgress(filtered, timeNow)
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
// Recompute per-day sun times only when passes list or UTC setting changes
val sunTimesKey = "${processed.firstOrNull()?.aosTime}-${processed.lastOrNull()?.aosTime}-$isUtc"
val sunTimes = if (sunTimesKey != lastSunTimesKey) {
lastSunTimesKey = sunTimesKey
computeSunTimes(processed, isUtc)
} else {
_uiState.value.sunTimes
satelliteRepo.passes.collectLatest { allPasses ->
while (isActive) {
val timeNow = System.currentTimeMillis()
val isUtc = _uiState.value.isUtc
val showDeepSpace = _uiState.value.showDeepSpace
val filtered = allPasses
.let { if (showDeepSpace) it else it.filter { pass -> !pass.isDeepSpace } }
val processed = computePassProgress(filtered, timeNow)
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
val sunTimes = computeSunTimes(processed, isUtc)
val grouped = groupPasses(processed, isUtc)
_uiState.update {
it.copy(
itemsList = processed,
groupedPasses = grouped,
sunTimes = 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) }
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) }
}
}
// 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>> {
val stationPos = settingsRepo.stationPosition.value
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
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 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 }) {
val riseSet = CelestialComputer.findSunRiseSet(stationPos, System.currentTimeMillis())
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) 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) {
if (pass.isDeepSpace) continue
@@ -150,6 +146,18 @@ class PassesViewModel(
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. */
private fun computePassProgress(passList: List<OrbitalPass>, time: Long): List<OrbitalPass> {
val result = ArrayList<OrbitalPass>(passList.size)
+5 -5
View File
@@ -12,21 +12,21 @@ jdkVersion = "17"
#noinspection UnusedVersionCatalogEntry
packageName = "com.rtbishop.look4sat"
android-gradle-plugin = "9.2.0"
android-gradle-plugin = "9.2.1"
androidx-core-ktx = "1.18.0"
androidx-core-splashscreen = "1.2.0"
androidx-room = "2.8.4"
compose-bom = "2026.04.01"
compose-bom = "2026.05.01"
compose-activity = "1.13.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-coroutines = "1.10.2"
kotlin-coroutines = "1.11.0"
kotlin-serialization = "1.11.0"
other-okhttp = "5.3.2"
+2 -2
View File
@@ -1,7 +1,7 @@
#Tue Apr 28 15:46:23 BST 2026
#Mon May 25 11:20:48 BST 2026
distributionBase=GRADLE_USER_HOME
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
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME