diff --git a/Bitkit/Components/QR.swift b/Bitkit/Components/QR.swift
index a3dc78b8b..add64c165 100644
--- a/Bitkit/Components/QR.swift
+++ b/Bitkit/Components/QR.swift
@@ -28,17 +28,22 @@ struct QR: View {
.aspectRatio(1, contentMode: .fit)
.frame(maxWidth: .infinity)
}
-
+ }
+ .overlay {
if let imageAsset {
- ZStack {
- Circle()
- .fill(Color.white)
- .frame(width: 68, height: 68)
-
- Image(imageAsset)
- .resizable()
- .scaledToFit()
- .frame(width: 50, height: 50)
+ GeometryReader { geometry in
+ let size = min(68, min(geometry.size.width, geometry.size.height) * 0.2)
+ ZStack {
+ Circle()
+ .fill(Color.white)
+ .frame(width: size, height: size)
+
+ Image(imageAsset)
+ .resizable()
+ .scaledToFit()
+ .frame(width: size * 50 / 68, height: size * 50 / 68)
+ }
+ .frame(width: geometry.size.width, height: geometry.size.height)
}
}
}
diff --git a/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift b/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift
index e2676c3a5..7b7bd3237 100644
--- a/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift
+++ b/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift
@@ -322,82 +322,76 @@ struct PaymentRequestAmountView: View {
}
var body: some View {
- VStack(spacing: 0) {
- SheetHeader(
- title: t("wallet__payment_request_amount"),
- showBackButton: true,
- action: target == nil ? nil : AnyView(targetAvatar),
- onBack: onBack
- )
+ GeometryReader { geometry in
+ ScrollView(showsIndicators: false) {
+ VStack(spacing: 0) {
+ SheetHeader(
+ title: t("wallet__payment_request_amount"),
+ showBackButton: true,
+ action: target == nil ? nil : AnyView(targetAvatar),
+ onBack: onBack
+ )
- if asset == .usd {
- NumberPadAmountText(value: dollarAmount.isEmpty ? "0" : dollarAmount, symbol: "$")
- .accessibilityIdentifier("\(testIdentifierPrefix)AmountField")
- } else {
- NumberPadTextField(viewModel: amountViewModel, showEditButton: false, isFocused: true,
- testIdentifier: "\(testIdentifierPrefix)AmountField")
- }
+ ZStack(alignment: .bottomLeading) {
+ NumberPadTextField(viewModel: amountViewModel, showEditButton: false, isFocused: true,
+ testIdentifier: "\(testIdentifierPrefix)AmountField")
+ .opacity(asset == .btc ? 1 : 0)
+ .accessibilityHidden(asset != .btc)
+ if asset == .usd {
+ NumberPadAmountText(value: dollarAmount.isEmpty ? "0" : dollarAmount, symbol: "$")
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .accessibilityIdentifier("\(testIdentifierPrefix)AmountField")
+ }
+ }
- Spacer()
+ Spacer().frame(height: 16)
+
+ amountControls(showUnit: allowedAssets.contains(.btc))
+ .hidden()
+ .overlay(alignment: .topTrailing) {
+ amountControls(showUnit: asset == .btc)
+ }
+ .padding(.bottom, 16)
- HStack {
- Spacer()
- ForEach(allowedAssets, id: \.self) { choice in
- NumberPadActionButton(text: choice.rawValue.uppercased(), color: choice == .usd ? .usdtAccent : .brandAccent,
- variant: asset == choice ? .primary : .secondary)
- {
- guard choice != asset else { return }
- do {
- let converted = try entryConversion.converted(amount, to: choice, rate: currency.paykitRate, at: Date())
- if choice == .usd {
- dollarAmount = converted.atomic == 0 ? "" : converted.inputValue
- } else {
- amountViewModel.updateFromSats(converted.atomic, currency: currency)
- }
- asset = choice
- conversionError = false
- } catch { conversionError = true }
+ if conversionError {
+ BodySText(t("wallet__payment_request_rate_unavailable"), textColor: .white64)
}
- .accessibilityIdentifier("\(testIdentifierPrefix)Asset-\(choice.rawValue)")
- }
- if asset == .btc {
- NumberPadActionButton(
- text: currency.primaryDisplay == .bitcoin ? "Bitcoin" : currency.selectedCurrency,
- imageName: "arrow-up-down", color: .brandAccent
- ) {
- withAnimation { amountViewModel.togglePrimaryDisplay(currency: currency) }
+ CustomDivider()
+ .accessibilityIdentifier("\(testIdentifierPrefix)AmountDivider")
+ Spacer(minLength: 16)
+ NumberPad(
+ type: asset == .usd ? .decimal : amountViewModel.getNumberPadType(currency: currency),
+ errorKey: amountViewModel.errorKey
+ ) { key in
+ entryConversion.reset()
+ if asset == .usd {
+ dollarAmount = NumberPadInputHandler.handleInput(
+ key: key,
+ current: dollarAmount,
+ maxLength: 16,
+ maxDecimals: 2
+ )
+ } else {
+ amountViewModel.handleNumberPadInput(key, currency: currency)
+ }
}
- .accessibilityIdentifier("\(testIdentifierPrefix)AmountUnit")
- }
- }
- .padding(.bottom, 12)
- if conversionError {
- BodySText(t("wallet__payment_request_rate_unavailable"), textColor: .white64)
- }
- NumberPad(
- type: asset == .usd ? .decimal : amountViewModel.getNumberPadType(currency: currency),
- errorKey: amountViewModel.errorKey
- ) { key in
- entryConversion.reset()
- if asset == .usd {
- dollarAmount = NumberPadInputHandler.handleInput(key: key, current: dollarAmount, maxLength: 16, maxDecimals: 2)
- } else {
- amountViewModel.handleNumberPadInput(key, currency: currency)
- }
- }
+ Spacer(minLength: 24)
- CustomButton(title: t("common__continue"), isDisabled: !allowedAssets.contains(asset) || (amount?.atomic ?? 0) == 0) {
- guard allowedAssets.contains(asset), let amount else { return }
- onContinue(
- PaykitPaymentRequestDraft(
- amount: amount, acceptedPaymentEndpointIdentifiers: initialDraft.acceptedPaymentEndpointIdentifiers,
- note: initialDraft.note,
- expiresAt: initialDraft.expiresAt
- )
- )
+ CustomButton(title: t("common__continue"), isDisabled: !allowedAssets.contains(asset) || (amount?.atomic ?? 0) == 0) {
+ guard allowedAssets.contains(asset), let amount else { return }
+ onContinue(
+ PaykitPaymentRequestDraft(
+ amount: amount, acceptedPaymentEndpointIdentifiers: initialDraft.acceptedPaymentEndpointIdentifiers,
+ note: initialDraft.note,
+ expiresAt: initialDraft.expiresAt
+ )
+ )
+ }
+ .accessibilityIdentifier("\(testIdentifierPrefix)AmountContinue")
+ }
+ .frame(minHeight: geometry.size.height, alignment: .top)
}
- .accessibilityIdentifier("\(testIdentifierPrefix)AmountContinue")
}
.padding(.horizontal, 16)
.sheetBackground()
@@ -418,6 +412,50 @@ struct PaymentRequestAmountView: View {
}
}
+ private func amountControls(showUnit: Bool) -> some View {
+ HStack {
+ Spacer(minLength: 0)
+ ViewThatFits(in: .horizontal) {
+ HStack(spacing: 8) { amountActions(showUnit: showUnit) }
+ .fixedSize(horizontal: true, vertical: false)
+ VStack(alignment: .trailing, spacing: 8) { amountActions(showUnit: showUnit) }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func amountActions(showUnit: Bool) -> some View {
+ HStack(spacing: 8) {
+ ForEach(allowedAssets, id: \.self) { choice in
+ NumberPadActionButton(text: choice.rawValue.uppercased(), color: choice == .usd ? .usdtAccent : .brandAccent,
+ variant: asset == choice ? .primary : .secondary)
+ {
+ guard choice != asset else { return }
+ do {
+ let converted = try entryConversion.converted(amount, to: choice, rate: currency.paykitRate, at: Date())
+ if choice == .usd {
+ dollarAmount = converted.atomic == 0 ? "" : converted.inputValue
+ } else {
+ amountViewModel.updateFromSats(converted.atomic, currency: currency)
+ }
+ asset = choice
+ conversionError = false
+ } catch { conversionError = true }
+ }
+ .accessibilityIdentifier("\(testIdentifierPrefix)Asset-\(choice.rawValue)")
+ }
+ }
+ if showUnit {
+ NumberPadActionButton(
+ text: currency.primaryDisplay == .bitcoin ? "Bitcoin" : currency.selectedCurrency,
+ imageName: "arrow-up-down", color: .brandAccent
+ ) {
+ withAnimation { amountViewModel.togglePrimaryDisplay(currency: currency) }
+ }
+ .accessibilityIdentifier("\(testIdentifierPrefix)AmountUnit")
+ }
+ }
+
@ViewBuilder
private var targetAvatar: some View {
if let target {
diff --git a/Bitkit/Views/Wallets/Send/SendContactSelectView.swift b/Bitkit/Views/Wallets/Send/SendContactSelectView.swift
index 7079e3254..047f20b6d 100644
--- a/Bitkit/Views/Wallets/Send/SendContactSelectView.swift
+++ b/Bitkit/Views/Wallets/Send/SendContactSelectView.swift
@@ -43,6 +43,7 @@ struct SendContactSelectView: View {
await pay(contact)
}
}
+ .disabled(selectedContactKey != nil)
.accessibilityIdentifier("SendContact-\(contact.publicKey)")
}
}
diff --git a/Bitkit/Views/Wallets/UsdtReceiveView.swift b/Bitkit/Views/Wallets/UsdtReceiveView.swift
index e93188d22..0c313f81b 100644
--- a/Bitkit/Views/Wallets/UsdtReceiveView.swift
+++ b/Bitkit/Views/Wallets/UsdtReceiveView.swift
@@ -195,9 +195,12 @@ struct UsdtReceiveAddressContent: View {
}
var body: some View {
- ScrollView(showsIndicators: false) {
- if !uri.isEmpty {
- if showDetails {
+ if !uri.isEmpty, !showDetails {
+ QrArea(uri: uri, imageAsset: "tether-circle", accentColor: .usdtAccent,
+ copyValue: address, shareText: shareText, copyMessage: t("usdt__receive_copied"), onEdit: onEdit)
+ } else {
+ ScrollView(showsIndicators: false) {
+ if !uri.isEmpty {
CopyAddressCard(
addresses: [CopyAddressPair(title: network + " " + t("wallet__activity_address"), address: address, type: .onchain,
shareText: shareText)],
@@ -209,16 +212,14 @@ struct UsdtReceiveAddressContent: View {
)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("UsdtReceiveAddress")
+ } else if let error {
+ BodySText(error, textColor: .brandAccent)
} else {
- QrArea(uri: uri, imageAsset: "tether-circle", accentColor: .usdtAccent,
- copyValue: address, shareText: shareText, copyMessage: t("usdt__receive_copied"), onEdit: onEdit)
+ ProgressView()
}
- } else if let error {
- BodySText(error, textColor: .brandAccent)
- } else {
- ProgressView()
}
}
+ Spacer(minLength: 0)
CustomButton(
title: networkTitle,
variant: .secondary,
diff --git a/BitkitTests/UsdtReceiveSharingTests.swift b/BitkitTests/UsdtReceiveSharingTests.swift
index 212dccba1..7efc48d4a 100644
--- a/BitkitTests/UsdtReceiveSharingTests.swift
+++ b/BitkitTests/UsdtReceiveSharingTests.swift
@@ -1,5 +1,6 @@
@testable import Bitkit
import CoreImage
+import SwiftUI
import XCTest
final class UsdtReceiveSharingTests: XCTestCase {
@@ -15,6 +16,76 @@ final class UsdtReceiveSharingTests: XCTestCase {
}
}
+ @MainActor
+ func testQrLogoPreservesSquareLayoutInTallContainer() {
+ for logo: String? in [nil, "btc", "tether-circle"] {
+ let host = UIHostingController(rootView: QR(content: "bitcoin:bcrt1qexample", imageAsset: logo))
+ for width: CGFloat in [140, 343] {
+ let size = host.sizeThatFits(in: CGSize(width: width, height: 600))
+ XCTAssertEqual(size.width, width, accuracy: 0.5)
+ XCTAssertEqual(size.height, width, accuracy: 0.5, "Logo must not add vertical space below the QR")
+ }
+ }
+ }
+
+ @MainActor
+ func testReceiveAddressContentFillsAvailableHeight() {
+ for width: CGFloat in [288, 370] {
+ for textSize: DynamicTypeSize in [.large, .accessibility5] {
+ let host = UIHostingController(rootView: VStack(spacing: 0) {
+ UsdtReceiveAddressContent(
+ address: "0x1111111111111111111111111111111111111111",
+ uri: "ethereum:token/transfer",
+ network: "Arbitrum One",
+ onEdit: {}, onNetwork: {}
+ )
+ }.environment(\.dynamicTypeSize, textSize))
+ let size = host.sizeThatFits(in: CGSize(width: width, height: 700))
+ XCTAssertEqual(size.height, 700, accuracy: 0.5, "The Receive footer must stay anchored at \(width) pt and \(textSize)")
+ }
+ }
+ }
+
+ @MainActor
+ func testCompactQrWithLogoRemainsScannable() async throws {
+ let detector = try XCTUnwrap(CIDetector(ofType: CIDetectorTypeQRCode, context: nil,
+ options: [CIDetectorAccuracy: CIDetectorAccuracyHigh]))
+ let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.first as? UIWindowScene)
+ let previousWindow = scene.windows.first(where: \.isKeyWindow)
+ defer { previousWindow?.makeKeyAndVisible() }
+ for (uri, logo) in [
+ (
+ "ethereum:0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9@42161/transfer?address=0x1111111111111111111111111111111111111111",
+ "tether-circle"
+ ),
+ ("bitcoin:bcrt1q5y0ejj4qxm2kahxdx3r6lqqcjr2wtj3agypvuu?amount=0.001", "btc"),
+ ] {
+ for height: CGFloat in [140, 343] {
+ let host = UIHostingController(rootView: QR(content: uri, imageAsset: logo))
+ let window = UIWindow(windowScene: scene)
+ window.frame = CGRect(x: 0, y: 0, width: 343, height: height)
+ window.rootViewController = host
+ window.makeKeyAndVisible()
+ defer { window.isHidden = true }
+ var decoded: String?
+ for _ in 0 ..< 40 {
+ try await Task.sleep(for: .milliseconds(50))
+ host.view.frame = window.bounds
+ host.view.layoutIfNeeded()
+ let image = UIGraphicsImageRenderer(size: window.bounds.size).image { context in
+ window.layer.render(in: context.cgContext)
+ }
+ let features = try detector.features(in: XCTUnwrap(CIImage(image: image)))
+ decoded = (features.first as? CIQRCodeFeature)?.messageString
+ if decoded == uri {
+ break
+ }
+ }
+ XCTAssertEqual(decoded, uri, "QR must remain scannable at height \(height)")
+ }
+ }
+ }
+
@MainActor
func testSharedQrEncodesOriginalPaymentUri() throws {
let address = "0x18503bD685aF84E6A7bC289898bAa1C3f5d8527E"
diff --git a/changelog.d/next/1003.fixed.md b/changelog.d/next/1003.fixed.md
new file mode 100644
index 000000000..ccdfba722
--- /dev/null
+++ b/changelog.d/next/1003.fixed.md
@@ -0,0 +1 @@
+Keep contact selection disabled while resolving a payment, stabilize payment amounts, and keep USDT receive controls usable on compact screens.
diff --git a/journeys/paykit-amount-layout.xml b/journeys/paykit-amount-layout.xml
new file mode 100644
index 000000000..90b711d05
--- /dev/null
+++ b/journeys/paykit-amount-layout.xml
@@ -0,0 +1,12 @@
+
+ Use an onboarded wallet with USDT enabled, an existing test contact and a current BTC/USD rate. Verify request denomination layout without sending or publishing a request.
+
+ Open Payment Requests and create a request for the test contact. Open Amount.
+ Select USD and enter 1.50. Verify the amount is leading-aligned near the header, the BTC/USD controls are trailing-aligned directly below it, and a divider separates them from the keypad.
+ Switch between BTC and USD several times. Verify the amount controls, divider and keypad stay at the same vertical positions, including when the Bitcoin unit button wraps below the denomination buttons at enlarged text sizes.
+ Verify every keypad key remains legible and tappable on a short screen at enlarged text sizes, including zero, decimal/000 and Delete. Scroll as needed to reach all keys and Continue. Continue to details and verify the request denomination and amount remain 1.50 USD. Cancel without sending.
+ Open Subscriptions and start creating a subscription for the same contact. Select USDT before opening Amount. Starting from zero, enter 1.50 without deleting first. Continue and verify the subscription amount is 1.50 USD. Reopen Amount and verify the existing cents are preserved. Repeat the layout check in BTC and USD. Verify the controls do not overlap the keypad or Continue.
+ Return to subscription details and verify accepted payment methods remain either USDT or Bitcoin methods, never both groups together. Cancel without sending.
+ Repeat the amount checks at a compact screen width and enlarged system text. Verify every control remains readable and reachable; controls may stack or wrap and the screen may scroll.
+
+
diff --git a/journeys/payment-requests/contact-request-or-pay.xml b/journeys/payment-requests/contact-request-or-pay.xml
index 7ed0d0cf6..3eb8c0a57 100644
--- a/journeys/payment-requests/contact-request-or-pay.xml
+++ b/journeys/payment-requests/contact-request-or-pay.xml
@@ -16,6 +16,6 @@
Verify the Request or Pay sheet (id "RequestOrPaySheet") appears
Tap Request on the sheet
Verify the Payment Request amount screen (id "PaymentRequestAmount") opens
- Open Send and choose Contact. Tap a saved contact and verify its row shows loading feedback while payment details resolve; other contacts cannot be selected meanwhile. When resolution finishes, verify the amount screen opens. Cancel without paying. Reopen Send, choose Contact and select the prepared contact with no payment endpoints. Verify the error is shown and contact selection becomes available for retry. Select the original payable contact and verify its amount screen opens, then cancel without paying.
+ Open Send and choose Contact. Tap a saved contact and verify only its row shows loading while payment details resolve; all contact rows are disabled meanwhile. Try selecting another contact and verify no second payment flow opens. When resolution finishes, verify the amount screen opens. Cancel without paying. Reopen Send, choose Contact and select the prepared contact with no payment endpoints. Verify the error is shown and contact selection becomes available for retry. Select the original payable contact and verify its amount screen opens, then cancel without paying.
diff --git a/journeys/usdt-wallet-receive-layout.xml b/journeys/usdt-wallet-receive-layout.xml
new file mode 100644
index 000000000..86e9cf341
--- /dev/null
+++ b/journeys/usdt-wallet-receive-layout.xml
@@ -0,0 +1,12 @@
+
+ Use an onboarded wallet with USDT enabled. For the hardware label check, use a paired hardware test wallet. No funds or payment submission are required.
+
+ Open Savings from Home and return. Open USDT from Home and return. Verify both wallets use the same slide transition without showing the Home contents through the destination.
+ If saved USDT activity or cross-network deposits exist, open their details and return. Verify the slide transition keeps an opaque background without showing the previous screen through the details.
+ Open Receive. Switch repeatedly between Savings, Spending and USDT. Verify Show Details stays at the same bottom position and no footer overlaps the system navigation area.
+ On USDT, verify Network: Arbitrum One is visible. Open network selection and return without creating a deposit. Verify the network remains visible and Show Details still opens details.
+ With a paired hardware wallet, switch between its receive tab and USDT. Verify the hardware tab retains its vendor name on every tab instead of changing to Hardware.
+ On a compact screen, verify the QR fits above Edit, Copy and Share without the Network button covering them. Tap Copy and verify the plain receiving address reaches the clipboard without opening network selection; scan the displayed QR and verify the payment link and selected network.
+ Repeat on a compact screen and with enlarged system text. Verify the selected network and bottom controls are visible or reachable and the hardware label remains consistent.
+
+