diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt
index d66d7099..a451b1b6 100644
--- a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt
+++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt
@@ -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(
diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/SwipeableItem.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/SwipeableItem.kt
new file mode 100644
index 00000000..5e6f448e
--- /dev/null
+++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/SwipeableItem.kt
@@ -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()
+ }
+ )
+ }
+}
diff --git a/core/presentation/src/main/res/drawable/ic_altitude.xml b/core/presentation/src/main/res/drawable/ic_altitude.xml
deleted file mode 100644
index d409c282..00000000
--- a/core/presentation/src/main/res/drawable/ic_altitude.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
diff --git a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesScreen.kt b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesScreen.kt
index fe5eafb0..b25547c9 100644
--- a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesScreen.kt
+++ b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesScreen.kt
@@ -17,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,
+ groupedPasses: Map>,
+ sunTimes: Map>,
+ focusedCatNum: Int?,
navigateToRadar: (Int, Long) -> Unit,
- refreshPasses: () -> Unit,
- sunTimes: Map>
+ 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>()
- 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(
diff --git a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesState.kt b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesState.kt
index ff0b1ccc..ebd9504a 100644
--- a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesState.kt
+++ b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesState.kt
@@ -32,9 +32,10 @@ data class PassesState(
val showDeepSpace: Boolean = true,
val modes: List = emptyList(),
val itemsList: List = emptyList(),
+ val groupedPasses: Map> = emptyMap(),
val shouldSeeWhatsNew: Boolean = false,
- // Map of dateLabel -> Pair(sunriseTime, sunsetTime) for each day group
- val sunTimes: Map> = emptyMap()
+ val sunTimes: Map> = 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
}
diff --git a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt
index d1fb9ac0..cde10e7c 100644
--- a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt
+++ b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt
@@ -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, isUtc: Boolean): Map> {
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>()
- // 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, isUtc: Boolean): Map> {
+ 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>()
+ 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, time: Long): List {
val result = ArrayList(passList.size)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 8a4b5a95..b53008c8 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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"
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 69233b8c..354565ba 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -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