diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt index eb92123fd2..a9174450c1 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt @@ -88,6 +88,7 @@ import to.bitkit.ui.screens.wallets.activity.components.CustomTabRowWithSpacing import to.bitkit.ui.screens.wallets.usdt.UsdtDepositReceiveScreen import to.bitkit.ui.shared.effects.SetMaxBrightness import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.shared.modifiers.swipePastEdges import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.shared.util.shareQrCode import to.bitkit.ui.shared.util.shareText @@ -387,6 +388,11 @@ fun ReceiveQrScreen( modifier = Modifier .weight(1f) .fillMaxWidth() + .swipePastEdges( + state = lazyListState, + onSwipePastStart = { selectReceiveTab(visibleTabs.last()) }, + onSwipePastEnd = { selectReceiveTab(visibleTabs.first()) }, + ) ) { itemsIndexed( items = visibleTabs, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositReceiveScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositReceiveScreen.kt index 2ba4797c0f..8562cdd6bb 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositReceiveScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtDepositReceiveScreen.kt @@ -141,7 +141,7 @@ internal fun ColumnScope.UsdtDepositReceiveScreen( .fillMaxWidth() .then( if (page == ReceivePage.ADDRESS) { - Modifier.swipeToChangeTab(tabIndex, tabs.size, { onSelectTab(tabs[it]) }) + Modifier.swipeToChangeTab(tabIndex, tabs.size, { onSelectTab(tabs[it]) }, wrapAround = true) } else { Modifier } diff --git a/app/src/main/java/to/bitkit/ui/shared/modifiers/SwipeToChangeTabModifier.kt b/app/src/main/java/to/bitkit/ui/shared/modifiers/SwipeToChangeTabModifier.kt index 82c6bd1c82..1ff4571e87 100644 --- a/app/src/main/java/to/bitkit/ui/shared/modifiers/SwipeToChangeTabModifier.kt +++ b/app/src/main/java/to/bitkit/ui/shared/modifiers/SwipeToChangeTabModifier.kt @@ -1,15 +1,24 @@ package to.bitkit.ui.shared.modifiers +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.composed +import androidx.compose.ui.input.pointer.AwaitPointerEventScope +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import kotlin.math.abs @@ -24,19 +33,17 @@ import kotlin.math.abs * @param currentTabIndex The currently selected tab index (0-based) * @param tabCount Total number of tabs available for navigation * @param onTabChange Callback invoked when user swipes to change tabs, receives the new tab index - * @param velocityThreshold Velocity threshold in px/s (default: 600f) - * @param distanceThreshold Distance threshold in dp (default: 50.dp) + * @param wrapAround Whether a swipe past the first or last tab selects the tab at the other end */ fun Modifier.swipeToChangeTab( currentTabIndex: Int, tabCount: Int, onTabChange: (Int) -> Unit, - velocityThreshold: Float = DEFAULT_VELOCITY_THRESHOLD, - distanceThreshold: Float = DEFAULT_DISTANCE_THRESHOLD_DP, + wrapAround: Boolean = false, ): Modifier = composed { val velocityTracker = remember { VelocityTracker() } var totalDragDistance by remember { mutableFloatStateOf(0f) } - val distanceThresholdPx = with(LocalDensity.current) { distanceThreshold.dp.toPx() } + val distanceThresholdPx = with(LocalDensity.current) { DEFAULT_DISTANCE_THRESHOLD_DP.dp.toPx() } pointerInput(currentTabIndex) { detectHorizontalDragGestures( @@ -49,19 +56,17 @@ fun Modifier.swipeToChangeTab( val dragDistance = totalDragDistance // Check if either velocity OR distance threshold is met - val shouldNavigate = abs(velocity) >= velocityThreshold || + val shouldNavigate = abs(velocity) >= DEFAULT_VELOCITY_THRESHOLD || abs(dragDistance) >= distanceThresholdPx if (shouldNavigate) { - when { + swipeTargetIndex( // Swipe right (previous tab) - positive velocity/drag - (velocity > 0 || dragDistance > 0) && currentTabIndex > 0 -> - onTabChange(currentTabIndex - 1) - - // Swipe left (next tab) - negative velocity/drag - (velocity < 0 || dragDistance < 0) && currentTabIndex < tabCount - 1 -> - onTabChange(currentTabIndex + 1) - } + isPrevious = velocity > 0 || dragDistance > 0, + currentTabIndex = currentTabIndex, + tabCount = tabCount, + wrapAround = wrapAround, + )?.let(onTabChange) } velocityTracker.resetTracking() @@ -75,6 +80,75 @@ fun Modifier.swipeToChangeTab( } } +private fun swipeTargetIndex(isPrevious: Boolean, currentTabIndex: Int, tabCount: Int, wrapAround: Boolean): Int? = + when { + isPrevious && currentTabIndex > 0 -> currentTabIndex - 1 + isPrevious && wrapAround -> tabCount - 1 + !isPrevious && currentTabIndex < tabCount - 1 -> currentTabIndex + 1 + !isPrevious && wrapAround -> 0 + else -> null + } + +/** + * Reports a horizontal swipe that pushes past the first or last page of a scrollable pager, so the pager can wrap + * around. It only observes the pointer: the pager keeps handling and consuming its own drag. + * + * @param state The pager's scroll state, read when the swipe starts + * @param onSwipePastStart Callback invoked for a swipe toward the start on the first page (to the right in LTR) + * @param onSwipePastEnd Callback invoked for a swipe toward the end on the last page (to the left in LTR) + * @param velocityThreshold Velocity threshold in px/s (default: 600f) + * @param distanceThreshold Distance threshold in dp (default: 50.dp) + */ +fun Modifier.swipePastEdges( + state: ScrollableState, + onSwipePastStart: () -> Unit, + onSwipePastEnd: () -> Unit, + velocityThreshold: Float = DEFAULT_VELOCITY_THRESHOLD, + distanceThreshold: Float = DEFAULT_DISTANCE_THRESHOLD_DP, +): Modifier = composed { + val currentOnSwipePastStart by rememberUpdatedState(onSwipePastStart) + val currentOnSwipePastEnd by rememberUpdatedState(onSwipePastEnd) + val distanceThresholdPx = with(LocalDensity.current) { distanceThreshold.dp.toPx() } + val layoutDirection = LocalLayoutDirection.current + + pointerInput(state, layoutDirection) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + val isAtStart = !state.canScrollBackward + val isAtEnd = !state.canScrollForward + if (!isAtStart && !isAtEnd) return@awaitEachGesture + + val velocityTracker = VelocityTracker() + velocityTracker.addPosition(down.uptimeMillis, down.position) + val up = awaitRelease(down, velocityTracker) ?: return@awaitEachGesture + + val drag = up.position - down.position + val isSwipe = abs(drag.x) > viewConfiguration.touchSlop && + abs(drag.x) > abs(drag.y) && + (abs(velocityTracker.calculateVelocity().x) >= velocityThreshold || abs(drag.x) >= distanceThresholdPx) + if (!isSwipe) return@awaitEachGesture + + // LazyRow scrolls the other way in RTL, so a swipe toward its start moves the finger left + val towardStart = if (layoutDirection == LayoutDirection.Rtl) drag.x < 0 else drag.x > 0 + when { + towardStart && isAtStart -> currentOnSwipePastStart() + !towardStart && isAtEnd -> currentOnSwipePastEnd() + } + } + } +} + +private suspend fun AwaitPointerEventScope.awaitRelease( + down: PointerInputChange, + velocityTracker: VelocityTracker, +): PointerInputChange? { + while (true) { + val change = awaitPointerEvent(PointerEventPass.Final).changes.firstOrNull { it.id == down.id } ?: return null + velocityTracker.addPosition(change.uptimeMillis, change.position) + if (!change.pressed) return change + } +} + // Reduced from 1500 to 600 for better iOS-like sensitivity private const val DEFAULT_VELOCITY_THRESHOLD = 600f diff --git a/changelog.d/next/1593.fixed.md b/changelog.d/next/1593.fixed.md new file mode 100644 index 0000000000..1523bc815e --- /dev/null +++ b/changelog.d/next/1593.fixed.md @@ -0,0 +1 @@ +Swiping past the first or last Receive tab now wraps around to the tab at the other end. diff --git a/journeys/README.md b/journeys/README.md index cafb490f9d..2ab2f273c5 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -186,6 +186,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `app-update/critical-update-onboarding.xml` | not ported yet — iOS already blocks at the top level in `AppScene`, so the journey applies there once written | | `home/pull-to-refresh-rates.xml` | not ported — iOS does not refresh exchange rates on pull to refresh | | `home/timed-reminders-do-not-interrupt.xml` | paired draft in synonymdev/bitkit-ios#1013; the journey name and actions match, but iOS device verification is incomplete. Android resumes Home after permission prompts and background/foreground; iOS uses primary-screen lifecycle callbacks and explicit rechecks. | +| `receive/receive-tabs-wraparound.xml` | same file and steps — on Android a swipe that starts at the screen edge is the system back gesture, which closes the sheet on every tab; start the swipes away from the edge | | `receive/receive-auto-tab-selection.xml` | not ported — the Auto tab override fix is Android-only so far; iOS parity not checked | | `security/pin-lock-on-resume.xml` | not ported yet — iOS already clears the PIN verification on entering the background, so the journey applies; it lands with the iOS side of synonymdev/bitkit-android#1298 | | `security/pin-result-long-label.xml` | not ported — the toggle exists on the iOS security success screen, but the overlap check is a follow-up | diff --git a/journeys/receive/receive-tabs-wraparound.xml b/journeys/receive/receive-tabs-wraparound.xml new file mode 100644 index 0000000000..1fabf103d6 --- /dev/null +++ b/journeys/receive/receive-tabs-wraparound.xml @@ -0,0 +1,33 @@ + + + On the Receive sheet, tab swipes wrap around: a swipe to the right on the first tab selects the + last tab, and a swipe to the left on the last tab selects the first. A sideways swipe never closes + the Receive sheet, also when it is released above the sheet, and a tap on a QR still copies it. + + Precondition: onboarded dev wallet with USDT enabled (introductory sheet or Settings, General, + Payments), no open Spending channel, no CJIT invoice pending and no hardware wallet paired, so the + tabs are Savings, Spending and USDT; a usable channel adds the Auto tab. Start on the wallet home + screen. The USDT address loads without funding; wait for the QR before swiping. + + Swipe with `adb shell input swipe` across the middle of the QR, about 60% of its width, in + 300 ms (`adb shell input swipe 220 1176 860 1176 300` to the right on a 1080 px wide screen). A + swipe that starts at the screen edge is the system back gesture, which closes the sheet on every + tab; start the swipes at least 100 px from the edge. + + + Tap Receive (testTag "Receive") and verify the Savings tab (testTag "Tab-savings") is selected + Swipe right across the Savings QR; verify the USDT tab (testTag "Tab-usdt") is selected and wait until the USDT QR is visible + Swipe left across the USDT QR; verify the Savings tab is selected + Swipe left across the Savings QR in 600 ms (`adb shell input swipe 860 1176 220 1176 600`); verify the Spending tab (testTag "Tab-spending") is selected + Swipe left across the Spending content in 600 ms; verify the USDT tab is selected and wait until its QR is visible + Swipe left from the top right of the USDT QR to above the sheet (`adb shell input swipe 950 720 100 200 300`); verify the Receive sheet is still open and the Savings tab is selected + Swipe right from the top left of the Savings QR to above the sheet (`adb shell input swipe 100 720 950 200 300`); verify the Receive sheet is still open and the USDT tab is selected + Swipe left across the USDT QR, then tap the Savings QR once; verify the "copied" tooltip appears + Close Receive, turn USDT off (Settings, General, Payments), return to the wallet home screen and tap Receive; verify the Savings tab is selected and only Savings and Spending show + Swipe right across the Savings QR; verify the Spending tab is selected + Swipe right across the Spending content; verify the Savings tab is selected + Swipe left across the Savings QR; verify the Spending tab is selected + Swipe left across the Spending content; verify the Savings tab is selected + Swipe left across the Savings QR; verify the Spending tab is selected + + diff --git a/journeys/receive/usdt-receive-swipe-tabs.xml b/journeys/receive/usdt-receive-swipe-tabs.xml index 95683d4fc5..fea00c104a 100644 --- a/journeys/receive/usdt-receive-swipe-tabs.xml +++ b/journeys/receive/usdt-receive-swipe-tabs.xml @@ -2,8 +2,9 @@ On the Receive sheet, swiping sideways over the QR changes tabs on every tab, USDT included. With USDT selected, a swipe to the right over its QR selects the tab before it (Spending) and - does not copy the address; a swipe to the left does nothing, since USDT is the last tab. A single - tap on the USDT QR still copies the address and shows the "copied" tooltip. + does not copy the address; a swipe to the left wraps around to the first tab (Savings), since + USDT is the last tab. A single tap on the USDT QR still copies the address and shows the "copied" + tooltip. Precondition: onboarded dev wallet with USDT enabled (introductory sheet or Settings, General, Payments), no CJIT invoice pending, start on the wallet home screen. The USDT address loads @@ -21,7 +22,7 @@ Swipe right across the QR and take a screenshot right away Verify the Spending tab (testTag "Tab-spending") is selected, the Lightning receive content or the Spending setup is shown, and the screenshot shows no "copied" tooltip Swipe left across the middle of the Spending content, whether it shows a Lightning QR or the Spending setup, in 600 ms (`adb shell input swipe 900 1200 150 1200 600`); verify the USDT tab is selected and wait until its QR is visible - Swipe left across the USDT QR and take a screenshot right away; verify the USDT tab stays selected and the screenshot shows no "copied" tooltip Tap the USDT QR once and verify the "copied" tooltip appears + Wait until the tooltip is gone, swipe left across the USDT QR and take a screenshot right away; verify the Savings tab (testTag "Tab-savings") is selected and the screenshot shows no "copied" tooltip