diff --git a/Bitkit/Extensions/View+SwipeGestures.swift b/Bitkit/Extensions/View+SwipeGestures.swift index 46ec555a2..93fa937d3 100644 --- a/Bitkit/Extensions/View+SwipeGestures.swift +++ b/Bitkit/Extensions/View+SwipeGestures.swift @@ -15,11 +15,13 @@ extension View { /// Swipe left/right to move between adjacent tabs (same order as `T.allCases` / `SegmentedControl`). /// Pass `tabs` when the control shows only some cases, in its display order. + /// With `wrapAround`, a swipe past the first or last tab selects the tab at the other end. /// Pass `simultaneous` inside a sheet, so a vertical drag still reaches the sheet's swipe-to-dismiss. @ViewBuilder func swipeSegmentedTabs( selection: Binding, tabs visibleTabs: [T]? = nil, + wrapAround: Bool = false, minimumDragDistance: CGFloat = 20, swipeThreshold: CGFloat = 50, animation: Animation = .easeInOut(duration: 0.2), @@ -38,10 +40,18 @@ extension View { withAnimation(animation) { selection.wrappedValue = tabs[currentIndex + 1] } + } else if horizontalAmount < -swipeThreshold, wrapAround, let first = tabs.first { + withAnimation(animation) { + selection.wrappedValue = first + } } else if horizontalAmount > swipeThreshold, currentIndex > 0 { withAnimation(animation) { selection.wrappedValue = tabs[currentIndex - 1] } + } else if horizontalAmount > swipeThreshold, wrapAround, let last = tabs.last { + withAnimation(animation) { + selection.wrappedValue = last + } } } if simultaneous { diff --git a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift index 28918abf6..85cd92dd6 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift @@ -129,6 +129,36 @@ struct ReceiveQr: View { ) } + /// A page of the receive `TabView`. The wrap pages sit before the first and after the last tab, so a swipe past + /// either end pages on and then selects the tab at the other end. They are hidden from accessibility, so their + /// copies of the end tab's buttons never match an identifier lookup. + private enum ReceivePage: Hashable { + case tab(ReceiveTab) + case wrapToLast + case wrapToFirst + } + + private var pageSelection: Binding { + Binding( + get: { .tab(selectedTab) }, + set: { page in + let tabs = availableTabItems.map(\.tab) + switch page { + case let .tab(tab): + selectedTabBinding.wrappedValue = tab + case .wrapToLast: + if let last = tabs.last { + selectedTabBinding.wrappedValue = last + } + case .wrapToFirst: + if let first = tabs.first { + selectedTabBinding.wrappedValue = first + } + } + } + ) + } + var showingCjitOnboarding: Bool { return !wallet.canCreateReceiveLightningInvoice && cjitInvoice == nil && selectedTab == .spending } @@ -155,7 +185,11 @@ struct ReceiveQr: View { .padding(.horizontal, 16) VStack(spacing: 0) { - TabView(selection: selectedTabBinding) { + TabView(selection: pageSelection) { + if let last = availableTabItems.last?.tab { + wrapPage(showing: last, tag: .wrapToLast) + } + if selectedHardwareWalletId != nil { tabContent(for: .hardware) } @@ -168,7 +202,11 @@ struct ReceiveQr: View { tabContent(for: .spending) if settings.isUsdtEnabled, cjitInvoice == nil { - Color.clear.tag(ReceiveTab.usdt) + Color.clear.tag(ReceivePage.tab(.usdt)) + } + + if let first = availableTabItems.first?.tab { + wrapPage(showing: first, tag: .wrapToFirst) } } .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) @@ -334,7 +372,17 @@ struct ReceiveQr: View { ) } - func tabContent(for tab: ReceiveTab) -> some View { + @ViewBuilder + private func wrapPage(showing tab: ReceiveTab, tag: ReceivePage) -> some View { + if tab == .usdt { + Color.clear.tag(tag) + } else { + tabContent(for: tab, tag: tag) + .accessibilityHidden(true) + } + } + + private func tabContent(for tab: ReceiveTab, tag: ReceivePage? = nil) -> some View { VStack(spacing: 0) { if needsInvoiceRefresh && tab != .hardware { if invoiceRefreshFailed { @@ -363,7 +411,7 @@ struct ReceiveQr: View { Spacer() } .padding(.horizontal) - .tag(tab) + .tag(tag ?? .tab(tab)) } @ViewBuilder diff --git a/Bitkit/Views/Wallets/UsdtReceiveView.swift b/Bitkit/Views/Wallets/UsdtReceiveView.swift index be5871b22..b98a72b5e 100644 --- a/Bitkit/Views/Wallets/UsdtReceiveView.swift +++ b/Bitkit/Views/Wallets/UsdtReceiveView.swift @@ -70,7 +70,7 @@ struct UsdtReceiveView: View { onEdit: { draftAmount = amount; page = .amount }, onNetwork: { page = .networks } ) - .swipeSegmentedTabs(selection: Binding(get: { .usdt }, set: onSelectTab), tabs: tabs.map(\.tab), simultaneous: true) + .swipeSegmentedTabs(selection: Binding(get: { .usdt }, set: onSelectTab), tabs: tabs.map(\.tab), wrapAround: true, simultaneous: true) case .networks: networkList case .amount: diff --git a/changelog.d/next/1027.fixed.md b/changelog.d/next/1027.fixed.md new file mode 100644 index 000000000..1523bc815 --- /dev/null +++ b/changelog.d/next/1027.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 d85f7b594..84ec3912b 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -235,6 +235,10 @@ after staging signup. Android also supports this signup flow, but this journey h `contacts/contacts-entry-points.xml` covers onboarding without an identity and the authenticated Contacts list. It has not yet been ported to Android. +`receive/receive-tabs-wraparound.xml` has a matching Android journey with the same name and actions. +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. + `payment-requests/success-dismissal.xml` has a matching Android journey with the same name and actions. Successful dismissal without a toast or duplicate payment is shared behavior, not an iOS-only feature. diff --git a/journeys/receive/receive-tabs-wraparound.xml b/journeys/receive/receive-tabs-wraparound.xml new file mode 100644 index 000000000..9c4dcac6a --- /dev/null +++ b/journeys/receive/receive-tabs-wraparound.xml @@ -0,0 +1,39 @@ + + + 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. 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, + 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 AXe across the middle of the QR, about 60% of its width, in 300 ms (`axe swipe + --start-x 80 --start-y 440 --end-x 330 --end-y 440 --duration 0.3 --udid ` to the right on a + 402 pt wide simulator). + + iOS: the paged `TabView` holds an extra page before the first tab and after the last one; paging + onto either selects the tab at the other end. Leaving USDT crossfades rather than slides, since USDT replaces + the pager with its own view. + + + Tap Receive (id "Receive") and verify the Savings tab (id "Tab-savings") is selected + Swipe right across the Savings QR; verify the USDT tab (id "Tab-usdt") is selected and wait until the USDT QR is visible + Swipe left across the USDT QR; verify the Savings tab is selected + Start a screen recording, tap the USDT tab (id "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 + 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 + Swipe left across the Savings QR in 1200 ms (`axe swipe --start-x 330 --start-y 440 --end-x 80 --end-y 440 --duration 1.2`); verify the Spending tab (id "Tab-spending") is selected + Swipe left across the Spending content in 1200 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 (`axe swipe --start-x 360 --start-y 300 --end-x 30 --end-y 60 --duration 0.3`); 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 (`axe swipe --start-x 40 --start-y 300 --end-x 380 --end-y 60 --duration 0.3`); 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 593b8a3d2..24480ede7 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 @@ -24,7 +25,7 @@ Swipe right across the QR and take a screenshot right away Verify the Spending tab (id "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 (`axe swipe --start-x 320 --start-y 420 --end-x 80 --end-y 420 --duration 0.6`); 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 (id "Tab-savings") is selected and the screenshot shows no "copied" tooltip