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
17 changes: 16 additions & 1 deletion app/src/main/java/to/bitkit/ui/components/QrCodeImage.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package to.bitkit.ui.components

import android.graphics.Bitmap
import android.util.LruCache
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
Expand Down Expand Up @@ -50,6 +51,14 @@ import to.bitkit.ui.theme.AppShapes
import to.bitkit.ui.theme.AppThemeSurface
import to.bitkit.ui.theme.Colors

/** Memory budget for generated QR bitmaps kept for reuse */
private const val QR_BITMAP_CACHE_BYTES = 16 * 1024 * 1024

/** Generated QR bitmaps by size and content, so a code composed again draws at once instead of loading */
private val qrBitmapCache = object : LruCache<String, Bitmap>(QR_BITMAP_CACHE_BYTES) {
override fun sizeOf(key: String, value: Bitmap) = value.byteCount
}

private const val QUIET_ZONE_MIN = 2
private const val QUIET_ZONE_MAX = 4
private const val QUIET_ZONE_RATIO = 150
Expand Down Expand Up @@ -160,10 +169,15 @@ fun QrCodeImage(
internal fun rememberQrBitmap(content: String, size: Dp): Bitmap? {
if (content.isEmpty()) return null

var bitmap by remember(content) { mutableStateOf<Bitmap?>(null) }
val sizePx = with(LocalDensity.current) { size.roundToPx() }
val cacheKey = "$sizePx:$content"
var bitmap by remember(content) { mutableStateOf<Bitmap?>(qrBitmapCache.get(cacheKey)) }

LaunchedEffect(content, size) {
qrBitmapCache.get(cacheKey)?.let {
bitmap = it
return@LaunchedEffect
}
bitmap = null // Always reset to show loading indicator

launch(Dispatchers.Default) {
Expand Down Expand Up @@ -204,6 +218,7 @@ internal fun rememberQrBitmap(content: String, size: Dp): Bitmap? {

newBitmap.setPixels(pixels, 0, matrixWidth, 0, 0, matrixWidth, matrixHeight)

qrBitmapCache.put(cacheKey, newBitmap)
bitmap = newBitmap
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
Expand All @@ -59,6 +60,7 @@ import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import org.lightningdevkit.ldknode.ChannelDetails
import to.bitkit.R
Expand Down Expand Up @@ -88,7 +90,6 @@ 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 @@ -192,10 +193,13 @@ fun ReceiveQrScreen(
}
}

// LazyRow state with snap behavior
// LazyRow state with snap behavior; the pages wrap each end with a copy of the tab at the other end
val scope = rememberCoroutineScope()
val receivePages = remember(visibleTabs) {
(listOf(visibleTabs.last()) + visibleTabs + visibleTabs.first()).toImmutableList()
}
val lazyListState = rememberLazyListState(
initialFirstVisibleItemIndex = visibleTabs.indexOf(defaultTab).coerceAtLeast(0),
initialFirstVisibleItemIndex = visibleTabs.pageIndexOf(defaultTab),
)

val snapBehavior = rememberSnapFlingBehavior(
Expand All @@ -219,14 +223,14 @@ fun ReceiveQrScreen(
appliedInitialTab = requestedTab
requestedTab?.let {
selectedTab = it
lazyListState.scrollToItem(visibleTabs.indexOf(it))
lazyListState.scrollToItem(visibleTabs.pageIndexOf(it))
return@LaunchedEffect
}
}
if (selectedTab !in visibleTabs) {
val fallbackTab = visibleTabs.defaultReceiveTab()
selectedTab = fallbackTab
lazyListState.scrollToItem(visibleTabs.indexOf(fallbackTab).coerceAtLeast(0))
lazyListState.scrollToItem(visibleTabs.pageIndexOf(fallbackTab))
}
}

Expand All @@ -236,18 +240,31 @@ fun ReceiveQrScreen(
}
if (!canCreateLightningInvoice && cjitInvoice.isNullOrEmpty()) {
selectedTab = ReceiveTab.SAVINGS
lazyListState.scrollToItem(visibleTabs.indexOf(ReceiveTab.SAVINGS).coerceAtLeast(0))
lazyListState.scrollToItem(visibleTabs.pageIndexOf(ReceiveTab.SAVINGS))
}
}

LaunchedEffect(lazyListState, visibleTabs.size) {
snapshotFlow { lazyListState.firstVisibleItemIndex }
.distinctUntilChanged()
.collect { index ->
if (selectedTab != ReceiveTab.USDT && index < visibleTabs.size && index > -1) {
val tab = visibleTabs[index]
selectedTab = tab
val tab = visibleTabs.getOrNull(index - 1) ?: return@collect
if (selectedTab != ReceiveTab.USDT) selectedTab = tab
}
}

// Swap a settled wrap page for the tab it shows, so a swipe past either end slides on to the other end
LaunchedEffect(lazyListState, visibleTabs) {
snapshotFlow { lazyListState.isScrollInProgress }
.filter { !it }
.collect {
val tab = when (lazyListState.firstVisibleItemIndex) {
0 -> visibleTabs.last()
visibleTabs.size + 1 -> visibleTabs.first()
else -> return@collect
}
selectedTab = tab
lazyListState.scrollToItem(visibleTabs.pageIndexOf(tab))
}
}

Expand All @@ -267,18 +284,18 @@ fun ReceiveQrScreen(
initialTab = initialTab,
)
if (!shouldAutoSwitch) return@LaunchedEffect
val autoIndex = visibleTabs.indexOf(ReceiveTab.AUTO)
if (autoIndex == -1 || lazyListState.firstVisibleItemIndex == autoIndex) return@LaunchedEffect
lazyListState.scrollToItem(autoIndex)
if (ReceiveTab.AUTO !in visibleTabs) return@LaunchedEffect
val autoPage = visibleTabs.pageIndexOf(ReceiveTab.AUTO)
if (lazyListState.firstVisibleItemIndex == autoPage) return@LaunchedEffect
lazyListState.scrollToItem(autoPage)
selectedTab = ReceiveTab.AUTO
}

// Auto-switch to Spending tab when CJIT is not null
LaunchedEffect(cjitInvoice) {
if (cjitInvoice != null) {
val spendingIndex = visibleTabs.indexOf(ReceiveTab.SPENDING)
if (spendingIndex != -1) {
lazyListState.animateScrollToItem(spendingIndex)
if (ReceiveTab.SPENDING in visibleTabs) {
lazyListState.animateScrollToItem(visibleTabs.pageIndexOf(ReceiveTab.SPENDING))
selectedTab = ReceiveTab.SPENDING
}
}
Expand All @@ -298,9 +315,14 @@ fun ReceiveQrScreen(
}

fun selectReceiveTab(tab: ReceiveTab) {
val wrapsToFirst = selectedTab == visibleTabs.last() && tab == visibleTabs.first()
selectedTab = tab
hasUserSelectedTab = true
scope.launch { lazyListState.scrollToItem(visibleTabs.indexOf(tab)) }
scope.launch {
// The pager composes again when leaving USDT; let it lay out before sliding
repeat(2) { withFrameNanos {} }
lazyListState.animateScrollToItem(if (wrapsToFirst) visibleTabs.size + 1 else visibleTabs.pageIndexOf(tab))
}
}

DisposableEffect(selectedTab) {
Expand Down Expand Up @@ -365,12 +387,17 @@ fun ReceiveQrScreen(
},
onTabChange = { tab ->
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
val newIndex = visibleTabs.indexOf(tab)
val newIndex = visibleTabs.pageIndexOf(tab)
hasUserSelectedTab = true
selectedTab = tab
showDetails = false
scope.launch {
lazyListState.animateScrollToItem(newIndex)
// USDT replaces the pager at once; land it on the USDT page so the next swipe starts there
Comment thread
ovitrif marked this conversation as resolved.
if (tab == ReceiveTab.USDT) {
lazyListState.scrollToItem(newIndex)
} else {
lazyListState.animateScrollToItem(newIndex)
}
}
},
modifier = Modifier.padding(horizontal = 16.dp)
Expand All @@ -388,15 +415,10 @@ fun ReceiveQrScreen(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.swipePastEdges(
state = lazyListState,
onSwipePastStart = { selectReceiveTab(visibleTabs.last()) },
onSwipePastEnd = { selectReceiveTab(visibleTabs.first()) },
)
) {
itemsIndexed(
items = visibleTabs,
key = { _, tab -> tab.name }
items = receivePages,
key = { index, tab -> receivePageKey(index, tab, visibleTabs.size) }
) { _, tab ->
val displayMode = getReceiveInvoiceDisplayMode(
tab = tab,
Expand Down Expand Up @@ -562,6 +584,14 @@ fun ReceiveQrScreen(
}
}

private fun List<ReceiveTab>.pageIndexOf(tab: ReceiveTab): Int = indexOf(tab).coerceAtLeast(0) + 1

private fun receivePageKey(index: Int, tab: ReceiveTab, tabCount: Int): String = when (index) {
0 -> "wrap-start-${tab.name}"
tabCount + 1 -> "wrap-end-${tab.name}"
else -> tab.name
}

private fun List<ReceiveTab>.defaultReceiveTab(): ReceiveTab {
return if (contains(ReceiveTab.AUTO)) ReceiveTab.AUTO else ReceiveTab.SAVINGS
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,15 @@
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 Down Expand Up @@ -89,66 +80,6 @@ private fun swipeTargetIndex(isPrevious: Boolean, currentTabIndex: Int, tabCount
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

Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/1608.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Swiping past the first or last Receive tab now slides to the other end like the other tabs.
15 changes: 10 additions & 5 deletions journeys/receive/receive-tabs-wraparound.xml
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<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
last tab, and a swipe to the left on the last tab selects the first. The wrap slides on to the
other end the way a swipe between neighbouring tabs does. 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,
Expand All @@ -12,16 +13,20 @@
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.
tab; start the swipes at least 100 px from the edge. The pager behind Savings, Auto and Spending
snaps back from a short synthetic swipe on the emulator, so swipe across it in 1200 ms
(`adb shell input swipe 150 1176 900 1176 1200`); the USDT QR takes the 300 ms swipe.
Comment thread
ovitrif marked this conversation as resolved.
</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>Start a screen recording, tap the USDT tab (testTag "Tab-usdt") and wait until the USDT QR is visible, then swipe left across the USDT QR; verify the Savings tab is selected and the recording goes straight from the USDT QR to the Savings QR, with no Auto or Spending QR and no second USDT screen in between</action>
<action>Start a screen recording, tap the USDT tab and wait until the USDT QR is visible, then tap the Savings tab; verify the Savings tab is selected and the recording shows the same direct change from USDT to Savings</action>
<action>Swipe left across the Savings QR in 1200 ms (`adb shell input swipe 900 1176 150 1176 1200`); verify the Spending tab (testTag "Tab-spending") is selected</action>
<action>Swipe left across the Spending content in 1200 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 right from the top left of the Savings QR to above the sheet (`adb shell input swipe 100 720 950 200 1200`); 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>
Expand Down
Loading