Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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(
Expand All @@ -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()
Expand All @@ -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(
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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

Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/1593.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Swiping past the first or last Receive tab now wraps around to the tab at the other end.
1 change: 1 addition & 0 deletions journeys/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
33 changes: 33 additions & 0 deletions journeys/receive/receive-tabs-wraparound.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<journey name="receive tabs wraparound">
<description>
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.
</description>
<actions>
<action>Tap Receive (testTag "Receive") and verify the Savings tab (testTag "Tab-savings") is selected</action>
<action>Swipe right across the Savings QR; verify the USDT tab (testTag "Tab-usdt") is selected and wait until the USDT QR is visible</action>
<action>Swipe left across the USDT QR; verify the Savings tab is selected</action>
<action>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</action>
<action>Swipe left across the Spending content in 600 ms; verify the USDT tab is selected and wait until its QR is visible</action>
<action>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</action>
<action>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</action>
<action>Swipe left across the USDT QR, then tap the Savings QR once; verify the "copied" tooltip appears</action>
<action>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</action>
<action>Swipe right across the Savings QR; verify the Spending tab is selected</action>
<action>Swipe right across the Spending content; verify the Savings tab is selected</action>
<action>Swipe left across the Savings QR; verify the Spending tab is selected</action>
<action>Swipe left across the Spending content; verify the Savings tab is selected</action>
<action>Swipe left across the Savings QR; verify the Spending tab is selected</action>
</actions>
</journey>
7 changes: 4 additions & 3 deletions journeys/receive/usdt-receive-swipe-tabs.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
<description>
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
Expand All @@ -21,7 +22,7 @@
<action>Swipe right across the QR and take a screenshot right away</action>
<action>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</action>
<action>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</action>
<action>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</action>
<action>Tap the USDT QR once and verify the "copied" tooltip appears</action>
<action>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</action>
</actions>
</journey>
Loading