From 8a2339cfbf0ca41ec980e8810ab9b7f5b73c7581 Mon Sep 17 00:00:00 2001 From: Arty Bishop Date: Mon, 25 May 2026 14:00:17 +0100 Subject: [PATCH] Implemented Swipe-to-Focus behavior for satellite passes --- .../look4sat/core/presentation/Components.kt | 6 - .../core/presentation/SwipeableItem.kt | 105 ++++++++++++++++++ .../src/main/res/drawable/ic_altitude.xml | 9 -- .../look4sat/feature/passes/PassesScreen.kt | 87 ++++++++------- .../look4sat/feature/passes/PassesState.kt | 7 +- .../feature/passes/PassesViewModel.kt | 72 ++++++------ gradle/libs.versions.toml | 10 +- gradle/wrapper/gradle-wrapper.properties | 4 +- 8 files changed, 202 insertions(+), 98 deletions(-) create mode 100644 core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/SwipeableItem.kt delete mode 100644 core/presentation/src/main/res/drawable/ic_altitude.xml 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