Skip to content
Open
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
25 changes: 15 additions & 10 deletions Bitkit/Components/QR.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
ben-kaufman marked this conversation as resolved.
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)
}
}
}
Expand Down
174 changes: 106 additions & 68 deletions Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
ovitrif marked this conversation as resolved.

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()
Expand All @@ -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) }
Comment thread
ben-kaufman marked this conversation as resolved.
.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 {
Expand Down
1 change: 1 addition & 0 deletions Bitkit/Views/Wallets/Send/SendContactSelectView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ struct SendContactSelectView: View {
await pay(contact)
}
}
.disabled(selectedContactKey != nil)
.accessibilityIdentifier("SendContact-\(contact.publicKey)")
}
}
Expand Down
19 changes: 10 additions & 9 deletions Bitkit/Views/Wallets/UsdtReceiveView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)],
Expand All @@ -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,
Expand Down
71 changes: 71 additions & 0 deletions BitkitTests/UsdtReceiveSharingTests.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
@testable import Bitkit
import CoreImage
import SwiftUI
import XCTest

final class UsdtReceiveSharingTests: XCTestCase {
Expand All @@ -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"
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/1003.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Keep contact selection disabled while resolving a payment, stabilize payment amounts, and keep USDT receive controls usable on compact screens.
12 changes: 12 additions & 0 deletions journeys/paykit-amount-layout.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<journey name="paykit amount layout">
<description>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.</description>
<actions>
<action>Open Payment Requests and create a request for the test contact. Open Amount.</action>
<action>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.</action>
<action>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.</action>
<action>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.</action>
<action>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.</action>
<action>Return to subscription details and verify accepted payment methods remain either USDT or Bitcoin methods, never both groups together. Cancel without sending.</action>
<action>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.</action>
</actions>
</journey>
Loading
Loading