diff --git a/Bitkit/Components/ActivityIndicator.swift b/Bitkit/Components/ActivityIndicator.swift index 04e445173..c1746503f 100644 --- a/Bitkit/Components/ActivityIndicator.swift +++ b/Bitkit/Components/ActivityIndicator.swift @@ -3,6 +3,7 @@ import SwiftUI struct ActivityIndicator: View { let size: CGFloat let theme: Theme + let tint: Color? enum Theme { case light @@ -12,13 +13,14 @@ struct ActivityIndicator: View { @State private var isRotating = false @State private var opacity: Double = 0 - init(size: CGFloat = 32, theme: Theme = .light) { + init(size: CGFloat = 32, theme: Theme = .light, tint: Color? = nil) { self.size = size self.theme = theme + self.tint = tint } var body: some View { - let color = theme == .light ? Color.white : Color.black + let color = tint ?? (theme == .light ? Color.white : Color.black) ZStack { Circle() diff --git a/Bitkit/Components/NumberPadActionButton.swift b/Bitkit/Components/NumberPadActionButton.swift index a377905b0..699ba3152 100644 --- a/Bitkit/Components/NumberPadActionButton.swift +++ b/Bitkit/Components/NumberPadActionButton.swift @@ -11,6 +11,7 @@ struct NumberPadActionButton: View { var color: Color = .purpleAccent var variant: NumberPadActionButtonVariant = .primary var disabled: Bool = false + var isLoading: Bool = false var action: () -> Void @State private var isPressed = false @@ -21,7 +22,10 @@ struct NumberPadActionButton: View { action() } label: { HStack(spacing: 8) { - if let imageName { + if isLoading { + ActivityIndicator(size: 10, tint: color) + .frame(width: 16, height: 16) + } else if let imageName { Image(imageName) .resizable() .aspectRatio(contentMode: .fit) @@ -40,7 +44,7 @@ struct NumberPadActionButton: View { ) .cornerRadius(8) } - .disabled(disabled) + .disabled(disabled || isLoading) .buttonStyle(NoAnimationButtonStyle()) .pressEvents( onPress: { @@ -50,6 +54,8 @@ struct NumberPadActionButton: View { isPressed = false } ) + .animation(.easeInOut(duration: 0.2), value: isLoading) + .animation(.easeInOut(duration: 0.2), value: text) } private var background: some View { diff --git a/Bitkit/Components/SwipeButton.swift b/Bitkit/Components/SwipeButton.swift index d25a9dcca..850055ea9 100644 --- a/Bitkit/Components/SwipeButton.swift +++ b/Bitkit/Components/SwipeButton.swift @@ -3,7 +3,9 @@ import SwiftUI struct SwipeButton: View { let title: String let accentColor: Color - /// Blocks the swipe and shows the knob spinner while a prerequisite is still loading. + /// Blocks interaction without presenting the post-swipe loading state. + var isDisabled = false + /// Blocks interaction and shows the knob spinner while an operation is running. var isLoading = false /// Optional binding for swipe progress (0...1), e.g. to drive animations in the parent. var swipeProgress: Binding? @@ -13,6 +15,10 @@ struct SwipeButton: View { @State private var isSubmitting = false private var isBusy: Bool { + isDisabled || isLoading || isSubmitting + } + + private var showsSpinner: Bool { isLoading || isSubmitting } @@ -43,11 +49,12 @@ struct SwipeButton: View { .frame(height: buttonHeight - innerPadding) .padding(.horizontal, innerPadding / 2) } + .opacity(isDisabled ? 0.5 : 1) // Track text BodySSBText(title) .frame(maxWidth: .infinity, alignment: .center) - .opacity(Double(1.0 - textProgress)) + .opacity(Double(1.0 - textProgress) * (isDisabled ? 0.5 : 1)) // Knob Circle() @@ -55,7 +62,7 @@ struct SwipeButton: View { .frame(width: buttonHeight - innerPadding, height: buttonHeight - innerPadding) .overlay( ZStack { - if isBusy { + if showsSpinner { ActivityIndicator(theme: .dark) } else { Image("arrow-right") @@ -75,6 +82,7 @@ struct SwipeButton: View { .accessibilityIdentifier("GRAB") .offset(x: clampedOffset) .padding(.horizontal, innerPadding / 2) + .opacity(isDisabled ? 0.5 : 1) .gesture( DragGesture() .onChanged { value in diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index 215e3ecc1..2e69de97e 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -307,6 +307,8 @@ final class HwSendCoordinator { private(set) var availableSats: UInt64 = 0 private(set) var previewFeeSats: UInt64 = 0 + private(set) var isFundingSourceLoading = false + private(set) var isPreviewLoading = false private(set) var isSigning = false private(set) var isBroadcastUnresolved = false private(set) var isPassphraseRequired = false @@ -346,7 +348,11 @@ final class HwSendCoordinator { self.availableSats = availableSats } - func selectWallet(_ walletId: String?, initialAvailableSats: UInt64 = 0) { + func selectWallet( + _ walletId: String?, + initialAvailableSats: UInt64 = 0, + showsLoading: Bool = false + ) { guard self.walletId != walletId else { return } guard operationTask == nil, !isBroadcastUnresolved else { return } @@ -356,6 +362,8 @@ final class HwSendCoordinator { pendingPayment = nil availableSats = walletId == nil ? 0 : initialAvailableSats previewFeeSats = 0 + isFundingSourceLoading = walletId != nil && showsLoading + isPreviewLoading = false isSigning = false isBroadcastUnresolved = false isPassphraseRequired = false @@ -365,23 +373,28 @@ final class HwSendCoordinator { func refreshAvailable( manager: HwWalletManager, destinationAddress: String, - satsPerVByte: UInt64 + satsPerVByte: UInt64? ) async { guard let walletId else { return } - guard !destinationAddress.isEmpty else { - if self.walletId == walletId { - availableSats = manager.fundingBalance(walletId: walletId) - } - return - } - availabilityRequestId += 1 let requestId = availabilityRequestId + isFundingSourceLoading = true func apply(_ available: UInt64) { guard self.walletId == walletId, availabilityRequestId == requestId else { return } availableSats = available + isFundingSourceLoading = false + } + + guard let satsPerVByte else { + apply(availableSats) + return + } + + guard !destinationAddress.isEmpty else { + apply(manager.fundingBalance(walletId: walletId)) + return } do { @@ -407,19 +420,27 @@ final class HwSendCoordinator { guard let walletId else { return nil } previewRequestId += 1 let requestId = previewRequestId + isPreviewLoading = true + previewFeeSats = 0 let request = PaymentRequest(address: address, sats: sats, satsPerVByte: satsPerVByte) if pendingPayment?.request != request { pendingPayment = nil } - let fee = try await manager.estimateOfflineFundingMiningFee( - walletId: walletId, - address: address, - sats: sats, - satsPerVByte: satsPerVByte - ) - guard self.walletId == walletId, previewRequestId == requestId else { return nil } - previewFeeSats = fee - return fee + do { + let signer = signerFactory(manager, address, satsPerVByte) + let fee = try await signer.estimateOfflineFundingMiningFee(walletId: walletId, address: address, sats: sats) + guard self.walletId == walletId, previewRequestId == requestId else { return nil } + previewFeeSats = fee + isFundingSourceLoading = false + isPreviewLoading = false + return fee + } catch { + if self.walletId == walletId, previewRequestId == requestId { + isFundingSourceLoading = false + isPreviewLoading = false + } + throw error + } } func signAndBroadcast( @@ -512,6 +533,10 @@ final class HwSendCoordinator { } func cancel() { + availabilityRequestId += 1 + previewRequestId += 1 + isFundingSourceLoading = false + isPreviewLoading = false isVerifyingPassphrase = false isPassphraseRequired = false guard !isBroadcastUnresolved else { return } diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift index 5a756201b..11cf84e39 100644 --- a/Bitkit/Views/Wallets/Send/SendAmountView.swift +++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift @@ -153,7 +153,8 @@ struct SendAmountView: View { imageName: canSwitchFundingSource ? "arrow-up-down" : nil, color: selectedSourceColor, variant: canSwitchFundingSource ? .primary : .secondary, - disabled: !canSwitchFundingSource || isContinuing + disabled: !canSwitchFundingSource || isContinuing, + isLoading: hwSend.isFundingSourceLoading ) { selectNextFundingSource() } @@ -183,7 +184,7 @@ struct SendAmountView: View { CustomButton( title: t("common__continue"), - isDisabled: !isValidAmount, + isDisabled: !isValidAmount || hwSend.isFundingSourceLoading, isLoading: isContinuing ) { await onContinue() @@ -368,7 +369,8 @@ struct SendAmountView: View { ) hwSend.selectWallet( walletId, - initialAvailableSats: balance > reserve ? balance - reserve : 0 + initialAvailableSats: balance > reserve ? balance - reserve : 0, + showsLoading: true ) app.selectedWalletToPayFrom = .onchain } @@ -387,18 +389,19 @@ struct SendAmountView: View { private func calculateMaxSendableAmount() async { // Make sure we have everything we need to calculate the max sendable amount guard hwSend.isActive || app.selectedWalletToPayFrom == .onchain else { return } - guard let address = app.scannedOnchainInvoice?.address else { return } - guard let feeRate = wallet.selectedFeeRateSatsPerVByte else { return } if hwSend.isActive { await hwSend.refreshAvailable( manager: hwWalletManager, - destinationAddress: address, - satsPerVByte: UInt64(feeRate) + destinationAddress: app.scannedOnchainInvoice?.address ?? "", + satsPerVByte: wallet.selectedFeeRateSatsPerVByte.map(UInt64.init) ) return } + guard let address = app.scannedOnchainInvoice?.address else { return } + guard let feeRate = wallet.selectedFeeRateSatsPerVByte else { return } + do { let maxAmount = try await wallet.calculateMaxSendableAmount( address: address, diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index b0b47a42e..5ff0f4095 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -76,6 +76,18 @@ struct SendConfirmationView: View { ?? t("hardware__device_model_trezor") } + private var isHardwarePreparationLoading: Bool { + hwSend.isActive && (hwSend.isFundingSourceLoading || hwSend.isPreviewLoading) + } + + private var isHardwareConfirmationUnavailable: Bool { + hwSend.isActive && (isHardwarePreparationLoading || hwSend.previewFeeSats == 0) + } + + private var displayedTransactionFee: Int { + transactionFee > 0 ? transactionFee : Int(hwSend.previewFeeSats) + } + /// `.instant` is only valid when paying from Lightning; align `selectedSpeed` with the current sat/vB on savings. private func reconcileInstantSpeedWhenSwitchingToOnChain() async { guard wallet.selectedSpeed == .instant else { return } @@ -202,7 +214,12 @@ struct SendConfirmationView: View { .accessibilityIdentifier("SendConfirmToggleDetails") } - SwipeButton(title: t("wallet__send_swipe"), accentColor: accentColor, swipeProgress: $swipeProgress) { + SwipeButton( + title: t("wallet__send_swipe"), + accentColor: accentColor, + isDisabled: isHardwareConfirmationUnavailable, + swipeProgress: $swipeProgress + ) { try await submitPayment() } } @@ -272,7 +289,8 @@ struct SendConfirmationView: View { imageName: canSwitchFundingSource ? "arrow-up-down" : nil, color: hwSend.isActive ? .blueAccent : .brandAccent, variant: canSwitchFundingSource ? .primary : .secondary, - disabled: !canSwitchFundingSource + disabled: !canSwitchFundingSource || isHardwarePreparationLoading, + isLoading: hwSend.isFundingSourceLoading ) { selectNextFundingSource() } @@ -307,29 +325,41 @@ struct SendConfirmationView: View { }) { SendSectionView(t("wallet__send_fee_and_speed")) { HStack(spacing: 0) { - Image(wallet.selectedSpeed.iconName) - .resizable() - .aspectRatio(contentMode: .fit) - .foregroundColor(wallet.selectedSpeed.iconColor) - .frame(width: 16, height: 16) - .padding(.trailing, 4) + Group { + if hwSend.isPreviewLoading { + ActivityIndicator(size: 10, tint: wallet.selectedSpeed.iconColor) + } else { + Image(wallet.selectedSpeed.iconName) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(wallet.selectedSpeed.iconColor) + } + } + .frame(width: 16, height: 16) + .padding(.trailing, 4) - if transactionFee > 0 { - let feeText = "\(wallet.selectedSpeed.title) (" - HStack(spacing: 0) { - BodySSBText(feeText) - MoneyText(sats: transactionFee, size: .bodySSB, symbol: true, symbolColor: .textPrimary) + HStack(spacing: 0) { + BodySSBText(wallet.selectedSpeed.title) + if displayedTransactionFee > 0 { + BodySSBText(" (") + MoneyText( + sats: displayedTransactionFee, + size: .bodySSB, + symbol: true, + symbolColor: .textPrimary + ) BodySSBText(")") } - - Image("pencil") - .foregroundColor(.textPrimary) - .frame(width: 12, height: 12) - .padding(.leading, 6) } + + Image("pencil") + .foregroundColor(.textPrimary) + .frame(width: 12, height: 12) + .padding(.leading, 6) } } } + .disabled(isHardwarePreparationLoading) SendSectionView(t("wallet__send_confirming_in")) { HStack(spacing: 0) { @@ -504,7 +534,8 @@ struct SendConfirmationView: View { ) hwSend.selectWallet( walletId, - initialAvailableSats: balance > reserve ? balance - reserve : 0 + initialAvailableSats: balance > reserve ? balance - reserve : 0, + showsLoading: true ) app.selectedWalletToPayFrom = .onchain } @@ -986,14 +1017,34 @@ struct SendConfirmationView: View { } guard let address = app.scannedOnchainInvoice?.address, - let amountSats = wallet.sendAmountSats, - let feeRate = wallet.selectedFeeRateSatsPerVByte + let amountSats = wallet.sendAmountSats else { + if hwSend.isActive { + await hwSend.refreshAvailable( + manager: hwWalletManager, + destinationAddress: "", + satsPerVByte: nil + ) + } + return + } + + guard let feeRate = wallet.selectedFeeRateSatsPerVByte else { + if hwSend.isActive { + await hwSend.refreshAvailable( + manager: hwWalletManager, + destinationAddress: address, + satsPerVByte: nil + ) + } return } do { if hwSend.isActive { + if transactionFee == 0, hwSend.previewFeeSats > 0 { + apply(hwSend.previewFeeSats) + } guard let fee = try await hwSend.preparePreview( manager: hwWalletManager, address: address, diff --git a/Bitkit/Views/Wallets/Send/SendFeeCustom.swift b/Bitkit/Views/Wallets/Send/SendFeeCustom.swift index a9c3543f7..490cbefb4 100644 --- a/Bitkit/Views/Wallets/Send/SendFeeCustom.swift +++ b/Bitkit/Views/Wallets/Send/SendFeeCustom.swift @@ -198,8 +198,8 @@ struct SendFeeCustom: View { do { try await wallet.setFeeRate(speed: .custom(satsPerVByte: feeRate)) app.selectedWalletToPayFrom = .onchain - await refreshHardwareMaxIfNeeded() navigationPath.removeLast() + await refreshHardwareMaxIfNeeded() } catch { Logger.error("Failed to set custom fee rate: \(error)") app.toast( diff --git a/Bitkit/Views/Wallets/Send/SendFeeRate.swift b/Bitkit/Views/Wallets/Send/SendFeeRate.swift index 249a9cf46..5481c9c7b 100644 --- a/Bitkit/Views/Wallets/Send/SendFeeRate.swift +++ b/Bitkit/Views/Wallets/Send/SendFeeRate.swift @@ -53,8 +53,8 @@ struct SendFeeRate: View { } else { try await wallet.setFeeRate(speed: speed) app.selectedWalletToPayFrom = .onchain - await refreshHardwareMaxIfNeeded() navigationPath.removeLast() + await refreshHardwareMaxIfNeeded() } } catch { Logger.error("Error setting fee rate: \(error)", context: "SendFeeRate") diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index d108bda4f..22cc81415 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -64,6 +64,119 @@ final class HwFundingSignerTests: XCTestCase { XCTAssertEqual(coordinator.availableSats, 42000) } + func testCoordinatorTracksFundingSourceRefresh() async { + let funding = MockHwFunding() + funding.maxSpendable = 42000 + let manager = HwWalletManager() + let coordinator = HwSendCoordinator( + signerFactory: { [self] _, address, satsPerVByte in + makeSigner( + funding: funding, + connecting: MockHwConnecting(), + feeRate: satsPerVByte, + address: address + ) + } + ) + coordinator.selectWallet("trezor:wallet", showsLoading: true) + + XCTAssertTrue(coordinator.isFundingSourceLoading) + await coordinator.refreshAvailable( + manager: manager, + destinationAddress: "bc1qtest", + satsPerVByte: 2 + ) + XCTAssertFalse(coordinator.isFundingSourceLoading) + XCTAssertEqual(coordinator.availableSats, 42000) + } + + func testCoordinatorSettlesFundingSourceLoadingWithoutFeeRate() async { + let manager = HwWalletManager() + let coordinator = HwSendCoordinator() + coordinator.selectWallet( + "trezor:wallet", + initialAvailableSats: 42000, + showsLoading: true + ) + + await coordinator.refreshAvailable( + manager: manager, + destinationAddress: "bc1qtest", + satsPerVByte: nil + ) + + XCTAssertFalse(coordinator.isFundingSourceLoading) + XCTAssertEqual(coordinator.availableSats, 42000) + } + + func testCoordinatorTracksPreviewPreparation() async throws { + let funding = MockHwFunding() + funding.estimateDelay = 0.05 + let manager = HwWalletManager() + let coordinator = HwSendCoordinator( + signerFactory: { [self] _, address, satsPerVByte in + makeSigner( + funding: funding, + connecting: MockHwConnecting(), + feeRate: satsPerVByte, + address: address + ) + } + ) + coordinator.selectWallet("trezor:wallet", showsLoading: true) + + let preview = Task { + try await coordinator.preparePreview( + manager: manager, + address: "bc1qtest", + sats: 42000, + satsPerVByte: 2 + ) + } + await Task.yield() + + XCTAssertTrue(coordinator.isFundingSourceLoading) + XCTAssertTrue(coordinator.isPreviewLoading) + XCTAssertEqual(coordinator.previewFeeSats, 0) + _ = try await preview.value + XCTAssertFalse(coordinator.isFundingSourceLoading) + XCTAssertFalse(coordinator.isPreviewLoading) + XCTAssertEqual(coordinator.previewFeeSats, funding.funding.miningFeeSats) + } + + func testCoordinatorSettlesLoadingWhenPreviewFails() async { + let funding = MockHwFunding() + funding.composeError = MockHwFunding.TestError() + let manager = HwWalletManager() + let coordinator = HwSendCoordinator( + signerFactory: { [self] _, address, satsPerVByte in + makeSigner( + funding: funding, + connecting: MockHwConnecting(), + feeRate: satsPerVByte, + address: address + ) + } + ) + coordinator.selectWallet("trezor:wallet", showsLoading: true) + + do { + _ = try await coordinator.preparePreview( + manager: manager, + address: "bc1qtest", + sats: 42000, + satsPerVByte: 2 + ) + XCTFail("Expected preview preparation to fail") + } catch { + XCTAssertTrue(error is MockHwFunding.TestError) + } + + XCTAssertFalse(coordinator.isFundingSourceLoading) + XCTAssertFalse(coordinator.isPreviewLoading) + XCTAssertEqual(coordinator.previewFeeSats, 0) + } + func testCoordinatorRetryReusesSignedPaymentAfterUncertainBroadcast() async throws { try await assertCoordinatorRetryReusesSignedPayment(error: HwTransferError.broadcastUncertain) } diff --git a/BitkitTests/HwTransferMocks.swift b/BitkitTests/HwTransferMocks.swift index 88dd6da05..5a3d99899 100644 --- a/BitkitTests/HwTransferMocks.swift +++ b/BitkitTests/HwTransferMocks.swift @@ -14,6 +14,7 @@ final class MockHwFunding: HwTransferFunding { var maxSpendableError: Error? var composeError: Error? var composeDelay: Double = 0 + var estimateDelay: Double = 0 var signError: Error? var signErrors: [Error] = [] var signDelay: Double = 0 @@ -68,6 +69,7 @@ final class MockHwFunding: HwTransferFunding { addressType _: AddressScriptType ) async throws -> UInt64 { estimateCalls.append((address, sats, satsPerVByte)) + if estimateDelay > 0 { try await Task.sleep(nanoseconds: UInt64(estimateDelay * 1_000_000_000)) } if let composeError { throw composeError } return funding.miningFeeSats } diff --git a/changelog.d/next/708.fixed.md b/changelog.d/next/708.fixed.md new file mode 100644 index 000000000..81b010825 --- /dev/null +++ b/changelog.d/next/708.fixed.md @@ -0,0 +1 @@ +Hardware-wallet send now clearly shows fee and funding-source preparation and prevents confirmation until the selected fee is ready.