From a68e2a8d50ac4767a1e8a7f9454889097732a967 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Thu, 29 Jan 2026 14:13:06 +0200 Subject: [PATCH 01/61] Add card reader reconnection support to Hardware module - Add CardReaderReconnectionState enum with idle, reconnecting, succeeded, and failed states - Add reconnectionEvents publisher and cancelReconnection() to CardReaderService protocol - Add reconnectionCancellation error case to CardReaderServiceError - Implement reconnection delegate methods in StripeCardReaderService - Add no-op implementations to NoOpCardReaderService --- .../CardReaderReconnectionState.swift | 18 ++++++ .../CardReader/CardReaderService.swift | 8 +++ .../CardReader/CardReaderServiceError.swift | 6 +- .../NoOpCardReaderService.swift | 11 ++++ .../StripeCardReaderService.swift | 57 +++++++++++++++++++ 5 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 Modules/Sources/Hardware/CardReader/CardReaderReconnectionState.swift diff --git a/Modules/Sources/Hardware/CardReader/CardReaderReconnectionState.swift b/Modules/Sources/Hardware/CardReader/CardReaderReconnectionState.swift new file mode 100644 index 00000000000..376a76e90cb --- /dev/null +++ b/Modules/Sources/Hardware/CardReader/CardReaderReconnectionState.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Represents the state of a card reader auto-reconnection attempt. +/// When a Bluetooth card reader unexpectedly disconnects, the Stripe Terminal SDK +/// automatically attempts to reconnect. These states track that process. +public enum CardReaderReconnectionState: Equatable { + /// No reconnection attempt is in progress. + case idle + + /// The SDK is attempting to automatically reconnect to the reader. + case reconnecting(reader: CardReader) + + /// The auto-reconnection succeeded. + case succeeded(reader: CardReader) + + /// The auto-reconnection failed. + case failed(reader: CardReader) +} diff --git a/Modules/Sources/Hardware/CardReader/CardReaderService.swift b/Modules/Sources/Hardware/CardReader/CardReaderService.swift index 743f581d9d5..8ec778d1b48 100644 --- a/Modules/Sources/Hardware/CardReader/CardReaderService.swift +++ b/Modules/Sources/Hardware/CardReader/CardReaderService.swift @@ -20,6 +20,9 @@ public protocol CardReaderService { /// The Publisher that emits when TTP Terms and Services are accepted var tapToPayCardReaderAcceptToSEvents: AnyPublisher { get } + /// The Publisher that emits reconnection state changes for Bluetooth readers + var reconnectionEvents: AnyPublisher { get } + // MARK: - Commands /// Checks for support of a given reader type and discovery method combination. Does not start discovery. @@ -73,4 +76,9 @@ public protocol CardReaderService { /// /// To check the progress of the update, observe the softwareUpdateEvents publisher. func installUpdate() -> Void + + /// Cancels an in-progress auto-reconnection attempt. + /// Use this when the user wants to manually connect a different reader + /// or cancel the automatic reconnection process. + func cancelReconnection() -> Future } diff --git a/Modules/Sources/Hardware/CardReader/CardReaderServiceError.swift b/Modules/Sources/Hardware/CardReader/CardReaderServiceError.swift index 20636fb0cde..cb47b981743 100644 --- a/Modules/Sources/Hardware/CardReader/CardReaderServiceError.swift +++ b/Modules/Sources/Hardware/CardReader/CardReaderServiceError.swift @@ -45,6 +45,9 @@ public enum CardReaderServiceError: Error { /// Error thrown while updating the reader firmware case softwareUpdate(underlyingError: UnderlyingError = .internalServiceError, batteryLevel: Double?) + /// Error thrown while cancelling an auto-reconnection attempt + case reconnectionCancellation(underlyingError: UnderlyingError = .internalServiceError) + /// The user has denied the app permission to use Bluetooth case bluetoothDenied @@ -74,7 +77,8 @@ extension CardReaderServiceError: LocalizedError { .refundCreation(let underlyingError), .refundPayment(let underlyingError, _), .refundCancellation(let underlyingError), - .softwareUpdate(let underlyingError, _): + .softwareUpdate(let underlyingError, _), + .reconnectionCancellation(let underlyingError): return underlyingError.errorDescription case .paymentCaptureWithPaymentMethod(underlyingError: let underlyingError, paymentMethod: _): return underlyingError.errorDescription ?? underlyingError.localizedDescription diff --git a/Modules/Sources/Hardware/CardReader/StripeCardReader/NoOpCardReaderService.swift b/Modules/Sources/Hardware/CardReader/StripeCardReader/NoOpCardReaderService.swift index 44ba4bf4251..9294db7b7c1 100644 --- a/Modules/Sources/Hardware/CardReader/StripeCardReader/NoOpCardReaderService.swift +++ b/Modules/Sources/Hardware/CardReader/StripeCardReader/NoOpCardReaderService.swift @@ -21,6 +21,10 @@ public struct NoOpCardReaderService: CardReaderService { public var tapToPayCardReaderAcceptToSEvents: AnyPublisher = PassthroughSubject().eraseToAnyPublisher() + /// The Publisher that emits reconnection state changes for Bluetooth readers + public var reconnectionEvents: AnyPublisher + = CurrentValueSubject(.idle).eraseToAnyPublisher() + public init() {} // MARK: - Commands @@ -110,4 +114,11 @@ public struct NoOpCardReaderService: CardReaderService { public func installUpdate() -> Void { // no-op } + + /// Cancels an in-progress auto-reconnection attempt. + public func cancelReconnection() -> Future { + return Future() { promise in + promise(.failure(NSError.init(domain: "noopcardreader", code: 0, userInfo: nil))) + } + } } diff --git a/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift b/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift index 537a5473b5b..682bc881d6f 100644 --- a/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift +++ b/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift @@ -10,6 +10,7 @@ public final class StripeCardReaderService: NSObject { private var discoveryCancellable: StripeTerminal.Cancelable? private var paymentCancellable: StripeTerminal.Cancelable? private var refundCancellable: StripeTerminal.Cancelable? + private var reconnectionCancelable: StripeTerminal.Cancelable? private var cancellables = Set() private var discoveredReadersSubject = CurrentValueSubject<[CardReader], Error>([]) @@ -18,6 +19,7 @@ public final class StripeCardReaderService: NSObject { private let readerEventsSubject = PassthroughSubject() private let softwareUpdateSubject = CurrentValueSubject(.none) private let tapToPayCardReaderAcceptToSSubject = PassthroughSubject() + private let reconnectionStateSubject = CurrentValueSubject(.idle) private var connectionAttemptInvalidated: Bool = false @@ -69,6 +71,10 @@ extension StripeCardReaderService: CardReaderService { tapToPayCardReaderAcceptToSSubject.eraseToAnyPublisher() } + public var reconnectionEvents: AnyPublisher { + reconnectionStateSubject.eraseToAnyPublisher() + } + // MARK: - CardReaderService conformance. Commands public func checkSupport(for cardReaderType: CardReaderType, @@ -628,6 +634,30 @@ extension StripeCardReaderService: CardReaderService { public func installUpdate() -> Void { Terminal.shared.installAvailableUpdate() } + + public func cancelReconnection() -> Future { + Future { [weak self] promise in + guard let self = self, + let reconnectionCancelable = self.reconnectionCancelable, + !reconnectionCancelable.completed else { + self?.reconnectionStateSubject.send(.idle) + return promise(.success(())) + } + + reconnectionCancelable.cancel { [weak self] error in + self?.reconnectionCancelable = nil + self?.connectedReadersSubject.send([]) + self?.reconnectionStateSubject.send(.idle) + + if let error = error { + let underlyingError = Self.logAndDecodeError(error) + promise(.failure(CardReaderServiceError.reconnectionCancellation(underlyingError: underlyingError))) + } else { + promise(.success(())) + } + } + } + } } struct CardReaderMetadata { @@ -992,6 +1022,33 @@ extension StripeCardReaderService: MobileReaderDelegate { public func reader(_ reader: Reader, didDisconnect reason: DisconnectReason) { connectedReadersSubject.send([]) } + + // MARK: - Reconnection delegate methods + + public func reader(_ reader: Reader, didStartReconnect cancelable: StripeTerminal.Cancelable) { + DDLogInfo("💳 Reader started auto-reconnection") + reconnectionCancelable = cancelable + let cardReader = CardReader(reader: reader) + reconnectionStateSubject.send(.reconnecting(reader: cardReader)) + } + + public func readerDidSucceedReconnect(_ reader: Reader) { + DDLogInfo("💳 Reader auto-reconnection succeeded") + reconnectionCancelable = nil + let cardReader = CardReader(reader: reader) + reconnectionStateSubject.send(.succeeded(reader: cardReader)) + connectedReadersSubject.send([cardReader]) + reconnectionStateSubject.send(.idle) + } + + public func readerDidFailReconnect(_ reader: Reader) { + DDLogError("💳 Reader auto-reconnection failed") + reconnectionCancelable = nil + let cardReader = CardReader(reader: reader) + reconnectionStateSubject.send(.failed(reader: cardReader)) + connectedReadersSubject.send([]) + reconnectionStateSubject.send(.idle) + } } extension StripeCardReaderService: TapToPayReaderDelegate { From de35344c883e5cf63e39cdab02d55d7bba2d5aaf Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Thu, 29 Jan 2026 14:13:10 +0200 Subject: [PATCH 02/61] Add reconnection actions to Yosemite layer - Add observeCardReaderReconnectionState action to publish reconnection state changes - Add cancelReconnection action to cancel in-progress auto-reconnection - Add CardReaderReconnectionState typealias to Model.swift - Implement action handlers in CardPresentPaymentStore --- .../Actions/CardPresentPaymentAction.swift | 7 ++++++ Modules/Sources/Yosemite/Model/Model.swift | 1 + .../Stores/CardPresentPaymentStore.swift | 25 +++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/Modules/Sources/Yosemite/Actions/CardPresentPaymentAction.swift b/Modules/Sources/Yosemite/Actions/CardPresentPaymentAction.swift index fd871cac95b..bec3fd301e0 100644 --- a/Modules/Sources/Yosemite/Actions/CardPresentPaymentAction.swift +++ b/Modules/Sources/Yosemite/Actions/CardPresentPaymentAction.swift @@ -102,4 +102,11 @@ public enum CardPresentPaymentAction: Action { /// Fetches Charge details by charge ID /// case fetchWCPayCharge(siteID: Int64, chargeID: String, onCompletion: (Result) -> Void) + + /// Provides a publisher for card reader reconnection state changes. + /// Used to observe when a Bluetooth card reader is attempting to auto-reconnect. + case observeCardReaderReconnectionState(onCompletion: (AnyPublisher) -> Void) + + /// Cancels an in-progress auto-reconnection attempt. + case cancelReconnection(onCompletion: (Result) -> Void) } diff --git a/Modules/Sources/Yosemite/Model/Model.swift b/Modules/Sources/Yosemite/Model/Model.swift index a877cdcaa3b..e95bc66f5a2 100644 --- a/Modules/Sources/Yosemite/Model/Model.swift +++ b/Modules/Sources/Yosemite/Model/Model.swift @@ -226,6 +226,7 @@ public typealias CardReaderDiscoveryMethod = Hardware.CardReaderDiscoveryMethod public typealias CardReaderEvent = Hardware.CardReaderEvent public typealias CardReaderInput = Hardware.CardReaderInput public typealias CardReaderSoftwareUpdateState = Hardware.CardReaderSoftwareUpdateState +public typealias CardReaderReconnectionState = Hardware.CardReaderReconnectionState public typealias CardReaderServiceDiscoveryStatus = Hardware.CardReaderServiceDiscoveryStatus public typealias CardReaderServiceError = Hardware.CardReaderServiceError public typealias CardReaderServiceUnderlyingError = Hardware.UnderlyingError diff --git a/Modules/Sources/Yosemite/Stores/CardPresentPaymentStore.swift b/Modules/Sources/Yosemite/Stores/CardPresentPaymentStore.swift index 6619d05c423..821b65e6499 100644 --- a/Modules/Sources/Yosemite/Stores/CardPresentPaymentStore.swift +++ b/Modules/Sources/Yosemite/Stores/CardPresentPaymentStore.swift @@ -138,6 +138,10 @@ public final class CardPresentPaymentStore: Store { publishCardReaderConnections(onCompletion: completion) case .fetchWCPayCharge(let siteID, let chargeID, let completion): fetchCharge(siteID: siteID, chargeID: chargeID, completion: completion) + case .observeCardReaderReconnectionState(let completion): + observeCardReaderReconnectionState(onCompletion: completion) + case .cancelReconnection(let completion): + cancelReconnection(onCompletion: completion) } } } @@ -426,6 +430,27 @@ private extension CardPresentPaymentStore { onCompletion(publisher) } + + func observeCardReaderReconnectionState(onCompletion: (AnyPublisher) -> Void) { + onCompletion(cardReaderService.reconnectionEvents) + } + + func cancelReconnection(onCompletion: @escaping (Result) -> Void) { + cardReaderService.cancelReconnection() + .subscribe(Subscribers.Sink( + receiveCompletion: { result in + switch result { + case .failure(let error): + onCompletion(.failure(error)) + case .finished: + break + } + }, + receiveValue: { + onCompletion(.success(())) + } + )) + } } // MARK: Networking Methods From fd6084eb1c3271676aaca5c1c9839f624b7641ff Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Thu, 29 Jan 2026 14:13:16 +0200 Subject: [PATCH 03/61] Add reconnecting status to POS payment interfaces - Add reconnecting case to CardPresentPaymentReaderConnectionStatus - Add cancelReconnection() to CardPresentPaymentFacade protocol - Implement no-op cancelReconnection in CardPresentPaymentPreviewService - Handle reconnectionCancellation error in CardPresentPaymentsRetryApproach --- .../Card Present Payments/CardPresentPaymentFacade.swift | 5 +++++ .../CardPresentPaymentPreviewService.swift | 8 ++++++++ .../CardPresentPaymentReaderConnectionStatus.swift | 1 + .../CardPresentPaymentsRetryApproach.swift | 3 ++- 4 files changed, 16 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/PointOfSale/Card Present Payments/CardPresentPaymentFacade.swift b/Modules/Sources/PointOfSale/Card Present Payments/CardPresentPaymentFacade.swift index 76835133320..0b50c2e83d6 100644 --- a/Modules/Sources/PointOfSale/Card Present Payments/CardPresentPaymentFacade.swift +++ b/Modules/Sources/PointOfSale/Card Present Payments/CardPresentPaymentFacade.swift @@ -55,4 +55,9 @@ public protocol CardPresentPaymentFacade { /// Cancels any in-progress payment, returning when complete func cancelPayment() async throws + + /// Cancels an in-progress auto-reconnection attempt. + /// Use this when the user wants to manually connect a different reader + /// or cancel the automatic reconnection process. + func cancelReconnection() async } diff --git a/Modules/Sources/PointOfSale/Card Present Payments/CardPresentPaymentPreviewService.swift b/Modules/Sources/PointOfSale/Card Present Payments/CardPresentPaymentPreviewService.swift index 98ae91bf7c2..47153bea531 100644 --- a/Modules/Sources/PointOfSale/Card Present Payments/CardPresentPaymentPreviewService.swift +++ b/Modules/Sources/PointOfSale/Card Present Payments/CardPresentPaymentPreviewService.swift @@ -41,6 +41,14 @@ final class CardPresentPaymentPreviewService: CardPresentPaymentFacade { // no-op } + func cancelPayment() async throws { + // no-op + } + + func cancelReconnection() async { + // no-op + } + func updateCardReaderSoftware() async throws { // no-op } diff --git a/Modules/Sources/PointOfSale/Card Present Payments/CardPresentPaymentReaderConnectionStatus.swift b/Modules/Sources/PointOfSale/Card Present Payments/CardPresentPaymentReaderConnectionStatus.swift index bff03df7a62..0f6d90ccd5c 100644 --- a/Modules/Sources/PointOfSale/Card Present Payments/CardPresentPaymentReaderConnectionStatus.swift +++ b/Modules/Sources/PointOfSale/Card Present Payments/CardPresentPaymentReaderConnectionStatus.swift @@ -5,4 +5,5 @@ public enum CardPresentPaymentReaderConnectionStatus: Equatable { case connected(CardPresentPaymentCardReader) case cancellingConnection case disconnecting + case reconnecting(CardPresentPaymentCardReader) } diff --git a/Modules/Sources/PointOfSale/Card Present Payments/CardPresentPaymentsRetryApproach.swift b/Modules/Sources/PointOfSale/Card Present Payments/CardPresentPaymentsRetryApproach.swift index 7af09b6d628..19d209adb11 100644 --- a/Modules/Sources/PointOfSale/Card Present Payments/CardPresentPaymentsRetryApproach.swift +++ b/Modules/Sources/PointOfSale/Card Present Payments/CardPresentPaymentsRetryApproach.swift @@ -29,7 +29,8 @@ private extension CardReaderServiceError { .paymentCancellation(underlyingError: let underlyingError), .refundCreation(underlyingError: let underlyingError), .refundCancellation(underlyingError: let underlyingError), - .softwareUpdate(underlyingError: let underlyingError, _): + .softwareUpdate(underlyingError: let underlyingError, _), + .reconnectionCancellation(underlyingError: let underlyingError): return underlyingError.retryApproach(with: retryAction) case .refundPayment(underlyingError: let underlyingError, shouldRetry: let shouldRetry): guard shouldRetry else { From 6ebc7404cfbbff6387258115c364216e8d92de63 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Thu, 29 Jan 2026 14:13:21 +0200 Subject: [PATCH 04/61] Wire up reconnection events in CardPresentPaymentService - Subscribe to reconnection state publisher and map to connection status - Implement cancelReconnection() to dispatch cancel action - Cancel any ongoing reconnection before starting manual connection --- .../CardPresentPaymentService.swift | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/WooCommerce/Classes/POS/Adaptors/Card Present Payments/CardPresentPaymentService.swift b/WooCommerce/Classes/POS/Adaptors/Card Present Payments/CardPresentPaymentService.swift index 67d04a6bf53..98f0f3a58d0 100644 --- a/WooCommerce/Classes/POS/Adaptors/Card Present Payments/CardPresentPaymentService.swift +++ b/WooCommerce/Classes/POS/Adaptors/Card Present Payments/CardPresentPaymentService.swift @@ -7,6 +7,7 @@ import struct Yosemite.CardReader import enum Yosemite.CardPresentPaymentAction import enum Yosemite.PaymentChannel import enum Hardware.CardReaderSoftwareUpdateState +import enum Yosemite.CardReaderReconnectionState import protocol Yosemite.StoresManager final class CardPresentPaymentService: CardPresentPaymentFacade { @@ -18,6 +19,8 @@ final class CardPresentPaymentService: CardPresentPaymentFacade { private let connectedReaderPublisher: AnyPublisher + private let reconnectionStatusPublisher: AnyPublisher + private let paymentEventSubject = PassthroughSubject() private let readerConnectionStatusSubject = PassthroughSubject() @@ -69,6 +72,9 @@ final class CardPresentPaymentService: CardPresentPaymentFacade { let connectedReaderPublisher = await Self.createCardReaderConnectionPublisher(stores: stores) self.connectedReaderPublisher = connectedReaderPublisher + let reconnectionStatusPublisher = await Self.createReconnectionStatusPublisher(stores: stores) + self.reconnectionStatusPublisher = reconnectionStatusPublisher + readerConnectionStatusPublisher = self.connectedReaderPublisher .map({ reader -> CardPresentPaymentReaderConnectionStatus in guard let reader else { @@ -78,6 +84,7 @@ final class CardPresentPaymentService: CardPresentPaymentFacade { }) .merge(with: paymentAlertsPresenterAdaptor.readerConnectionStatusPublisher) .merge(with: readerConnectionStatusSubject) + .merge(with: reconnectionStatusPublisher) .receive(on: DispatchQueue.main) .eraseToAnyPublisher() @@ -89,6 +96,9 @@ final class CardPresentPaymentService: CardPresentPaymentFacade { @MainActor func connectReader(using connectionMethod: CardReaderConnectionMethod) async throws -> CardPresentPaymentReaderConnectionResult { + // Cancel any ongoing auto-reconnection attempt before starting a manual connection + await cancelReconnection() + // What happens if this gets called while there's another connection ongoing? let preflightControllerAdaptor = CardPresentPaymentPreflightAdaptor(preflightController: createPreflightController()) @@ -199,6 +209,22 @@ final class CardPresentPaymentService: CardPresentPaymentFacade { paymentTask?.cancel() paymentTask = nil } + + @MainActor + func cancelReconnection() async { + await withCheckedContinuation { continuation in + var nillableContinuation: CheckedContinuation? = continuation + + let action = CardPresentPaymentAction.cancelReconnection { result in + if case .failure(let error) = result { + DDLogError("Failed to cancel reconnection: \(error)") + } + nillableContinuation?.resume() + nillableContinuation = nil + } + stores.dispatch(action) + } + } } private extension CardPresentPaymentService { @@ -240,6 +266,46 @@ private extension CardPresentPaymentService { } } + @MainActor + static func createReconnectionStatusPublisher(stores: StoresManager) async -> AnyPublisher { + return await withCheckedContinuation { continuation in + var nillableContinuation: CheckedContinuation, Never>? = continuation + + let action = CardPresentPaymentAction.observeCardReaderReconnectionState { reconnectionPublisher in + let statusPublisher = reconnectionPublisher + .compactMap { state -> CardPresentPaymentReaderConnectionStatus? in + switch state { + case .idle: + // Don't emit status for idle - other publishers handle connected/disconnected + return nil + case .reconnecting(let reader): + let cardReader = CardPresentPaymentCardReader( + name: reader.name ?? reader.id, + batteryLevel: reader.batteryLevel, + softwareVersion: reader.softwareVersion + ) + return .reconnecting(cardReader) + case .succeeded(let reader): + let cardReader = CardPresentPaymentCardReader( + name: reader.name ?? reader.id, + batteryLevel: reader.batteryLevel, + softwareVersion: reader.softwareVersion + ) + return .connected(cardReader) + case .failed: + return .disconnected + } + } + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + + nillableContinuation?.resume(returning: statusPublisher) + nillableContinuation = nil + } + stores.dispatch(action) + } + } + func createPreflightController() -> CardPresentPaymentPreflightController< CardPresentPaymentTapToPayReaderConnectionAlertsProvider, CardPresentPaymentBluetoothReaderConnectionAlertsProvider, From 0959e6208e264f7a311a45590e4ec8218bb106f6 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Thu, 29 Jan 2026 14:13:29 +0200 Subject: [PATCH 05/61] Add reconnecting UI state to POS - Add reconnecting case to CardReaderConnectionStatusView with spinner and cancel menu - Handle reconnecting case in TotalsView payment UI logic - Add cancelReconnection() method to PointOfSaleAggregateModel --- .../Models/PointOfSaleAggregateModel.swift | 8 +++- .../CardReaderConnectionStatusView.swift | 37 ++++++++++++++++++- .../PointOfSale/Presentation/TotalsView.swift | 2 +- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift b/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift index a809b18f365..4994ff59e5b 100644 --- a/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift +++ b/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift @@ -381,6 +381,12 @@ extension PointOfSaleAggregateModel { } } + func cancelReconnection() { + Task { @MainActor [weak self] in + await self?.cardPresentPaymentService.cancelReconnection() + } + } + func updateCardReaderSoftware() { //TODO: analytics.track(.cardReaderUpdateTapped) Task { @MainActor [weak self] in @@ -399,7 +405,7 @@ extension PointOfSaleAggregateModel { switch status { case .connected: return true - case .disconnected, .disconnecting, .cancellingConnection: + case .disconnected, .disconnecting, .cancellingConnection, .reconnecting: return false } } diff --git a/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/CardReaderConnectionStatusView.swift b/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/CardReaderConnectionStatusView.swift index b28606d9dcb..80191abc67a 100644 --- a/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/CardReaderConnectionStatusView.swift +++ b/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/CardReaderConnectionStatusView.swift @@ -57,6 +57,27 @@ struct CardReaderConnectionStatusView: View { .padding(Constants.disconnectedBorderInset) } .accessibilityIdentifier("pos-connect-reader-button") + case .reconnecting: + Menu { + Button { + posModel.cancelReconnection() + } label: { + Text(Localization.cancelReconnection) + } + } label: { + HStack(spacing: Constants.buttonImageAndTextSpacing) { + ProgressView() + .progressViewStyle(POSProgressViewStyle( + size: Constants.progressIndicatorDimension * scale, + lineWidth: Constants.progressIndicatorLineWidth * scale + )) + Text(Localization.readerReconnecting) + .foregroundColor(connectedFontColor) + } + .padding(.horizontal, Constants.horizontalPadding) + .frame(maxHeight: .infinity) + } + .accessibilityIdentifier("pos-reader-reconnecting") } } .font(Constants.font) @@ -148,8 +169,8 @@ private extension CardReaderConnectionStatusView { ) static let disconnectCardReader = NSLocalizedString( - "pointOfSale.floatingButtons.disconnectCardReader.button.title", - value: "Disconnect Reader", + "pointOfSale.floatingButtons.disconnectCardReader.button.title.2", + value: "Disconnect reader", comment: "The title of the menu button to disconnect a connected card reader, as confirmation." ) @@ -159,6 +180,18 @@ private extension CardReaderConnectionStatusView { comment: "The title of the floating button to indicate that the reader is not ready for another " + "connection, usually because a connection has just been cancelled" ) + + static let readerReconnecting = NSLocalizedString( + "pointOfSale.floatingButtons.readerReconnecting.title", + value: "Reconnecting…", + comment: "The title of the floating button to indicate that the reader is attempting to reconnect." + ) + + static let cancelReconnection = NSLocalizedString( + "pointOfSale.floatingButtons.cancelReconnection.button.title", + value: "Cancel reconnection", + comment: "The title of the menu button to cancel an ongoing card reader reconnection attempt." + ) } } diff --git a/Modules/Sources/PointOfSale/Presentation/TotalsView.swift b/Modules/Sources/PointOfSale/Presentation/TotalsView.swift index 3e7c4b4ec5b..09ca5b9a674 100644 --- a/Modules/Sources/PointOfSale/Presentation/TotalsView.swift +++ b/Modules/Sources/PointOfSale/Presentation/TotalsView.swift @@ -154,7 +154,7 @@ private extension TotalsView { } switch posModel.cardReaderConnectionStatus { - case .connected, .disconnecting, .cancellingConnection: + case .connected, .disconnecting, .cancellingConnection, .reconnecting: // Show card payment UI if there's a message, or cash payment UI when not idle switch posModel.paymentState.activePaymentMethod { case .cash: From b33ca6e1edebb3eeadbec9f1c964e1457b67d90f Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Thu, 29 Jan 2026 14:13:34 +0200 Subject: [PATCH 06/61] Add tests for card reader reconnection - Add reconnection simulation methods to MockCardReaderService - Add tests for observeCardReaderReconnectionState action - Add test for cancelReconnection action - Update MockCardPresentPaymentService with cancelReconnection - Add cancelReconnection test to PointOfSaleAggregateModelTests - Update CardPresentPaymentServiceScreenshotMock --- .../Mocks/MockCardPresentPaymentService.swift | 7 ++ .../PointOfSaleAggregateModelTests.swift | 21 ++++ .../MockCardReaderService.swift | 33 ++++++ .../Stores/CardPresentPaymentStoreTests.swift | 103 ++++++++++++++++++ ...dPresentPaymentServiceScreenshotMock.swift | 4 + 5 files changed, 168 insertions(+) diff --git a/Modules/Tests/PointOfSaleTests/Mocks/MockCardPresentPaymentService.swift b/Modules/Tests/PointOfSaleTests/Mocks/MockCardPresentPaymentService.swift index 7a85097e531..58c5b0caa98 100644 --- a/Modules/Tests/PointOfSaleTests/Mocks/MockCardPresentPaymentService.swift +++ b/Modules/Tests/PointOfSaleTests/Mocks/MockCardPresentPaymentService.swift @@ -65,4 +65,11 @@ final class MockCardPresentPaymentService: CardPresentPaymentFacade { func updateCardReaderSoftware() async throws { // no-op } + + var cancelReconnectionCalled = false + var onCancelReconnectionCalled: (() async -> Void)? + func cancelReconnection() async { + cancelReconnectionCalled = true + await onCancelReconnectionCalled?() + } } diff --git a/Modules/Tests/PointOfSaleTests/Models/PointOfSaleAggregateModelTests.swift b/Modules/Tests/PointOfSaleTests/Models/PointOfSaleAggregateModelTests.swift index ea4b4a2dc6c..1958dc3d34c 100644 --- a/Modules/Tests/PointOfSaleTests/Models/PointOfSaleAggregateModelTests.swift +++ b/Modules/Tests/PointOfSaleTests/Models/PointOfSaleAggregateModelTests.swift @@ -920,6 +920,27 @@ struct PointOfSaleAggregateModelTests { #expect(analytics.events.first(where: { $0.eventName == "card_reader_disconnect_tapped" }) != nil) } + @Test func cancelReconnection_calls_cardPresentPaymentService_cancelReconnection() async { + // Given + let itemsController = MockPointOfSaleItemsController() + let sut = makePointOfSaleAggregateModel( + itemsController: itemsController, + cardPresentPaymentService: cardPresentPaymentService, + orderController: orderController, + analytics: analytics) + + // When + await withCheckedContinuation { continuation in + cardPresentPaymentService.onCancelReconnectionCalled = { + continuation.resume() + } + sut.cancelReconnection() + } + + // Then + #expect(cardPresentPaymentService.cancelReconnectionCalled == true) + } + @Test func checkout_when_invoked_then_tracks_trackCheckoutTapped() async throws { // Given let analyticsTracker = MockPOSCollectOrderPaymentAnalyticsTracker() diff --git a/Modules/Tests/YosemiteTests/Mocks/CardPresentPayments/MockCardReaderService.swift b/Modules/Tests/YosemiteTests/Mocks/CardPresentPayments/MockCardReaderService.swift index 7b4ed8aa977..3d6566b9ae4 100644 --- a/Modules/Tests/YosemiteTests/Mocks/CardPresentPayments/MockCardReaderService.swift +++ b/Modules/Tests/YosemiteTests/Mocks/CardPresentPayments/MockCardReaderService.swift @@ -25,6 +25,10 @@ final class MockCardReaderService: CardReaderService { PassthroughSubject().eraseToAnyPublisher() } + var reconnectionEvents: AnyPublisher { + reconnectionEventsSubject.eraseToAnyPublisher() + } + /// Boolean flag Indicates that clients have called the start method var didHitStart = false @@ -67,6 +71,10 @@ final class MockCardReaderService: CardReaderService { private let connectedReadersSubject = CurrentValueSubject<[CardReader], Never>([]) private let discoveryStatusSubject = CurrentValueSubject(.idle) + private let reconnectionEventsSubject = CurrentValueSubject(.idle) + + /// Boolean flag indicates that clients have called the cancelReconnection method + var didHitCancelReconnection = false init() { @@ -176,6 +184,15 @@ final class MockCardReaderService: CardReaderService { func installUpdate() -> Void { } + + func cancelReconnection() -> Future { + didHitCancelReconnection = true + return Future { promise in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { + promise(.success(())) + } + } + } } extension MockCardReaderService { @@ -188,6 +205,22 @@ extension MockCardReaderService { func whenWaitForInsertedCardToBeRemoved(thenReturn future: Future) { waitForInsertedCardToBeRemovedFuture = future } + + func simulateReconnectionStarted(reader: CardReader) { + reconnectionEventsSubject.send(.reconnecting(reader: reader)) + } + + func simulateReconnectionSucceeded(reader: CardReader) { + reconnectionEventsSubject.send(.succeeded(reader: reader)) + connectedReadersSubject.send([reader]) + reconnectionEventsSubject.send(.idle) + } + + func simulateReconnectionFailed(reader: CardReader) { + reconnectionEventsSubject.send(.failed(reader: reader)) + connectedReadersSubject.send([]) + reconnectionEventsSubject.send(.idle) + } } private extension MockCardReaderService { diff --git a/Modules/Tests/YosemiteTests/Stores/CardPresentPaymentStoreTests.swift b/Modules/Tests/YosemiteTests/Stores/CardPresentPaymentStoreTests.swift index e512e7fc812..03609ef8d72 100644 --- a/Modules/Tests/YosemiteTests/Stores/CardPresentPaymentStoreTests.swift +++ b/Modules/Tests/YosemiteTests/Stores/CardPresentPaymentStoreTests.swift @@ -822,4 +822,107 @@ final class CardPresentPaymentStoreTests: XCTestCase { XCTAssertNotNil(mockCardReaderService.spyCheckSupportMinimumOperatingSystemVersionOverride) } + + // MARK: - CardPresentPaymentAction.observeCardReaderReconnectionState + + func test_observeCardReaderReconnectionState_returns_publisher() { + let expectation = self.expectation(description: "Reconnection state publisher received") + + let action = CardPresentPaymentAction.observeCardReaderReconnectionState { publisher in + XCTAssertNotNil(publisher) + expectation.fulfill() + } + + cardPresentStore.onAction(action) + + wait(for: [expectation], timeout: Constants.expectationTimeout) + } + + func test_observeCardReaderReconnectionState_emits_reconnecting_state() { + let expectation = self.expectation(description: "Reconnecting state received") + + let reader = MockCardReader.bbposChipper2XBT() + + let action = CardPresentPaymentAction.observeCardReaderReconnectionState { [weak self] publisher in + let cancellable = publisher + .dropFirst() // Skip initial .idle + .sink { state in + if case .reconnecting(let reconnectingReader) = state { + XCTAssertEqual(reconnectingReader.serial, reader.serial) + expectation.fulfill() + } + } + // Simulate reconnection started + self?.mockCardReaderService.simulateReconnectionStarted(reader: reader) + _ = cancellable + } + + cardPresentStore.onAction(action) + + wait(for: [expectation], timeout: Constants.expectationTimeout) + } + + func test_observeCardReaderReconnectionState_emits_succeeded_state() { + let expectation = self.expectation(description: "Reconnection succeeded state received") + + let reader = MockCardReader.bbposChipper2XBT() + + let action = CardPresentPaymentAction.observeCardReaderReconnectionState { [weak self] publisher in + let cancellable = publisher + .dropFirst() // Skip initial .idle + .sink { state in + if case .succeeded(let reconnectedReader) = state { + XCTAssertEqual(reconnectedReader.serial, reader.serial) + expectation.fulfill() + } + } + // Simulate reconnection succeeded + self?.mockCardReaderService.simulateReconnectionSucceeded(reader: reader) + _ = cancellable + } + + cardPresentStore.onAction(action) + + wait(for: [expectation], timeout: Constants.expectationTimeout) + } + + func test_observeCardReaderReconnectionState_emits_failed_state() { + let expectation = self.expectation(description: "Reconnection failed state received") + + let reader = MockCardReader.bbposChipper2XBT() + + let action = CardPresentPaymentAction.observeCardReaderReconnectionState { [weak self] publisher in + let cancellable = publisher + .dropFirst() // Skip initial .idle + .sink { state in + if case .failed(let failedReader) = state { + XCTAssertEqual(failedReader.serial, reader.serial) + expectation.fulfill() + } + } + // Simulate reconnection failed + self?.mockCardReaderService.simulateReconnectionFailed(reader: reader) + _ = cancellable + } + + cardPresentStore.onAction(action) + + wait(for: [expectation], timeout: Constants.expectationTimeout) + } + + // MARK: - CardPresentPaymentAction.cancelReconnection + + func test_cancelReconnection_action_hits_cancelReconnection_in_service() { + let expectation = self.expectation(description: "Cancel reconnection completed") + + let action = CardPresentPaymentAction.cancelReconnection { result in + XCTAssertTrue(result.isSuccess) + expectation.fulfill() + } + + cardPresentStore.onAction(action) + + wait(for: [expectation], timeout: Constants.expectationTimeout) + XCTAssertTrue(mockCardReaderService.didHitCancelReconnection) + } } diff --git a/WooCommerce/Classes/POS/Mocks/CardPresentPaymentServiceScreenshotMock.swift b/WooCommerce/Classes/POS/Mocks/CardPresentPaymentServiceScreenshotMock.swift index 9a7b948eeb7..d1a31cb1a99 100644 --- a/WooCommerce/Classes/POS/Mocks/CardPresentPaymentServiceScreenshotMock.swift +++ b/WooCommerce/Classes/POS/Mocks/CardPresentPaymentServiceScreenshotMock.swift @@ -75,4 +75,8 @@ final class CardPresentPaymentServiceScreenshotMock: CardPresentPaymentFacade { func cancelPayment() async throws { // No-op for screenshots } + + func cancelReconnection() async { + // No-op for screenshots + } } From ff08de878eba5161b77cbd6cfb71c885f710bec3 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Thu, 29 Jan 2026 15:03:15 +0200 Subject: [PATCH 07/61] Add reconnection state to Card Reader Settings - Observe reconnection state in BluetoothCardReaderSettingsConnectedViewModel - Keep showing connected reader view during reconnection - Disable update/disconnect buttons and show spinner during reconnection --- ...CardReaderSettingsConnectedViewModel.swift | 24 ++++++++++++++++--- ...eaderSettingsConnectedViewController.swift | 9 ++++--- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothCardReaderSettingsConnectedViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothCardReaderSettingsConnectedViewModel.swift index 0bf0fc2abeb..21a6aaf9d5a 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothCardReaderSettingsConnectedViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothCardReaderSettingsConnectedViewModel.swift @@ -27,6 +27,8 @@ final class BluetoothCardReaderSettingsConnectedViewModel: PaymentSettingsFlowPr private(set) var readerDisconnectInProgress: Bool = false + private(set) var readerReconnectionInProgress: Bool = false + private var subscriptions = Set() var connectedReaderID: String? @@ -145,6 +147,24 @@ final class BluetoothCardReaderSettingsConnectedViewModel: PaymentSettingsFlowPr .store(in: &self.subscriptions) } ServiceLocator.stores.dispatch(softwareUpdateAction) + + let reconnectionAction = CardPresentPaymentAction.observeCardReaderReconnectionState { reconnectionEvents in + reconnectionEvents + .sink { [weak self] state in + guard let self = self else { return } + + switch state { + case .reconnecting: + self.readerReconnectionInProgress = true + case .succeeded, .failed, .idle: + self.readerReconnectionInProgress = false + } + self.reevaluateShouldShow() + self.didUpdate?() + } + .store(in: &self.subscriptions) + } + ServiceLocator.stores.dispatch(reconnectionAction) } /// This screen is only used for managing Bluetooth card readers. @@ -264,11 +284,9 @@ final class BluetoothCardReaderSettingsConnectedViewModel: PaymentSettingsFlowPr if !didGetConnectedReaders { newShouldShow = .isUnknown - } else if connectedReaders.isEmpty { + } else if connectedReaders.isEmpty && !readerReconnectionInProgress { newShouldShow = .isFalse } else if connectedReaders.includesTapToPayReader() { - /// This screen only supports management of Bluetooth readers, and will have started disconnection - /// from Tap to Pay on iPhone in this instance. newShouldShow = .isFalse } else { newShouldShow = .isTrue diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsConnectedViewController.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsConnectedViewController.swift index 43b91581896..396af5ce928 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsConnectedViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsConnectedViewController.swift @@ -201,9 +201,11 @@ private extension CardReaderSettingsConnectedViewController { let readerDisconnectInProgress = viewModel.readerDisconnectInProgress let readerUpdateInProgress = viewModel.readerUpdateInProgress + let readerReconnectionInProgress = viewModel.readerReconnectionInProgress cell.enableButton(viewModel.optionalReaderUpdateAvailable && !readerDisconnectInProgress && - !readerUpdateInProgress) + !readerUpdateInProgress && + !readerReconnectionInProgress) cell.showActivityIndicator(readerUpdateInProgress) cell.selectionStyle = .none @@ -218,8 +220,9 @@ private extension CardReaderSettingsConnectedViewController { let readerDisconnectInProgress = viewModel.readerDisconnectInProgress let readerUpdateInProgress = viewModel.readerUpdateInProgress - cell.enableButton(!readerDisconnectInProgress && !readerUpdateInProgress) - cell.showActivityIndicator(readerDisconnectInProgress) + let readerReconnectionInProgress = viewModel.readerReconnectionInProgress + cell.enableButton(!readerDisconnectInProgress && !readerUpdateInProgress && !readerReconnectionInProgress) + cell.showActivityIndicator(readerDisconnectInProgress || readerReconnectionInProgress) cell.selectionStyle = .none cell.backgroundColor = .clear From 599d5d3ffbebaaac83054106fb7f3955de06b035 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Thu, 29 Jan 2026 15:03:20 +0200 Subject: [PATCH 08/61] Cancel reconnection before starting new reader search Cancel any ongoing auto-reconnection before initiating a new Bluetooth reader discovery to prevent conflicts. --- .../CardReaderConnectionController.swift | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardReaderConnectionController.swift b/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardReaderConnectionController.swift index 5858d886649..99f2e75aadf 100644 --- a/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardReaderConnectionController.swift +++ b/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardReaderConnectionController.swift @@ -171,8 +171,20 @@ where AlertProvider.AlertDetails == AlertPresenter.AlertDetails { func searchAndConnect(onCompletion: @escaping (Result) -> Void) { Task { @MainActor [weak self] in - self?.onCompletion = onCompletion - self?.state = .initializing + guard let self = self else { return } + await self.cancelReconnection() + self.onCompletion = onCompletion + self.state = .initializing + } + } + + @MainActor + private func cancelReconnection() async { + await withCheckedContinuation { continuation in + let action = CardPresentPaymentAction.cancelReconnection { _ in + continuation.resume() + } + stores.dispatch(action) } } } From 298ecf3a99b7bc7dd58868b3e6dc806ec778910d Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Thu, 29 Jan 2026 15:03:24 +0200 Subject: [PATCH 09/61] Add release note for card reader auto-reconnection --- RELEASE-NOTES.txt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 534eae4b543..20bea54bc41 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -2,10 +2,7 @@ *** Use [*****] to indicate smoke tests of all critical flows should be run on the final IPA before release (e.g. major library or OS update). 24.1 ----- - - -24.1 ------ +- [*] POS: Show reconnecting status when Bluetooth card reader temporarily disconnects [https://github.com/woocommerce/woocommerce-ios/pull/16586] - [Internal]: Update Stripe SDK to 5.1.1 [https://github.com/woocommerce/woocommerce-ios/pull/16493] 24.0 From da41b835f6fb6b8147778a843eb24874a1b7a29c Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Fri, 30 Jan 2026 11:35:05 +0200 Subject: [PATCH 10/61] Fix tests for cancelReconnection action Add cancelReconnection action handling to MockCardPresentPaymentsStoresManager and RefundSubmissionUseCaseTests to prevent test timeouts. --- .../Mocks/MockCardPresentPaymentsStoresManager.swift | 2 ++ .../CardPresentPayments/RefundSubmissionUseCaseTests.swift | 2 ++ 2 files changed, 4 insertions(+) diff --git a/WooCommerce/WooCommerceTests/Mocks/MockCardPresentPaymentsStoresManager.swift b/WooCommerce/WooCommerceTests/Mocks/MockCardPresentPaymentsStoresManager.swift index 598bf58c1c3..5934a1756ac 100644 --- a/WooCommerce/WooCommerceTests/Mocks/MockCardPresentPaymentsStoresManager.swift +++ b/WooCommerce/WooCommerceTests/Mocks/MockCardPresentPaymentsStoresManager.swift @@ -93,6 +93,8 @@ final class MockCardPresentPaymentsStoresManager: DefaultStoresManager { onCompletion(paymentExtension) case .disconnect(let onCompletion): onCompletion(Result.success(())) + case .cancelReconnection(let onCompletion): + onCompletion(Result.success(())) default: break } diff --git a/WooCommerce/WooCommerceTests/ViewModels/CardPresentPayments/RefundSubmissionUseCaseTests.swift b/WooCommerce/WooCommerceTests/ViewModels/CardPresentPayments/RefundSubmissionUseCaseTests.swift index 5a77625bafb..9a3840946f8 100644 --- a/WooCommerce/WooCommerceTests/ViewModels/CardPresentPayments/RefundSubmissionUseCaseTests.swift +++ b/WooCommerce/WooCommerceTests/ViewModels/CardPresentPayments/RefundSubmissionUseCaseTests.swift @@ -442,6 +442,8 @@ private extension RefundSubmissionUseCaseTests { completion?(cancelRefundResult) } else if case let .cancelCardReaderDiscovery(completion) = action { completion(cancelCardReaderDiscoveryResult) + } else if case let .cancelReconnection(completion) = action { + completion(.success(())) } } } From dfd279cbb6ff5be4606464364fc933da19b52076 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:00:40 +0200 Subject: [PATCH 11/61] Handle card reader reconnection in tests Update MainTabBarControllerTests to handle the .observeCardReaderReconnectionState action in the test action handler. Otherwise, initialization of CardPresentPaymentService stalls the test. --- .../ViewRelated/MainTabBarControllerTests.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift index 6533eca730d..4a9a4cfd65b 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift @@ -452,6 +452,9 @@ final class MainTabBarControllerTests: XCTestCase { if case let .observeCardReaderUpdateState(completion) = action { completion(Just(.none).eraseToAnyPublisher()) } + if case let .observeCardReaderReconnectionState(completion) = action { + completion(Just(.idle).eraseToAnyPublisher()) + } } guard let tabBarController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(creator: { coder in From b2066db435568becc6793eb83c2be6f95c76e809 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:33:06 +0200 Subject: [PATCH 12/61] Enable auto-reconnection for Bluetooth card readers Configure the Stripe Terminal SDK to automatically attempt reconnection when a Bluetooth reader unexpectedly disconnects. --- .../CardReader/StripeCardReader/StripeCardReaderService.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift b/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift index 682bc881d6f..3572437846a 100644 --- a/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift +++ b/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift @@ -499,6 +499,7 @@ extension StripeCardReaderService: CardReaderService { switch result { case .success(let locationId): let buildConfig = BluetoothConnectionConfigurationBuilder(delegate: self, locationId: locationId) + .setAutoReconnectOnUnexpectedDisconnect(true) do { let config = try buildConfig.build() return promise(.success(config)) From be3bfe879a31f73bdf9273edb28ec12946788ee8 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:33:21 +0200 Subject: [PATCH 13/61] Update reconnection delegate to include disconnect reason Update the didStartReconnect delegate method to use the new Stripe SDK signature that includes the disconnect reason parameter. Also clear connected readers when reconnection starts so the UI correctly shows the reconnecting state instead of connected. --- .../StripeCardReader/StripeCardReaderService.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift b/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift index 3572437846a..b9c9e7c339c 100644 --- a/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift +++ b/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift @@ -1026,9 +1026,11 @@ extension StripeCardReaderService: MobileReaderDelegate { // MARK: - Reconnection delegate methods - public func reader(_ reader: Reader, didStartReconnect cancelable: StripeTerminal.Cancelable) { - DDLogInfo("💳 Reader started auto-reconnection") + public func reader(_ reader: Reader, didStartReconnect cancelable: Cancelable, disconnectReason: DisconnectReason) { + DDLogInfo("💳 Reader started auto-reconnection, reason: \(disconnectReason)") reconnectionCancelable = cancelable + // Clear connected readers so the UI shows reconnecting state instead of connected + connectedReadersSubject.send([]) let cardReader = CardReader(reader: reader) reconnectionStateSubject.send(.reconnecting(reader: cardReader)) } From 821b52eb9481072739db1db157037fc392eef1af Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:33:37 +0200 Subject: [PATCH 14/61] Treat cancel reconnection race condition as success When canceling reconnection, if the Stripe SDK returns error code cancelFailedAlreadyCompleted (1010), treat it as success rather than an error. This race condition occurs when reconnection completes naturally just before the cancel request is processed. Since the user's intent to stop reconnection was effectively achieved, there's no need to log an error. --- .../StripeCardReader/StripeCardReaderService.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift b/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift index b9c9e7c339c..ea287c48244 100644 --- a/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift +++ b/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift @@ -650,7 +650,12 @@ extension StripeCardReaderService: CardReaderService { self?.connectedReadersSubject.send([]) self?.reconnectionStateSubject.send(.idle) - if let error = error { + // Treat cancelFailedAlreadyCompleted as success - reconnection already completed, + // so the user's intent to stop reconnection was effectively achieved. + if let error = error as? ErrorCode, + error.code == .cancelFailedAlreadyCompleted { + promise(.success(())) + } else if let error = error { let underlyingError = Self.logAndDecodeError(error) promise(.failure(CardReaderServiceError.reconnectionCancellation(underlyingError: underlyingError))) } else { From d43f786593822f5f7b4e641c204e0f48eed59a7f Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:56:54 +0200 Subject: [PATCH 15/61] Refine reconnection cancel behavior & Combine updates Stripe: Adjust reconnection cancellation handler to avoid clearing connectedReaders when cancellation failed due to already-completed reconnection; only clear readers when cancellation actually succeeded or failed for other reasons, and ensure reconnectionState is set to .idle in the appropriate branches. Yosemite store: Replace .subscribe(Subscribers.Sink(...)) with .sink(...) and store the returned AnyCancellable in the cancellables set to retain the subscription. WooCommerce adaptor: Remove the preemptive await cancelReconnection() call from connectReader to avoid unnecessarily cancelling/interrupting concurrent reconnection logic before starting a manual connection. --- .../StripeCardReader/StripeCardReaderService.swift | 9 +++++++-- .../Yosemite/Stores/CardPresentPaymentStore.swift | 5 +++-- .../CardPresentPaymentService.swift | 3 --- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift b/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift index ea287c48244..cd0f56e95a6 100644 --- a/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift +++ b/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift @@ -647,18 +647,23 @@ extension StripeCardReaderService: CardReaderService { reconnectionCancelable.cancel { [weak self] error in self?.reconnectionCancelable = nil - self?.connectedReadersSubject.send([]) - self?.reconnectionStateSubject.send(.idle) // Treat cancelFailedAlreadyCompleted as success - reconnection already completed, // so the user's intent to stop reconnection was effectively achieved. + // In this case, don't clear connected readers as readerDidSucceedReconnect may have set them. if let error = error as? ErrorCode, error.code == .cancelFailedAlreadyCompleted { + self?.reconnectionStateSubject.send(.idle) promise(.success(())) } else if let error = error { + self?.connectedReadersSubject.send([]) + self?.reconnectionStateSubject.send(.idle) let underlyingError = Self.logAndDecodeError(error) promise(.failure(CardReaderServiceError.reconnectionCancellation(underlyingError: underlyingError))) } else { + // Cancellation succeeded - reconnection was stopped, clear connected readers + self?.connectedReadersSubject.send([]) + self?.reconnectionStateSubject.send(.idle) promise(.success(())) } } diff --git a/Modules/Sources/Yosemite/Stores/CardPresentPaymentStore.swift b/Modules/Sources/Yosemite/Stores/CardPresentPaymentStore.swift index 821b65e6499..389e597f804 100644 --- a/Modules/Sources/Yosemite/Stores/CardPresentPaymentStore.swift +++ b/Modules/Sources/Yosemite/Stores/CardPresentPaymentStore.swift @@ -437,7 +437,7 @@ private extension CardPresentPaymentStore { func cancelReconnection(onCompletion: @escaping (Result) -> Void) { cardReaderService.cancelReconnection() - .subscribe(Subscribers.Sink( + .sink( receiveCompletion: { result in switch result { case .failure(let error): @@ -449,7 +449,8 @@ private extension CardPresentPaymentStore { receiveValue: { onCompletion(.success(())) } - )) + ) + .store(in: &cancellables) } } diff --git a/WooCommerce/Classes/POS/Adaptors/Card Present Payments/CardPresentPaymentService.swift b/WooCommerce/Classes/POS/Adaptors/Card Present Payments/CardPresentPaymentService.swift index 98f0f3a58d0..2f088a3e069 100644 --- a/WooCommerce/Classes/POS/Adaptors/Card Present Payments/CardPresentPaymentService.swift +++ b/WooCommerce/Classes/POS/Adaptors/Card Present Payments/CardPresentPaymentService.swift @@ -96,9 +96,6 @@ final class CardPresentPaymentService: CardPresentPaymentFacade { @MainActor func connectReader(using connectionMethod: CardReaderConnectionMethod) async throws -> CardPresentPaymentReaderConnectionResult { - // Cancel any ongoing auto-reconnection attempt before starting a manual connection - await cancelReconnection() - // What happens if this gets called while there's another connection ongoing? let preflightControllerAdaptor = CardPresentPaymentPreflightAdaptor(preflightController: createPreflightController()) From c1a7548ca2c1b2966b9e599d7072ae7121411426 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:25:17 +0200 Subject: [PATCH 16/61] Restore comment explaining Tap to Pay reader handling --- .../BluetoothCardReaderSettingsConnectedViewModel.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothCardReaderSettingsConnectedViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothCardReaderSettingsConnectedViewModel.swift index 21a6aaf9d5a..19aa8ec6582 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothCardReaderSettingsConnectedViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothCardReaderSettingsConnectedViewModel.swift @@ -287,6 +287,8 @@ final class BluetoothCardReaderSettingsConnectedViewModel: PaymentSettingsFlowPr } else if connectedReaders.isEmpty && !readerReconnectionInProgress { newShouldShow = .isFalse } else if connectedReaders.includesTapToPayReader() { + /// This screen only supports management of Bluetooth readers, and will have started disconnection + /// from Tap to Pay on iPhone in this instance. newShouldShow = .isFalse } else { newShouldShow = .isTrue From e6243a11763ea4e1445161b4c8046f0e8ba569c0 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Fri, 30 Jan 2026 17:39:56 +0200 Subject: [PATCH 17/61] Add reconnection state UI to card reader settings - Show "Reconnecting to card reader..." status in connected view - Add "Cancel Reconnection" button during reconnection - Preserve reader info (name, battery, firmware) during reconnection - Prevent searching view from showing during reconnection - Refactor button states using enums for cleaner code --- ...CardReaderSettingsConnectedViewModel.swift | 26 ++- ...eaderSettingsConnectedViewController.swift | 153 +++++++++++++++--- ...CardReaderSettingsSearchingViewModel.swift | 31 +++- 3 files changed, 184 insertions(+), 26 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothCardReaderSettingsConnectedViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothCardReaderSettingsConnectedViewModel.swift index 19aa8ec6582..2e13b126d1a 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothCardReaderSettingsConnectedViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothCardReaderSettingsConnectedViewModel.swift @@ -28,6 +28,7 @@ final class BluetoothCardReaderSettingsConnectedViewModel: PaymentSettingsFlowPr private(set) var readerDisconnectInProgress: Bool = false private(set) var readerReconnectionInProgress: Bool = false + private var reconnectingReader: CardReader? private var subscriptions = Set() @@ -35,7 +36,11 @@ final class BluetoothCardReaderSettingsConnectedViewModel: PaymentSettingsFlowPr var connectedReaderBatteryLevel: String? var connectedReaderSoftwareVersion: String? var connectedReaderModel: String? { - connectedReaders.first?.readerType.model + currentReader?.readerType.model + } + + private var currentReader: CardReader? { + connectedReaders.first ?? reconnectingReader } /// The connected gateway ID (plugin slug) - useful for the view controller's tracks events @@ -154,13 +159,15 @@ final class BluetoothCardReaderSettingsConnectedViewModel: PaymentSettingsFlowPr guard let self = self else { return } switch state { - case .reconnecting: + case .reconnecting(let reader): self.readerReconnectionInProgress = true + self.reconnectingReader = reader case .succeeded, .failed, .idle: self.readerReconnectionInProgress = false + self.reconnectingReader = nil } + self.updateProperties() self.reevaluateShouldShow() - self.didUpdate?() } .store(in: &self.subscriptions) } @@ -190,11 +197,11 @@ final class BluetoothCardReaderSettingsConnectedViewModel: PaymentSettingsFlowPr } private func updateReaderID() { - connectedReaderID = connectedReaders.first?.id + connectedReaderID = currentReader?.id } private func updateBatteryLevel() { - guard let batteryLevel = connectedReaders.first?.batteryLevel else { + guard let batteryLevel = currentReader?.batteryLevel else { connectedReaderBatteryLevel = Localization.unknownBatteryStatus return } @@ -205,7 +212,7 @@ final class BluetoothCardReaderSettingsConnectedViewModel: PaymentSettingsFlowPr } private func updateSoftwareVersion() { - guard let softwareVersion = connectedReaders.first?.softwareVersion else { + guard let softwareVersion = currentReader?.softwareVersion else { connectedReaderSoftwareVersion = Localization.unknownSoftwareVersion return } @@ -254,6 +261,13 @@ final class BluetoothCardReaderSettingsConnectedViewModel: PaymentSettingsFlowPr didUpdate?() } + /// Dispatch a request to cancel an in-progress reconnection attempt + /// + func cancelReconnection() { + let action = CardPresentPaymentAction.cancelReconnection { _ in } + ServiceLocator.stores.dispatch(action) + } + /// Dispatch a request to disconnect from a reader /// func disconnectReader() { diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsConnectedViewController.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsConnectedViewController.swift index 396af5ce928..e942bdba43b 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsConnectedViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsConnectedViewController.swift @@ -169,15 +169,10 @@ private extension CardReaderSettingsConnectedViewController { } private func configureUpdatePrompt(cell: LeftImageTableViewCell) { - if viewModel.optionalReaderUpdateAvailable { - cell.configure(image: .infoOutlineImage, text: Localization.updatePromptText) - cell.backgroundColor = .warningBackground - cell.imageView?.tintColor = .warning - } else { - cell.configure(image: .infoOutlineImage, text: Localization.updateNotNeeded) - cell.backgroundColor = .none - cell.imageView?.tintColor = .info - } + let state = UpdatePromptState(viewModel: viewModel) + cell.configure(image: .infoOutlineImage, text: state.text) + cell.backgroundColor = state.backgroundColor + cell.imageView?.tintColor = state.tintColor cell.selectionStyle = .none cell.textLabel?.numberOfLines = 0 cell.textLabel?.textColor = .text @@ -213,17 +208,12 @@ private extension CardReaderSettingsConnectedViewController { } private func configureDisconnectButton(cell: ButtonTableViewCell) { - let style: ButtonTableViewCell.Style = viewModel.optionalReaderUpdateAvailable ? .secondary : .primary - cell.configure(style: style, title: Localization.disconnectButtonTitle) { [weak self] in - self?.viewModel.disconnectReader() + let state = DisconnectButtonState(viewModel: viewModel) + cell.configure(style: state.style, title: state.title) { [weak self, state] in + state.action(self?.viewModel) } - - let readerDisconnectInProgress = viewModel.readerDisconnectInProgress - let readerUpdateInProgress = viewModel.readerUpdateInProgress - let readerReconnectionInProgress = viewModel.readerReconnectionInProgress - cell.enableButton(!readerDisconnectInProgress && !readerUpdateInProgress && !readerReconnectionInProgress) - cell.showActivityIndicator(readerDisconnectInProgress || readerReconnectionInProgress) - + cell.enableButton(state.isEnabled) + cell.showActivityIndicator(state.showActivityIndicator) cell.selectionStyle = .none cell.backgroundColor = .clear } @@ -312,6 +302,119 @@ private enum Row: CaseIterable { } } +private enum UpdatePromptState { + case reconnecting + case updateAvailable + case upToDate + + init(viewModel: BluetoothCardReaderSettingsConnectedViewModel) { + switch (viewModel.readerReconnectionInProgress, viewModel.optionalReaderUpdateAvailable) { + case (true, _): + self = .reconnecting + case (false, true): + self = .updateAvailable + case (false, false): + self = .upToDate + } + } + + var text: String { + switch self { + case .reconnecting: + return Localization.reconnectingText + case .updateAvailable: + return Localization.updatePromptText + case .upToDate: + return Localization.updateNotNeeded + } + } + + var backgroundColor: UIColor? { + switch self { + case .reconnecting, .updateAvailable: + return .warningBackground + case .upToDate: + return .none + } + } + + var tintColor: UIColor { + switch self { + case .reconnecting, .updateAvailable: + return .warning + case .upToDate: + return .info + } + } +} + +private enum DisconnectButtonState { + case reconnecting + case disconnecting + case updating + case idle(updateAvailable: Bool) + + init(viewModel: BluetoothCardReaderSettingsConnectedViewModel) { + switch (viewModel.readerReconnectionInProgress, viewModel.readerDisconnectInProgress, viewModel.readerUpdateInProgress) { + case (true, _, _): + self = .reconnecting + case (false, true, _): + self = .disconnecting + case (false, false, true): + self = .updating + case (false, false, false): + self = .idle(updateAvailable: viewModel.optionalReaderUpdateAvailable) + } + } + + var title: String { + switch self { + case .reconnecting: + return Localization.cancelReconnectionButtonTitle + case .disconnecting, .updating, .idle: + return Localization.disconnectButtonTitle + } + } + + var style: ButtonTableViewCell.Style { + switch self { + case .reconnecting, .disconnecting, .updating: + return .primary + case .idle(let updateAvailable): + return updateAvailable ? .secondary : .primary + } + } + + var isEnabled: Bool { + switch self { + case .reconnecting, .idle: + return true + case .disconnecting, .updating: + return false + } + } + + var showActivityIndicator: Bool { + switch self { + case .disconnecting: + return true + case .reconnecting, .updating, .idle: + return false + } + } + + func action(_ viewModel: BluetoothCardReaderSettingsConnectedViewModel?) { + switch self { + case .reconnecting: + viewModel?.cancelReconnection() + case .disconnecting, .updating, .idle: + viewModel?.disconnectReader() + } + } +} + +private typealias Localization = CardReaderSettingsConnectedViewController.Localization + // MARK: - Localization // private extension CardReaderSettingsConnectedViewController { @@ -346,5 +449,17 @@ private extension CardReaderSettingsConnectedViewController { comment: "Settings > Manage Card Reader > Connected Reader > A button to disconnect the reader" ) + static let reconnectingText = NSLocalizedString( + "cardReaderSettingsConnectedViewController.reconnectingText", + value: "Reconnecting to card reader...", + comment: "Settings > Manage Card Reader > Connected Reader > Status message when reader is reconnecting" + ) + + static let cancelReconnectionButtonTitle = NSLocalizedString( + "cardReaderSettingsConnectedViewController.cancelReconnectionButtonTitle", + value: "Cancel Reconnection", + comment: "Settings > Manage Card Reader > Connected Reader > A button to cancel the reconnection attempt" + ) + } } diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsSearchingViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsSearchingViewModel.swift index b9bc61ba470..ee07f4da532 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsSearchingViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsSearchingViewModel.swift @@ -14,6 +14,8 @@ final class CardReaderSettingsSearchingViewModel: PaymentSettingsFlowPresentedVi } } + private(set) var readerReconnectionInProgress: Bool = false + private(set) var knownReaderProvider: CardReaderSettingsKnownReaderProvider? private(set) var siteID: Int64 @@ -47,6 +49,7 @@ final class CardReaderSettingsSearchingViewModel: PaymentSettingsFlowPresentedVi beginKnownReaderObservation() beginConnectedReaderObservation() + beginReconnectionObservation() updateLearnMoreUrl(stores: stores) } @@ -89,11 +92,37 @@ final class CardReaderSettingsSearchingViewModel: PaymentSettingsFlowPresentedVi ServiceLocator.stores.dispatch(connectedAction) } + /// Set up to observe reader reconnection state + /// + private func beginReconnectionObservation() { + let reconnectionAction = CardPresentPaymentAction.observeCardReaderReconnectionState { reconnectionEvents in + reconnectionEvents + .sink { [weak self] state in + guard let self = self else { return } + + switch state { + case .reconnecting: + self.readerReconnectionInProgress = true + case .succeeded, .failed, .idle: + self.readerReconnectionInProgress = false + } + self.reevaluateShouldShow() + } + .store(in: &self.subscriptions) + } + ServiceLocator.stores.dispatch(reconnectionAction) + } + /// Updates whether the view this viewModel is associated with should be shown or not /// Notifies the viewModel owner if a change occurs via didChangeShouldShow /// private func reevaluateShouldShow() { - let newShouldShow: CardReaderSettingsTriState = noConnectedReader + let newShouldShow: CardReaderSettingsTriState + if readerReconnectionInProgress { + newShouldShow = .isFalse + } else { + newShouldShow = noConnectedReader + } let didChange = newShouldShow != shouldShow From ee5b9669ec998f3bf4963edb4bc1b6c5874db314 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Fri, 30 Jan 2026 18:11:29 +0200 Subject: [PATCH 18/61] Add reconnection state UI to POS Settings - Show reader info during reconnection via PointOfSaleSettingsController - Add reconnecting menu button with cancel option - Disable firmware update during reconnection - Add test for reconnecting state providing reader info - Update mock to support connection status directly --- .../PointOfSaleSettingsController.swift | 2 +- .../POSSettingsHardwareDetailView.swift | 106 ++++++++++++++---- .../POSSettingsControllerTests.swift | 22 ++++ .../Mocks/MockCardPresentPaymentService.swift | 26 +++-- 4 files changed, 124 insertions(+), 32 deletions(-) diff --git a/Modules/Sources/PointOfSale/Models/PointOfSaleSettingsController.swift b/Modules/Sources/PointOfSale/Models/PointOfSaleSettingsController.swift index 5dbb90c1951..2d6278b10d6 100644 --- a/Modules/Sources/PointOfSale/Models/PointOfSaleSettingsController.swift +++ b/Modules/Sources/PointOfSale/Models/PointOfSaleSettingsController.swift @@ -61,7 +61,7 @@ protocol POSSettingsControllerProtocol { guard let self else { return } let cardReader: CardPresentPaymentCardReader? switch connectionStatus { - case .connected(let reader): + case .connected(let reader), .reconnecting(let reader): cardReader = reader default: cardReader = nil diff --git a/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsHardwareDetailView.swift b/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsHardwareDetailView.swift index 5e5d882db27..d47256f9a08 100644 --- a/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsHardwareDetailView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsHardwareDetailView.swift @@ -15,31 +15,29 @@ struct POSSettingsHardwareDetailView: View { @State private var showSupport: Bool = false private var cardReaderName: String { - if let cardReaderName = settingsController.connectedCardReader?.name { - return cardReaderName - } else { - return Localization.cardReaderNotConnected - } + settingsController.connectedCardReader?.name ?? Localization.cardReaderNotConnected } private var formattedBatteryLevel: String { - if let batteryLevel = settingsController.connectedCardReader?.batteryLevel { - return String(format: Localization.batteryLevelFormat, 100 * batteryLevel) - } else { + guard let batteryLevel = settingsController.connectedCardReader?.batteryLevel else { return Localization.batteryLevelUnknown } + return String(format: Localization.batteryLevelFormat, 100 * batteryLevel) } private var formattedCardReaderFirmware: String { - if let softwareVersion = settingsController.connectedCardReader?.softwareVersion { - return softwareVersion - } else { - return Localization.firmwareVersionUnknown + settingsController.connectedCardReader?.softwareVersion ?? Localization.firmwareVersionUnknown + } + + private var isReconnecting: Bool { + if case .reconnecting = posModel.cardReaderConnectionStatus { + return true } + return false } private var shouldShowUpdateFirmwareButton: Bool { - posModel.isCardReaderUpdateAvailable + posModel.isCardReaderUpdateAvailable && !isReconnecting } private var backgroundColor: Color { @@ -145,6 +143,60 @@ private extension POSSettingsHardwareDetailView { } } + @ViewBuilder + var cardReaderNameRow: some View { + VStack(alignment: .leading, spacing: POSPadding.small) { + HStack(alignment: .center, spacing: POSSpacing.medium) { + VStack(alignment: .leading, spacing: POSPadding.small) { + Text(Localization.readerModelTitle) + .font(.posBodyMediumRegular()) + Text(cardReaderName) + .font(.posBodyMediumRegular()) + .foregroundStyle(.secondary) + } + + Spacer() + + if isReconnecting { + reconnectingMenuButton + } else { + Button(Localization.cardReaderDisconnectTitle) { + posModel.disconnectCardReader() + } + .buttonStyle(POSInfoCardButtonStyle(size: .compact, variant: .default, isLoading: false)) + } + } + + Divider() + .padding(.top, POSPadding.small) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + var reconnectingMenuButton: some View { + Menu { + Button(Localization.cancelReconnectionTitle) { + posModel.cancelReconnection() + } + } label: { + HStack(spacing: POSSpacing.small) { + ProgressView() + .progressViewStyle(POSProgressViewStyle(size: 16, lineWidth: 4)) + Text(Localization.reconnectingButtonTitle) + } + .font(.posBodySmallBold()) + .foregroundColor(.posOnSurface) + .padding(.vertical, POSPadding.small) + .padding(.horizontal, POSPadding.medium) + .background(Color.posSurfaceContainerLowest) + .cornerRadius(POSCornerRadiusStyle.small.value) + .overlay( + RoundedRectangle(cornerRadius: POSCornerRadiusStyle.small.value) + .strokeBorder(Color.posOnSurface, lineWidth: 2) + ) + } + } + var cardReadersView: some View { VStack(spacing: POSSpacing.none) { POSPageHeaderView( @@ -156,19 +208,15 @@ private extension POSSettingsHardwareDetailView { ScrollView { VStack(spacing: POSSpacing.small) { - if case .connected = posModel.cardReaderConnectionStatus { + switch posModel.cardReaderConnectionStatus { + case .connected, .reconnecting: if shouldShowUpdateFirmwareButton { POSSettingsCardWithIcon(title: Localization.updateFirmwareBannerTitle, subtitle: Localization.updateFirmwareBannerSubtitle) } POSInformationCard { VStack(spacing: POSSpacing.small) { - POSInformationCardFieldRow(label: Localization.readerModelTitle, - value: cardReaderName, - buttonTitle: Localization.cardReaderDisconnectTitle, - buttonAction: { - posModel.disconnectCardReader() - }) + cardReaderNameRow POSInformationCardFieldRow(label: Localization.readerBatteryTitle, value: formattedBatteryLevel) @@ -183,10 +231,10 @@ private extension POSSettingsHardwareDetailView { buttonStyle: .primary) } } - } else { + default: POSSettingsCard(title: Localization.cardReaderConnectTitle, - subtitle: Localization.cardReaderConnectSubtitle, - action: { + subtitle: Localization.cardReaderConnectSubtitle, + action: { posModel.connectCardReader() }) } @@ -471,6 +519,18 @@ private extension POSSettingsHardwareDetailView { value: "Cancel", comment: "Button to dismiss the support form from POS settings." ) + + static let reconnectingButtonTitle = NSLocalizedString( + "pointOfSaleSettingsHardwareDetailView.reconnectingButtonTitle", + value: "Reconnecting…", + comment: "Button title shown when card reader is reconnecting in POS settings." + ) + + static let cancelReconnectionTitle = NSLocalizedString( + "pointOfSaleSettingsHardwareDetailView.cancelReconnectionTitle", + value: "Cancel reconnection", + comment: "Menu option to cancel card reader reconnection in POS settings." + ) } } diff --git a/Modules/Tests/PointOfSaleTests/Controllers/POSSettingsControllerTests.swift b/Modules/Tests/PointOfSaleTests/Controllers/POSSettingsControllerTests.swift index 1de2f9187c6..50472f6f127 100644 --- a/Modules/Tests/PointOfSaleTests/Controllers/POSSettingsControllerTests.swift +++ b/Modules/Tests/PointOfSaleTests/Controllers/POSSettingsControllerTests.swift @@ -55,6 +55,28 @@ struct POSSettingsControllerTests { #expect(sut.connectedCardReader?.batteryLevel == 0.75) } + @Test func cardReader_reconnecting_provides_reader_info() async throws { + // Given + let mockService = MockCardPresentPaymentService() + let sut = PointOfSaleSettingsController(siteID: sampleSiteID, + settingsService: mockSettingsService, + cardPresentPaymentService: mockService, + pluginsService: mockPluginService, + defaultSiteName: "Test Store", + siteSettings: [], + grdbManager: nil, + catalogSyncCoordinator: nil, + isLocalCatalogEligible: true) + + // When + let cardReader = CardPresentPaymentCardReader(name: "WisePad 3", batteryLevel: 0.75) + mockService.connectionStatus = .reconnecting(cardReader) + + // Then + #expect(sut.connectedCardReader?.name == "WisePad 3") + #expect(sut.connectedCardReader?.batteryLevel == 0.75) + } + } diff --git a/Modules/Tests/PointOfSaleTests/Mocks/MockCardPresentPaymentService.swift b/Modules/Tests/PointOfSaleTests/Mocks/MockCardPresentPaymentService.swift index 58c5b0caa98..4cc864089b3 100644 --- a/Modules/Tests/PointOfSaleTests/Mocks/MockCardPresentPaymentService.swift +++ b/Modules/Tests/PointOfSaleTests/Mocks/MockCardPresentPaymentService.swift @@ -9,7 +9,23 @@ final class MockCardPresentPaymentService: CardPresentPaymentFacade { // MARK: - Variables for emitting events in unit tests @Published var paymentEvent: CardPresentPaymentEvent = .idle - @Published var connectedReader: CardPresentPaymentCardReader? + @Published var connectionStatus: CardPresentPaymentReaderConnectionStatus = .disconnected + + var connectedReader: CardPresentPaymentCardReader? { + get { + if case .connected(let reader) = connectionStatus { + return reader + } + return nil + } + set { + if let reader = newValue { + connectionStatus = .connected(reader) + } else { + connectionStatus = .disconnected + } + } + } var cancelPaymentCalled = false @@ -20,13 +36,7 @@ final class MockCardPresentPaymentService: CardPresentPaymentFacade { } var readerConnectionStatusPublisher: AnyPublisher { - $connectedReader.map { reader -> CardPresentPaymentReaderConnectionStatus in - guard let reader else { - return .disconnected - } - return .connected(reader) - } - .eraseToAnyPublisher() + $connectionStatus.eraseToAnyPublisher() } func connectReader(using connectionMethod: CardReaderConnectionMethod) async throws -> CardPresentPaymentReaderConnectionResult { From 0187e278cfcf2423cf00e86f2cbfba3b87012f6e Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:12:54 +0200 Subject: [PATCH 19/61] Add reconnection state UI to TotalsView checkout screen Show "Reconnecting reader..." message when reader is reconnecting during checkout. This prevents the confusing "Scanning for readers" popup by not auto-starting payment collection during reconnection. --- ...tPaymentReconnectingMessageViewModel.swift | 22 + ...resentPaymentReconnectingMessageView.swift | 67 +++ .../PointOfSale/Presentation/TotalsView.swift | 38 +- .../ViewHelpers/TotalsViewHelper.swift | 31 ++ .../POSOrderListControllerTests.swift | 399 +++++++----------- .../Mocks/MockPOSRefundsService.swift | 11 + .../ViewHelpers/TotalsViewHelperTests.swift | 70 +++ 7 files changed, 381 insertions(+), 257 deletions(-) create mode 100644 Modules/Sources/PointOfSale/Presentation/Card Present Payments/Reader Messages/PointOfSaleCardPresentPaymentReconnectingMessageViewModel.swift create mode 100644 Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSaleCardPresentPaymentReconnectingMessageView.swift diff --git a/Modules/Sources/PointOfSale/Presentation/Card Present Payments/Reader Messages/PointOfSaleCardPresentPaymentReconnectingMessageViewModel.swift b/Modules/Sources/PointOfSale/Presentation/Card Present Payments/Reader Messages/PointOfSaleCardPresentPaymentReconnectingMessageViewModel.swift new file mode 100644 index 00000000000..4b0ad638d24 --- /dev/null +++ b/Modules/Sources/PointOfSale/Presentation/Card Present Payments/Reader Messages/PointOfSaleCardPresentPaymentReconnectingMessageViewModel.swift @@ -0,0 +1,22 @@ +import Foundation + +struct PointOfSaleCardPresentPaymentReconnectingMessageViewModel { + let title = Localization.title + let cancelReconnectionButtonTitle = Localization.cancelReconnection +} + +private extension PointOfSaleCardPresentPaymentReconnectingMessageViewModel { + enum Localization { + static let title = NSLocalizedString( + "pointOfSale.cardPresent.reconnecting.title", + value: "Reconnecting reader...", + comment: "Title shown on the Point of Sale Checkout when the card reader is reconnecting" + ) + + static let cancelReconnection = NSLocalizedString( + "pointOfSale.cardPresent.cancelReconnection.button.title", + value: "Cancel reconnection", + comment: "Button to cancel card reader reconnection, shown on the Point of Sale Checkout" + ) + } +} diff --git a/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSaleCardPresentPaymentReconnectingMessageView.swift b/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSaleCardPresentPaymentReconnectingMessageView.swift new file mode 100644 index 00000000000..b7664d1c95e --- /dev/null +++ b/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSaleCardPresentPaymentReconnectingMessageView.swift @@ -0,0 +1,67 @@ +import SwiftUI + +struct PointOfSaleCardPresentPaymentReconnectingMessageView: View { + private let viewModel = PointOfSaleCardPresentPaymentReconnectingMessageViewModel() + private let cancelReconnection: () -> Void + @ScaledMetric private var scale: CGFloat = 1.0 + + @State private var width: CGFloat = 0 + + init(cancelReconnection: @escaping () -> Void) { + self.cancelReconnection = cancelReconnection + } + + var body: some View { + VStack(alignment: .center, spacing: POSSpacing.none) { + ProgressView() + .progressViewStyle(POSProgressViewStyle()) + .frame(width: Constants.spinnerDimension, height: Constants.spinnerDimension) + + Spacer() + .frame(height: dynamicSpacing(PointOfSaleCardPresentPaymentLayout.imageAndTextSpacing)) + + Text(viewModel.title) + .font(.posHeadingBold) + .foregroundStyle(Color.posOnSurface) + .accessibilityAddTraits(.isHeader) + .padding(.horizontal, PointOfSaleCardPresentPaymentLayout.horizontalPadding) + + Spacer() + .frame(height: dynamicSpacing(PointOfSaleCardPresentPaymentLayout.textAndButtonSpacing)) + + Button { + cancelReconnection() + } label: { + Text(viewModel.cancelReconnectionButtonTitle) + .minimumScaleFactor(1) + } + .buttonStyle(POSFilledButtonStyle(size: .normal)) + .frame(width: width * 0.5) + } + .frame(maxWidth: .infinity) + .measureWidth({ containerWidth in + width = containerWidth + }) + .multilineTextAlignment(.center) + } + + private func dynamicSpacing(_ spacing: CGFloat) -> CGFloat { + guard scale > 1 else { + return spacing + } + + return spacing * (1 / scale) + } +} + +private extension PointOfSaleCardPresentPaymentReconnectingMessageView { + enum Constants { + static let spinnerDimension: CGFloat = 160 + } +} + +#if DEBUG +#Preview { + PointOfSaleCardPresentPaymentReconnectingMessageView(cancelReconnection: {}) +} +#endif diff --git a/Modules/Sources/PointOfSale/Presentation/TotalsView.swift b/Modules/Sources/PointOfSale/Presentation/TotalsView.swift index 09ca5b9a674..a9dbd1f91e9 100644 --- a/Modules/Sources/PointOfSale/Presentation/TotalsView.swift +++ b/Modules/Sources/PointOfSale/Presentation/TotalsView.swift @@ -37,7 +37,8 @@ struct TotalsView: View { orderState: posModel.orderState, cardReaderConnectionStatus: posModel.cardReaderConnectionStatus, cardPresentPaymentInlineMessage: posModel.cardPresentPaymentInlineMessage, - connectCardReaderAction: posModel.connectCardReader + connectCardReaderAction: posModel.connectCardReader, + cancelReconnectionAction: posModel.cancelReconnection ) } @@ -148,13 +149,13 @@ private extension TotalsView { private var isShowingPaymentView: Bool { guard posModel.orderState.isLoaded else { // When the order's being created or synced, we only show the shimmering totals. - // Before the order exists, we don’t want to show the card payment status, as it will + // Before the order exists, we don't want to show the card payment status, as it will // show for a second initially, then disappear the moment we start syncing the order. return false } switch posModel.cardReaderConnectionStatus { - case .connected, .disconnecting, .cancellingConnection, .reconnecting: + case .connected, .disconnecting, .cancellingConnection: // Show card payment UI if there's a message, or cash payment UI when not idle switch posModel.paymentState.activePaymentMethod { case .cash: @@ -162,6 +163,16 @@ private extension TotalsView { case .card: return posModel.cardPresentPaymentInlineMessage != nil } + case .reconnecting: + switch posModel.paymentState.activePaymentMethod { + case .cash: + return true + case .card: + let viewHelper = TotalsViewHelper() + return posModel.cardPresentPaymentInlineMessage != nil || + viewHelper.shouldShowReconnectingMessage(readerConnectionStatus: posModel.cardReaderConnectionStatus, + paymentState: posModel.paymentState) + } case .disconnected: // Since the reader is disconnected, this will show the "Connect your reader" CTA button view. return true @@ -197,8 +208,13 @@ private extension TotalsView { .validatingOrder, .preparingReader, .processingPayment: - if TotalsViewHelper().shouldShowDisconnectedMessage(readerConnectionStatus: posModel.cardReaderConnectionStatus, - paymentState: posModel.paymentState) { + let viewHelper = TotalsViewHelper() + if viewHelper.shouldShowReconnectingMessage(readerConnectionStatus: posModel.cardReaderConnectionStatus, + paymentState: posModel.paymentState) { + return .primary + } + if viewHelper.shouldShowDisconnectedMessage(readerConnectionStatus: posModel.cardReaderConnectionStatus, + paymentState: posModel.paymentState) { return .outlined } } @@ -440,6 +456,7 @@ private struct PaymentViewContent: View { let cardReaderConnectionStatus: CardPresentPaymentReaderConnectionStatus let cardPresentPaymentInlineMessage: PointOfSaleCardPresentPaymentMessageType? let connectCardReaderAction: () -> Void + let cancelReconnectionAction: () -> Void private let viewHelper = TotalsViewHelper() @@ -469,7 +486,8 @@ private struct PaymentViewContent: View { cardReaderConnectionStatus: cardReaderConnectionStatus, paymentState: paymentState, cardPresentPaymentInlineMessage: cardPresentPaymentInlineMessage, - connectCardReaderAction: connectCardReaderAction + connectCardReaderAction: connectCardReaderAction, + cancelReconnectionAction: cancelReconnectionAction ) } } @@ -505,12 +523,18 @@ private struct CardPaymentView: View { let paymentState: PointOfSalePaymentState let cardPresentPaymentInlineMessage: PointOfSaleCardPresentPaymentMessageType? let connectCardReaderAction: () -> Void + let cancelReconnectionAction: () -> Void private let viewHelper = TotalsViewHelper() var body: some View { - if viewHelper.shouldShowDisconnectedMessage(readerConnectionStatus: cardReaderConnectionStatus, + if viewHelper.shouldShowReconnectingMessage(readerConnectionStatus: cardReaderConnectionStatus, paymentState: paymentState) { + PointOfSaleCardPresentPaymentReconnectingMessageView { + cancelReconnectionAction() + } + } else if viewHelper.shouldShowDisconnectedMessage(readerConnectionStatus: cardReaderConnectionStatus, + paymentState: paymentState) { PointOfSaleCardPresentPaymentReaderDisconnectedMessageView { connectCardReaderAction() } diff --git a/Modules/Sources/PointOfSale/ViewHelpers/TotalsViewHelper.swift b/Modules/Sources/PointOfSale/ViewHelpers/TotalsViewHelper.swift index eebc39ed3e2..f09512bb5b0 100644 --- a/Modules/Sources/PointOfSale/ViewHelpers/TotalsViewHelper.swift +++ b/Modules/Sources/PointOfSale/ViewHelpers/TotalsViewHelper.swift @@ -51,6 +51,33 @@ struct TotalsViewHelper { } } + func shouldShowReconnectingMessage(readerConnectionStatus: CardPresentPaymentReaderConnectionStatus, + paymentState: PointOfSalePaymentState) -> Bool { + guard case .reconnecting = readerConnectionStatus else { + return false + } + + switch paymentState.activePaymentMethod { + case .cash: + return false + case .card: + switch paymentState.card { + case .idle, + .acceptingCard, + .preparingReader: + return true + case .validatingOrder, + .validatingOrderError, + .paymentIntentCreationError, + .processingPayment, + .cardInserted, + .paymentError, + .cardPaymentSuccessful: + return false + } + } + } + func shouldShowCollectCashPaymentButton(orderState: PointOfSaleOrderState, paymentState: PointOfSalePaymentState, cardReaderConnectionStatus: CardPresentPaymentReaderConnectionStatus) -> Bool { @@ -58,6 +85,10 @@ struct TotalsViewHelper { return false } + if case .reconnecting = cardReaderConnectionStatus { + return false + } + switch paymentState.activePaymentMethod { case .cash: return false diff --git a/Modules/Tests/PointOfSaleTests/Controllers/POSOrderListControllerTests.swift b/Modules/Tests/PointOfSaleTests/Controllers/POSOrderListControllerTests.swift index b6e60abb158..bb00ca9e336 100644 --- a/Modules/Tests/PointOfSaleTests/Controllers/POSOrderListControllerTests.swift +++ b/Modules/Tests/PointOfSaleTests/Controllers/POSOrderListControllerTests.swift @@ -4,7 +4,6 @@ import Foundation @testable import PointOfSale import enum Yosemite.POSOrderListServiceError import struct NetworkingCore.Order -import Observation import struct Yosemite.POSOrder import struct Yosemite.POSOrderItem import typealias Yosemite.OrderItemAttribute @@ -382,6 +381,7 @@ final class POSOrderListControllerTests { #expect(sut.selectedOrder?.customerEmail == "selected-updated@example.com") } + @MainActor @Test func selectOrder_then_calls_refunds_service_for_selected_order() async throws { // Given featureFlags.isPointOfSaleRefundsi1Enabled = true @@ -390,33 +390,32 @@ final class POSOrderListControllerTests { async let calledOrder = refundsService.awaitProvidePointOfSaleRefundsCall() // When - await sut.selectOrder(order) + sut.selectOrder(order) // Then #expect(await calledOrder == order) } + @MainActor @Test func selectOrder_then_updates_refunds_state_with_loading() async throws { // Given featureFlags.isPointOfSaleRefundsi1Enabled = true let order = MockPOSOrderListService.makeInitialOrders()[0] // When - await sut.selectOrder(order) + sut.selectOrder(order) // Then - let didBecomeLoading = await waitForCondition { [weak self] in - guard let sut = self?.sut else { return false } - if case .loading = sut.selectedOrderRefundsState { return true } - return false - } - #expect( - didBecomeLoading, + { + if case .loading = sut.selectedOrderRefundsState { return true } + return false + }(), "Expected selectedOrderRefundsState to become .loading before timeout" ) } + @MainActor @Test func selectOrder_when_provides_refunds_then_updates_refunds_state_with_results() async throws { // Given featureFlags.isPointOfSaleRefundsi1Enabled = true @@ -426,17 +425,14 @@ final class POSOrderListControllerTests { refundsService.providePointOfSaleRefundsResultToReturn = expectedResult // When - await sut.selectOrder(order) + await selectOrderAwaitRefundsCompletion(order) // Then - let didLoad = await waitForCondition { [weak self] in - guard let sut = self?.sut else { return false } - if case .loaded = sut.selectedOrderRefundsState { return true } - return false - } - #expect( - didLoad, + { + if case .loaded = sut.selectedOrderRefundsState { return true } + return false + }(), "Expected selectedOrderRefundsState to become .loaded before timeout" ) @@ -448,6 +444,7 @@ final class POSOrderListControllerTests { #expect(loadedResult.isFullyRefunded == expectedResult.isFullyRefunded) } + @MainActor @Test func selectOrder_when_refunds_service_errors_then_failed_contains_same_error_type() async throws { // Given featureFlags.isPointOfSaleRefundsi1Enabled = true @@ -457,17 +454,14 @@ final class POSOrderListControllerTests { refundsService.errorToThrow = TestError() // When - await sut.selectOrder(order) + await selectOrderAwaitRefundsCompletion(order) // Then - let didFail = await waitForCondition { [weak self] in - guard let sut = self?.sut else { return false } - if case .failed = sut.selectedOrderRefundsState { return true } - return false - } - #expect( - didFail, + { + if case .failed = sut.selectedOrderRefundsState { return true } + return false + }(), "Expected selectedOrderRefundsState to become .failed before timeout" ) @@ -479,28 +473,31 @@ final class POSOrderListControllerTests { #expect(error is TestError) } + @MainActor @Test func refundActionAvailability_when_feature_flag_disabled_then_unavailable() async throws { // Given featureFlags.isPointOfSaleRefundsi1Enabled = false let order = MockPOSOrderListService.makeInitialOrders()[0] // When - await sut.selectOrder(order) + sut.selectOrder(order) // Then - let availability = await MainActor.run { sut.refundActionAvailability } + let availability = sut.refundActionAvailability #expect(availability == .unavailable) } + @MainActor @Test func refundActionAvailability_when_no_selected_order_then_unavailable() async throws { // Given featureFlags.isPointOfSaleRefundsi1Enabled = true // When / Then - let availability = await MainActor.run { sut.refundActionAvailability } + let availability = sut.refundActionAvailability #expect(availability == .unavailable) } + @MainActor @Test func refundActionAvailability_when_refunds_loading_then_unknown() async throws { // Given featureFlags.isPointOfSaleRefundsi1Enabled = true @@ -508,15 +505,14 @@ final class POSOrderListControllerTests { refundsService.shouldSuspendProvidePointOfSaleRefunds = true // When - await MainActor.run { - sut.selectOrder(order) - #expect(sut.refundActionAvailability == .unknown) - } + sut.selectOrder(order) + #expect(sut.refundActionAvailability == .unknown) // Then refundsService.resumeProvidePointOfSaleRefunds() } + @MainActor @Test func refundActionAvailability_when_refunds_failed_then_unavailable() async throws { // Given featureFlags.isPointOfSaleRefundsi1Enabled = true @@ -526,20 +522,14 @@ final class POSOrderListControllerTests { refundsService.errorToThrow = TestError() // When - await sut.selectOrder(order) + await selectOrderAwaitRefundsCompletion(order) // Then - let didFail = await waitForCondition { [weak self] in - guard let sut = self?.sut else { return false } - if case .failed = sut.selectedOrderRefundsState { return true } - return false - } - #expect(didFail) - - let availability = await MainActor.run { sut.refundActionAvailability } + let availability = sut.refundActionAvailability #expect(availability == .unavailable) } + @MainActor @Test func refundActionAvailability_when_refunds_loaded_and_not_fully_refunded_then_available() async throws { // Given featureFlags.isPointOfSaleRefundsi1Enabled = true @@ -554,20 +544,14 @@ final class POSOrderListControllerTests { ) // When - await sut.selectOrder(order) + await selectOrderAwaitRefundsCompletion(order) // Then - let didLoad = await waitForCondition { [weak self] in - guard let sut = self?.sut else { return false } - if case .loaded = sut.selectedOrderRefundsState { return true } - return false - } - #expect(didLoad) - - let availability = await MainActor.run { sut.refundActionAvailability } + let availability = sut.refundActionAvailability #expect(availability == .available) } + @MainActor @Test func refundActionAvailability_when_refunds_loaded_and_fully_refunded_then_unavailable() async throws { // Given featureFlags.isPointOfSaleRefundsi1Enabled = true @@ -582,22 +566,16 @@ final class POSOrderListControllerTests { ) // When - await sut.selectOrder(order) + await selectOrderAwaitRefundsCompletion(order) // Then - let didLoad = await waitForCondition { [weak self] in - guard let sut = self?.sut else { return false } - if case .loaded = sut.selectedOrderRefundsState { return true } - return false - } - #expect(didLoad) - - let availability = await MainActor.run { sut.refundActionAvailability } + let availability = sut.refundActionAvailability #expect(availability == .unavailable) } // MARK: - Refund Item Selection Tests + @MainActor @Test func startRefundFlow_when_product_has_multiple_quantities_then_creates_one_row_per_unit() async throws { // Given let order = makeOrder(lineItems: [ @@ -606,16 +584,15 @@ final class POSOrderListControllerTests { ]) // When - let itemCount = await MainActor.run { - sut.selectOrder(order) - sut.startRefundFlow() - return sut.refundSelectableItems.count - } + sut.selectOrder(order) + sut.startRefundFlow() + let itemCount = sut.refundSelectableItems.count // Then #expect(itemCount == 4) } + @MainActor @Test func startRefundFlow_then_all_items_are_selected_by_default() async throws { // Given let order = makeOrder(lineItems: [ @@ -623,11 +600,9 @@ final class POSOrderListControllerTests { ]) // When - let items = await MainActor.run { - sut.selectOrder(order) - sut.startRefundFlow() - return sut.refundSelectableItems - } + sut.selectOrder(order) + sut.startRefundFlow() + let items = sut.refundSelectableItems // Then #expect(items.count == 2) @@ -636,88 +611,77 @@ final class POSOrderListControllerTests { } } + @MainActor @Test func startRefundFlow_when_no_selected_order_then_items_remain_empty() async throws { // When - let items = await MainActor.run { - sut.startRefundFlow() - return sut.refundSelectableItems - } + sut.startRefundFlow() + let items = sut.refundSelectableItems // Then #expect(items.isEmpty) } + @MainActor @Test func toggleRefundItemSelection_then_toggles_item_at_index() async throws { // Given let order = makeOrder(lineItems: [ makePOSOrderItem(itemID: 1, quantity: 2, formattedPrice: "$10.00", formattedTotal: "$20.00") ]) - await MainActor.run { - sut.selectOrder(order) - sut.startRefundFlow() - } + sut.selectOrder(order) + sut.startRefundFlow() // When - let isSelectedAfterToggle = await MainActor.run { - sut.toggleRefundItemSelection(at: 0) - return sut.refundSelectableItems[0].isSelected - } + sut.toggleRefundItemSelection(at: 0) + let isSelectedAfterToggle = sut.refundSelectableItems[0].isSelected // Then #expect(isSelectedAfterToggle == false) } + @MainActor @Test func toggleRefundItemSelection_when_index_out_of_bounds_then_does_not_crash() async throws { // When - let items = await MainActor.run { - sut.toggleRefundItemSelection(at: 999) - return sut.refundSelectableItems - } + sut.toggleRefundItemSelection(at: 999) + let items = sut.refundSelectableItems // Then #expect(items.isEmpty) } + @MainActor @Test func clearRefundSelection_then_removes_all_items() async throws { // Given let order = makeOrder(lineItems: [ makePOSOrderItem(itemID: 1, quantity: 2, formattedPrice: "$10.00", formattedTotal: "$20.00") ]) - let initialCount = await MainActor.run { - sut.selectOrder(order) - sut.startRefundFlow() - return sut.refundSelectableItems.count - } + sut.selectOrder(order) + sut.startRefundFlow() + let initialCount = sut.refundSelectableItems.count try #require(initialCount == 2) // When - let finalCount = await MainActor.run { - sut.clearRefundSelection() - return sut.refundSelectableItems.count - } + sut.clearRefundSelection() + let finalCount = sut.refundSelectableItems.count // Then #expect(finalCount == 0) } + @MainActor @Test func toggleAllRefundItemsSelection_when_all_selected_then_deselects_all() async throws { // Given let order = makeOrder(lineItems: [ makePOSOrderItem(itemID: 1, quantity: 2, formattedPrice: "$10.00", formattedTotal: "$20.00") ]) - await MainActor.run { - sut.selectOrder(order) - sut.startRefundFlow() - } + sut.selectOrder(order) + sut.startRefundFlow() // When - let items = await MainActor.run { - sut.toggleAllRefundItemsSelection() - return sut.refundSelectableItems - } + sut.toggleAllRefundItemsSelection() + let items = sut.refundSelectableItems // Then for item in items { @@ -725,23 +689,20 @@ final class POSOrderListControllerTests { } } + @MainActor @Test func toggleAllRefundItemsSelection_when_some_deselected_then_selects_all() async throws { // Given let order = makeOrder(lineItems: [ makePOSOrderItem(itemID: 1, quantity: 2, formattedPrice: "$10.00", formattedTotal: "$20.00") ]) - await MainActor.run { - sut.selectOrder(order) - sut.startRefundFlow() - sut.toggleRefundItemSelection(at: 0) // Deselect first item - } + sut.selectOrder(order) + sut.startRefundFlow() + sut.toggleRefundItemSelection(at: 0) // Deselect first item // When - let items = await MainActor.run { - sut.toggleAllRefundItemsSelection() - return sut.refundSelectableItems - } + sut.toggleAllRefundItemsSelection() + let items = sut.refundSelectableItems // Then for item in items { @@ -749,23 +710,20 @@ final class POSOrderListControllerTests { } } + @MainActor @Test func toggleAllRefundItemsSelection_when_none_selected_then_selects_all() async throws { // Given let order = makeOrder(lineItems: [ makePOSOrderItem(itemID: 1, quantity: 2, formattedPrice: "$10.00", formattedTotal: "$20.00") ]) - await MainActor.run { - sut.selectOrder(order) - sut.startRefundFlow() - sut.toggleAllRefundItemsSelection() // Deselect all - } + sut.selectOrder(order) + sut.startRefundFlow() + sut.toggleAllRefundItemsSelection() // Deselect all // When - let items = await MainActor.run { - sut.toggleAllRefundItemsSelection() // Should select all - return sut.refundSelectableItems - } + sut.toggleAllRefundItemsSelection() // Should select all + let items = sut.refundSelectableItems // Then for item in items { @@ -775,17 +733,17 @@ final class POSOrderListControllerTests { // MARK: - Prepare Refund Review Data Tests + @MainActor @Test func preparePOSRefundReviewData_when_no_selected_order_then_returns_nil() async throws { // When - let reviewData = await MainActor.run { - sut.startRefundFlow() - return sut.preparePOSRefundReviewData() - } + sut.startRefundFlow() + let reviewData = sut.preparePOSRefundReviewData() // Then #expect(reviewData == nil) } + @MainActor @Test func preparePOSRefundReviewData_when_no_items_selected_then_returns_nil() async throws { // Given let order = makeOrder(lineItems: [ @@ -793,17 +751,16 @@ final class POSOrderListControllerTests { ]) // When - let reviewData = await MainActor.run { - sut.selectOrder(order) - sut.startRefundFlow() - sut.toggleAllRefundItemsSelection() // Deselect all - return sut.preparePOSRefundReviewData() - } + sut.selectOrder(order) + sut.startRefundFlow() + sut.toggleAllRefundItemsSelection() // Deselect all + let reviewData = sut.preparePOSRefundReviewData() // Then #expect(reviewData == nil) } + @MainActor @Test func preparePOSRefundReviewData_then_returns_correct_items_count() async throws { // Given let order = makeOrder(lineItems: [ @@ -811,16 +768,15 @@ final class POSOrderListControllerTests { ]) // When - let reviewData = await MainActor.run { - sut.selectOrder(order) - sut.startRefundFlow() - return sut.preparePOSRefundReviewData() - } + sut.selectOrder(order) + sut.startRefundFlow() + let reviewData = sut.preparePOSRefundReviewData() // Then #expect(reviewData?.itemsCount == 3) } + @MainActor @Test func preparePOSRefundReviewData_then_returns_correct_subtotal() async throws { // Given let order = makeOrder(lineItems: [ @@ -829,17 +785,16 @@ final class POSOrderListControllerTests { ]) // When - let reviewData = await MainActor.run { - sut.selectOrder(order) - sut.startRefundFlow() - return sut.preparePOSRefundReviewData() - } + sut.selectOrder(order) + sut.startRefundFlow() + let reviewData = sut.preparePOSRefundReviewData() // Then // 2 × $10.00 + 1 × $5.50 = $25.50 #expect(reviewData?.formattedItemsSubtotal == "$25.50") } + @MainActor @Test func preparePOSRefundReviewData_when_full_refund_then_uses_original_tax() async throws { // Given - item with quantity 2 and totalTax of $1.50 (for both units) let order = makeOrder(lineItems: [ @@ -847,16 +802,15 @@ final class POSOrderListControllerTests { ]) // When - all items selected (full refund) - let reviewData = await MainActor.run { - sut.selectOrder(order) - sut.startRefundFlow() - return sut.preparePOSRefundReviewData() - } + sut.selectOrder(order) + sut.startRefundFlow() + let reviewData = sut.preparePOSRefundReviewData() // Then - should use original totalTax directly ($1.50) #expect(reviewData?.formattedTax == "$1.50") } + @MainActor @Test func preparePOSRefundReviewData_when_partial_refund_then_calculates_proportional_tax() async throws { // Given - item with quantity 2 and totalTax of $1.50 (for both units) let order = makeOrder(lineItems: [ @@ -864,17 +818,16 @@ final class POSOrderListControllerTests { ]) // When - only 1 of 2 items selected (partial refund) - let reviewData = await MainActor.run { - sut.selectOrder(order) - sut.startRefundFlow() - sut.toggleRefundItemSelection(at: 0) // Deselect first item, leaving 1 selected - return sut.preparePOSRefundReviewData() - } + sut.selectOrder(order) + sut.startRefundFlow() + sut.toggleRefundItemSelection(at: 0) // Deselect first item, leaving 1 selected + let reviewData = sut.preparePOSRefundReviewData() // Then - should calculate proportionally: $1.50 / 2 × 1 = $0.75 #expect(reviewData?.formattedTax == "$0.75") } + @MainActor @Test func preparePOSRefundReviewData_then_returns_correct_total() async throws { // Given let order = makeOrder(lineItems: [ @@ -882,16 +835,15 @@ final class POSOrderListControllerTests { ]) // When - let reviewData = await MainActor.run { - sut.selectOrder(order) - sut.startRefundFlow() - return sut.preparePOSRefundReviewData() - } + sut.selectOrder(order) + sut.startRefundFlow() + let reviewData = sut.preparePOSRefundReviewData() // Then - $10.00 + $1.00 = $11.00 #expect(reviewData?.formattedRefundTotal == "$11.00") } + @MainActor @Test func preparePOSRefundReviewData_then_returns_via_payment_method_title() async throws { // Given let order = makeOrder(paymentMethodTitle: "WooCommerce In-Person Payments", lineItems: [ @@ -899,16 +851,15 @@ final class POSOrderListControllerTests { ]) // When - let reviewData = await MainActor.run { - sut.selectOrder(order) - sut.startRefundFlow() - return sut.preparePOSRefundReviewData() - } + sut.selectOrder(order) + sut.startRefundFlow() + let reviewData = sut.preparePOSRefundReviewData() // Then #expect(reviewData?.paymentMethodDescription == "Via WooCommerce In-Person Payments") } + @MainActor @Test func preparePOSRefundReviewData_then_refund_reason_is_nil_by_default() async throws { // Given let order = makeOrder(lineItems: [ @@ -916,11 +867,9 @@ final class POSOrderListControllerTests { ]) // When - let reviewData = await MainActor.run { - sut.selectOrder(order) - sut.startRefundFlow() - return sut.preparePOSRefundReviewData() - } + sut.selectOrder(order) + sut.startRefundFlow() + let reviewData = sut.preparePOSRefundReviewData() // Then #expect(reviewData?.refundReason == nil) @@ -928,6 +877,7 @@ final class POSOrderListControllerTests { // MARK: - Currency Formatting Tests + @MainActor @Test func preparePOSRefundReviewData_with_EUR_currency_then_formats_with_comma_decimal_separator() async throws { // Given - EUR with comma decimal separator and dot thousand separator let eurSettings = CurrencySettings( @@ -944,11 +894,9 @@ final class POSOrderListControllerTests { ]) // When - let reviewData = await MainActor.run { - controller.selectOrder(order) - controller.startRefundFlow() - return controller.preparePOSRefundReviewData() - } + controller.selectOrder(order) + controller.startRefundFlow() + let reviewData = controller.preparePOSRefundReviewData() // Then - 2 × €1,172.02 = €2,344.04, tax = €234.40, total = €2,578.44 // Note: CurrencyFormatter uses non-breaking space (\u{00A0}) before currency symbol @@ -957,6 +905,7 @@ final class POSOrderListControllerTests { #expect(reviewData?.formattedRefundTotal == "2.578,44\u{00A0}€") } + @MainActor @Test func preparePOSRefundReviewData_with_JPY_currency_then_formats_without_decimals() async throws { // Given - JPY with no decimals let jpySettings = CurrencySettings( @@ -973,11 +922,9 @@ final class POSOrderListControllerTests { ]) // When - let reviewData = await MainActor.run { - controller.selectOrder(order) - controller.startRefundFlow() - return controller.preparePOSRefundReviewData() - } + controller.selectOrder(order) + controller.startRefundFlow() + let reviewData = controller.preparePOSRefundReviewData() // Then #expect(reviewData?.formattedItemsSubtotal == "¥2,344") @@ -985,6 +932,7 @@ final class POSOrderListControllerTests { #expect(reviewData?.formattedRefundTotal == "¥2,578") } + @MainActor @Test func preparePOSRefundReviewData_with_GBP_and_large_values_then_formats_correctly() async throws { // Given - GBP with large values let gbpSettings = CurrencySettings( @@ -1001,11 +949,9 @@ final class POSOrderListControllerTests { ]) // When - let reviewData = await MainActor.run { - controller.selectOrder(order) - controller.startRefundFlow() - return controller.preparePOSRefundReviewData() - } + controller.selectOrder(order) + controller.startRefundFlow() + let reviewData = controller.preparePOSRefundReviewData() // Then #expect(reviewData?.formattedItemsSubtotal == "£12,345.67") @@ -1013,6 +959,7 @@ final class POSOrderListControllerTests { #expect(reviewData?.formattedRefundTotal == "£14,814.80") } + @MainActor @Test func preparePOSRefundReviewData_with_USD_and_large_value_from_screenshot_then_formats_correctly() async throws { // Given - USD with value from screenshot: $2,344.04 let order = makeOrder(lineItems: [ @@ -1020,11 +967,9 @@ final class POSOrderListControllerTests { ]) // When - let reviewData = await MainActor.run { - sut.selectOrder(order) - sut.startRefundFlow() - return sut.preparePOSRefundReviewData() - } + sut.selectOrder(order) + sut.startRefundFlow() + let reviewData = sut.preparePOSRefundReviewData() // Then #expect(reviewData?.formattedItemsSubtotal == "$2,344.04") @@ -1047,13 +992,7 @@ final class POSOrderListControllerTests { let order = makeOrder(id: 123, lineItems: [ makePOSOrderItem(itemID: 1, quantity: 1, price: 10.00, formattedPrice: "$10.00") ]) - - sut.selectOrder(order) - _ = await waitForCondition { [weak self] in - guard let sut = self?.sut else { return false } - if case .loaded = sut.selectedOrderRefundsState { return true } - return false - } + await selectOrderAwaitRefundsCompletion(order) sut.startRefundFlow() // When @@ -1078,13 +1017,7 @@ final class POSOrderListControllerTests { makePOSOrderItem(itemID: 1, quantity: 2, price: 10.00, formattedPrice: "$10.00"), makePOSOrderItem(itemID: 2, quantity: 1, price: 5.00, formattedPrice: "$5.00") ]) - - sut.selectOrder(order) - _ = await waitForCondition { [weak self] in - guard let sut = self?.sut else { return false } - if case .loaded = sut.selectedOrderRefundsState { return true } - return false - } + await selectOrderAwaitRefundsCompletion(order) sut.startRefundFlow() sut.toggleRefundItemSelection(at: 0) // Deselect first item of itemID 1 @@ -1111,13 +1044,7 @@ final class POSOrderListControllerTests { let order = makeOrder(lineItems: [ makePOSOrderItem(itemID: 1, quantity: 1, price: 10.00, formattedPrice: "$10.00") ]) - - sut.selectOrder(order) - _ = await waitForCondition { [weak self] in - guard let sut = self?.sut else { return false } - if case .loaded = sut.selectedOrderRefundsState { return true } - return false - } + await selectOrderAwaitRefundsCompletion(order) sut.startRefundFlow() // When @@ -1140,13 +1067,7 @@ final class POSOrderListControllerTests { let order = makeOrder(lineItems: [ makePOSOrderItem(itemID: 1, quantity: 2, price: 10.00, formattedPrice: "$10.00") ]) - - sut.selectOrder(order) - _ = await waitForCondition { [weak self] in - guard let sut = self?.sut else { return false } - if case .loaded = sut.selectedOrderRefundsState { return true } - return false - } + await selectOrderAwaitRefundsCompletion(order) sut.startRefundFlow() let initialCount = sut.refundSelectableItems.count try #require(initialCount == 2) @@ -1171,13 +1092,7 @@ final class POSOrderListControllerTests { let order = makeOrder(lineItems: [ makePOSOrderItem(itemID: 1, quantity: 1, price: 10.00, formattedPrice: "$10.00") ]) - - sut.selectOrder(order) - _ = await waitForCondition { [weak self] in - guard let sut = self?.sut else { return false } - if case .loaded = sut.selectedOrderRefundsState { return true } - return false - } + await selectOrderAwaitRefundsCompletion(order) sut.startRefundFlow() struct TestError: Error {} @@ -1207,13 +1122,7 @@ final class POSOrderListControllerTests { let order = makeOrder(lineItems: [ makePOSOrderItem(itemID: 42, quantity: 3, price: 15.50, totalTax: 1.55, formattedPrice: "$15.50") ]) - - sut.selectOrder(order) - _ = await waitForCondition { [weak self] in - guard let sut = self?.sut else { return false } - if case .loaded = sut.selectedOrderRefundsState { return true } - return false - } + await selectOrderAwaitRefundsCompletion(order) sut.startRefundFlow() // When @@ -1243,13 +1152,7 @@ final class POSOrderListControllerTests { let order = makeOrder(lineItems: [ makePOSOrderItem(itemID: 1, quantity: 1, price: 10.00, formattedPrice: "$10.00") ]) - - sut.selectOrder(order) - _ = await waitForCondition { [weak self] in - guard let sut = self?.sut else { return false } - if case .loaded = sut.selectedOrderRefundsState { return true } - return false - } + await selectOrderAwaitRefundsCompletion(order) sut.startRefundFlow() // When @@ -1272,13 +1175,7 @@ final class POSOrderListControllerTests { let order = makeOrder(lineItems: [ makePOSOrderItem(itemID: 1, quantity: 1, price: 10.00, formattedPrice: "$10.00") ]) - - sut.selectOrder(order) - _ = await waitForCondition { [weak self] in - guard let sut = self?.sut else { return false } - if case .loaded = sut.selectedOrderRefundsState { return true } - return false - } + await selectOrderAwaitRefundsCompletion(order) sut.startRefundFlow() // When @@ -1305,13 +1202,7 @@ final class POSOrderListControllerTests { orderListService.loadOrderResult = order await sut.loadOrders() - - sut.selectOrder(order) - _ = await waitForCondition { [weak self] in - guard let sut = self?.sut else { return false } - if case .loaded = sut.selectedOrderRefundsState { return true } - return false - } + await selectOrderAwaitRefundsCompletion(order) sut.startRefundFlow() // When @@ -1324,6 +1215,14 @@ final class POSOrderListControllerTests { } private extension POSOrderListControllerTests { + @MainActor + func selectOrderAwaitRefundsCompletion(_ order: POSOrder) async { + async let completion = refundsService.awaitProvidePointOfSaleRefundsCompletion() + sut.selectOrder(order) + await completion + await Task.yield() + } + func makeController(currencySettings: CurrencySettings) -> POSOrderListController { let provider = MockCurrencySettingsProvider(currencySettings: currencySettings) let formatter = CurrencyFormatter(currencySettings: currencySettings) diff --git a/Modules/Tests/PointOfSaleTests/Mocks/MockPOSRefundsService.swift b/Modules/Tests/PointOfSaleTests/Mocks/MockPOSRefundsService.swift index c37170400c9..c7796980a3b 100644 --- a/Modules/Tests/PointOfSaleTests/Mocks/MockPOSRefundsService.swift +++ b/Modules/Tests/PointOfSaleTests/Mocks/MockPOSRefundsService.swift @@ -6,6 +6,7 @@ final class MockPOSRefundsService: POSRefundsServiceProtocol { var errorToThrow: Error? private var continuation: CheckedContinuation? + private var completionContinuation: CheckedContinuation? var shouldSuspendProvidePointOfSaleRefunds = false private var refundsContinuation: CheckedContinuation? @@ -16,6 +17,12 @@ final class MockPOSRefundsService: POSRefundsServiceProtocol { } } + func awaitProvidePointOfSaleRefundsCompletion() async { + await withCheckedContinuation { cont in + completionContinuation = cont + } + } + func resumeProvidePointOfSaleRefunds() { refundsContinuation?.resume() refundsContinuation = nil @@ -25,6 +32,10 @@ final class MockPOSRefundsService: POSRefundsServiceProtocol { spyProvidePointOfSaleRefundsOrder = order continuation?.resume(returning: order) continuation = nil + defer { + completionContinuation?.resume() + completionContinuation = nil + } if shouldSuspendProvidePointOfSaleRefunds { await withCheckedContinuation { (cont: CheckedContinuation) in diff --git a/Modules/Tests/PointOfSaleTests/ViewHelpers/TotalsViewHelperTests.swift b/Modules/Tests/PointOfSaleTests/ViewHelpers/TotalsViewHelperTests.swift index 7d8f6d07bfb..14ec61626c7 100644 --- a/Modules/Tests/PointOfSaleTests/ViewHelpers/TotalsViewHelperTests.swift +++ b/Modules/Tests/PointOfSaleTests/ViewHelpers/TotalsViewHelperTests.swift @@ -185,4 +185,74 @@ struct TotalsViewHelperTests { #expect(TotalsViewHelper().shouldShowTotalDiscountField(cart: cart, orderTotals: orderTotals) == false) } + + // MARK: - shouldShowReconnectingMessage tests + + @Test(arguments: [ + (CardPresentPaymentReaderConnectionStatus.reconnecting(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.idle), + (CardPresentPaymentReaderConnectionStatus.reconnecting(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.acceptingCard), + (CardPresentPaymentReaderConnectionStatus.reconnecting(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.preparingReader) + ]) + func test_shouldShowReconnectingMessage_returns_true_when_reconnecting_and_no_card_payment_ongoing( + readerConnectionStatus: CardPresentPaymentReaderConnectionStatus, + paymentState: PointOfSaleCardPaymentState) { + #expect(TotalsViewHelper().shouldShowReconnectingMessage(readerConnectionStatus: readerConnectionStatus, + paymentState: PointOfSalePaymentState(card: paymentState, cash: .idle))) + } + + @Test(arguments: [ + (CardPresentPaymentReaderConnectionStatus.reconnecting(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.cardPaymentSuccessful), + (CardPresentPaymentReaderConnectionStatus.reconnecting(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.paymentError), + (CardPresentPaymentReaderConnectionStatus.reconnecting(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.processingPayment), + (CardPresentPaymentReaderConnectionStatus.reconnecting(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.validatingOrder), + (CardPresentPaymentReaderConnectionStatus.reconnecting(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.validatingOrderError) + ]) + func test_shouldShowReconnectingMessage_returns_false_when_card_payment_ongoing( + readerConnectionStatus: CardPresentPaymentReaderConnectionStatus, + paymentState: PointOfSaleCardPaymentState) { + #expect(TotalsViewHelper().shouldShowReconnectingMessage(readerConnectionStatus: readerConnectionStatus, + paymentState: PointOfSalePaymentState(card: paymentState, cash: .idle)) == false) + } + + @Test(arguments: [ + (CardPresentPaymentReaderConnectionStatus.connected(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.idle), + (CardPresentPaymentReaderConnectionStatus.connected(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.acceptingCard), + (CardPresentPaymentReaderConnectionStatus.connected(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.preparingReader) + ]) + func test_shouldShowReconnectingMessage_returns_false_when_reader_connected( + readerConnectionStatus: CardPresentPaymentReaderConnectionStatus, + paymentState: PointOfSaleCardPaymentState) { + #expect(TotalsViewHelper().shouldShowReconnectingMessage(readerConnectionStatus: readerConnectionStatus, + paymentState: PointOfSalePaymentState(card: paymentState, cash: .idle)) == false) + } + + @Test(arguments: [ + (CardPresentPaymentReaderConnectionStatus.disconnected, PointOfSaleCardPaymentState.idle), + (CardPresentPaymentReaderConnectionStatus.disconnected, PointOfSaleCardPaymentState.acceptingCard), + (CardPresentPaymentReaderConnectionStatus.disconnected, PointOfSaleCardPaymentState.preparingReader) + ]) + func test_shouldShowReconnectingMessage_returns_false_when_reader_disconnected( + readerConnectionStatus: CardPresentPaymentReaderConnectionStatus, + paymentState: PointOfSaleCardPaymentState) { + #expect(TotalsViewHelper().shouldShowReconnectingMessage(readerConnectionStatus: readerConnectionStatus, + paymentState: PointOfSalePaymentState(card: paymentState, cash: .idle)) == false) + } + + // MARK: - shouldShowCollectCashPaymentButton reconnecting tests + + @Test(arguments: [ + PointOfSaleCardPaymentState.idle, + PointOfSaleCardPaymentState.acceptingCard, + PointOfSaleCardPaymentState.validatingOrderError, + PointOfSaleCardPaymentState.paymentIntentCreationError + ]) + func test_shouldShowCollectCashPaymentButton_returns_false_when_reconnecting( + cardPaymentState: PointOfSaleCardPaymentState) { + #expect(TotalsViewHelper().shouldShowCollectCashPaymentButton(orderState: .loaded(.init(cartTotal: "10", + orderTotal: "10", + taxTotal: "10", + orderTotalDecimal: 10)), + paymentState: PointOfSalePaymentState(card: cardPaymentState, cash: .idle), + cardReaderConnectionStatus: .reconnecting(.init(name: "", batteryLevel: nil))) == false) + } } From 8cb0197c72847c03fcd143abeb7a0819224f58cb Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:42:22 +0200 Subject: [PATCH 20/61] Revert unrelated test refactoring in POSOrderListControllerTests --- .../POSOrderListControllerTests.swift | 399 +++++++++++------- .../Mocks/MockPOSRefundsService.swift | 11 - 2 files changed, 250 insertions(+), 160 deletions(-) diff --git a/Modules/Tests/PointOfSaleTests/Controllers/POSOrderListControllerTests.swift b/Modules/Tests/PointOfSaleTests/Controllers/POSOrderListControllerTests.swift index bb00ca9e336..b6e60abb158 100644 --- a/Modules/Tests/PointOfSaleTests/Controllers/POSOrderListControllerTests.swift +++ b/Modules/Tests/PointOfSaleTests/Controllers/POSOrderListControllerTests.swift @@ -4,6 +4,7 @@ import Foundation @testable import PointOfSale import enum Yosemite.POSOrderListServiceError import struct NetworkingCore.Order +import Observation import struct Yosemite.POSOrder import struct Yosemite.POSOrderItem import typealias Yosemite.OrderItemAttribute @@ -381,7 +382,6 @@ final class POSOrderListControllerTests { #expect(sut.selectedOrder?.customerEmail == "selected-updated@example.com") } - @MainActor @Test func selectOrder_then_calls_refunds_service_for_selected_order() async throws { // Given featureFlags.isPointOfSaleRefundsi1Enabled = true @@ -390,32 +390,33 @@ final class POSOrderListControllerTests { async let calledOrder = refundsService.awaitProvidePointOfSaleRefundsCall() // When - sut.selectOrder(order) + await sut.selectOrder(order) // Then #expect(await calledOrder == order) } - @MainActor @Test func selectOrder_then_updates_refunds_state_with_loading() async throws { // Given featureFlags.isPointOfSaleRefundsi1Enabled = true let order = MockPOSOrderListService.makeInitialOrders()[0] // When - sut.selectOrder(order) + await sut.selectOrder(order) // Then + let didBecomeLoading = await waitForCondition { [weak self] in + guard let sut = self?.sut else { return false } + if case .loading = sut.selectedOrderRefundsState { return true } + return false + } + #expect( - { - if case .loading = sut.selectedOrderRefundsState { return true } - return false - }(), + didBecomeLoading, "Expected selectedOrderRefundsState to become .loading before timeout" ) } - @MainActor @Test func selectOrder_when_provides_refunds_then_updates_refunds_state_with_results() async throws { // Given featureFlags.isPointOfSaleRefundsi1Enabled = true @@ -425,14 +426,17 @@ final class POSOrderListControllerTests { refundsService.providePointOfSaleRefundsResultToReturn = expectedResult // When - await selectOrderAwaitRefundsCompletion(order) + await sut.selectOrder(order) // Then + let didLoad = await waitForCondition { [weak self] in + guard let sut = self?.sut else { return false } + if case .loaded = sut.selectedOrderRefundsState { return true } + return false + } + #expect( - { - if case .loaded = sut.selectedOrderRefundsState { return true } - return false - }(), + didLoad, "Expected selectedOrderRefundsState to become .loaded before timeout" ) @@ -444,7 +448,6 @@ final class POSOrderListControllerTests { #expect(loadedResult.isFullyRefunded == expectedResult.isFullyRefunded) } - @MainActor @Test func selectOrder_when_refunds_service_errors_then_failed_contains_same_error_type() async throws { // Given featureFlags.isPointOfSaleRefundsi1Enabled = true @@ -454,14 +457,17 @@ final class POSOrderListControllerTests { refundsService.errorToThrow = TestError() // When - await selectOrderAwaitRefundsCompletion(order) + await sut.selectOrder(order) // Then + let didFail = await waitForCondition { [weak self] in + guard let sut = self?.sut else { return false } + if case .failed = sut.selectedOrderRefundsState { return true } + return false + } + #expect( - { - if case .failed = sut.selectedOrderRefundsState { return true } - return false - }(), + didFail, "Expected selectedOrderRefundsState to become .failed before timeout" ) @@ -473,31 +479,28 @@ final class POSOrderListControllerTests { #expect(error is TestError) } - @MainActor @Test func refundActionAvailability_when_feature_flag_disabled_then_unavailable() async throws { // Given featureFlags.isPointOfSaleRefundsi1Enabled = false let order = MockPOSOrderListService.makeInitialOrders()[0] // When - sut.selectOrder(order) + await sut.selectOrder(order) // Then - let availability = sut.refundActionAvailability + let availability = await MainActor.run { sut.refundActionAvailability } #expect(availability == .unavailable) } - @MainActor @Test func refundActionAvailability_when_no_selected_order_then_unavailable() async throws { // Given featureFlags.isPointOfSaleRefundsi1Enabled = true // When / Then - let availability = sut.refundActionAvailability + let availability = await MainActor.run { sut.refundActionAvailability } #expect(availability == .unavailable) } - @MainActor @Test func refundActionAvailability_when_refunds_loading_then_unknown() async throws { // Given featureFlags.isPointOfSaleRefundsi1Enabled = true @@ -505,14 +508,15 @@ final class POSOrderListControllerTests { refundsService.shouldSuspendProvidePointOfSaleRefunds = true // When - sut.selectOrder(order) - #expect(sut.refundActionAvailability == .unknown) + await MainActor.run { + sut.selectOrder(order) + #expect(sut.refundActionAvailability == .unknown) + } // Then refundsService.resumeProvidePointOfSaleRefunds() } - @MainActor @Test func refundActionAvailability_when_refunds_failed_then_unavailable() async throws { // Given featureFlags.isPointOfSaleRefundsi1Enabled = true @@ -522,14 +526,20 @@ final class POSOrderListControllerTests { refundsService.errorToThrow = TestError() // When - await selectOrderAwaitRefundsCompletion(order) + await sut.selectOrder(order) // Then - let availability = sut.refundActionAvailability + let didFail = await waitForCondition { [weak self] in + guard let sut = self?.sut else { return false } + if case .failed = sut.selectedOrderRefundsState { return true } + return false + } + #expect(didFail) + + let availability = await MainActor.run { sut.refundActionAvailability } #expect(availability == .unavailable) } - @MainActor @Test func refundActionAvailability_when_refunds_loaded_and_not_fully_refunded_then_available() async throws { // Given featureFlags.isPointOfSaleRefundsi1Enabled = true @@ -544,14 +554,20 @@ final class POSOrderListControllerTests { ) // When - await selectOrderAwaitRefundsCompletion(order) + await sut.selectOrder(order) // Then - let availability = sut.refundActionAvailability + let didLoad = await waitForCondition { [weak self] in + guard let sut = self?.sut else { return false } + if case .loaded = sut.selectedOrderRefundsState { return true } + return false + } + #expect(didLoad) + + let availability = await MainActor.run { sut.refundActionAvailability } #expect(availability == .available) } - @MainActor @Test func refundActionAvailability_when_refunds_loaded_and_fully_refunded_then_unavailable() async throws { // Given featureFlags.isPointOfSaleRefundsi1Enabled = true @@ -566,16 +582,22 @@ final class POSOrderListControllerTests { ) // When - await selectOrderAwaitRefundsCompletion(order) + await sut.selectOrder(order) // Then - let availability = sut.refundActionAvailability + let didLoad = await waitForCondition { [weak self] in + guard let sut = self?.sut else { return false } + if case .loaded = sut.selectedOrderRefundsState { return true } + return false + } + #expect(didLoad) + + let availability = await MainActor.run { sut.refundActionAvailability } #expect(availability == .unavailable) } // MARK: - Refund Item Selection Tests - @MainActor @Test func startRefundFlow_when_product_has_multiple_quantities_then_creates_one_row_per_unit() async throws { // Given let order = makeOrder(lineItems: [ @@ -584,15 +606,16 @@ final class POSOrderListControllerTests { ]) // When - sut.selectOrder(order) - sut.startRefundFlow() - let itemCount = sut.refundSelectableItems.count + let itemCount = await MainActor.run { + sut.selectOrder(order) + sut.startRefundFlow() + return sut.refundSelectableItems.count + } // Then #expect(itemCount == 4) } - @MainActor @Test func startRefundFlow_then_all_items_are_selected_by_default() async throws { // Given let order = makeOrder(lineItems: [ @@ -600,9 +623,11 @@ final class POSOrderListControllerTests { ]) // When - sut.selectOrder(order) - sut.startRefundFlow() - let items = sut.refundSelectableItems + let items = await MainActor.run { + sut.selectOrder(order) + sut.startRefundFlow() + return sut.refundSelectableItems + } // Then #expect(items.count == 2) @@ -611,77 +636,88 @@ final class POSOrderListControllerTests { } } - @MainActor @Test func startRefundFlow_when_no_selected_order_then_items_remain_empty() async throws { // When - sut.startRefundFlow() - let items = sut.refundSelectableItems + let items = await MainActor.run { + sut.startRefundFlow() + return sut.refundSelectableItems + } // Then #expect(items.isEmpty) } - @MainActor @Test func toggleRefundItemSelection_then_toggles_item_at_index() async throws { // Given let order = makeOrder(lineItems: [ makePOSOrderItem(itemID: 1, quantity: 2, formattedPrice: "$10.00", formattedTotal: "$20.00") ]) - sut.selectOrder(order) - sut.startRefundFlow() + await MainActor.run { + sut.selectOrder(order) + sut.startRefundFlow() + } // When - sut.toggleRefundItemSelection(at: 0) - let isSelectedAfterToggle = sut.refundSelectableItems[0].isSelected + let isSelectedAfterToggle = await MainActor.run { + sut.toggleRefundItemSelection(at: 0) + return sut.refundSelectableItems[0].isSelected + } // Then #expect(isSelectedAfterToggle == false) } - @MainActor @Test func toggleRefundItemSelection_when_index_out_of_bounds_then_does_not_crash() async throws { // When - sut.toggleRefundItemSelection(at: 999) - let items = sut.refundSelectableItems + let items = await MainActor.run { + sut.toggleRefundItemSelection(at: 999) + return sut.refundSelectableItems + } // Then #expect(items.isEmpty) } - @MainActor @Test func clearRefundSelection_then_removes_all_items() async throws { // Given let order = makeOrder(lineItems: [ makePOSOrderItem(itemID: 1, quantity: 2, formattedPrice: "$10.00", formattedTotal: "$20.00") ]) - sut.selectOrder(order) - sut.startRefundFlow() - let initialCount = sut.refundSelectableItems.count + let initialCount = await MainActor.run { + sut.selectOrder(order) + sut.startRefundFlow() + return sut.refundSelectableItems.count + } try #require(initialCount == 2) // When - sut.clearRefundSelection() - let finalCount = sut.refundSelectableItems.count + let finalCount = await MainActor.run { + sut.clearRefundSelection() + return sut.refundSelectableItems.count + } // Then #expect(finalCount == 0) } - @MainActor @Test func toggleAllRefundItemsSelection_when_all_selected_then_deselects_all() async throws { // Given let order = makeOrder(lineItems: [ makePOSOrderItem(itemID: 1, quantity: 2, formattedPrice: "$10.00", formattedTotal: "$20.00") ]) - sut.selectOrder(order) - sut.startRefundFlow() + await MainActor.run { + sut.selectOrder(order) + sut.startRefundFlow() + } // When - sut.toggleAllRefundItemsSelection() - let items = sut.refundSelectableItems + let items = await MainActor.run { + sut.toggleAllRefundItemsSelection() + return sut.refundSelectableItems + } // Then for item in items { @@ -689,20 +725,23 @@ final class POSOrderListControllerTests { } } - @MainActor @Test func toggleAllRefundItemsSelection_when_some_deselected_then_selects_all() async throws { // Given let order = makeOrder(lineItems: [ makePOSOrderItem(itemID: 1, quantity: 2, formattedPrice: "$10.00", formattedTotal: "$20.00") ]) - sut.selectOrder(order) - sut.startRefundFlow() - sut.toggleRefundItemSelection(at: 0) // Deselect first item + await MainActor.run { + sut.selectOrder(order) + sut.startRefundFlow() + sut.toggleRefundItemSelection(at: 0) // Deselect first item + } // When - sut.toggleAllRefundItemsSelection() - let items = sut.refundSelectableItems + let items = await MainActor.run { + sut.toggleAllRefundItemsSelection() + return sut.refundSelectableItems + } // Then for item in items { @@ -710,20 +749,23 @@ final class POSOrderListControllerTests { } } - @MainActor @Test func toggleAllRefundItemsSelection_when_none_selected_then_selects_all() async throws { // Given let order = makeOrder(lineItems: [ makePOSOrderItem(itemID: 1, quantity: 2, formattedPrice: "$10.00", formattedTotal: "$20.00") ]) - sut.selectOrder(order) - sut.startRefundFlow() - sut.toggleAllRefundItemsSelection() // Deselect all + await MainActor.run { + sut.selectOrder(order) + sut.startRefundFlow() + sut.toggleAllRefundItemsSelection() // Deselect all + } // When - sut.toggleAllRefundItemsSelection() // Should select all - let items = sut.refundSelectableItems + let items = await MainActor.run { + sut.toggleAllRefundItemsSelection() // Should select all + return sut.refundSelectableItems + } // Then for item in items { @@ -733,17 +775,17 @@ final class POSOrderListControllerTests { // MARK: - Prepare Refund Review Data Tests - @MainActor @Test func preparePOSRefundReviewData_when_no_selected_order_then_returns_nil() async throws { // When - sut.startRefundFlow() - let reviewData = sut.preparePOSRefundReviewData() + let reviewData = await MainActor.run { + sut.startRefundFlow() + return sut.preparePOSRefundReviewData() + } // Then #expect(reviewData == nil) } - @MainActor @Test func preparePOSRefundReviewData_when_no_items_selected_then_returns_nil() async throws { // Given let order = makeOrder(lineItems: [ @@ -751,16 +793,17 @@ final class POSOrderListControllerTests { ]) // When - sut.selectOrder(order) - sut.startRefundFlow() - sut.toggleAllRefundItemsSelection() // Deselect all - let reviewData = sut.preparePOSRefundReviewData() + let reviewData = await MainActor.run { + sut.selectOrder(order) + sut.startRefundFlow() + sut.toggleAllRefundItemsSelection() // Deselect all + return sut.preparePOSRefundReviewData() + } // Then #expect(reviewData == nil) } - @MainActor @Test func preparePOSRefundReviewData_then_returns_correct_items_count() async throws { // Given let order = makeOrder(lineItems: [ @@ -768,15 +811,16 @@ final class POSOrderListControllerTests { ]) // When - sut.selectOrder(order) - sut.startRefundFlow() - let reviewData = sut.preparePOSRefundReviewData() + let reviewData = await MainActor.run { + sut.selectOrder(order) + sut.startRefundFlow() + return sut.preparePOSRefundReviewData() + } // Then #expect(reviewData?.itemsCount == 3) } - @MainActor @Test func preparePOSRefundReviewData_then_returns_correct_subtotal() async throws { // Given let order = makeOrder(lineItems: [ @@ -785,16 +829,17 @@ final class POSOrderListControllerTests { ]) // When - sut.selectOrder(order) - sut.startRefundFlow() - let reviewData = sut.preparePOSRefundReviewData() + let reviewData = await MainActor.run { + sut.selectOrder(order) + sut.startRefundFlow() + return sut.preparePOSRefundReviewData() + } // Then // 2 × $10.00 + 1 × $5.50 = $25.50 #expect(reviewData?.formattedItemsSubtotal == "$25.50") } - @MainActor @Test func preparePOSRefundReviewData_when_full_refund_then_uses_original_tax() async throws { // Given - item with quantity 2 and totalTax of $1.50 (for both units) let order = makeOrder(lineItems: [ @@ -802,15 +847,16 @@ final class POSOrderListControllerTests { ]) // When - all items selected (full refund) - sut.selectOrder(order) - sut.startRefundFlow() - let reviewData = sut.preparePOSRefundReviewData() + let reviewData = await MainActor.run { + sut.selectOrder(order) + sut.startRefundFlow() + return sut.preparePOSRefundReviewData() + } // Then - should use original totalTax directly ($1.50) #expect(reviewData?.formattedTax == "$1.50") } - @MainActor @Test func preparePOSRefundReviewData_when_partial_refund_then_calculates_proportional_tax() async throws { // Given - item with quantity 2 and totalTax of $1.50 (for both units) let order = makeOrder(lineItems: [ @@ -818,16 +864,17 @@ final class POSOrderListControllerTests { ]) // When - only 1 of 2 items selected (partial refund) - sut.selectOrder(order) - sut.startRefundFlow() - sut.toggleRefundItemSelection(at: 0) // Deselect first item, leaving 1 selected - let reviewData = sut.preparePOSRefundReviewData() + let reviewData = await MainActor.run { + sut.selectOrder(order) + sut.startRefundFlow() + sut.toggleRefundItemSelection(at: 0) // Deselect first item, leaving 1 selected + return sut.preparePOSRefundReviewData() + } // Then - should calculate proportionally: $1.50 / 2 × 1 = $0.75 #expect(reviewData?.formattedTax == "$0.75") } - @MainActor @Test func preparePOSRefundReviewData_then_returns_correct_total() async throws { // Given let order = makeOrder(lineItems: [ @@ -835,15 +882,16 @@ final class POSOrderListControllerTests { ]) // When - sut.selectOrder(order) - sut.startRefundFlow() - let reviewData = sut.preparePOSRefundReviewData() + let reviewData = await MainActor.run { + sut.selectOrder(order) + sut.startRefundFlow() + return sut.preparePOSRefundReviewData() + } // Then - $10.00 + $1.00 = $11.00 #expect(reviewData?.formattedRefundTotal == "$11.00") } - @MainActor @Test func preparePOSRefundReviewData_then_returns_via_payment_method_title() async throws { // Given let order = makeOrder(paymentMethodTitle: "WooCommerce In-Person Payments", lineItems: [ @@ -851,15 +899,16 @@ final class POSOrderListControllerTests { ]) // When - sut.selectOrder(order) - sut.startRefundFlow() - let reviewData = sut.preparePOSRefundReviewData() + let reviewData = await MainActor.run { + sut.selectOrder(order) + sut.startRefundFlow() + return sut.preparePOSRefundReviewData() + } // Then #expect(reviewData?.paymentMethodDescription == "Via WooCommerce In-Person Payments") } - @MainActor @Test func preparePOSRefundReviewData_then_refund_reason_is_nil_by_default() async throws { // Given let order = makeOrder(lineItems: [ @@ -867,9 +916,11 @@ final class POSOrderListControllerTests { ]) // When - sut.selectOrder(order) - sut.startRefundFlow() - let reviewData = sut.preparePOSRefundReviewData() + let reviewData = await MainActor.run { + sut.selectOrder(order) + sut.startRefundFlow() + return sut.preparePOSRefundReviewData() + } // Then #expect(reviewData?.refundReason == nil) @@ -877,7 +928,6 @@ final class POSOrderListControllerTests { // MARK: - Currency Formatting Tests - @MainActor @Test func preparePOSRefundReviewData_with_EUR_currency_then_formats_with_comma_decimal_separator() async throws { // Given - EUR with comma decimal separator and dot thousand separator let eurSettings = CurrencySettings( @@ -894,9 +944,11 @@ final class POSOrderListControllerTests { ]) // When - controller.selectOrder(order) - controller.startRefundFlow() - let reviewData = controller.preparePOSRefundReviewData() + let reviewData = await MainActor.run { + controller.selectOrder(order) + controller.startRefundFlow() + return controller.preparePOSRefundReviewData() + } // Then - 2 × €1,172.02 = €2,344.04, tax = €234.40, total = €2,578.44 // Note: CurrencyFormatter uses non-breaking space (\u{00A0}) before currency symbol @@ -905,7 +957,6 @@ final class POSOrderListControllerTests { #expect(reviewData?.formattedRefundTotal == "2.578,44\u{00A0}€") } - @MainActor @Test func preparePOSRefundReviewData_with_JPY_currency_then_formats_without_decimals() async throws { // Given - JPY with no decimals let jpySettings = CurrencySettings( @@ -922,9 +973,11 @@ final class POSOrderListControllerTests { ]) // When - controller.selectOrder(order) - controller.startRefundFlow() - let reviewData = controller.preparePOSRefundReviewData() + let reviewData = await MainActor.run { + controller.selectOrder(order) + controller.startRefundFlow() + return controller.preparePOSRefundReviewData() + } // Then #expect(reviewData?.formattedItemsSubtotal == "¥2,344") @@ -932,7 +985,6 @@ final class POSOrderListControllerTests { #expect(reviewData?.formattedRefundTotal == "¥2,578") } - @MainActor @Test func preparePOSRefundReviewData_with_GBP_and_large_values_then_formats_correctly() async throws { // Given - GBP with large values let gbpSettings = CurrencySettings( @@ -949,9 +1001,11 @@ final class POSOrderListControllerTests { ]) // When - controller.selectOrder(order) - controller.startRefundFlow() - let reviewData = controller.preparePOSRefundReviewData() + let reviewData = await MainActor.run { + controller.selectOrder(order) + controller.startRefundFlow() + return controller.preparePOSRefundReviewData() + } // Then #expect(reviewData?.formattedItemsSubtotal == "£12,345.67") @@ -959,7 +1013,6 @@ final class POSOrderListControllerTests { #expect(reviewData?.formattedRefundTotal == "£14,814.80") } - @MainActor @Test func preparePOSRefundReviewData_with_USD_and_large_value_from_screenshot_then_formats_correctly() async throws { // Given - USD with value from screenshot: $2,344.04 let order = makeOrder(lineItems: [ @@ -967,9 +1020,11 @@ final class POSOrderListControllerTests { ]) // When - sut.selectOrder(order) - sut.startRefundFlow() - let reviewData = sut.preparePOSRefundReviewData() + let reviewData = await MainActor.run { + sut.selectOrder(order) + sut.startRefundFlow() + return sut.preparePOSRefundReviewData() + } // Then #expect(reviewData?.formattedItemsSubtotal == "$2,344.04") @@ -992,7 +1047,13 @@ final class POSOrderListControllerTests { let order = makeOrder(id: 123, lineItems: [ makePOSOrderItem(itemID: 1, quantity: 1, price: 10.00, formattedPrice: "$10.00") ]) - await selectOrderAwaitRefundsCompletion(order) + + sut.selectOrder(order) + _ = await waitForCondition { [weak self] in + guard let sut = self?.sut else { return false } + if case .loaded = sut.selectedOrderRefundsState { return true } + return false + } sut.startRefundFlow() // When @@ -1017,7 +1078,13 @@ final class POSOrderListControllerTests { makePOSOrderItem(itemID: 1, quantity: 2, price: 10.00, formattedPrice: "$10.00"), makePOSOrderItem(itemID: 2, quantity: 1, price: 5.00, formattedPrice: "$5.00") ]) - await selectOrderAwaitRefundsCompletion(order) + + sut.selectOrder(order) + _ = await waitForCondition { [weak self] in + guard let sut = self?.sut else { return false } + if case .loaded = sut.selectedOrderRefundsState { return true } + return false + } sut.startRefundFlow() sut.toggleRefundItemSelection(at: 0) // Deselect first item of itemID 1 @@ -1044,7 +1111,13 @@ final class POSOrderListControllerTests { let order = makeOrder(lineItems: [ makePOSOrderItem(itemID: 1, quantity: 1, price: 10.00, formattedPrice: "$10.00") ]) - await selectOrderAwaitRefundsCompletion(order) + + sut.selectOrder(order) + _ = await waitForCondition { [weak self] in + guard let sut = self?.sut else { return false } + if case .loaded = sut.selectedOrderRefundsState { return true } + return false + } sut.startRefundFlow() // When @@ -1067,7 +1140,13 @@ final class POSOrderListControllerTests { let order = makeOrder(lineItems: [ makePOSOrderItem(itemID: 1, quantity: 2, price: 10.00, formattedPrice: "$10.00") ]) - await selectOrderAwaitRefundsCompletion(order) + + sut.selectOrder(order) + _ = await waitForCondition { [weak self] in + guard let sut = self?.sut else { return false } + if case .loaded = sut.selectedOrderRefundsState { return true } + return false + } sut.startRefundFlow() let initialCount = sut.refundSelectableItems.count try #require(initialCount == 2) @@ -1092,7 +1171,13 @@ final class POSOrderListControllerTests { let order = makeOrder(lineItems: [ makePOSOrderItem(itemID: 1, quantity: 1, price: 10.00, formattedPrice: "$10.00") ]) - await selectOrderAwaitRefundsCompletion(order) + + sut.selectOrder(order) + _ = await waitForCondition { [weak self] in + guard let sut = self?.sut else { return false } + if case .loaded = sut.selectedOrderRefundsState { return true } + return false + } sut.startRefundFlow() struct TestError: Error {} @@ -1122,7 +1207,13 @@ final class POSOrderListControllerTests { let order = makeOrder(lineItems: [ makePOSOrderItem(itemID: 42, quantity: 3, price: 15.50, totalTax: 1.55, formattedPrice: "$15.50") ]) - await selectOrderAwaitRefundsCompletion(order) + + sut.selectOrder(order) + _ = await waitForCondition { [weak self] in + guard let sut = self?.sut else { return false } + if case .loaded = sut.selectedOrderRefundsState { return true } + return false + } sut.startRefundFlow() // When @@ -1152,7 +1243,13 @@ final class POSOrderListControllerTests { let order = makeOrder(lineItems: [ makePOSOrderItem(itemID: 1, quantity: 1, price: 10.00, formattedPrice: "$10.00") ]) - await selectOrderAwaitRefundsCompletion(order) + + sut.selectOrder(order) + _ = await waitForCondition { [weak self] in + guard let sut = self?.sut else { return false } + if case .loaded = sut.selectedOrderRefundsState { return true } + return false + } sut.startRefundFlow() // When @@ -1175,7 +1272,13 @@ final class POSOrderListControllerTests { let order = makeOrder(lineItems: [ makePOSOrderItem(itemID: 1, quantity: 1, price: 10.00, formattedPrice: "$10.00") ]) - await selectOrderAwaitRefundsCompletion(order) + + sut.selectOrder(order) + _ = await waitForCondition { [weak self] in + guard let sut = self?.sut else { return false } + if case .loaded = sut.selectedOrderRefundsState { return true } + return false + } sut.startRefundFlow() // When @@ -1202,7 +1305,13 @@ final class POSOrderListControllerTests { orderListService.loadOrderResult = order await sut.loadOrders() - await selectOrderAwaitRefundsCompletion(order) + + sut.selectOrder(order) + _ = await waitForCondition { [weak self] in + guard let sut = self?.sut else { return false } + if case .loaded = sut.selectedOrderRefundsState { return true } + return false + } sut.startRefundFlow() // When @@ -1215,14 +1324,6 @@ final class POSOrderListControllerTests { } private extension POSOrderListControllerTests { - @MainActor - func selectOrderAwaitRefundsCompletion(_ order: POSOrder) async { - async let completion = refundsService.awaitProvidePointOfSaleRefundsCompletion() - sut.selectOrder(order) - await completion - await Task.yield() - } - func makeController(currencySettings: CurrencySettings) -> POSOrderListController { let provider = MockCurrencySettingsProvider(currencySettings: currencySettings) let formatter = CurrencyFormatter(currencySettings: currencySettings) diff --git a/Modules/Tests/PointOfSaleTests/Mocks/MockPOSRefundsService.swift b/Modules/Tests/PointOfSaleTests/Mocks/MockPOSRefundsService.swift index c7796980a3b..c37170400c9 100644 --- a/Modules/Tests/PointOfSaleTests/Mocks/MockPOSRefundsService.swift +++ b/Modules/Tests/PointOfSaleTests/Mocks/MockPOSRefundsService.swift @@ -6,7 +6,6 @@ final class MockPOSRefundsService: POSRefundsServiceProtocol { var errorToThrow: Error? private var continuation: CheckedContinuation? - private var completionContinuation: CheckedContinuation? var shouldSuspendProvidePointOfSaleRefunds = false private var refundsContinuation: CheckedContinuation? @@ -17,12 +16,6 @@ final class MockPOSRefundsService: POSRefundsServiceProtocol { } } - func awaitProvidePointOfSaleRefundsCompletion() async { - await withCheckedContinuation { cont in - completionContinuation = cont - } - } - func resumeProvidePointOfSaleRefunds() { refundsContinuation?.resume() refundsContinuation = nil @@ -32,10 +25,6 @@ final class MockPOSRefundsService: POSRefundsServiceProtocol { spyProvidePointOfSaleRefundsOrder = order continuation?.resume(returning: order) continuation = nil - defer { - completionContinuation?.resume() - completionContinuation = nil - } if shouldSuspendProvidePointOfSaleRefunds { await withCheckedContinuation { (cont: CheckedContinuation) in From cc76fc5e96c597a7168811aeef61fb92a04bc4cc Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:49:41 +0200 Subject: [PATCH 21/61] Fix "Try another payment method" button when reader disconnects When the reader disconnects during payment preparation, the error view's "Try another payment method" button now navigates back to cart view instead of silently doing nothing. --- .../PointOfSale/Models/PointOfSaleAggregateModel.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift b/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift index 4994ff59e5b..0f251b676f1 100644 --- a/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift +++ b/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift @@ -482,7 +482,13 @@ extension PointOfSaleAggregateModel { func cancelThenCollectPayment() async { try? await cardPresentPaymentService.cancelPayment() - await collectCardPayment() + + switch cardReaderConnectionStatus { + case .connected: + await collectCardPayment() + case .disconnected, .disconnecting, .cancellingConnection, .reconnecting: + addMoreToCart() + } } @Sendable private func setupReaderReconnectionObservation() { From cbe1f6f6885f7a9d75712d52ceede83e8dde9742 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Mon, 2 Feb 2026 14:56:32 +0200 Subject: [PATCH 22/61] Show reconnecting UI during payment error state When a reader disconnects during payment, show the reconnecting UI instead of the payment error screen. This prevents users from tapping buttons that would hang because cancelPaymentIntent() cannot complete during reconnection (Stripe SDK limitation). Also simplified cancelThenCollectPayment() and adjusted reconnecting message view layout with spacers for proper centering. --- .../PointOfSale/Models/PointOfSaleAggregateModel.swift | 8 +------- ...tOfSaleCardPresentPaymentReconnectingMessageView.swift | 2 ++ .../PointOfSale/ViewHelpers/TotalsViewHelper.swift | 2 +- .../ViewHelpers/TotalsViewHelperTests.swift | 4 ++-- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift b/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift index 0f251b676f1..4994ff59e5b 100644 --- a/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift +++ b/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift @@ -482,13 +482,7 @@ extension PointOfSaleAggregateModel { func cancelThenCollectPayment() async { try? await cardPresentPaymentService.cancelPayment() - - switch cardReaderConnectionStatus { - case .connected: - await collectCardPayment() - case .disconnected, .disconnecting, .cancellingConnection, .reconnecting: - addMoreToCart() - } + await collectCardPayment() } @Sendable private func setupReaderReconnectionObservation() { diff --git a/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSaleCardPresentPaymentReconnectingMessageView.swift b/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSaleCardPresentPaymentReconnectingMessageView.swift index b7664d1c95e..98775a82f85 100644 --- a/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSaleCardPresentPaymentReconnectingMessageView.swift +++ b/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSaleCardPresentPaymentReconnectingMessageView.swift @@ -13,6 +13,7 @@ struct PointOfSaleCardPresentPaymentReconnectingMessageView: View { var body: some View { VStack(alignment: .center, spacing: POSSpacing.none) { + Spacer().frame(minHeight: 0) ProgressView() .progressViewStyle(POSProgressViewStyle()) .frame(width: Constants.spinnerDimension, height: Constants.spinnerDimension) @@ -37,6 +38,7 @@ struct PointOfSaleCardPresentPaymentReconnectingMessageView: View { } .buttonStyle(POSFilledButtonStyle(size: .normal)) .frame(width: width * 0.5) + Spacer().frame(minHeight: 0) } .frame(maxWidth: .infinity) .measureWidth({ containerWidth in diff --git a/Modules/Sources/PointOfSale/ViewHelpers/TotalsViewHelper.swift b/Modules/Sources/PointOfSale/ViewHelpers/TotalsViewHelper.swift index f09512bb5b0..02984481530 100644 --- a/Modules/Sources/PointOfSale/ViewHelpers/TotalsViewHelper.swift +++ b/Modules/Sources/PointOfSale/ViewHelpers/TotalsViewHelper.swift @@ -64,6 +64,7 @@ struct TotalsViewHelper { switch paymentState.card { case .idle, .acceptingCard, + .paymentError, .preparingReader: return true case .validatingOrder, @@ -71,7 +72,6 @@ struct TotalsViewHelper { .paymentIntentCreationError, .processingPayment, .cardInserted, - .paymentError, .cardPaymentSuccessful: return false } diff --git a/Modules/Tests/PointOfSaleTests/ViewHelpers/TotalsViewHelperTests.swift b/Modules/Tests/PointOfSaleTests/ViewHelpers/TotalsViewHelperTests.swift index 14ec61626c7..978c5f8f0ad 100644 --- a/Modules/Tests/PointOfSaleTests/ViewHelpers/TotalsViewHelperTests.swift +++ b/Modules/Tests/PointOfSaleTests/ViewHelpers/TotalsViewHelperTests.swift @@ -191,7 +191,8 @@ struct TotalsViewHelperTests { @Test(arguments: [ (CardPresentPaymentReaderConnectionStatus.reconnecting(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.idle), (CardPresentPaymentReaderConnectionStatus.reconnecting(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.acceptingCard), - (CardPresentPaymentReaderConnectionStatus.reconnecting(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.preparingReader) + (CardPresentPaymentReaderConnectionStatus.reconnecting(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.preparingReader), + (CardPresentPaymentReaderConnectionStatus.reconnecting(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.paymentError) ]) func test_shouldShowReconnectingMessage_returns_true_when_reconnecting_and_no_card_payment_ongoing( readerConnectionStatus: CardPresentPaymentReaderConnectionStatus, @@ -202,7 +203,6 @@ struct TotalsViewHelperTests { @Test(arguments: [ (CardPresentPaymentReaderConnectionStatus.reconnecting(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.cardPaymentSuccessful), - (CardPresentPaymentReaderConnectionStatus.reconnecting(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.paymentError), (CardPresentPaymentReaderConnectionStatus.reconnecting(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.processingPayment), (CardPresentPaymentReaderConnectionStatus.reconnecting(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.validatingOrder), (CardPresentPaymentReaderConnectionStatus.reconnecting(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.validatingOrderError) From 16d38d29a60111b723ab10fde2ec1a8be42c8dc7 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Wed, 4 Feb 2026 08:58:08 +0200 Subject: [PATCH 23/61] Clear stale reconnectionCancelable in early-return branch --- .../CardReader/StripeCardReader/StripeCardReaderService.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift b/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift index cd0f56e95a6..6b2ffcbc789 100644 --- a/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift +++ b/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift @@ -641,6 +641,7 @@ extension StripeCardReaderService: CardReaderService { guard let self = self, let reconnectionCancelable = self.reconnectionCancelable, !reconnectionCancelable.completed else { + self?.reconnectionCancelable = nil self?.reconnectionStateSubject.send(.idle) return promise(.success(())) } From c2ad8c5503c37397d2475bc25274e0ec8c762b5b Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:00:05 +0200 Subject: [PATCH 24/61] Rename test to better reflect payment states being tested --- .../PointOfSaleTests/ViewHelpers/TotalsViewHelperTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Tests/PointOfSaleTests/ViewHelpers/TotalsViewHelperTests.swift b/Modules/Tests/PointOfSaleTests/ViewHelpers/TotalsViewHelperTests.swift index 978c5f8f0ad..bb3944d649f 100644 --- a/Modules/Tests/PointOfSaleTests/ViewHelpers/TotalsViewHelperTests.swift +++ b/Modules/Tests/PointOfSaleTests/ViewHelpers/TotalsViewHelperTests.swift @@ -194,7 +194,7 @@ struct TotalsViewHelperTests { (CardPresentPaymentReaderConnectionStatus.reconnecting(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.preparingReader), (CardPresentPaymentReaderConnectionStatus.reconnecting(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.paymentError) ]) - func test_shouldShowReconnectingMessage_returns_true_when_reconnecting_and_no_card_payment_ongoing( + func test_shouldShowReconnectingMessage_returns_true_when_reconnecting_and_payment_not_actively_processing( readerConnectionStatus: CardPresentPaymentReaderConnectionStatus, paymentState: PointOfSaleCardPaymentState) { #expect(TotalsViewHelper().shouldShowReconnectingMessage(readerConnectionStatus: readerConnectionStatus, From bc009f22f26047669b9cb9ac0241647ce6e5a22d Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:01:32 +0200 Subject: [PATCH 25/61] Simplify cancelFailedAlreadyCompleted comment --- .../CardReader/StripeCardReader/StripeCardReaderService.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift b/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift index 6b2ffcbc789..44363a9b8e2 100644 --- a/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift +++ b/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift @@ -649,9 +649,7 @@ extension StripeCardReaderService: CardReaderService { reconnectionCancelable.cancel { [weak self] error in self?.reconnectionCancelable = nil - // Treat cancelFailedAlreadyCompleted as success - reconnection already completed, - // so the user's intent to stop reconnection was effectively achieved. - // In this case, don't clear connected readers as readerDidSucceedReconnect may have set them. + // Treat cancelFailedAlreadyCompleted as success - reconnection already completed. if let error = error as? ErrorCode, error.code == .cancelFailedAlreadyCompleted { self?.reconnectionStateSubject.send(.idle) From ddec4df2b78cbdce494b3fd2189a62b470c24054 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:11:10 +0200 Subject: [PATCH 26/61] Use human-readable disconnect reason in reconnection log --- .../CardReader/StripeCardReader/StripeCardReaderService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift b/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift index 44363a9b8e2..22cc1f2bec4 100644 --- a/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift +++ b/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift @@ -1036,7 +1036,7 @@ extension StripeCardReaderService: MobileReaderDelegate { // MARK: - Reconnection delegate methods public func reader(_ reader: Reader, didStartReconnect cancelable: Cancelable, disconnectReason: DisconnectReason) { - DDLogInfo("💳 Reader started auto-reconnection, reason: \(disconnectReason)") + DDLogInfo("💳 Reader started auto-reconnection, reason: \(Terminal.stringFromDisconnectReason(disconnectReason))") reconnectionCancelable = cancelable // Clear connected readers so the UI shows reconnecting state instead of connected connectedReadersSubject.send([]) From 53d101f2f5641f9172d021fd4254ef7f62fc59ed Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:38:04 +0200 Subject: [PATCH 27/61] Add loading state to cancel button in payment reconnecting view Shows spinner on cancel button after tap to provide user feedback. --- ...PointOfSaleCardPresentPaymentReconnectingMessageView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSaleCardPresentPaymentReconnectingMessageView.swift b/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSaleCardPresentPaymentReconnectingMessageView.swift index 98775a82f85..c44ff60e696 100644 --- a/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSaleCardPresentPaymentReconnectingMessageView.swift +++ b/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSaleCardPresentPaymentReconnectingMessageView.swift @@ -6,6 +6,7 @@ struct PointOfSaleCardPresentPaymentReconnectingMessageView: View { @ScaledMetric private var scale: CGFloat = 1.0 @State private var width: CGFloat = 0 + @State private var isCancelling = false init(cancelReconnection: @escaping () -> Void) { self.cancelReconnection = cancelReconnection @@ -31,12 +32,13 @@ struct PointOfSaleCardPresentPaymentReconnectingMessageView: View { .frame(height: dynamicSpacing(PointOfSaleCardPresentPaymentLayout.textAndButtonSpacing)) Button { + isCancelling = true cancelReconnection() } label: { Text(viewModel.cancelReconnectionButtonTitle) .minimumScaleFactor(1) } - .buttonStyle(POSFilledButtonStyle(size: .normal)) + .buttonStyle(POSFilledButtonStyle(size: .normal, isLoading: isCancelling)) .frame(width: width * 0.5) Spacer().frame(minHeight: 0) } From 0c79e5422c4893a93e6ca7b6255add7dbe07f7e1 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:38:08 +0200 Subject: [PATCH 28/61] Add loading state to cancel reconnection in floating button menu Shows "Cancelling..." text and disables menu while cancellation is in progress. --- .../CardReaderConnectionStatusView.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/CardReaderConnectionStatusView.swift b/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/CardReaderConnectionStatusView.swift index 80191abc67a..af8e308c828 100644 --- a/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/CardReaderConnectionStatusView.swift +++ b/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/CardReaderConnectionStatusView.swift @@ -5,6 +5,7 @@ struct CardReaderConnectionStatusView: View { @Environment(PointOfSaleAggregateModel.self) private var posModel @ScaledMetric private var scale: CGFloat = 1.0 @Environment(\.isEnabled) var isEnabled + @State private var isCancellingReconnection = false @ViewBuilder private func circleIcon(with color: Color) -> some View { @@ -60,6 +61,7 @@ struct CardReaderConnectionStatusView: View { case .reconnecting: Menu { Button { + isCancellingReconnection = true posModel.cancelReconnection() } label: { Text(Localization.cancelReconnection) @@ -71,12 +73,13 @@ struct CardReaderConnectionStatusView: View { size: Constants.progressIndicatorDimension * scale, lineWidth: Constants.progressIndicatorLineWidth * scale )) - Text(Localization.readerReconnecting) + Text(isCancellingReconnection ? Localization.cancellingReconnection : Localization.readerReconnecting) .foregroundColor(connectedFontColor) } .padding(.horizontal, Constants.horizontalPadding) .frame(maxHeight: .infinity) } + .disabled(isCancellingReconnection) .accessibilityIdentifier("pos-reader-reconnecting") } } @@ -192,6 +195,12 @@ private extension CardReaderConnectionStatusView { value: "Cancel reconnection", comment: "The title of the menu button to cancel an ongoing card reader reconnection attempt." ) + + static let cancellingReconnection = NSLocalizedString( + "pointOfSale.floatingButtons.cancellingReconnection.title", + value: "Cancelling…", + comment: "The title of the floating button to indicate that the reader reconnection is being cancelled." + ) } } From 0a7fe826f4f61df8b98bbc57d0688422d69f23b7 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:38:12 +0200 Subject: [PATCH 29/61] Add loading state to cancel reconnection in POS settings hardware view Shows "Cancelling..." text and disables menu while cancellation is in progress. --- .../Settings/POSSettingsHardwareDetailView.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsHardwareDetailView.swift b/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsHardwareDetailView.swift index d47256f9a08..be917fbc231 100644 --- a/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsHardwareDetailView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsHardwareDetailView.swift @@ -13,6 +13,7 @@ struct POSSettingsHardwareDetailView: View { @State private var showBarcodeScanningDocumentationModal: Bool = false @State private var showCardReaderDocumentationModal: Bool = false @State private var showSupport: Bool = false + @State private var isCancellingReconnection: Bool = false private var cardReaderName: String { settingsController.connectedCardReader?.name ?? Localization.cardReaderNotConnected @@ -176,13 +177,14 @@ private extension POSSettingsHardwareDetailView { var reconnectingMenuButton: some View { Menu { Button(Localization.cancelReconnectionTitle) { + isCancellingReconnection = true posModel.cancelReconnection() } } label: { HStack(spacing: POSSpacing.small) { ProgressView() .progressViewStyle(POSProgressViewStyle(size: 16, lineWidth: 4)) - Text(Localization.reconnectingButtonTitle) + Text(isCancellingReconnection ? Localization.cancellingReconnectionTitle : Localization.reconnectingButtonTitle) } .font(.posBodySmallBold()) .foregroundColor(.posOnSurface) @@ -195,6 +197,7 @@ private extension POSSettingsHardwareDetailView { .strokeBorder(Color.posOnSurface, lineWidth: 2) ) } + .disabled(isCancellingReconnection) } var cardReadersView: some View { @@ -531,6 +534,12 @@ private extension POSSettingsHardwareDetailView { value: "Cancel reconnection", comment: "Menu option to cancel card reader reconnection in POS settings." ) + + static let cancellingReconnectionTitle = NSLocalizedString( + "pointOfSaleSettingsHardwareDetailView.cancellingReconnectionTitle", + value: "Cancelling…", + comment: "Button title shown when card reader reconnection is being cancelled in POS settings." + ) } } From 932b7fad0da2c924ff74e4db161d09e074fd81ef Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:38:16 +0200 Subject: [PATCH 30/61] Track reconnection cancellation in progress state Adds readerReconnectionCancellationInProgress flag to: - Show loading state during cancellation - Keep connected view visible until cancellation completes --- .../BluetoothCardReaderSettingsConnectedViewModel.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothCardReaderSettingsConnectedViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothCardReaderSettingsConnectedViewModel.swift index 2e13b126d1a..004af176df7 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothCardReaderSettingsConnectedViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothCardReaderSettingsConnectedViewModel.swift @@ -28,6 +28,7 @@ final class BluetoothCardReaderSettingsConnectedViewModel: PaymentSettingsFlowPr private(set) var readerDisconnectInProgress: Bool = false private(set) var readerReconnectionInProgress: Bool = false + private(set) var readerReconnectionCancellationInProgress: Bool = false private var reconnectingReader: CardReader? private var subscriptions = Set() @@ -164,6 +165,7 @@ final class BluetoothCardReaderSettingsConnectedViewModel: PaymentSettingsFlowPr self.reconnectingReader = reader case .succeeded, .failed, .idle: self.readerReconnectionInProgress = false + self.readerReconnectionCancellationInProgress = false self.reconnectingReader = nil } self.updateProperties() @@ -264,6 +266,9 @@ final class BluetoothCardReaderSettingsConnectedViewModel: PaymentSettingsFlowPr /// Dispatch a request to cancel an in-progress reconnection attempt /// func cancelReconnection() { + readerReconnectionCancellationInProgress = true + didUpdate?() + let action = CardPresentPaymentAction.cancelReconnection { _ in } ServiceLocator.stores.dispatch(action) } @@ -298,7 +303,7 @@ final class BluetoothCardReaderSettingsConnectedViewModel: PaymentSettingsFlowPr if !didGetConnectedReaders { newShouldShow = .isUnknown - } else if connectedReaders.isEmpty && !readerReconnectionInProgress { + } else if connectedReaders.isEmpty && !readerReconnectionInProgress && !readerReconnectionCancellationInProgress { newShouldShow = .isFalse } else if connectedReaders.includesTapToPayReader() { /// This screen only supports management of Bluetooth readers, and will have started disconnection From 76620fc7c4765f4c6f32799e1a3a3e59a9a7e0c3 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:38:20 +0200 Subject: [PATCH 31/61] Add cancellingReconnection button state in card reader settings Shows "Cancelling..." with spinner and disables button during cancellation. --- ...eaderSettingsConnectedViewController.swift | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsConnectedViewController.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsConnectedViewController.swift index e942bdba43b..c906fb9bb90 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsConnectedViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsConnectedViewController.swift @@ -350,19 +350,25 @@ private enum UpdatePromptState { private enum DisconnectButtonState { case reconnecting + case cancellingReconnection case disconnecting case updating case idle(updateAvailable: Bool) init(viewModel: BluetoothCardReaderSettingsConnectedViewModel) { - switch (viewModel.readerReconnectionInProgress, viewModel.readerDisconnectInProgress, viewModel.readerUpdateInProgress) { - case (true, _, _): + switch (viewModel.readerReconnectionCancellationInProgress, + viewModel.readerReconnectionInProgress, + viewModel.readerDisconnectInProgress, + viewModel.readerUpdateInProgress) { + case (true, _, _, _): + self = .cancellingReconnection + case (false, true, _, _): self = .reconnecting - case (false, true, _): + case (false, false, true, _): self = .disconnecting - case (false, false, true): + case (false, false, false, true): self = .updating - case (false, false, false): + case (false, false, false, false): self = .idle(updateAvailable: viewModel.optionalReaderUpdateAvailable) } } @@ -371,6 +377,8 @@ private enum DisconnectButtonState { switch self { case .reconnecting: return Localization.cancelReconnectionButtonTitle + case .cancellingReconnection: + return Localization.cancellingReconnectionButtonTitle case .disconnecting, .updating, .idle: return Localization.disconnectButtonTitle } @@ -378,7 +386,7 @@ private enum DisconnectButtonState { var style: ButtonTableViewCell.Style { switch self { - case .reconnecting, .disconnecting, .updating: + case .reconnecting, .cancellingReconnection, .disconnecting, .updating: return .primary case .idle(let updateAvailable): return updateAvailable ? .secondary : .primary @@ -389,14 +397,14 @@ private enum DisconnectButtonState { switch self { case .reconnecting, .idle: return true - case .disconnecting, .updating: + case .cancellingReconnection, .disconnecting, .updating: return false } } var showActivityIndicator: Bool { switch self { - case .disconnecting: + case .cancellingReconnection, .disconnecting: return true case .reconnecting, .updating, .idle: return false @@ -407,7 +415,9 @@ private enum DisconnectButtonState { switch self { case .reconnecting: viewModel?.cancelReconnection() - case .disconnecting, .updating, .idle: + case .cancellingReconnection, .disconnecting, .updating: + break + case .idle: viewModel?.disconnectReader() } } @@ -461,5 +471,11 @@ private extension CardReaderSettingsConnectedViewController { comment: "Settings > Manage Card Reader > Connected Reader > A button to cancel the reconnection attempt" ) + static let cancellingReconnectionButtonTitle = NSLocalizedString( + "cardReaderSettingsConnectedViewController.cancellingReconnectionButtonTitle", + value: "Cancelling...", + comment: "Settings > Manage Card Reader > Connected Reader > Button title shown while cancellation is in progress" + ) + } } From adcfb6d1122e1a65a69fd4014a58d9ceb0a0a73e Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:38:25 +0200 Subject: [PATCH 32/61] Skip auto-search after reconnection cancellation Prevents automatic reader search when transitioning to searching view after user cancels reconnection. Manual search still works normally. --- ...ReaderSettingsSearchingViewController.swift | 7 +++++++ .../CardReaderSettingsSearchingViewModel.swift | 18 +++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsSearchingViewController.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsSearchingViewController.swift index c1dd01f26d2..4c682dab2de 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsSearchingViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsSearchingViewController.swift @@ -95,6 +95,12 @@ private extension CardReaderSettingsSearchingViewController { return } + /// Don't auto-search if reconnection was just cancelled or failed + /// + guard !viewModel.shouldSkipAutoSearch() else { + return + } + searchAndConnect() didBeginSearchAutomatically = true } @@ -104,6 +110,7 @@ private extension CardReaderSettingsSearchingViewController { // private extension CardReaderSettingsSearchingViewController { func searchAndConnect() { + viewModel.clearSkipAutoSearch() connectionController?.searchAndConnect() { [weak self] _ in /// No need for logic here. Once connected, the connected reader will publish /// through the `cardReaderAvailableSubscription` diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsSearchingViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsSearchingViewModel.swift index ee07f4da532..c56d819501e 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsSearchingViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsSearchingViewModel.swift @@ -15,6 +15,7 @@ final class CardReaderSettingsSearchingViewModel: PaymentSettingsFlowPresentedVi } private(set) var readerReconnectionInProgress: Bool = false + private(set) var skipAutoSearchAfterReconnectionEnds: Bool = false private(set) var knownReaderProvider: CardReaderSettingsKnownReaderProvider? private(set) var siteID: Int64 @@ -61,6 +62,14 @@ final class CardReaderSettingsSearchingViewModel: PaymentSettingsFlowPresentedVi knownReaderID != nil } + func shouldSkipAutoSearch() -> Bool { + skipAutoSearchAfterReconnectionEnds + } + + func clearSkipAutoSearch() { + skipAutoSearchAfterReconnectionEnds = false + } + /// Monitor for a known reader /// private func beginKnownReaderObservation() { @@ -103,7 +112,14 @@ final class CardReaderSettingsSearchingViewModel: PaymentSettingsFlowPresentedVi switch state { case .reconnecting: self.readerReconnectionInProgress = true - case .succeeded, .failed, .idle: + self.skipAutoSearchAfterReconnectionEnds = false + case .succeeded: + self.readerReconnectionInProgress = false + self.skipAutoSearchAfterReconnectionEnds = false + case .failed, .idle: + if self.readerReconnectionInProgress { + self.skipAutoSearchAfterReconnectionEnds = true + } self.readerReconnectionInProgress = false } self.reevaluateShouldShow() From 5241d596aa2c86ae404eac5ab7753f2995c96e35 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:38:30 +0200 Subject: [PATCH 33/61] Add tests for skip auto-search after reconnection cancellation Adds reconnection state simulation to mock and tests for: - Skip auto-search when reconnection is cancelled - Allow auto-search when reconnection succeeds - Clear skip flag when user manually triggers search --- ...MockCardPresentPaymentsStoresManager.swift | 23 +++++ ...eaderSettingsSearchingViewModelTests.swift | 84 +++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/WooCommerce/WooCommerceTests/Mocks/MockCardPresentPaymentsStoresManager.swift b/WooCommerce/WooCommerceTests/Mocks/MockCardPresentPaymentsStoresManager.swift index 5934a1756ac..b0806fa051b 100644 --- a/WooCommerce/WooCommerceTests/Mocks/MockCardPresentPaymentsStoresManager.swift +++ b/WooCommerce/WooCommerceTests/Mocks/MockCardPresentPaymentsStoresManager.swift @@ -17,6 +17,7 @@ final class MockCardPresentPaymentsStoresManager: DefaultStoresManager { private var failUpdate: Bool private var failConnection: Bool private var softwareUpdateSubject: CurrentValueSubject = .init(.none) + private var reconnectionSubject: CurrentValueSubject = .init(.idle) private var paymentExtension: CardPresentPaymentsPlugin var receivedActions: [CardPresentPaymentAction] = [] @@ -95,6 +96,8 @@ final class MockCardPresentPaymentsStoresManager: DefaultStoresManager { onCompletion(Result.success(())) case .cancelReconnection(let onCompletion): onCompletion(Result.success(())) + case .observeCardReaderReconnectionState(let onCompletion): + onCompletion(reconnectionEvents) default: break } @@ -103,6 +106,10 @@ final class MockCardPresentPaymentsStoresManager: DefaultStoresManager { var softwareUpdateEvents: AnyPublisher { softwareUpdateSubject.eraseToAnyPublisher() } + + var reconnectionEvents: AnyPublisher { + reconnectionSubject.eraseToAnyPublisher() + } } extension MockCardPresentPaymentsStoresManager { @@ -146,6 +153,22 @@ extension MockCardPresentPaymentsStoresManager { softwareUpdateSubject.send(.available) } + func simulateReconnecting(reader: CardReader) { + reconnectionSubject.send(.reconnecting(reader)) + } + + func simulateReconnectionSucceeded() { + reconnectionSubject.send(.succeeded) + } + + func simulateReconnectionFailed() { + reconnectionSubject.send(.failed) + } + + func simulateReconnectionIdle() { + reconnectionSubject.send(.idle) + } + func insertSamplePaymentGateway(forSiteID siteID: Int64) { let paymentGatewayAccount = PaymentGatewayAccount .fake() diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/CardReaderSettings/CardReaderSettingsSearchingViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/CardReaderSettings/CardReaderSettingsSearchingViewModelTests.swift index 004130a6d92..00947c619f7 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/CardReaderSettings/CardReaderSettingsSearchingViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/CardReaderSettings/CardReaderSettingsSearchingViewModelTests.swift @@ -59,4 +59,88 @@ final class CardReaderSettingsSearchingViewModelTests: XCTestCase { wait(for: [expectation], timeout: Constants.expectationTimeout) } + + // MARK: - Skip Auto Search Tests + + func test_shouldSkipAutoSearch_returns_true_when_reconnection_is_cancelled() { + let mockKnownReaderProvider = MockKnownReaderProvider() + + let mockStoresManager = MockCardPresentPaymentsStoresManager( + connectedReaders: [], + discoveredReaders: [], + sessionManager: SessionManager.testingInstance + ) + ServiceLocator.setStores(mockStoresManager) + + let viewModel = CardReaderSettingsSearchingViewModel( + didChangeShouldShow: nil, + knownReaderProvider: mockKnownReaderProvider, + configuration: TestConstants.mockConfiguration, + cardReaderConnectionAnalyticsTracker: .init(configuration: TestConstants.mockConfiguration, + siteID: 0, + connectionType: .userInitiated, + stores: mockStoresManager)) + + // Simulate reconnection starting then becoming idle (cancelled) + mockStoresManager.simulateReconnecting(reader: MockCardReader.bbposChipper2XBT()) + mockStoresManager.simulateReconnectionIdle() + + XCTAssertTrue(viewModel.shouldSkipAutoSearch()) + } + + func test_shouldSkipAutoSearch_returns_false_when_reconnection_succeeds() { + let mockKnownReaderProvider = MockKnownReaderProvider() + + let mockStoresManager = MockCardPresentPaymentsStoresManager( + connectedReaders: [], + discoveredReaders: [], + sessionManager: SessionManager.testingInstance + ) + ServiceLocator.setStores(mockStoresManager) + + let viewModel = CardReaderSettingsSearchingViewModel( + didChangeShouldShow: nil, + knownReaderProvider: mockKnownReaderProvider, + configuration: TestConstants.mockConfiguration, + cardReaderConnectionAnalyticsTracker: .init(configuration: TestConstants.mockConfiguration, + siteID: 0, + connectionType: .userInitiated, + stores: mockStoresManager)) + + // Simulate reconnection starting then succeeding + mockStoresManager.simulateReconnecting(reader: MockCardReader.bbposChipper2XBT()) + mockStoresManager.simulateReconnectionSucceeded() + + XCTAssertFalse(viewModel.shouldSkipAutoSearch()) + } + + func test_shouldSkipAutoSearch_returns_false_after_clearSkipAutoSearch_is_called() { + let mockKnownReaderProvider = MockKnownReaderProvider() + + let mockStoresManager = MockCardPresentPaymentsStoresManager( + connectedReaders: [], + discoveredReaders: [], + sessionManager: SessionManager.testingInstance + ) + ServiceLocator.setStores(mockStoresManager) + + let viewModel = CardReaderSettingsSearchingViewModel( + didChangeShouldShow: nil, + knownReaderProvider: mockKnownReaderProvider, + configuration: TestConstants.mockConfiguration, + cardReaderConnectionAnalyticsTracker: .init(configuration: TestConstants.mockConfiguration, + siteID: 0, + connectionType: .userInitiated, + stores: mockStoresManager)) + + // Simulate reconnection cancelled (sets skip flag) + mockStoresManager.simulateReconnecting(reader: MockCardReader.bbposChipper2XBT()) + mockStoresManager.simulateReconnectionIdle() + XCTAssertTrue(viewModel.shouldSkipAutoSearch()) + + // Clear the skip flag (simulates user tapping Connect button) + viewModel.clearSkipAutoSearch() + + XCTAssertFalse(viewModel.shouldSkipAutoSearch()) + } } From ba2076fa79bddf610a270f3c7c14389343c7a3a8 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:44:44 +0200 Subject: [PATCH 34/61] Add log messages for reconnection cancellation Helps with testing by logging: - When no active reconnection is in progress - When cancellation is requested - When reconnection already completed - When cancellation succeeds --- .../StripeCardReader/StripeCardReaderService.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift b/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift index 22cc1f2bec4..987e0bed3e3 100644 --- a/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift +++ b/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift @@ -641,17 +641,20 @@ extension StripeCardReaderService: CardReaderService { guard let self = self, let reconnectionCancelable = self.reconnectionCancelable, !reconnectionCancelable.completed else { + DDLogInfo("💳 Reconnection cancellation: no active reconnection in progress") self?.reconnectionCancelable = nil self?.reconnectionStateSubject.send(.idle) return promise(.success(())) } + DDLogInfo("💳 Reconnection cancellation requested") reconnectionCancelable.cancel { [weak self] error in self?.reconnectionCancelable = nil // Treat cancelFailedAlreadyCompleted as success - reconnection already completed. if let error = error as? ErrorCode, error.code == .cancelFailedAlreadyCompleted { + DDLogInfo("💳 Reconnection cancellation: reconnection already completed") self?.reconnectionStateSubject.send(.idle) promise(.success(())) } else if let error = error { @@ -660,7 +663,7 @@ extension StripeCardReaderService: CardReaderService { let underlyingError = Self.logAndDecodeError(error) promise(.failure(CardReaderServiceError.reconnectionCancellation(underlyingError: underlyingError))) } else { - // Cancellation succeeded - reconnection was stopped, clear connected readers + DDLogInfo("💳 Reconnection cancellation succeeded") self?.connectedReadersSubject.send([]) self?.reconnectionStateSubject.send(.idle) promise(.success(())) From 87a020323da4e9fd2c95ef8d2efeea843baba06a Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:29:21 +0200 Subject: [PATCH 35/61] Revert "Show reconnecting UI during payment error state" This reverts commit cbe1f6f6885f7a9d75712d52ceede83e8dde9742. --- .../PointOfSale/Models/PointOfSaleAggregateModel.swift | 8 +++++++- ...tOfSaleCardPresentPaymentReconnectingMessageView.swift | 2 -- .../PointOfSale/ViewHelpers/TotalsViewHelper.swift | 2 +- .../ViewHelpers/TotalsViewHelperTests.swift | 4 ++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift b/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift index 4994ff59e5b..0f251b676f1 100644 --- a/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift +++ b/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift @@ -482,7 +482,13 @@ extension PointOfSaleAggregateModel { func cancelThenCollectPayment() async { try? await cardPresentPaymentService.cancelPayment() - await collectCardPayment() + + switch cardReaderConnectionStatus { + case .connected: + await collectCardPayment() + case .disconnected, .disconnecting, .cancellingConnection, .reconnecting: + addMoreToCart() + } } @Sendable private func setupReaderReconnectionObservation() { diff --git a/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSaleCardPresentPaymentReconnectingMessageView.swift b/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSaleCardPresentPaymentReconnectingMessageView.swift index c44ff60e696..98fa693d49c 100644 --- a/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSaleCardPresentPaymentReconnectingMessageView.swift +++ b/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSaleCardPresentPaymentReconnectingMessageView.swift @@ -14,7 +14,6 @@ struct PointOfSaleCardPresentPaymentReconnectingMessageView: View { var body: some View { VStack(alignment: .center, spacing: POSSpacing.none) { - Spacer().frame(minHeight: 0) ProgressView() .progressViewStyle(POSProgressViewStyle()) .frame(width: Constants.spinnerDimension, height: Constants.spinnerDimension) @@ -40,7 +39,6 @@ struct PointOfSaleCardPresentPaymentReconnectingMessageView: View { } .buttonStyle(POSFilledButtonStyle(size: .normal, isLoading: isCancelling)) .frame(width: width * 0.5) - Spacer().frame(minHeight: 0) } .frame(maxWidth: .infinity) .measureWidth({ containerWidth in diff --git a/Modules/Sources/PointOfSale/ViewHelpers/TotalsViewHelper.swift b/Modules/Sources/PointOfSale/ViewHelpers/TotalsViewHelper.swift index 02984481530..f09512bb5b0 100644 --- a/Modules/Sources/PointOfSale/ViewHelpers/TotalsViewHelper.swift +++ b/Modules/Sources/PointOfSale/ViewHelpers/TotalsViewHelper.swift @@ -64,7 +64,6 @@ struct TotalsViewHelper { switch paymentState.card { case .idle, .acceptingCard, - .paymentError, .preparingReader: return true case .validatingOrder, @@ -72,6 +71,7 @@ struct TotalsViewHelper { .paymentIntentCreationError, .processingPayment, .cardInserted, + .paymentError, .cardPaymentSuccessful: return false } diff --git a/Modules/Tests/PointOfSaleTests/ViewHelpers/TotalsViewHelperTests.swift b/Modules/Tests/PointOfSaleTests/ViewHelpers/TotalsViewHelperTests.swift index bb3944d649f..0af67732978 100644 --- a/Modules/Tests/PointOfSaleTests/ViewHelpers/TotalsViewHelperTests.swift +++ b/Modules/Tests/PointOfSaleTests/ViewHelpers/TotalsViewHelperTests.swift @@ -191,8 +191,7 @@ struct TotalsViewHelperTests { @Test(arguments: [ (CardPresentPaymentReaderConnectionStatus.reconnecting(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.idle), (CardPresentPaymentReaderConnectionStatus.reconnecting(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.acceptingCard), - (CardPresentPaymentReaderConnectionStatus.reconnecting(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.preparingReader), - (CardPresentPaymentReaderConnectionStatus.reconnecting(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.paymentError) + (CardPresentPaymentReaderConnectionStatus.reconnecting(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.preparingReader) ]) func test_shouldShowReconnectingMessage_returns_true_when_reconnecting_and_payment_not_actively_processing( readerConnectionStatus: CardPresentPaymentReaderConnectionStatus, @@ -203,6 +202,7 @@ struct TotalsViewHelperTests { @Test(arguments: [ (CardPresentPaymentReaderConnectionStatus.reconnecting(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.cardPaymentSuccessful), + (CardPresentPaymentReaderConnectionStatus.reconnecting(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.paymentError), (CardPresentPaymentReaderConnectionStatus.reconnecting(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.processingPayment), (CardPresentPaymentReaderConnectionStatus.reconnecting(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.validatingOrder), (CardPresentPaymentReaderConnectionStatus.reconnecting(.init(name: "", batteryLevel: nil)), PointOfSaleCardPaymentState.validatingOrderError) From e753f624a681d82d896e4382989fad5396dfa465 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Wed, 4 Feb 2026 16:11:05 +0200 Subject: [PATCH 36/61] Cancel reconnection before cancelPayment to prevent UI hang When a card reader disconnects during payment and auto-reconnection starts, the Stripe SDK's cancelPaymentIntent() blocks until reconnection completes or is cancelled. This caused the UI to hang when users tapped "Try Again" or "Go back to checkout" buttons. Fix by checking if reconnection is in progress before calling cancelPayment(), and cancelling reconnection first if needed. --- .../Models/PointOfSaleAggregateModel.swift | 4 ++ .../PointOfSaleAggregateModelTests.swift | 46 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift b/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift index 0f251b676f1..cd2d5c7e746 100644 --- a/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift +++ b/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift @@ -481,6 +481,10 @@ extension PointOfSaleAggregateModel { } func cancelThenCollectPayment() async { + if case .reconnecting = cardReaderConnectionStatus { + await cardPresentPaymentService.cancelReconnection() + } + try? await cardPresentPaymentService.cancelPayment() switch cardReaderConnectionStatus { diff --git a/Modules/Tests/PointOfSaleTests/Models/PointOfSaleAggregateModelTests.swift b/Modules/Tests/PointOfSaleTests/Models/PointOfSaleAggregateModelTests.swift index 1958dc3d34c..3ff591261f1 100644 --- a/Modules/Tests/PointOfSaleTests/Models/PointOfSaleAggregateModelTests.swift +++ b/Modules/Tests/PointOfSaleTests/Models/PointOfSaleAggregateModelTests.swift @@ -708,6 +708,52 @@ struct PointOfSaleAggregateModelTests { #expect(cardPresentPaymentService.collectPaymentWasCalled) } + @Test func cancelThenCollectPayment_cancels_reconnection_first_when_reconnecting() async throws { + // Given + let itemsController = MockPointOfSaleItemsController() + let sut = makePointOfSaleAggregateModel( + itemsController: itemsController, + cardPresentPaymentService: cardPresentPaymentService, + orderController: orderController) + + // Set connection status to reconnecting + let reader = CardPresentPaymentCardReader(name: "Test Reader", batteryLevel: 0.5) + cardPresentPaymentService.connectionStatus = .reconnecting(reader) + + // Wait for the status to propagate + try await Task.sleep(nanoseconds: 100_000_000) + + // When + await sut.cancelThenCollectPayment() + + // Then + #expect(cardPresentPaymentService.cancelReconnectionCalled == true) + #expect(cardPresentPaymentService.cancelPaymentCalled == true) + } + + @Test func cancelThenCollectPayment_does_not_cancel_reconnection_when_not_reconnecting() async throws { + // Given + let itemsController = MockPointOfSaleItemsController() + let sut = makePointOfSaleAggregateModel( + itemsController: itemsController, + cardPresentPaymentService: cardPresentPaymentService, + orderController: orderController) + + // Set connection status to connected (not reconnecting) + let reader = CardPresentPaymentCardReader(name: "Test Reader", batteryLevel: 0.5) + cardPresentPaymentService.connectionStatus = .connected(reader) + + // Wait for the status to propagate + try await Task.sleep(nanoseconds: 100_000_000) + + // When + await sut.cancelThenCollectPayment() + + // Then + #expect(cardPresentPaymentService.cancelReconnectionCalled == false) + #expect(cardPresentPaymentService.cancelPaymentCalled == true) + } + // MARK: Onboarding @Test func cardPresentPaymentOnboardingViewContainer_is_non_nil_when_onboarding_is_required() async throws { // Given From fad489982ff3cb7196a8813ce47a91659aaf282b Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Wed, 4 Feb 2026 17:10:23 +0200 Subject: [PATCH 37/61] Cancel reconnection before starting a new payment --- .../StripeCardReader/StripeCardReaderService.swift | 1 - .../Collect Payments/CollectOrderPaymentUseCase.swift | 10 ++++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift b/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift index 987e0bed3e3..2fca72a20ef 100644 --- a/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift +++ b/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift @@ -641,7 +641,6 @@ extension StripeCardReaderService: CardReaderService { guard let self = self, let reconnectionCancelable = self.reconnectionCancelable, !reconnectionCancelable.completed else { - DDLogInfo("💳 Reconnection cancellation: no active reconnection in progress") self?.reconnectionCancelable = nil self?.reconnectionStateSubject.send(.idle) return promise(.success(())) diff --git a/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift b/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift index d3612865ec9..16149bc454a 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift @@ -192,6 +192,8 @@ where TapToPayAlertProvider.AlertDetails == AlertPresenter.AlertDetails, } .store(in: &cancellables) + cancelReconnectionIfNeeded() + Task { await preflightController.start(discoveryMethod: discoveryMethod) } @@ -209,6 +211,14 @@ where TapToPayAlertProvider.AlertDetails == AlertPresenter.AlertDetails, // MARK: Private functions private extension CollectOrderPaymentUseCase { + + /// Cancels an automatic card reader reconnection since a new payment cannot begin while a reconnection is ongoing + /// + func cancelReconnectionIfNeeded() { + let action = CardPresentPaymentAction.cancelReconnection { _ in } + stores.dispatch(action) + } + /// Checks whether the amount to be collected is valid: (not nil, convertible to decimal, higher than minimum amount ...) /// func isTotalAmountValid() -> Bool { From b56fbbeeaf8a641e96828663fae7a6b53622a880 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Wed, 4 Feb 2026 17:43:35 +0200 Subject: [PATCH 38/61] Reset reconnection flags and update cancel Reset UI reconnection cancellation flags on view appear to avoid stale state in several POS views. Make cancelReconnectionIfNeeded run asynchronously on the main actor by awaiting it from a Task and annotating the function with @MainActor to ensure store updates happen on the main thread. Update test mocks and unit tests to pass a CardReader parameter for reconnection succeeded/failed simulation. --- .../CardReaderConnectionStatusView.swift | 3 +++ ...tOfSaleCardPresentPaymentReconnectingMessageView.swift | 3 +++ .../Settings/POSSettingsHardwareDetailView.swift | 3 +++ .../Collect Payments/CollectOrderPaymentUseCase.swift | 5 ++++- .../Mocks/MockCardPresentPaymentsStoresManager.swift | 8 ++++---- .../CardReaderSettingsSearchingViewModelTests.swift | 5 +++-- 6 files changed, 20 insertions(+), 7 deletions(-) diff --git a/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/CardReaderConnectionStatusView.swift b/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/CardReaderConnectionStatusView.swift index af8e308c828..cf6e7e4dd6a 100644 --- a/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/CardReaderConnectionStatusView.swift +++ b/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/CardReaderConnectionStatusView.swift @@ -84,6 +84,9 @@ struct CardReaderConnectionStatusView: View { } } .font(Constants.font) + .onAppear { + isCancellingReconnection = false + } .dynamicTypeSize(...DynamicTypeSize.accessibility2) .opacity(isEnabled ? 1 : 0.5) } diff --git a/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSaleCardPresentPaymentReconnectingMessageView.swift b/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSaleCardPresentPaymentReconnectingMessageView.swift index 98fa693d49c..e25c64bd9ee 100644 --- a/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSaleCardPresentPaymentReconnectingMessageView.swift +++ b/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSaleCardPresentPaymentReconnectingMessageView.swift @@ -45,6 +45,9 @@ struct PointOfSaleCardPresentPaymentReconnectingMessageView: View { width = containerWidth }) .multilineTextAlignment(.center) + .onAppear { + isCancelling = false + } } private func dynamicSpacing(_ spacing: CGFloat) -> CGFloat { diff --git a/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsHardwareDetailView.swift b/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsHardwareDetailView.swift index be917fbc231..902af3099fa 100644 --- a/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsHardwareDetailView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsHardwareDetailView.swift @@ -198,6 +198,9 @@ private extension POSSettingsHardwareDetailView { ) } .disabled(isCancellingReconnection) + .onAppear { + isCancellingReconnection = false + } } var cardReadersView: some View { diff --git a/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift b/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift index 16149bc454a..7c8307bd6fe 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift @@ -192,7 +192,9 @@ where TapToPayAlertProvider.AlertDetails == AlertPresenter.AlertDetails, } .store(in: &cancellables) - cancelReconnectionIfNeeded() + Task { + await cancelReconnectionIfNeeded() + } Task { await preflightController.start(discoveryMethod: discoveryMethod) @@ -214,6 +216,7 @@ private extension CollectOrderPaymentUseCase { /// Cancels an automatic card reader reconnection since a new payment cannot begin while a reconnection is ongoing /// + @MainActor func cancelReconnectionIfNeeded() { let action = CardPresentPaymentAction.cancelReconnection { _ in } stores.dispatch(action) diff --git a/WooCommerce/WooCommerceTests/Mocks/MockCardPresentPaymentsStoresManager.swift b/WooCommerce/WooCommerceTests/Mocks/MockCardPresentPaymentsStoresManager.swift index b0806fa051b..f20d3457a53 100644 --- a/WooCommerce/WooCommerceTests/Mocks/MockCardPresentPaymentsStoresManager.swift +++ b/WooCommerce/WooCommerceTests/Mocks/MockCardPresentPaymentsStoresManager.swift @@ -157,12 +157,12 @@ extension MockCardPresentPaymentsStoresManager { reconnectionSubject.send(.reconnecting(reader)) } - func simulateReconnectionSucceeded() { - reconnectionSubject.send(.succeeded) + func simulateReconnectionSucceeded(reader: CardReader) { + reconnectionSubject.send(.succeeded(reader: reader)) } - func simulateReconnectionFailed() { - reconnectionSubject.send(.failed) + func simulateReconnectionFailed(reader: CardReader) { + reconnectionSubject.send(.failed(reader: reader)) } func simulateReconnectionIdle() { diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/CardReaderSettings/CardReaderSettingsSearchingViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/CardReaderSettings/CardReaderSettingsSearchingViewModelTests.swift index 00947c619f7..7b382a9cd3f 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/CardReaderSettings/CardReaderSettingsSearchingViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/CardReaderSettings/CardReaderSettingsSearchingViewModelTests.swift @@ -108,8 +108,9 @@ final class CardReaderSettingsSearchingViewModelTests: XCTestCase { stores: mockStoresManager)) // Simulate reconnection starting then succeeding - mockStoresManager.simulateReconnecting(reader: MockCardReader.bbposChipper2XBT()) - mockStoresManager.simulateReconnectionSucceeded() + let reader = MockCardReader.bbposChipper2XBT() + mockStoresManager.simulateReconnecting(reader: reader) + mockStoresManager.simulateReconnectionSucceeded(reader: reader) XCTAssertFalse(viewModel.shouldSkipAutoSearch()) } From f8c966b18641f095e0920d283c4a6c0186bf4af1 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Wed, 4 Feb 2026 18:15:10 +0200 Subject: [PATCH 39/61] Update MockCardPresentPaymentsStoresManager.swift --- .../Mocks/MockCardPresentPaymentsStoresManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WooCommerce/WooCommerceTests/Mocks/MockCardPresentPaymentsStoresManager.swift b/WooCommerce/WooCommerceTests/Mocks/MockCardPresentPaymentsStoresManager.swift index f20d3457a53..875d4c183c9 100644 --- a/WooCommerce/WooCommerceTests/Mocks/MockCardPresentPaymentsStoresManager.swift +++ b/WooCommerce/WooCommerceTests/Mocks/MockCardPresentPaymentsStoresManager.swift @@ -154,7 +154,7 @@ extension MockCardPresentPaymentsStoresManager { } func simulateReconnecting(reader: CardReader) { - reconnectionSubject.send(.reconnecting(reader)) + reconnectionSubject.send(.reconnecting(reader: reader)) } func simulateReconnectionSucceeded(reader: CardReader) { From cc8d4a4cf445b9b73d9b7a72a994f629ad681555 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Mon, 9 Mar 2026 14:44:27 +0200 Subject: [PATCH 40/61] Add reconnection cancellation to POSPaymentModel.cancelThenCollectPayment Cancel active reconnection before cancelling payment to prevent UI hangs when reader is in reconnecting state. --- Modules/Sources/PointOfSale/Controllers/POSPaymentModel.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Modules/Sources/PointOfSale/Controllers/POSPaymentModel.swift b/Modules/Sources/PointOfSale/Controllers/POSPaymentModel.swift index b65c45ef3bd..111fca169c9 100644 --- a/Modules/Sources/PointOfSale/Controllers/POSPaymentModel.swift +++ b/Modules/Sources/PointOfSale/Controllers/POSPaymentModel.swift @@ -170,6 +170,10 @@ extension POSPaymentModel { } func cancelThenCollectPayment() async { + if case .reconnecting = cardReaderConnectionStatus { + await cardPresentPaymentService.cancelReconnection() + } + try? await cardPresentPaymentService.cancelPayment() await collectCardPayment() } From bfdd9260141bcee69918b9dc73b3d3c786e3b21f Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:01:10 +0200 Subject: [PATCH 41/61] Remove accidentally committed worktree submodule reference --- .claude/worktrees/confident-black | 1 - 1 file changed, 1 deletion(-) delete mode 160000 .claude/worktrees/confident-black diff --git a/.claude/worktrees/confident-black b/.claude/worktrees/confident-black deleted file mode 160000 index 7f2f490ff5d..00000000000 --- a/.claude/worktrees/confident-black +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7f2f490ff5dde11fb42f2711433202e9000e62c6 From 0c3ca2a320e44727d25d87e4dedfb42d7dcf6703 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:29:36 +0200 Subject: [PATCH 42/61] Pass cancelReconnectionAction to bookings flow and add @MainActor - Add cancelReconnection() to POSPaymentModel so both cart and bookings flows can cancel reconnection - Pass cancelReconnectionAction in POSPaymentContentView - Delegate cancelReconnection through paymentModel in aggregate model - Add @MainActor since calling paymentModel synchronously requires it --- .../PointOfSale/Controllers/POSPaymentModel.swift | 10 ++++++++++ .../PointOfSale/Models/PointOfSaleAggregateModel.swift | 5 ++--- .../Presentation/POSPaymentContentView.swift | 1 + 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/Modules/Sources/PointOfSale/Controllers/POSPaymentModel.swift b/Modules/Sources/PointOfSale/Controllers/POSPaymentModel.swift index 111fca169c9..da681436a81 100644 --- a/Modules/Sources/PointOfSale/Controllers/POSPaymentModel.swift +++ b/Modules/Sources/PointOfSale/Controllers/POSPaymentModel.swift @@ -175,6 +175,10 @@ extension POSPaymentModel { } try? await cardPresentPaymentService.cancelPayment() + + guard case .connected = cardReaderConnectionStatus else { + return + } await collectCardPayment() } @@ -185,6 +189,12 @@ extension POSPaymentModel { } } + func cancelReconnection() { + Task { @MainActor [weak self] in + await self?.cardPresentPaymentService.cancelReconnection() + } + } + func disconnectCardReader() { analytics.track(.cardReaderDisconnectTapped) Task { @MainActor [weak self] in diff --git a/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift b/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift index c2765776d5a..2e10e67fbce 100644 --- a/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift +++ b/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift @@ -375,10 +375,9 @@ extension PointOfSaleAggregateModel { paymentModel.connectCardReader() } + @MainActor func cancelReconnection() { - Task { @MainActor [weak self] in - await self?.cardPresentPaymentService.cancelReconnection() - } + paymentModel.cancelReconnection() } @MainActor diff --git a/Modules/Sources/PointOfSale/Presentation/POSPaymentContentView.swift b/Modules/Sources/PointOfSale/Presentation/POSPaymentContentView.swift index 81a805ec3c1..ff1cc2057bc 100644 --- a/Modules/Sources/PointOfSale/Presentation/POSPaymentContentView.swift +++ b/Modules/Sources/PointOfSale/Presentation/POSPaymentContentView.swift @@ -92,6 +92,7 @@ struct POSPaymentContentView: View { paymentState: paymentModel.paymentState, cardPresentPaymentInlineMessage: paymentModel.cardPresentPaymentInlineMessage, connectCardReaderAction: paymentModel.connectCardReader, + cancelReconnectionAction: paymentModel.cancelReconnection, showLoadingWhenIdle: !paymentModel.isZeroTotal) } } From 3019abd04c053ed165254eb1abf848cc8b7c01fc Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:31:47 +0200 Subject: [PATCH 43/61] Fix cancelThenCollectPayment tests to set state before creating SUT Set connected reader and reconnecting status before creating the aggregate model to avoid needing Task.sleep for state propagation. --- .../Models/PointOfSaleAggregateModelTests.swift | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/Modules/Tests/PointOfSaleTests/Models/PointOfSaleAggregateModelTests.swift b/Modules/Tests/PointOfSaleTests/Models/PointOfSaleAggregateModelTests.swift index 7426f2db699..062fa5e48cc 100644 --- a/Modules/Tests/PointOfSaleTests/Models/PointOfSaleAggregateModelTests.swift +++ b/Modules/Tests/PointOfSaleTests/Models/PointOfSaleAggregateModelTests.swift @@ -736,6 +736,7 @@ struct PointOfSaleAggregateModelTests { @Test func cancelThenCollectPayment_still_collects_payment_when_cancellation_fails() async throws { // Given let itemsController = MockPointOfSaleItemsController() + cardPresentPaymentService.connectedReader = .init(name: "Test Reader", batteryLevel: 0.5) let sut = makePointOfSaleAggregateModel( itemsController: itemsController, cardPresentPaymentService: cardPresentPaymentService, @@ -758,18 +759,13 @@ struct PointOfSaleAggregateModelTests { @Test func cancelThenCollectPayment_cancels_reconnection_first_when_reconnecting() async throws { // Given let itemsController = MockPointOfSaleItemsController() + let reader = CardPresentPaymentCardReader(name: "Test Reader", batteryLevel: 0.5) + cardPresentPaymentService.connectionStatus = .reconnecting(reader) let sut = makePointOfSaleAggregateModel( itemsController: itemsController, cardPresentPaymentService: cardPresentPaymentService, orderController: orderController) - // Set connection status to reconnecting - let reader = CardPresentPaymentCardReader(name: "Test Reader", batteryLevel: 0.5) - cardPresentPaymentService.connectionStatus = .reconnecting(reader) - - // Wait for the status to propagate - try await Task.sleep(nanoseconds: 100_000_000) - // When await sut.cancelThenCollectPayment() From 7bd9dd59580e6022a33f119b7dab733cc926edfc Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:05:03 +0200 Subject: [PATCH 44/61] Fix race condition in cancelReconnectionIfNeeded by making it properly async Merge the two separate Task blocks into one sequential block so cancellation completes before preflight starts. Use withCheckedContinuation to actually await the store action completion. --- .../CollectOrderPaymentUseCase.swift | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift b/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift index 7c8307bd6fe..b57e1b0b7b5 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift @@ -194,9 +194,6 @@ where TapToPayAlertProvider.AlertDetails == AlertPresenter.AlertDetails, Task { await cancelReconnectionIfNeeded() - } - - Task { await preflightController.start(discoveryMethod: discoveryMethod) } } @@ -217,9 +214,15 @@ private extension CollectOrderPaymentUseCase { /// Cancels an automatic card reader reconnection since a new payment cannot begin while a reconnection is ongoing /// @MainActor - func cancelReconnectionIfNeeded() { - let action = CardPresentPaymentAction.cancelReconnection { _ in } - stores.dispatch(action) + func cancelReconnectionIfNeeded() async { + await withCheckedContinuation { continuation in + var nillableContinuation: CheckedContinuation? = continuation + let action = CardPresentPaymentAction.cancelReconnection { _ in + nillableContinuation?.resume() + nillableContinuation = nil + } + stores.dispatch(action) + } } /// Checks whether the amount to be collected is valid: (not nil, convertible to decimal, higher than minimum amount ...) From e8af23f6ec7da71195fe4ebeedc8d30fab0f5a59 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:05:09 +0200 Subject: [PATCH 45/61] Promote TotalsViewHelper to stored property in TotalsView Replace inline TotalsViewHelper() and POSPaymentViewHelper() instantiations inside computed properties with existing stored properties to avoid redundant allocations on every SwiftUI body evaluation. --- .../PointOfSale/Presentation/TotalsView.swift | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/Modules/Sources/PointOfSale/Presentation/TotalsView.swift b/Modules/Sources/PointOfSale/Presentation/TotalsView.swift index 5a5774e71f9..c1b68eb8a90 100644 --- a/Modules/Sources/PointOfSale/Presentation/TotalsView.swift +++ b/Modules/Sources/PointOfSale/Presentation/TotalsView.swift @@ -5,6 +5,7 @@ struct TotalsView: View { @Environment(PointOfSaleAggregateModel.self) private var posModel @Environment(POSPaymentModel.self) private var paymentModel private let viewHelper = POSPaymentViewHelper() + private let totalsViewHelper = TotalsViewHelper() /// Used together with .matchedGeometryEffect to synchronize the animations of shimmeringLineView and text fields. /// This makes SwiftUI treat these views as a single entity in the context of animation. @@ -154,10 +155,9 @@ private extension TotalsView { case .cash: return true case .card: - let viewHelper = TotalsViewHelper() return paymentModel.cardPresentPaymentInlineMessage != nil || - viewHelper.shouldShowReconnectingMessage(readerConnectionStatus: paymentModel.cardReaderConnectionStatus, - paymentState: paymentModel.paymentState) + totalsViewHelper.shouldShowReconnectingMessage(readerConnectionStatus: paymentModel.cardReaderConnectionStatus, + paymentState: paymentModel.paymentState) } case .disconnected: // Since the reader is disconnected, this will show the "Connect your reader" CTA button view. @@ -194,13 +194,12 @@ private extension TotalsView { .validatingOrder, .preparingReader, .processingPayment: - let viewHelper = TotalsViewHelper() - if viewHelper.shouldShowReconnectingMessage(readerConnectionStatus: paymentModel.cardReaderConnectionStatus, - paymentState: paymentModel.paymentState) { + if totalsViewHelper.shouldShowReconnectingMessage(readerConnectionStatus: paymentModel.cardReaderConnectionStatus, + paymentState: paymentModel.paymentState) { return .primary } - if POSPaymentViewHelper().shouldShowDisconnectedMessage(readerConnectionStatus: paymentModel.cardReaderConnectionStatus, - paymentState: paymentModel.paymentState) { + if viewHelper.shouldShowDisconnectedMessage(readerConnectionStatus: paymentModel.cardReaderConnectionStatus, + paymentState: paymentModel.paymentState) { return .outlined } } From 7426f96f019251f7356d18dced596d997ca04a8e Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:05:18 +0200 Subject: [PATCH 46/61] Replace Task.sleep with deterministic test setup for reconnection test Set connectionStatus before creating the SUT so the initial publisher value is already .connected when the subscription starts, removing the need for a fragile 100ms sleep. --- .../Models/PointOfSaleAggregateModelTests.swift | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/Modules/Tests/PointOfSaleTests/Models/PointOfSaleAggregateModelTests.swift b/Modules/Tests/PointOfSaleTests/Models/PointOfSaleAggregateModelTests.swift index 062fa5e48cc..fd9f1474819 100644 --- a/Modules/Tests/PointOfSaleTests/Models/PointOfSaleAggregateModelTests.swift +++ b/Modules/Tests/PointOfSaleTests/Models/PointOfSaleAggregateModelTests.swift @@ -777,18 +777,13 @@ struct PointOfSaleAggregateModelTests { @Test func cancelThenCollectPayment_does_not_cancel_reconnection_when_not_reconnecting() async throws { // Given let itemsController = MockPointOfSaleItemsController() + let reader = CardPresentPaymentCardReader(name: "Test Reader", batteryLevel: 0.5) + cardPresentPaymentService.connectionStatus = .connected(reader) let sut = makePointOfSaleAggregateModel( itemsController: itemsController, cardPresentPaymentService: cardPresentPaymentService, orderController: orderController) - // Set connection status to connected (not reconnecting) - let reader = CardPresentPaymentCardReader(name: "Test Reader", batteryLevel: 0.5) - cardPresentPaymentService.connectionStatus = .connected(reader) - - // Wait for the status to propagate - try await Task.sleep(nanoseconds: 100_000_000) - // When await sut.cancelThenCollectPayment() From 8ce334b4a8282b57b16c02457d76a9e37ae875b4 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:05:24 +0200 Subject: [PATCH 47/61] Handle cancelReconnection error in BluetoothCardReaderSettingsConnectedViewModel Log the error and reset readerReconnectionCancellationInProgress on failure to prevent the UI from being stuck in a permanent cancelling state. --- .../BluetoothCardReaderSettingsConnectedViewModel.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothCardReaderSettingsConnectedViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothCardReaderSettingsConnectedViewModel.swift index 004af176df7..e014738f1e5 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothCardReaderSettingsConnectedViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothCardReaderSettingsConnectedViewModel.swift @@ -269,7 +269,14 @@ final class BluetoothCardReaderSettingsConnectedViewModel: PaymentSettingsFlowPr readerReconnectionCancellationInProgress = true didUpdate?() - let action = CardPresentPaymentAction.cancelReconnection { _ in } + let action = CardPresentPaymentAction.cancelReconnection { [weak self] result in + guard let self else { return } + if case .failure(let error) = result { + DDLogError("Failed to cancel reader reconnection: \(error)") + self.readerReconnectionCancellationInProgress = false + self.didUpdate?() + } + } ServiceLocator.stores.dispatch(action) } From 8511b0219232e22bdfeaedff3d8a79d70aae00e7 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:05:29 +0200 Subject: [PATCH 48/61] Reset isCancellingReconnection on status change instead of onAppear Use .onChange(of: cardReaderConnectionStatus) so the flag resets whenever the connection status transitions, not only when the view appears. --- .../CardReaderConnection/CardReaderConnectionStatusView.swift | 2 +- .../Presentation/Settings/POSSettingsHardwareDetailView.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/CardReaderConnectionStatusView.swift b/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/CardReaderConnectionStatusView.swift index cf6e7e4dd6a..edbe415c40e 100644 --- a/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/CardReaderConnectionStatusView.swift +++ b/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/CardReaderConnectionStatusView.swift @@ -84,7 +84,7 @@ struct CardReaderConnectionStatusView: View { } } .font(Constants.font) - .onAppear { + .onChange(of: posModel.cardReaderConnectionStatus) { isCancellingReconnection = false } .dynamicTypeSize(...DynamicTypeSize.accessibility2) diff --git a/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsHardwareDetailView.swift b/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsHardwareDetailView.swift index 902af3099fa..23c54ce32f3 100644 --- a/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsHardwareDetailView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsHardwareDetailView.swift @@ -198,7 +198,7 @@ private extension POSSettingsHardwareDetailView { ) } .disabled(isCancellingReconnection) - .onAppear { + .onChange(of: posModel.cardReaderConnectionStatus) { isCancellingReconnection = false } } From da8e3d631639be460efc38b685d047b51e979435 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:05:35 +0200 Subject: [PATCH 49/61] Fix Cancel Reconnection button to use sentence case Change from title case "Cancel Reconnection" to sentence case "Cancel reconnection" for consistency with all other button labels in the reconnection flow. Update localization key accordingly. --- .../CardReaderSettingsConnectedViewController.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsConnectedViewController.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsConnectedViewController.swift index c906fb9bb90..cc4cd8b5d5e 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsConnectedViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsConnectedViewController.swift @@ -466,8 +466,8 @@ private extension CardReaderSettingsConnectedViewController { ) static let cancelReconnectionButtonTitle = NSLocalizedString( - "cardReaderSettingsConnectedViewController.cancelReconnectionButtonTitle", - value: "Cancel Reconnection", + "cardReaderSettingsConnectedViewController.cancelReconnectionButtonTitle.v2", + value: "Cancel reconnection", comment: "Settings > Manage Card Reader > Connected Reader > A button to cancel the reconnection attempt" ) From 9a85ad0cbd844ebe2ea0eb9611a27bd4a7cca5ae Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:05:43 +0200 Subject: [PATCH 50/61] Return success from NoOpCardReaderService.cancelReconnection A no-op cancel should succeed rather than fail, consistent with how the real service treats cancellation when no reconnection is in progress. --- .../CardReader/StripeCardReader/NoOpCardReaderService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/Hardware/CardReader/StripeCardReader/NoOpCardReaderService.swift b/Modules/Sources/Hardware/CardReader/StripeCardReader/NoOpCardReaderService.swift index 9294db7b7c1..9e878254f51 100644 --- a/Modules/Sources/Hardware/CardReader/StripeCardReader/NoOpCardReaderService.swift +++ b/Modules/Sources/Hardware/CardReader/StripeCardReader/NoOpCardReaderService.swift @@ -118,7 +118,7 @@ public struct NoOpCardReaderService: CardReaderService { /// Cancels an in-progress auto-reconnection attempt. public func cancelReconnection() -> Future { return Future() { promise in - promise(.failure(NSError.init(domain: "noopcardreader", code: 0, userInfo: nil))) + promise(.success(())) } } } From 74b1a62409714ae0fe05cecefb1a549c1713c5fb Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:05:48 +0200 Subject: [PATCH 51/61] Make cancelReconnectionAction non-optional in POSCardPaymentContentView All call sites provide a non-nil value, so the optional adds no safety and silently no-ops if accidentally nil. Matches connectCardReaderAction. --- .../PointOfSale/Presentation/POSPaymentContentView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/PointOfSale/Presentation/POSPaymentContentView.swift b/Modules/Sources/PointOfSale/Presentation/POSPaymentContentView.swift index ff1cc2057bc..fd6e8f4e529 100644 --- a/Modules/Sources/PointOfSale/Presentation/POSPaymentContentView.swift +++ b/Modules/Sources/PointOfSale/Presentation/POSPaymentContentView.swift @@ -226,7 +226,7 @@ struct POSCardPaymentContentView: View { let paymentState: PointOfSalePaymentState let cardPresentPaymentInlineMessage: PointOfSaleCardPresentPaymentMessageType? let connectCardReaderAction: () -> Void - var cancelReconnectionAction: (() -> Void)? + let cancelReconnectionAction: () -> Void var showLoadingWhenIdle: Bool = false private let viewHelper = POSPaymentViewHelper() @@ -237,7 +237,7 @@ struct POSCardPaymentContentView: View { if totalsViewHelper.shouldShowReconnectingMessage(readerConnectionStatus: cardReaderConnectionStatus, paymentState: paymentState) { PointOfSaleCardPresentPaymentReconnectingMessageView { - cancelReconnectionAction?() + cancelReconnectionAction() } } else if viewHelper.shouldShowDisconnectedMessage(readerConnectionStatus: cardReaderConnectionStatus, paymentState: paymentState) { From 9e817ddf4378955687890ca72ed0cc0289b41401 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:05:55 +0200 Subject: [PATCH 52/61] Guard reconnection failure log behind active cancelable check Only log the error when a reconnection was actually in progress to avoid noisy log output from expected SDK callbacks. --- .../CardReader/StripeCardReader/StripeCardReaderService.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift b/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift index c91a936f558..b153c26a811 100644 --- a/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift +++ b/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift @@ -1058,7 +1058,9 @@ extension StripeCardReaderService: MobileReaderDelegate { } public func readerDidFailReconnect(_ reader: Reader) { - DDLogError("💳 Reader auto-reconnection failed") + if reconnectionCancelable != nil { + DDLogError("💳 Reader auto-reconnection failed") + } reconnectionCancelable = nil let cardReader = CardReader(reader: reader) reconnectionStateSubject.send(.failed(reader: cardReader)) From 17f408c1a6985c9f308e71f07ed4e4f47e2c952e Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:05:59 +0200 Subject: [PATCH 53/61] Remove extra blank lines in POSSettingsControllerTests --- .../Controllers/POSSettingsControllerTests.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Modules/Tests/PointOfSaleTests/Controllers/POSSettingsControllerTests.swift b/Modules/Tests/PointOfSaleTests/Controllers/POSSettingsControllerTests.swift index 50472f6f127..e757575a44e 100644 --- a/Modules/Tests/PointOfSaleTests/Controllers/POSSettingsControllerTests.swift +++ b/Modules/Tests/PointOfSaleTests/Controllers/POSSettingsControllerTests.swift @@ -76,9 +76,6 @@ struct POSSettingsControllerTests { #expect(sut.connectedCardReader?.name == "WisePad 3") #expect(sut.connectedCardReader?.batteryLevel == 0.75) } - - - } private final class MockPointOfSaleSettingsService: PointOfSaleSettingsServiceProtocol { From 8a4ba7db80f150da7e989077bc9c86828005b3bb Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:43:28 +0300 Subject: [PATCH 54/61] Move POS reconnection release note from 24.4 to 24.6 --- RELEASE-NOTES.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 9b9beeba973..3a36642a50f 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -2,6 +2,7 @@ *** Use [*****] to indicate smoke tests of all critical flows should be run on the final IPA before release (e.g. major library or OS update). 24.6 ----- +- [*] POS: Show reconnecting status when Bluetooth card reader temporarily disconnects [https://github.com/woocommerce/woocommerce-ios/pull/16586] - [*] Add troubleshooting tool for login [https://github.com/woocommerce/woocommerce-ios/pull/16890] @@ -13,7 +14,6 @@ 24.4 ----- -- [*] POS: Show reconnecting status when Bluetooth card reader temporarily disconnects [https://github.com/woocommerce/woocommerce-ios/pull/16586] - [*] Fix site discovery showing errors for stores already connected to the account [https://github.com/woocommerce/woocommerce-ios/pull/16835] - [*] Remove the flashing empty state in booking list when updating filters [https://github.com/woocommerce/woocommerce-ios/pull/16824] - [*] Fix My Store dashboard showing different revenue numbers than wp-admin by respecting the store's date type setting [https://github.com/woocommerce/woocommerce-ios/pull/16812] From c929acffa11e86877224d743a5abd88f82c14d3c Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Thu, 9 Apr 2026 10:46:47 +0300 Subject: [PATCH 55/61] Improve reconnection handling and logging Refactor Stripe card reader reconnection flow to safely handle a nil self early and clean up the reconnection cancelable state. Suppress noisy error logs when a reconnection failure occurs after the user has cancelled, and add an error log if cancelling reconnection via CardPresentPaymentAction fails. Also fix an incorrect import path for CardReaderSoftwareUpdateState. --- .../StripeCardReader/StripeCardReaderService.swift | 14 ++++++++++---- .../CardPresentPaymentService.swift | 2 +- .../CardReaderConnectionController.swift | 5 ++++- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift b/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift index b153c26a811..f792e6a7148 100644 --- a/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift +++ b/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift @@ -640,11 +640,14 @@ extension StripeCardReaderService: CardReaderService { public func cancelReconnection() -> Future { Future { [weak self] promise in - guard let self = self, - let reconnectionCancelable = self.reconnectionCancelable, + guard let self else { + return promise(.success(())) + } + + guard let reconnectionCancelable = self.reconnectionCancelable, !reconnectionCancelable.completed else { - self?.reconnectionCancelable = nil - self?.reconnectionStateSubject.send(.idle) + self.reconnectionCancelable = nil + self.reconnectionStateSubject.send(.idle) return promise(.success(())) } @@ -1058,6 +1061,9 @@ extension StripeCardReaderService: MobileReaderDelegate { } public func readerDidFailReconnect(_ reader: Reader) { + // Only log as an error if reconnection was still in progress. + // When the user cancels reconnection, the cancelable is nilled out before + // this delegate fires, so the failure is expected and not worth logging. if reconnectionCancelable != nil { DDLogError("💳 Reader auto-reconnection failed") } diff --git a/WooCommerce/Classes/POS/Adaptors/Card Present Payments/CardPresentPaymentService.swift b/WooCommerce/Classes/POS/Adaptors/Card Present Payments/CardPresentPaymentService.swift index 2f088a3e069..e4b5312160c 100644 --- a/WooCommerce/Classes/POS/Adaptors/Card Present Payments/CardPresentPaymentService.swift +++ b/WooCommerce/Classes/POS/Adaptors/Card Present Payments/CardPresentPaymentService.swift @@ -6,7 +6,7 @@ import struct Yosemite.CardPresentPaymentsConfiguration import struct Yosemite.CardReader import enum Yosemite.CardPresentPaymentAction import enum Yosemite.PaymentChannel -import enum Hardware.CardReaderSoftwareUpdateState +import enum Yosemite.CardReaderSoftwareUpdateState import enum Yosemite.CardReaderReconnectionState import protocol Yosemite.StoresManager diff --git a/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardReaderConnectionController.swift b/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardReaderConnectionController.swift index 99f2e75aadf..d6f99386cce 100644 --- a/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardReaderConnectionController.swift +++ b/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardReaderConnectionController.swift @@ -181,7 +181,10 @@ where AlertProvider.AlertDetails == AlertPresenter.AlertDetails { @MainActor private func cancelReconnection() async { await withCheckedContinuation { continuation in - let action = CardPresentPaymentAction.cancelReconnection { _ in + let action = CardPresentPaymentAction.cancelReconnection { result in + if case .failure(let error) = result { + DDLogError("⚠️ Failed to cancel reader reconnection: \(error)") + } continuation.resume() } stores.dispatch(action) From 51093debb2dc2502bcce70f73ca553f8175e2a99 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Mon, 13 Apr 2026 09:28:27 +0300 Subject: [PATCH 56/61] Reuse progressIndicatingCardReaderStatus in reconnecting state --- .../CardReaderConnectionStatusView.swift | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/CardReaderConnectionStatusView.swift b/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/CardReaderConnectionStatusView.swift index edbe415c40e..d617089b541 100644 --- a/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/CardReaderConnectionStatusView.swift +++ b/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/CardReaderConnectionStatusView.swift @@ -67,17 +67,9 @@ struct CardReaderConnectionStatusView: View { Text(Localization.cancelReconnection) } } label: { - HStack(spacing: Constants.buttonImageAndTextSpacing) { - ProgressView() - .progressViewStyle(POSProgressViewStyle( - size: Constants.progressIndicatorDimension * scale, - lineWidth: Constants.progressIndicatorLineWidth * scale - )) - Text(isCancellingReconnection ? Localization.cancellingReconnection : Localization.readerReconnecting) - .foregroundColor(connectedFontColor) - } - .padding(.horizontal, Constants.horizontalPadding) - .frame(maxHeight: .infinity) + progressIndicatingCardReaderStatus( + title: isCancellingReconnection ? Localization.cancellingReconnection : Localization.readerReconnecting + ) } .disabled(isCancellingReconnection) .accessibilityIdentifier("pos-reader-reconnecting") From 2034bb21f2009f754b517007db3a31a24507b4b0 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Mon, 13 Apr 2026 09:29:18 +0300 Subject: [PATCH 57/61] Update button casing to match each view's convention --- .../CardReaderSettingsConnectedViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsConnectedViewController.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsConnectedViewController.swift index cc4cd8b5d5e..da687eadc37 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsConnectedViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsConnectedViewController.swift @@ -467,7 +467,7 @@ private extension CardReaderSettingsConnectedViewController { static let cancelReconnectionButtonTitle = NSLocalizedString( "cardReaderSettingsConnectedViewController.cancelReconnectionButtonTitle.v2", - value: "Cancel reconnection", + value: "Cancel Reconnection", comment: "Settings > Manage Card Reader > Connected Reader > A button to cancel the reconnection attempt" ) From 2f6148b0b5d0b6203bd52c9a4cc55e7d9c8a48cd Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Mon, 13 Apr 2026 09:55:18 +0300 Subject: [PATCH 58/61] Simplify isShowingPaymentView and remove redundant comment --- .../PointOfSale/Presentation/TotalsView.swift | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/Modules/Sources/PointOfSale/Presentation/TotalsView.swift b/Modules/Sources/PointOfSale/Presentation/TotalsView.swift index 34e857de5ef..da6a0cb21c9 100644 --- a/Modules/Sources/PointOfSale/Presentation/TotalsView.swift +++ b/Modules/Sources/PointOfSale/Presentation/TotalsView.swift @@ -142,21 +142,13 @@ private extension TotalsView { private var isShowingPaymentView: Bool { guard posModel.orderState.isLoaded else { - // When the order's being created or synced, we only show the shimmering totals. - // Before the order exists, we don't want to show the card payment status, as it will - // show for a second initially, then disappear the moment we start syncing the order. return false } switch paymentModel.cardReaderConnectionStatus { - case .connected, .disconnecting, .cancellingConnection: - switch displayPaymentState.activePaymentMethod { - case .cash: - return true - case .card: - return paymentModel.cardPresentPaymentInlineMessage != nil - } - case .reconnecting: + case .disconnected: + return true + case .connected, .disconnecting, .cancellingConnection, .reconnecting: switch displayPaymentState.activePaymentMethod { case .cash: return true @@ -165,8 +157,6 @@ private extension TotalsView { totalsViewHelper.shouldShowReconnectingMessage(readerConnectionStatus: paymentModel.cardReaderConnectionStatus, paymentState: displayPaymentState) } - case .disconnected: - return true } } From 9f1615b30e78e3c27cb490b31e8937330c9dafae Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Mon, 13 Apr 2026 09:56:33 +0300 Subject: [PATCH 59/61] Use nillableContinuation in cancelReconnection for safety --- .../CardPresentPayments/CardReaderConnectionController.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardReaderConnectionController.swift b/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardReaderConnectionController.swift index d6f99386cce..8d765b29acd 100644 --- a/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardReaderConnectionController.swift +++ b/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardReaderConnectionController.swift @@ -181,11 +181,13 @@ where AlertProvider.AlertDetails == AlertPresenter.AlertDetails { @MainActor private func cancelReconnection() async { await withCheckedContinuation { continuation in + var nillableContinuation: CheckedContinuation? = continuation let action = CardPresentPaymentAction.cancelReconnection { result in if case .failure(let error) = result { DDLogError("⚠️ Failed to cancel reader reconnection: \(error)") } - continuation.resume() + nillableContinuation?.resume() + nillableContinuation = nil } stores.dispatch(action) } From 3c40cedf66655acbdee9b90e2631ee07cf6b34db Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Mon, 13 Apr 2026 09:57:27 +0300 Subject: [PATCH 60/61] Extract compound condition into descriptive variable in reevaluateShouldShow --- .../BluetoothCardReaderSettingsConnectedViewModel.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothCardReaderSettingsConnectedViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothCardReaderSettingsConnectedViewModel.swift index e014738f1e5..15690dc0fbd 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothCardReaderSettingsConnectedViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothCardReaderSettingsConnectedViewModel.swift @@ -308,9 +308,11 @@ final class BluetoothCardReaderSettingsConnectedViewModel: PaymentSettingsFlowPr private func reevaluateShouldShow() { var newShouldShow: CardReaderSettingsTriState = .isUnknown + let hasNoActiveOrReconnectingReaders = connectedReaders.isEmpty && !readerReconnectionInProgress && !readerReconnectionCancellationInProgress + if !didGetConnectedReaders { newShouldShow = .isUnknown - } else if connectedReaders.isEmpty && !readerReconnectionInProgress && !readerReconnectionCancellationInProgress { + } else if hasNoActiveOrReconnectingReaders { newShouldShow = .isFalse } else if connectedReaders.includesTapToPayReader() { /// This screen only supports management of Bluetooth readers, and will have started disconnection From 3dca8065d1bb5c7f8bf282660ef714f0a1d422d9 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:04:56 +0300 Subject: [PATCH 61/61] Tie DisconnectButtonState action and enabled state together --- ...eaderSettingsConnectedViewController.swift | 27 +++++++------------ 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsConnectedViewController.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsConnectedViewController.swift index da687eadc37..1e68034c81f 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsConnectedViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsConnectedViewController.swift @@ -209,10 +209,11 @@ private extension CardReaderSettingsConnectedViewController { private func configureDisconnectButton(cell: ButtonTableViewCell) { let state = DisconnectButtonState(viewModel: viewModel) - cell.configure(style: state.style, title: state.title) { [weak self, state] in - state.action(self?.viewModel) + let action = state.action(viewModel) + cell.configure(style: state.style, title: state.title) { + action?() } - cell.enableButton(state.isEnabled) + cell.enableButton(action != nil) cell.showActivityIndicator(state.showActivityIndicator) cell.selectionStyle = .none cell.backgroundColor = .clear @@ -393,15 +394,6 @@ private enum DisconnectButtonState { } } - var isEnabled: Bool { - switch self { - case .reconnecting, .idle: - return true - case .cancellingReconnection, .disconnecting, .updating: - return false - } - } - var showActivityIndicator: Bool { switch self { case .cancellingReconnection, .disconnecting: @@ -411,14 +403,15 @@ private enum DisconnectButtonState { } } - func action(_ viewModel: BluetoothCardReaderSettingsConnectedViewModel?) { + /// Returns the action for this state, or `nil` if the button should be disabled. + func action(_ viewModel: BluetoothCardReaderSettingsConnectedViewModel) -> (() -> Void)? { switch self { case .reconnecting: - viewModel?.cancelReconnection() - case .cancellingReconnection, .disconnecting, .updating: - break + return { viewModel.cancelReconnection() } case .idle: - viewModel?.disconnectReader() + return { viewModel.disconnectReader() } + case .cancellingReconnection, .disconnecting, .updating: + return nil } } }