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