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..9e878254f51 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(.success(())) + } + } } diff --git a/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift b/Modules/Sources/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift index 680dccb03a2..f792e6a7148 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, @@ -493,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)) @@ -630,6 +637,44 @@ extension StripeCardReaderService: CardReaderService { public func installUpdate() -> Void { Terminal.shared.installAvailableUpdate() } + + public func cancelReconnection() -> Future { + Future { [weak self] promise in + guard let self else { + return promise(.success(())) + } + + guard let reconnectionCancelable = self.reconnectionCancelable, + !reconnectionCancelable.completed else { + 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 { + self?.connectedReadersSubject.send([]) + self?.reconnectionStateSubject.send(.idle) + let underlyingError = Self.logAndDecodeError(error) + promise(.failure(CardReaderServiceError.reconnectionCancellation(underlyingError: underlyingError))) + } else { + DDLogInfo("💳 Reconnection cancellation succeeded") + self?.connectedReadersSubject.send([]) + self?.reconnectionStateSubject.send(.idle) + promise(.success(())) + } + } + } + } } struct CardReaderMetadata { @@ -994,6 +1039,40 @@ extension StripeCardReaderService: MobileReaderDelegate { public func reader(_ reader: Reader, didDisconnect reason: DisconnectReason) { connectedReadersSubject.send([]) } + + // MARK: - Reconnection delegate methods + + public func reader(_ reader: Reader, didStartReconnect cancelable: Cancelable, disconnectReason: 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([]) + 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) { + // 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") + } + reconnectionCancelable = nil + let cardReader = CardReader(reader: reader) + reconnectionStateSubject.send(.failed(reader: cardReader)) + connectedReadersSubject.send([]) + reconnectionStateSubject.send(.idle) + } } extension StripeCardReaderService: TapToPayReaderDelegate { 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 { diff --git a/Modules/Sources/PointOfSale/Controllers/POSPaymentModel.swift b/Modules/Sources/PointOfSale/Controllers/POSPaymentModel.swift index 8d5db9e3fb9..5725e551b84 100644 --- a/Modules/Sources/PointOfSale/Controllers/POSPaymentModel.swift +++ b/Modules/Sources/PointOfSale/Controllers/POSPaymentModel.swift @@ -111,7 +111,7 @@ extension POSPaymentModel { switch status { case .connected: return true - case .disconnected, .disconnecting, .cancellingConnection: + case .disconnected, .disconnecting, .cancellingConnection, .reconnecting: return false } } @@ -171,7 +171,15 @@ extension POSPaymentModel { } func cancelThenCollectPayment() async { + if case .reconnecting = cardReaderConnectionStatus { + await cardPresentPaymentService.cancelReconnection() + } + try? await cardPresentPaymentService.cancelPayment() + + guard case .connected = cardReaderConnectionStatus else { + return + } await collectCardPayment() } @@ -182,6 +190,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 1be6b3bb59b..596df6aace6 100644 --- a/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift +++ b/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift @@ -382,6 +382,11 @@ extension PointOfSaleAggregateModel { paymentModel.connectCardReader() } + @MainActor + func cancelReconnection() { + paymentModel.cancelReconnection() + } + @MainActor func disconnectCardReader() { paymentModel.disconnectCardReader() 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/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/CardReaderConnectionStatusView.swift b/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/CardReaderConnectionStatusView.swift index b28606d9dcb..d617089b541 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 { @@ -57,9 +58,27 @@ struct CardReaderConnectionStatusView: View { .padding(Constants.disconnectedBorderInset) } .accessibilityIdentifier("pos-connect-reader-button") + case .reconnecting: + Menu { + Button { + isCancellingReconnection = true + posModel.cancelReconnection() + } label: { + Text(Localization.cancelReconnection) + } + } label: { + progressIndicatingCardReaderStatus( + title: isCancellingReconnection ? Localization.cancellingReconnection : Localization.readerReconnecting + ) + } + .disabled(isCancellingReconnection) + .accessibilityIdentifier("pos-reader-reconnecting") } } .font(Constants.font) + .onChange(of: posModel.cardReaderConnectionStatus) { + isCancellingReconnection = false + } .dynamicTypeSize(...DynamicTypeSize.accessibility2) .opacity(isEnabled ? 1 : 0.5) } @@ -148,8 +167,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 +178,24 @@ 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." + ) + + 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." + ) } } 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..e25c64bd9ee --- /dev/null +++ b/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSaleCardPresentPaymentReconnectingMessageView.swift @@ -0,0 +1,72 @@ +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 + @State private var isCancelling = false + + 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 { + isCancelling = true + cancelReconnection() + } label: { + Text(viewModel.cancelReconnectionButtonTitle) + .minimumScaleFactor(1) + } + .buttonStyle(POSFilledButtonStyle(size: .normal, isLoading: isCancelling)) + .frame(width: width * 0.5) + } + .frame(maxWidth: .infinity) + .measureWidth({ containerWidth in + width = containerWidth + }) + .multilineTextAlignment(.center) + .onAppear { + isCancelling = false + } + } + + 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/POSPaymentContentView.swift b/Modules/Sources/PointOfSale/Presentation/POSPaymentContentView.swift index 5b98909f5b4..c9be90b6d6e 100644 --- a/Modules/Sources/PointOfSale/Presentation/POSPaymentContentView.swift +++ b/Modules/Sources/PointOfSale/Presentation/POSPaymentContentView.swift @@ -105,6 +105,7 @@ struct POSPaymentContentView: View { paymentState: displayPaymentState, cardPresentPaymentInlineMessage: paymentModel.cardPresentPaymentInlineMessage, connectCardReaderAction: paymentModel.connectCardReader, + cancelReconnectionAction: paymentModel.cancelReconnection, showLoadingWhenIdle: !paymentModel.isZeroTotal) } } @@ -236,9 +237,11 @@ struct POSCardPaymentContentView: View { let paymentState: PointOfSalePaymentState let cardPresentPaymentInlineMessage: PointOfSaleCardPresentPaymentMessageType? let connectCardReaderAction: () -> Void + let cancelReconnectionAction: () -> Void var showLoadingWhenIdle: Bool = false private let viewHelper = POSPaymentViewHelper() + private let totalsViewHelper = TotalsViewHelper() @Namespace private var paymentMessageNamespace private var paymentMessageAnimation: POSCardPresentPaymentInLineMessageAnimation { .init(namespace: paymentMessageNamespace) @@ -246,7 +249,12 @@ struct POSCardPaymentContentView: View { @ViewBuilder var body: some View { - if viewHelper.shouldShowDisconnectedMessage(readerConnectionStatus: cardReaderConnectionStatus, + if totalsViewHelper.shouldShowReconnectingMessage(readerConnectionStatus: cardReaderConnectionStatus, + paymentState: paymentState) { + PointOfSaleCardPresentPaymentReconnectingMessageView { + cancelReconnectionAction() + } + } else if viewHelper.shouldShowDisconnectedMessage(readerConnectionStatus: cardReaderConnectionStatus, paymentState: paymentState) { PointOfSaleCardPresentPaymentReaderDisconnectedMessageView(animation: paymentMessageAnimation) { connectCardReaderAction() diff --git a/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsHardwareDetailView.swift b/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsHardwareDetailView.swift index 18e7036a42f..7f52815d5b4 100644 --- a/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsHardwareDetailView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsHardwareDetailView.swift @@ -13,33 +13,32 @@ 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 { - 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 +144,65 @@ 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) { + isCancellingReconnection = true + posModel.cancelReconnection() + } + } label: { + HStack(spacing: POSSpacing.small) { + ProgressView() + .progressViewStyle(POSProgressViewStyle(size: 16, lineWidth: 4)) + Text(isCancellingReconnection ? Localization.cancellingReconnectionTitle : 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) + ) + } + .disabled(isCancellingReconnection) + .onChange(of: posModel.cardReaderConnectionStatus) { + isCancellingReconnection = false + } + } + var cardReadersView: some View { VStack(spacing: POSSpacing.none) { POSPageHeaderView( @@ -156,19 +214,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 +237,10 @@ private extension POSSettingsHardwareDetailView { buttonStyle: .primary) } } - } else { + default: POSSettingsCard(title: Localization.cardReaderConnectTitle, - subtitle: Localization.cardReaderConnectSubtitle, - action: { + subtitle: Localization.cardReaderConnectSubtitle, + action: { posModel.connectCardReader() }) } @@ -471,6 +525,24 @@ 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." + ) + + static let cancellingReconnectionTitle = NSLocalizedString( + "pointOfSaleSettingsHardwareDetailView.cancellingReconnectionTitle", + value: "Cancelling…", + comment: "Button title shown when card reader reconnection is being cancelled in POS settings." + ) } } diff --git a/Modules/Sources/PointOfSale/Presentation/TotalsView.swift b/Modules/Sources/PointOfSale/Presentation/TotalsView.swift index 288522b52dd..da6a0cb21c9 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. @@ -45,7 +46,8 @@ struct TotalsView: View { orderState: posModel.orderState, cardReaderConnectionStatus: paymentModel.cardReaderConnectionStatus, cardPresentPaymentInlineMessage: paymentModel.cardPresentPaymentInlineMessage, - connectCardReaderAction: paymentModel.connectCardReader + connectCardReaderAction: paymentModel.connectCardReader, + cancelReconnectionAction: posModel.cancelReconnection ) } @@ -139,7 +141,23 @@ private extension TotalsView { } private var isShowingPaymentView: Bool { - posModel.orderState.isLoaded + guard posModel.orderState.isLoaded else { + return false + } + + switch paymentModel.cardReaderConnectionStatus { + case .disconnected: + return true + case .connected, .disconnecting, .cancellingConnection, .reconnecting: + switch displayPaymentState.activePaymentMethod { + case .cash: + return true + case .card: + return paymentModel.cardPresentPaymentInlineMessage != nil || + totalsViewHelper.shouldShowReconnectingMessage(readerConnectionStatus: paymentModel.cardReaderConnectionStatus, + paymentState: displayPaymentState) + } + } } private var cardReaderViewLayout: PaymentViewLayout { @@ -171,8 +189,12 @@ private extension TotalsView { .validatingOrder, .preparingReader, .processingPayment: - if POSPaymentViewHelper().shouldShowDisconnectedMessage(readerConnectionStatus: paymentModel.cardReaderConnectionStatus, - paymentState: displayPaymentState) { + if totalsViewHelper.shouldShowReconnectingMessage(readerConnectionStatus: paymentModel.cardReaderConnectionStatus, + paymentState: displayPaymentState) { + return .primary + } + if viewHelper.shouldShowDisconnectedMessage(readerConnectionStatus: paymentModel.cardReaderConnectionStatus, + paymentState: displayPaymentState) { return .primary } } @@ -415,6 +437,7 @@ private struct PaymentViewContent: View { let cardReaderConnectionStatus: CardPresentPaymentReaderConnectionStatus let cardPresentPaymentInlineMessage: PointOfSaleCardPresentPaymentMessageType? let connectCardReaderAction: () -> Void + let cancelReconnectionAction: () -> Void @Namespace private var paymentMessageNamespace private let viewHelper = POSPaymentViewHelper() @@ -445,7 +468,8 @@ private struct PaymentViewContent: View { cardReaderConnectionStatus: cardReaderConnectionStatus, paymentState: paymentState, cardPresentPaymentInlineMessage: cardPresentPaymentInlineMessage, - connectCardReaderAction: connectCardReaderAction) + connectCardReaderAction: connectCardReaderAction, + cancelReconnectionAction: cancelReconnectionAction) } } } diff --git a/Modules/Sources/PointOfSale/ViewHelpers/TotalsViewHelper.swift b/Modules/Sources/PointOfSale/ViewHelpers/TotalsViewHelper.swift index 1c063db24ee..159b725cc86 100644 --- a/Modules/Sources/PointOfSale/ViewHelpers/TotalsViewHelper.swift +++ b/Modules/Sources/PointOfSale/ViewHelpers/TotalsViewHelper.swift @@ -4,6 +4,33 @@ import Foundation struct TotalsViewHelper { private let paymentViewHelper = POSPaymentViewHelper() + 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 + } + } + } + /// Cash payment button visibility for the cart flow (adds order state guards on top of the base check). func shouldShowCollectCashPaymentButton(orderState: PointOfSaleOrderState, paymentState: PointOfSalePaymentState, @@ -12,6 +39,10 @@ struct TotalsViewHelper { return false } + if case .reconnecting = cardReaderConnectionStatus { + return false + } + let isZeroTotal: Bool = if case let .loaded(totals) = orderState { totals.orderTotalDecimal.isZero } else { diff --git a/Modules/Sources/Yosemite/Actions/CardPresentPaymentAction.swift b/Modules/Sources/Yosemite/Actions/CardPresentPaymentAction.swift index a0717e7b222..1a6f8fd162b 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 785bbc45d8c..484f419055b 100644 --- a/Modules/Sources/Yosemite/Model/Model.swift +++ b/Modules/Sources/Yosemite/Model/Model.swift @@ -228,6 +228,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 60fc2213c86..c9041f1f97a 100644 --- a/Modules/Sources/Yosemite/Stores/CardPresentPaymentStore.swift +++ b/Modules/Sources/Yosemite/Stores/CardPresentPaymentStore.swift @@ -139,6 +139,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) } } } @@ -430,6 +434,28 @@ private extension CardPresentPaymentStore { onCompletion(publisher) } + + func observeCardReaderReconnectionState(onCompletion: (AnyPublisher) -> Void) { + onCompletion(cardReaderService.reconnectionEvents) + } + + func cancelReconnection(onCompletion: @escaping (Result) -> Void) { + cardReaderService.cancelReconnection() + .sink( + receiveCompletion: { result in + switch result { + case .failure(let error): + onCompletion(.failure(error)) + case .finished: + break + } + }, + receiveValue: { + onCompletion(.success(())) + } + ) + .store(in: &cancellables) + } } // MARK: Networking Methods diff --git a/Modules/Tests/PointOfSaleTests/Controllers/POSSettingsControllerTests.swift b/Modules/Tests/PointOfSaleTests/Controllers/POSSettingsControllerTests.swift index 1de2f9187c6..e757575a44e 100644 --- a/Modules/Tests/PointOfSaleTests/Controllers/POSSettingsControllerTests.swift +++ b/Modules/Tests/PointOfSaleTests/Controllers/POSSettingsControllerTests.swift @@ -55,8 +55,27 @@ 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) + } } private final class MockPointOfSaleSettingsService: PointOfSaleSettingsServiceProtocol { diff --git a/Modules/Tests/PointOfSaleTests/Mocks/MockCardPresentPaymentService.swift b/Modules/Tests/PointOfSaleTests/Mocks/MockCardPresentPaymentService.swift index 7a85097e531..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 { @@ -65,4 +75,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 57ccc554c53..2e5de8d2ed2 100644 --- a/Modules/Tests/PointOfSaleTests/Models/PointOfSaleAggregateModelTests.swift +++ b/Modules/Tests/PointOfSaleTests/Models/PointOfSaleAggregateModelTests.swift @@ -740,6 +740,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, @@ -759,6 +760,42 @@ struct PointOfSaleAggregateModelTests { #expect(cardPresentPaymentService.collectPaymentWasCalled) } + @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) + + // 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 reader = CardPresentPaymentCardReader(name: "Test Reader", batteryLevel: 0.5) + cardPresentPaymentService.connectionStatus = .connected(reader) + let sut = makePointOfSaleAggregateModel( + itemsController: itemsController, + cardPresentPaymentService: cardPresentPaymentService, + orderController: orderController) + + // 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 @@ -971,6 +1008,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/PointOfSaleTests/ViewHelpers/TotalsViewHelperTests.swift b/Modules/Tests/PointOfSaleTests/ViewHelpers/TotalsViewHelperTests.swift index 2fff3990508..ce49569d92a 100644 --- a/Modules/Tests/PointOfSaleTests/ViewHelpers/TotalsViewHelperTests.swift +++ b/Modules/Tests/PointOfSaleTests/ViewHelpers/TotalsViewHelperTests.swift @@ -140,4 +140,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_payment_not_actively_processing( + 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) + } } 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 3603891d230..830c175c812 100644 --- a/Modules/Tests/YosemiteTests/Stores/CardPresentPaymentStoreTests.swift +++ b/Modules/Tests/YosemiteTests/Stores/CardPresentPaymentStoreTests.swift @@ -824,6 +824,109 @@ 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) + } + // MARK: - CardPresentPaymentAction.reset func test_reset_clears_config_provider_context() { diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 82a0d8897a2..e6e75fef376 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] - [*] POS: visual fixes in iOS 26 [https://github.com/woocommerce/woocommerce-ios/pull/16898] - [*] Add troubleshooting tool for login [https://github.com/woocommerce/woocommerce-ios/pull/16890] - [*] Update Tap to Pay requirement to iOS 18.0.1 [https://github.com/woocommerce/woocommerce-ios/pull/16914] diff --git a/WooCommerce/Classes/POS/Adaptors/Card Present Payments/CardPresentPaymentService.swift b/WooCommerce/Classes/POS/Adaptors/Card Present Payments/CardPresentPaymentService.swift index 67d04a6bf53..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,8 @@ 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 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() @@ -199,6 +206,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 +263,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, 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 + } } diff --git a/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardReaderConnectionController.swift b/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardReaderConnectionController.swift index 5858d886649..8d765b29acd 100644 --- a/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardReaderConnectionController.swift +++ b/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardReaderConnectionController.swift @@ -171,8 +171,25 @@ 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 + var nillableContinuation: CheckedContinuation? = continuation + let action = CardPresentPaymentAction.cancelReconnection { result in + if case .failure(let error) = result { + DDLogError("⚠️ Failed to cancel reader reconnection: \(error)") + } + nillableContinuation?.resume() + nillableContinuation = nil + } + stores.dispatch(action) } } } diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothCardReaderSettingsConnectedViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothCardReaderSettingsConnectedViewModel.swift index 0bf0fc2abeb..15690dc0fbd 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothCardReaderSettingsConnectedViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothCardReaderSettingsConnectedViewModel.swift @@ -27,13 +27,21 @@ 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() var connectedReaderID: String? 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 @@ -145,6 +153,27 @@ 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(let reader): + self.readerReconnectionInProgress = true + self.reconnectingReader = reader + case .succeeded, .failed, .idle: + self.readerReconnectionInProgress = false + self.readerReconnectionCancellationInProgress = false + self.reconnectingReader = nil + } + self.updateProperties() + self.reevaluateShouldShow() + } + .store(in: &self.subscriptions) + } + ServiceLocator.stores.dispatch(reconnectionAction) } /// This screen is only used for managing Bluetooth card readers. @@ -170,11 +199,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 } @@ -185,7 +214,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 } @@ -234,6 +263,23 @@ final class BluetoothCardReaderSettingsConnectedViewModel: PaymentSettingsFlowPr didUpdate?() } + /// Dispatch a request to cancel an in-progress reconnection attempt + /// + func cancelReconnection() { + readerReconnectionCancellationInProgress = true + didUpdate?() + + 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) + } + /// Dispatch a request to disconnect from a reader /// func disconnectReader() { @@ -262,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 { + } else if hasNoActiveOrReconnectingReaders { newShouldShow = .isFalse } else if connectedReaders.includesTapToPayReader() { /// This screen only supports management of Bluetooth readers, and will have started disconnection diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsConnectedViewController.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsConnectedViewController.swift index 43b91581896..1e68034c81f 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 @@ -201,9 +196,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 @@ -211,16 +208,13 @@ 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) + let action = state.action(viewModel) + cell.configure(style: state.style, title: state.title) { + action?() } - - let readerDisconnectInProgress = viewModel.readerDisconnectInProgress - let readerUpdateInProgress = viewModel.readerUpdateInProgress - cell.enableButton(!readerDisconnectInProgress && !readerUpdateInProgress) - cell.showActivityIndicator(readerDisconnectInProgress) - + cell.enableButton(action != nil) + cell.showActivityIndicator(state.showActivityIndicator) cell.selectionStyle = .none cell.backgroundColor = .clear } @@ -309,6 +303,121 @@ 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 cancellingReconnection + case disconnecting + case updating + case idle(updateAvailable: Bool) + + init(viewModel: BluetoothCardReaderSettingsConnectedViewModel) { + switch (viewModel.readerReconnectionCancellationInProgress, + viewModel.readerReconnectionInProgress, + viewModel.readerDisconnectInProgress, + viewModel.readerUpdateInProgress) { + case (true, _, _, _): + self = .cancellingReconnection + case (false, true, _, _): + self = .reconnecting + case (false, false, true, _): + self = .disconnecting + case (false, false, false, true): + self = .updating + case (false, false, false, false): + self = .idle(updateAvailable: viewModel.optionalReaderUpdateAvailable) + } + } + + var title: String { + switch self { + case .reconnecting: + return Localization.cancelReconnectionButtonTitle + case .cancellingReconnection: + return Localization.cancellingReconnectionButtonTitle + case .disconnecting, .updating, .idle: + return Localization.disconnectButtonTitle + } + } + + var style: ButtonTableViewCell.Style { + switch self { + case .reconnecting, .cancellingReconnection, .disconnecting, .updating: + return .primary + case .idle(let updateAvailable): + return updateAvailable ? .secondary : .primary + } + } + + var showActivityIndicator: Bool { + switch self { + case .cancellingReconnection, .disconnecting: + return true + case .reconnecting, .updating, .idle: + return false + } + } + + /// Returns the action for this state, or `nil` if the button should be disabled. + func action(_ viewModel: BluetoothCardReaderSettingsConnectedViewModel) -> (() -> Void)? { + switch self { + case .reconnecting: + return { viewModel.cancelReconnection() } + case .idle: + return { viewModel.disconnectReader() } + case .cancellingReconnection, .disconnecting, .updating: + return nil + } + } +} + +private typealias Localization = CardReaderSettingsConnectedViewController.Localization + // MARK: - Localization // private extension CardReaderSettingsConnectedViewController { @@ -343,5 +452,23 @@ 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.v2", + value: "Cancel Reconnection", + 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" + ) + } } 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 b9bc61ba470..c56d819501e 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsSearchingViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsSearchingViewModel.swift @@ -14,6 +14,9 @@ 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 @@ -47,6 +50,7 @@ final class CardReaderSettingsSearchingViewModel: PaymentSettingsFlowPresentedVi beginKnownReaderObservation() beginConnectedReaderObservation() + beginReconnectionObservation() updateLearnMoreUrl(stores: stores) } @@ -58,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() { @@ -89,11 +101,44 @@ 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 + 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() + } + .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 diff --git a/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift b/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift index d3612865ec9..b57e1b0b7b5 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift @@ -193,6 +193,7 @@ where TapToPayAlertProvider.AlertDetails == AlertPresenter.AlertDetails, .store(in: &cancellables) Task { + await cancelReconnectionIfNeeded() await preflightController.start(discoveryMethod: discoveryMethod) } } @@ -209,6 +210,21 @@ 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 + /// + @MainActor + 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 ...) /// func isTotalAmountValid() -> Bool { diff --git a/WooCommerce/WooCommerceTests/Mocks/MockCardPresentPaymentsStoresManager.swift b/WooCommerce/WooCommerceTests/Mocks/MockCardPresentPaymentsStoresManager.swift index 951442ce4e6..b5e03ce3a0c 100644 --- a/WooCommerce/WooCommerceTests/Mocks/MockCardPresentPaymentsStoresManager.swift +++ b/WooCommerce/WooCommerceTests/Mocks/MockCardPresentPaymentsStoresManager.swift @@ -18,6 +18,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] = [] @@ -94,6 +95,10 @@ final class MockCardPresentPaymentsStoresManager: DefaultStoresManager { onCompletion(paymentExtension) case .disconnect(let onCompletion): onCompletion(Result.success(())) + case .cancelReconnection(let onCompletion): + onCompletion(Result.success(())) + case .observeCardReaderReconnectionState(let onCompletion): + onCompletion(reconnectionEvents) default: break } @@ -102,6 +107,10 @@ final class MockCardPresentPaymentsStoresManager: DefaultStoresManager { var softwareUpdateEvents: AnyPublisher { softwareUpdateSubject.eraseToAnyPublisher() } + + var reconnectionEvents: AnyPublisher { + reconnectionSubject.eraseToAnyPublisher() + } } extension MockCardPresentPaymentsStoresManager { @@ -145,6 +154,22 @@ extension MockCardPresentPaymentsStoresManager { softwareUpdateSubject.send(.available) } + func simulateReconnecting(reader: CardReader) { + reconnectionSubject.send(.reconnecting(reader: reader)) + } + + func simulateReconnectionSucceeded(reader: CardReader) { + reconnectionSubject.send(.succeeded(reader: reader)) + } + + func simulateReconnectionFailed(reader: CardReader) { + reconnectionSubject.send(.failed(reader: reader)) + } + + func simulateReconnectionIdle() { + reconnectionSubject.send(.idle) + } + func insertSamplePaymentGateway(forSiteID siteID: Int64) { let paymentGatewayAccount = PaymentGatewayAccount .fake() diff --git a/WooCommerce/WooCommerceTests/ViewModels/CardPresentPayments/RefundSubmissionUseCaseTests.swift b/WooCommerce/WooCommerceTests/ViewModels/CardPresentPayments/RefundSubmissionUseCaseTests.swift index 0a54b45f225..d39ebe5f976 100644 --- a/WooCommerce/WooCommerceTests/ViewModels/CardPresentPayments/RefundSubmissionUseCaseTests.swift +++ b/WooCommerce/WooCommerceTests/ViewModels/CardPresentPayments/RefundSubmissionUseCaseTests.swift @@ -443,6 +443,8 @@ private extension RefundSubmissionUseCaseTests { completion?(cancelRefundResult) } else if case let .cancelCardReaderDiscovery(completion) = action { completion(cancelCardReaderDiscoveryResult) + } else if case let .cancelReconnection(completion) = action { + completion(.success(())) } } } diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/CardReaderSettings/CardReaderSettingsSearchingViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/CardReaderSettings/CardReaderSettingsSearchingViewModelTests.swift index 004130a6d92..7b382a9cd3f 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/CardReaderSettings/CardReaderSettingsSearchingViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/CardReaderSettings/CardReaderSettingsSearchingViewModelTests.swift @@ -59,4 +59,89 @@ 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 + let reader = MockCardReader.bbposChipper2XBT() + mockStoresManager.simulateReconnecting(reader: reader) + mockStoresManager.simulateReconnectionSucceeded(reader: reader) + + 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()) + } } diff --git a/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift index cd7fe56168f..e9dee2aa4d5 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift @@ -458,6 +458,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