diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 4ff7b19916c..6b6a11b23e1 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -2,7 +2,7 @@ 11.3 ----- - +- [*] In-Person Payments: Show spinner while preparing reader for payment, instead of saying it's ready before it is. [https://github.com/woocommerce/woocommerce-ios/pull/8115] 11.2 ----- diff --git a/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalPreparingReader.swift b/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalPreparingReader.swift new file mode 100644 index 00000000000..8d7ed092c45 --- /dev/null +++ b/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalPreparingReader.swift @@ -0,0 +1,70 @@ +import UIKit + +/// Modal presented when an error occurs while connecting to a reader due to problems with the address +/// +final class CardPresentModalPreparingReader: CardPresentPaymentsModalViewModel { + let cancelAction: (() -> Void) + + let textMode: PaymentsModalTextMode = .reducedTopInfo + let actionsMode: PaymentsModalActionsMode = .secondaryOnlyAction + + let topTitle: String = Localization.title + + var topSubtitle: String? = nil + + let image: UIImage = .paymentErrorImage + + let showLoadingIndicator = true + + var primaryButtonTitle: String? = nil + + let secondaryButtonTitle: String? = Localization.cancel + + let auxiliaryButtonTitle: String? = nil + + var bottomTitle: String? = Localization.bottomTitle + + let bottomSubtitle: String? = Localization.bottomSubitle + + var accessibilityLabel: String? { + return topTitle + } + + init(cancelAction: @escaping () -> Void) { + self.cancelAction = cancelAction + } + + func didTapPrimaryButton(in viewController: UIViewController?) { + + } + + func didTapSecondaryButton(in viewController: UIViewController?) { + cancelAction() + } + + func didTapAuxiliaryButton(in viewController: UIViewController?) { } +} + +private extension CardPresentModalPreparingReader { + enum Localization { + static let title = NSLocalizedString( + "Getting ready to collect payment", + comment: "Title of the alert presented with a spinner while the reader is being prepared" + ) + + static let bottomTitle = NSLocalizedString( + "Connecting to reader", + comment: "Bottom title of the alert presented with a spinner while the reader is being prepared" + ) + + static let bottomSubitle = NSLocalizedString( + "Please wait...", + comment: "Bottom subtitle of the alert presented with a spinner while the reader is being prepared" + ) + + static let cancel = NSLocalizedString( + "Cancel", + comment: "Button to dismiss the alert presented while the reader is being prepared." + ) + } +} diff --git a/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentPaymentsModalViewModel.swift b/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentPaymentsModalViewModel.swift index feeb7a38665..a9637dae016 100644 --- a/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentPaymentsModalViewModel.swift +++ b/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentPaymentsModalViewModel.swift @@ -18,6 +18,8 @@ protocol CardPresentPaymentsModalViewModel { /// An illustration accompanying the modal var image: UIImage { get } + var showLoadingIndicator: Bool { get } + /// Provides a title for a primary action button var primaryButtonTitle: String? { get } @@ -110,4 +112,8 @@ extension CardPresentPaymentsModalViewModel { var auxiliaryButtonimage: UIImage? { get { return nil } } + + var showLoadingIndicator: Bool { + get { return false } + } } diff --git a/WooCommerce/Classes/ViewModels/CardPresentPayments/PaymentCaptureOrchestrator.swift b/WooCommerce/Classes/ViewModels/CardPresentPayments/PaymentCaptureOrchestrator.swift index d19dc27a5c2..d6d559e8a04 100644 --- a/WooCommerce/Classes/ViewModels/CardPresentPayments/PaymentCaptureOrchestrator.swift +++ b/WooCommerce/Classes/ViewModels/CardPresentPayments/PaymentCaptureOrchestrator.swift @@ -50,57 +50,48 @@ final class PaymentCaptureOrchestrator { stores.dispatch(setAccount) - paymentParameters( - order: order, - orderTotal: orderTotal, - country: paymentGatewayAccount.country, - statementDescriptor: paymentGatewayAccount.statementDescriptor, - paymentMethodTypes: paymentMethodTypes, - stripeSmallestCurrencyUnitMultiplier: stripeSmallestCurrencyUnitMultiplier - ) { [weak self] result in - guard let self = self else { return } - - switch result { - case let .success(parameters): - /// Briefly suppress pass (wallet) presentation so that the merchant doesn't attempt to pay for the buyer's order when the - /// reader begins to collect payment. - /// - self.suppressPassPresentation() - - let paymentAction = CardPresentPaymentAction.collectPayment( - siteID: order.siteID, - orderID: order.orderID, - parameters: parameters, - onCardReaderMessage: { event in - switch event { - case .waitingForInput: - onWaitingForInput() - case .displayMessage(let message): - onDisplayMessage(message) - case .cardRemovedAfterClientSidePaymentCapture: - onProcessingMessage() - default: - break - } - }, - onProcessingCompletion: { intent in - onProcessingCompletion(intent) - }, - onCompletion: { [weak self] result in - self?.allowPassPresentation() - self?.completePaymentIntentCapture( - order: order, - captureResult: result, - onCompletion: onCompletion - ) - } + let parameters = paymentParameters(order: order, + orderTotal: orderTotal, + country: paymentGatewayAccount.country, + statementDescriptor: paymentGatewayAccount.statementDescriptor, + paymentMethodTypes: paymentMethodTypes, + stripeSmallestCurrencyUnitMultiplier: stripeSmallestCurrencyUnitMultiplier) + + /// Briefly suppress pass (wallet) presentation so that the merchant doesn't attempt to pay for the buyer's order when the + /// reader begins to collect payment. + /// + suppressPassPresentation() + + let paymentAction = CardPresentPaymentAction.collectPayment( + siteID: order.siteID, + orderID: order.orderID, + parameters: parameters, + onCardReaderMessage: { event in + switch event { + case .waitingForInput: + onWaitingForInput() + case .displayMessage(let message): + onDisplayMessage(message) + case .cardRemovedAfterClientSidePaymentCapture: + onProcessingMessage() + default: + break + } + }, + onProcessingCompletion: { intent in + onProcessingCompletion(intent) + }, + onCompletion: { [weak self] result in + self?.allowPassPresentation() + self?.completePaymentIntentCapture( + order: order, + captureResult: result, + onCompletion: onCompletion ) - - self.stores.dispatch(paymentAction) - case let .failure(error): - onCompletion(Result.failure(error)) } - } + ) + + stores.dispatch(paymentAction) } func cancelPayment(onCompletion: @escaping (Result) -> Void) { @@ -204,37 +195,25 @@ private extension PaymentCaptureOrchestrator { country: String, statementDescriptor: String?, paymentMethodTypes: [String], - stripeSmallestCurrencyUnitMultiplier: Decimal, - onCompletion: @escaping ((Result) -> Void)) { - paymentReceiptEmailParameterDeterminer.receiptEmail(from: order) { [weak self] result in - guard let self = self else { return } - - var receiptEmail: String? - if case let .success(email) = result { - receiptEmail = email - } - - let metadata = PaymentIntent.initMetadata( - store: self.stores.sessionManager.defaultSite?.name, - customerName: self.buildCustomerNameFromBillingAddress(order.billingAddress), - customerEmail: order.billingAddress?.email, - siteURL: self.stores.sessionManager.defaultSite?.url, - orderID: order.orderID, - paymentType: PaymentIntent.PaymentTypes.single - ) - - let parameters = PaymentParameters(amount: orderTotal as Decimal, - currency: order.currency, - stripeSmallestCurrencyUnitMultiplier: stripeSmallestCurrencyUnitMultiplier, - applicationFee: self.applicationFee(for: orderTotal, country: country), - receiptDescription: self.receiptDescription(orderNumber: order.number), - statementDescription: statementDescriptor, - receiptEmail: receiptEmail, - paymentMethodTypes: paymentMethodTypes, - metadata: metadata) - - onCompletion(Result.success(parameters)) - } + stripeSmallestCurrencyUnitMultiplier: Decimal) -> PaymentParameters { + let metadata = PaymentIntent.initMetadata( + store: stores.sessionManager.defaultSite?.name, + customerName: buildCustomerNameFromBillingAddress(order.billingAddress), + customerEmail: order.billingAddress?.email, + siteURL: stores.sessionManager.defaultSite?.url, + orderID: order.orderID, + paymentType: PaymentIntent.PaymentTypes.single + ) + + return PaymentParameters(amount: orderTotal as Decimal, + currency: order.currency, + stripeSmallestCurrencyUnitMultiplier: stripeSmallestCurrencyUnitMultiplier, + applicationFee: applicationFee(for: orderTotal, country: country), + receiptDescription: receiptDescription(orderNumber: order.number), + statementDescription: statementDescriptor, + receiptEmail: paymentReceiptEmailParameterDeterminer.receiptEmail(from: order), + paymentMethodTypes: paymentMethodTypes, + metadata: metadata) } private func applicationFee(for orderTotal: NSDecimalNumber, country: String) -> Decimal? { diff --git a/WooCommerce/Classes/ViewModels/CardPresentPayments/PaymentReceiptEmailParameterDeterminer.swift b/WooCommerce/Classes/ViewModels/CardPresentPayments/PaymentReceiptEmailParameterDeterminer.swift index a981dc8a321..9b0d7a43f7b 100644 --- a/WooCommerce/Classes/ViewModels/CardPresentPayments/PaymentReceiptEmailParameterDeterminer.swift +++ b/WooCommerce/Classes/ViewModels/CardPresentPayments/PaymentReceiptEmailParameterDeterminer.swift @@ -4,20 +4,17 @@ import Yosemite /// Determines the email to be set (if any) on a receipt /// protocol ReceiptEmailParameterDeterminer { - func receiptEmail(from order: Order, onCompletion: @escaping ((Result) -> Void)) + func receiptEmail(from order: Order) -> String? } /// Determines the email to be set (if any) on a payment receipt depending on the current payment plugins (WCPay, Stripe) configuration /// struct PaymentReceiptEmailParameterDeterminer: ReceiptEmailParameterDeterminer { private let cardPresentPluginsDataProvider: CardPresentPluginsDataProviderProtocol - private let stores: StoresManager private static let defaultConfiguration = CardPresentConfigurationLoader(stores: ServiceLocator.stores).configuration - init(cardPresentPluginsDataProvider: CardPresentPluginsDataProviderProtocol = CardPresentPluginsDataProvider(configuration: Self.defaultConfiguration), - stores: StoresManager = ServiceLocator.stores) { + init(cardPresentPluginsDataProvider: CardPresentPluginsDataProviderProtocol = CardPresentPluginsDataProvider(configuration: Self.defaultConfiguration)) { self.cardPresentPluginsDataProvider = cardPresentPluginsDataProvider - self.stores = stores } /// We do not need to set the receipt email if WCPay is installed and active @@ -25,53 +22,29 @@ struct PaymentReceiptEmailParameterDeterminer: ReceiptEmailParameterDeterminer { /// /// - Parameters: /// - order: the order associated with the payment - /// - onCompletion: closure invoked with the result of the inquiry, containing the email (if any) or error + /// - Returns: + /// - `String?`: the email for the reciept, if any. Even if there is an email, this will return `nil` for stores which send the receipt server-side. /// - func receiptEmail(from order: Order, onCompletion: @escaping ((Result) -> Void)) { - synchronizePlugins(from: order.siteID) { result in - switch result { - case .success(): - onCompletion(Result.success(receiptEmail(from: order))) - case let .failure(error): - onCompletion(Result.failure(error)) - } - } - } - - private func receiptEmail(from order: Order) -> String? { + func receiptEmail(from order: Order) -> String? { let wcPay = cardPresentPluginsDataProvider.getWCPayPlugin() let stripe = cardPresentPluginsDataProvider.getStripePlugin() - let paymentPluginsInstalledAndActiveStatus = cardPresentPluginsDataProvider.paymentPluginsInstalledAndActiveStatus(wcPay: wcPay, stripe: stripe) + let paymentPluginsStatus = cardPresentPluginsDataProvider.paymentPluginsInstalledAndActiveStatus(wcPay: wcPay, stripe: stripe) - guard paymentPluginsInstalledAndActiveStatus != .bothAreInstalledAndActive else { + guard paymentPluginsStatus != .bothAreInstalledAndActive else { return nil } guard let wcPay = wcPay, - paymentPluginsInstalledAndActiveStatus == .onlyWCPayIsInstalledAndActive else { + paymentPluginsStatus == .onlyWCPayIsInstalledAndActive else { return order.billingAddress?.email } return wcPayPluginSendsReceiptEmail(version: wcPay.version) ? nil : order.billingAddress?.email } - private func synchronizePlugins(from siteID: Int64, onCompletion: @escaping ((Result) -> Void)) { - let systemPluginsAction = SystemStatusAction.synchronizeSystemPlugins(siteID: siteID) { result in - if case let .failure(error) = result { - DDLogError("[PaymentReceiptEmailParameterDeterminer] Error syncing system plugins: \(error)") - onCompletion(Result.failure(error)) - } else { - onCompletion(Result.success(())) - } - } - - stores.dispatch(systemPluginsAction) - } - private func wcPayPluginSendsReceiptEmail(version: String) -> Bool { - let comparisonResult = VersionHelpers.compare(version, Constants.minimumWCPayPluginVersionThatSendsReceiptEmail) - - return comparisonResult == .orderedDescending || comparisonResult == .orderedSame + VersionHelpers.isVersionSupported(version: version, + minimumRequired: Constants.minimumWCPayPluginVersionThatSendsReceiptEmail) } } diff --git a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsPaymentAlerts.swift b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsPaymentAlerts.swift index c73a08b41c5..79d82460b57 100644 --- a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsPaymentAlerts.swift +++ b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsPaymentAlerts.swift @@ -17,7 +17,10 @@ final class OrderDetailsPaymentAlerts: OrderDetailsPaymentAlertsProtocol { if let controller = _modalController { return controller } else { - let controller = CardPresentPaymentsModalViewController(viewModel: readerIsReady(onCancel: {})) + let controller = CardPresentPaymentsModalViewController( + viewModel: CardPresentModalPreparingReader(cancelAction: { [weak self] in + self?.presentingController?.dismiss(animated: true) + })) _modalController = controller return controller } @@ -43,6 +46,10 @@ final class OrderDetailsPaymentAlerts: OrderDetailsPaymentAlertsProtocol { } } + func preparingReader(onCancel: @escaping () -> Void) { + presentViewModel(viewModel: CardPresentModalPreparingReader(cancelAction: onCancel)) + } + func readerIsReady(title: String, amount: String, onCancel: @escaping () -> Void) { self.name = title self.amount = amount diff --git a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsPaymentAlertsProtocol.swift b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsPaymentAlertsProtocol.swift index 9c924a40331..88fe90e5981 100644 --- a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsPaymentAlertsProtocol.swift +++ b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsPaymentAlertsProtocol.swift @@ -4,6 +4,8 @@ import UIKit protocol OrderDetailsPaymentAlertsProtocol { func presentViewModel(viewModel: CardPresentPaymentsModalViewModel) + func preparingReader(onCancel: @escaping () -> Void) + func readerIsReady(title: String, amount: String, onCancel: @escaping () -> Void) func tapOrInsertCard(onCancel: @escaping () -> Void) diff --git a/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardPresentPaymentsModalViewController.swift b/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardPresentPaymentsModalViewController.swift index 41a4f1a85dd..30dd4aec703 100644 --- a/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardPresentPaymentsModalViewController.swift +++ b/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardPresentPaymentsModalViewController.swift @@ -1,4 +1,5 @@ import UIKit +import SwiftUI import WordPressAuthenticator import SafariServices @@ -32,7 +33,7 @@ final class CardPresentPaymentsModalViewController: UIViewController, CardReader @IBOutlet weak var heightConstraint: NSLayoutConstraint! @IBOutlet weak var widthConstraint: NSLayoutConstraint! - + private var loadingView: UIView? init(viewModel: CardPresentPaymentsModalViewModel) { self.viewModel = viewModel @@ -46,6 +47,7 @@ final class CardPresentPaymentsModalViewController: UIViewController, CardReader override func viewDidLoad() { super.viewDidLoad() + createViews() initializeContent() setBackgroundColor() setButtonsActions() @@ -95,6 +97,24 @@ private extension CardPresentPaymentsModalViewController { containerView.backgroundColor = .tertiarySystemBackground } + func createViews() { + createLoadingIndicator() + } + + func createLoadingIndicator() { + let loadingIndicator = ProgressView() + .progressViewStyle(IndefiniteCircularProgressViewStyle(size: 96.0)) + .background(Color(.tertiarySystemBackground)) + let host = ConstraintsUpdatingHostingController(rootView: loadingIndicator) + add(host) + + guard let index = mainStackView.arrangedSubviews.firstIndex(of: imageView) else { + return + } + mainStackView.insertArrangedSubview(host.view, at: index) + loadingView = host.view + } + func styleContent() { styleTopTitle() if shouldShowTopSubtitle() { @@ -182,6 +202,8 @@ private extension CardPresentPaymentsModalViewController { configureImageView() + configureLoadingIndicator() + if shouldShowActionButtons() { configureActionButtonsView() styleActionButtons() @@ -226,6 +248,11 @@ private extension CardPresentPaymentsModalViewController { func configureImageView() { imageView.image = viewModel.image + imageView.isHidden = viewModel.showLoadingIndicator + } + + func configureLoadingIndicator() { + loadingView?.isHidden = !viewModel.showLoadingIndicator } func setButtonsActions() { diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsAlerts.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsAlerts.swift index b923c2fea28..379e947b1bf 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsAlerts.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderSettingsAlerts.swift @@ -224,6 +224,10 @@ private extension CardReaderSettingsAlerts { CardPresentModalFoundReader(name: name, connect: connect, continueSearch: continueSearch, cancel: cancel) } + func preparingReader(from: UIViewController, cancel: @escaping () -> Void) -> CardPresentPaymentsModalViewModel { + CardPresentModalPreparingReader(cancelAction: cancel) + } + func setViewModelAndPresent(from: UIViewController, viewModel: CardPresentPaymentsModalViewModel) { guard modalController == nil else { modalController?.setViewModel(viewModel) diff --git a/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift b/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift index fd53be685ce..cc2d0266716 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift @@ -150,6 +150,7 @@ final class CollectOrderPaymentUseCase: NSObject, CollectOrderPaymentProtocol { // Inform about the collect payment state switch result { case .failure(CollectOrderPaymentUseCaseError.flowCanceledByUser): + self.rootViewController.presentedViewController?.dismiss(animated: true) return onCancel() default: onCollect(result.map { _ in () }) // Transforms Result to Result @@ -271,13 +272,11 @@ private extension CollectOrderPaymentUseCase { return } - // Show reader ready alert - alerts.readerIsReady(title: Localization.collectPaymentTitle(username: order.billingAddress?.firstName), - amount: formattedAmount, - onCancel: { [weak self] in - self?.cancelPayment { + // Show preparing reader alert + alerts.preparingReader(onCancel: { [weak self] in + self?.cancelPayment(onCompleted: { onCompletion(.failure(CollectOrderPaymentUseCaseError.flowCanceledByUser)) - } + }) }) // Start collect payment process @@ -288,12 +287,14 @@ private extension CollectOrderPaymentUseCase { paymentMethodTypes: configuration.paymentMethods.map(\.rawValue), stripeSmallestCurrencyUnitMultiplier: configuration.stripeSmallestCurrencyUnitMultiplier, onWaitingForInput: { [weak self] in - // Request card input - self?.alerts.tapOrInsertCard(onCancel: { [weak self] in - self?.cancelPayment { - onCompletion(.failure(CollectOrderPaymentUseCaseError.flowCanceledByUser)) - } - }) + guard let self = self else { return } + self.alerts.readerIsReady(title: Localization.collectPaymentTitle(username: self.order.billingAddress?.firstName), + amount: self.formattedAmount, + onCancel: { [weak self] in + self?.cancelPayment { + onCompletion(.failure(CollectOrderPaymentUseCaseError.flowCanceledByUser)) + } + }) }, onProcessingMessage: { [weak self] in // Waiting message diff --git a/WooCommerce/Classes/ViewRelated/Orders/Refund/RefundSubmissionUseCase.swift b/WooCommerce/Classes/ViewRelated/Orders/Refund/RefundSubmissionUseCase.swift index 523b15f0e9f..ca04865d73e 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Refund/RefundSubmissionUseCase.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Refund/RefundSubmissionUseCase.swift @@ -295,9 +295,7 @@ private extension RefundSubmissionUseCase { paymentGatewayAccount: PaymentGatewayAccount, onCompletion: @escaping (Result) -> ()) { // Shows reader ready alert. - alerts.readerIsReady(title: Localization.refundPaymentTitle(username: order.billingAddress?.firstName), - amount: formattedAmount, - onCancel: { [weak self] in + alerts.preparingReader(onCancel: { [weak self] in self?.cancelRefund(charge: charge, paymentGatewayAccount: paymentGatewayAccount, onCompletion: onCompletion) }) @@ -307,7 +305,10 @@ private extension RefundSubmissionUseCase { paymentGatewayAccount: paymentGatewayAccount, onWaitingForInput: { [weak self] in // Requests card input. - self?.alerts.tapOrInsertCard(onCancel: { [weak self] in + guard let self = self else { return } + self.alerts.readerIsReady(title: Localization.refundPaymentTitle(username: self.order.billingAddress?.firstName), + amount: self.formattedAmount, + onCancel: { [weak self] in self?.cancelRefund(charge: charge, paymentGatewayAccount: paymentGatewayAccount, onCompletion: onCompletion) }) }, onProcessingMessage: { [weak self] in diff --git a/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/IndefiniteCircularProgressViewStyle.swift b/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/IndefiniteCircularProgressViewStyle.swift new file mode 100644 index 00000000000..9f1f7cd01e4 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/IndefiniteCircularProgressViewStyle.swift @@ -0,0 +1,88 @@ +import SwiftUI + +public struct IndefiniteCircularProgressViewStyle: ProgressViewStyle { + var size: CGFloat + private let arcStart: Double = Constants.initialArcStart + private let animationDuration: Double = 1.6 + + @State private var arcEnd: Double = Constants.initialArcEnd + @State private var rotation: Angle = Constants.threeQuarterRotation + @State private var viewRotation: Angle = .radians(0) + @State private var arcTimer: Timer? + + public func makeBody(configuration: ProgressViewStyleConfiguration) -> some View { + VStack { + ZStack { + progressCircleView() + .rotationEffect(viewRotation) + }.padding() + configuration.label + } + .onAppear() { + animateArc() + arcTimer = Timer.scheduledTimer(withTimeInterval: animationDuration, repeats: true) { _ in + animateArc() + } + // Gradual rotation of the view to avoid the arc stopping and starting in the same place each spin. + withAnimation(.linear(duration: animationDuration*8) + .repeatForever(autoreverses: false)) { + viewRotation += Constants.fullRotation + } + } + .onDisappear() { + arcTimer?.invalidate() + } + } + + private func progressCircleView() -> some View { + Circle() + .stroke( + Color(.primary), + lineWidth: Constants.lineWidth) + .opacity(Constants.backgroundOpacity) + .overlay(progressFill()) + .frame(width: size, height: size) + } + + private func progressFill() -> some View { + Circle() + .trim( + from: CGFloat(Constants.initialArcStart), + to: CGFloat(arcEnd)) + .stroke( + Color(.primary), + style: StrokeStyle(lineWidth: Constants.lineWidth, lineCap: .round)) + .frame(width: size) + .rotationEffect(rotation) + } + + private func animateArc() { + // Animate the end of the arc going to 100% + withAnimation( + .easeInOut(duration: animationDuration/2)) { + arcEnd = Constants.fullCircle + } + // Halfway through the above, but slower, rotate the arc 1 turn, and move the end back to the start + // This is a bit of a trick, and results in an apparently growing/shrinking arc around the circle. + withAnimation( + .easeOut(duration: animationDuration) + .delay(animationDuration/4)) { + arcEnd = Constants.initialArcEnd + rotation += Constants.fullRotation + } + } +} + +private extension IndefiniteCircularProgressViewStyle { + enum Constants { + static let lineWidth: CGFloat = 10.0 + static let backgroundOpacity: CGFloat = 0.2 + + static let initialArcStart: Double = 0 + static let initialArcEnd: Double = 0.05 + static let fullCircle: Double = 1 + + static let threeQuarterRotation: Angle = .radians((9 * Double.pi)/6) + static let fullRotation: Angle = .radians(Double.pi * 2) + } +} diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 933598a1846..b32dc7c9d8a 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -458,6 +458,8 @@ 035C6DEB273EA12D00F70406 /* SoftwareUpdateTypeProperty.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035C6DEA273EA12D00F70406 /* SoftwareUpdateTypeProperty.swift */; }; 035F2308275690970019E1B0 /* CardPresentModalConnectingFailedUpdatePostalCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035F2307275690970019E1B0 /* CardPresentModalConnectingFailedUpdatePostalCode.swift */; }; 0366EAE12909A37800B51755 /* JustInTimeMessageAnnouncementCardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0366EAE02909A37800B51755 /* JustInTimeMessageAnnouncementCardViewModel.swift */; }; + 036CA6F129229C9E00E4DF4F /* IndefiniteCircularProgressViewStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 036CA6F029229C9E00E4DF4F /* IndefiniteCircularProgressViewStyle.swift */; }; + 036CA6B9291E8D4B00E4DF4F /* CardPresentModalPreparingReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 036CA6B8291E8D4B00E4DF4F /* CardPresentModalPreparingReader.swift */; }; 036F6EA6281847D5006D84F8 /* PaymentCaptureOrchestratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 036F6EA5281847D5006D84F8 /* PaymentCaptureOrchestratorTests.swift */; }; 0371C3682875E47B00277E2C /* FeatureAnnouncementCardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0371C3672875E47B00277E2C /* FeatureAnnouncementCardViewModel.swift */; }; 0371C36A2876DBCA00277E2C /* FeatureAnnouncementCardViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0371C3692876DBCA00277E2C /* FeatureAnnouncementCardViewModelTests.swift */; }; @@ -2421,6 +2423,8 @@ 035C6DEA273EA12D00F70406 /* SoftwareUpdateTypeProperty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftwareUpdateTypeProperty.swift; sourceTree = ""; }; 035F2307275690970019E1B0 /* CardPresentModalConnectingFailedUpdatePostalCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentModalConnectingFailedUpdatePostalCode.swift; sourceTree = ""; }; 0366EAE02909A37800B51755 /* JustInTimeMessageAnnouncementCardViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JustInTimeMessageAnnouncementCardViewModel.swift; sourceTree = ""; }; + 036CA6F029229C9E00E4DF4F /* IndefiniteCircularProgressViewStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndefiniteCircularProgressViewStyle.swift; sourceTree = ""; }; + 036CA6B8291E8D4B00E4DF4F /* CardPresentModalPreparingReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentModalPreparingReader.swift; sourceTree = ""; }; 036F6EA5281847D5006D84F8 /* PaymentCaptureOrchestratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentCaptureOrchestratorTests.swift; sourceTree = ""; }; 0371C3672875E47B00277E2C /* FeatureAnnouncementCardViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureAnnouncementCardViewModel.swift; sourceTree = ""; }; 0371C3692876DBCA00277E2C /* FeatureAnnouncementCardViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureAnnouncementCardViewModelTests.swift; sourceTree = ""; }; @@ -6024,6 +6028,7 @@ 03076D35290C162E008EE839 /* WebViewSheet.swift */, 03076D37290C223D008EE839 /* WooNavigationSheet.swift */, 02EAA4C92911004B00918DAB /* TextFieldStyles.swift */, + 036CA6F029229C9E00E4DF4F /* IndefiniteCircularProgressViewStyle.swift */, ); path = "SwiftUI Components"; sourceTree = ""; @@ -8391,6 +8396,7 @@ 035F2307275690970019E1B0 /* CardPresentModalConnectingFailedUpdatePostalCode.swift */, 318477E427A33C650058C7E9 /* CardPresentModalConnectingFailedChargeReader.swift */, 031B10E2274FE2AE007390BA /* CardPresentModalConnectionFailedUpdateAddress.swift */, + 036CA6B8291E8D4B00E4DF4F /* CardPresentModalPreparingReader.swift */, D8EE9697264D3CCB0033B2F9 /* ReceiptViewModel.swift */, D8752EF6265E60F4008ACC80 /* PaymentCaptureCelebration.swift */, 03AA165D2719B7EF005CCB7B /* ReceiptActionCoordinator.swift */, @@ -9725,6 +9731,7 @@ B59D49CD219B587E006BF0AD /* UILabel+OrderStatus.swift in Sources */, 265BCA0C2430E741004E53EE /* ProductCategoryTableViewCell.swift in Sources */, 02ACD25A2852E11700EC928E /* RemoveAppleIDAccessCoordinator.swift in Sources */, + 036CA6F129229C9E00E4DF4F /* IndefiniteCircularProgressViewStyle.swift in Sources */, 451A9973260E39270059D135 /* ShippingLabelPackageNumberRow.swift in Sources */, AEE2610F26E664CE00B142A0 /* EditOrderAddressFormViewModel.swift in Sources */, 0371C36E2876E92D00277E2C /* UpsellCardReadersCampaign.swift in Sources */, @@ -10030,6 +10037,7 @@ 7E7C5F7A2719A8F900315B61 /* EditProductCategoryListViewController.swift in Sources */, 77E53EC82510FE07003D385F /* ProductDownloadsEditableData.swift in Sources */, 0235595B24496E88004BE2B8 /* BottomSheetListSelectorViewController+DrawerPresentable.swift in Sources */, + 036CA6B9291E8D4B00E4DF4F /* CardPresentModalPreparingReader.swift in Sources */, 0227958D237A51F300787C63 /* OptionsTableViewController+Styles.swift in Sources */, B541B226218A412C008FE7C1 /* UIFont+Woo.swift in Sources */, 4580BA7723F19D4A00B5F764 /* ProductSettingsViewModel.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/Mocks/MockOrderDetailsPaymentAlerts.swift b/WooCommerce/WooCommerceTests/Mocks/MockOrderDetailsPaymentAlerts.swift index e24f76cfb03..b179c810263 100644 --- a/WooCommerce/WooCommerceTests/Mocks/MockOrderDetailsPaymentAlerts.swift +++ b/WooCommerce/WooCommerceTests/Mocks/MockOrderDetailsPaymentAlerts.swift @@ -6,6 +6,8 @@ final class MockOrderDetailsPaymentAlerts { // Public closures to mock alert actions and properties for assertions. var cancelReaderIsReadyAlert: (() -> Void)? + var cancelPreparingReaderAlert: (() -> Void)? + var cancelTapOrInsertCardAlert: (() -> Void)? var error: Error? @@ -19,6 +21,10 @@ final class MockOrderDetailsPaymentAlerts { } extension MockOrderDetailsPaymentAlerts: OrderDetailsPaymentAlertsProtocol { + func preparingReader(onCancel: @escaping () -> Void) { + cancelPreparingReaderAlert = onCancel + } + func presentViewModel(viewModel: CardPresentPaymentsModalViewModel) { // no-op } diff --git a/WooCommerce/WooCommerceTests/ViewModels/CardPresentPayments/CollectOrderPaymentUseCaseTests.swift b/WooCommerce/WooCommerceTests/ViewModels/CardPresentPayments/CollectOrderPaymentUseCaseTests.swift index 35e349aab7d..266f6c226cf 100644 --- a/WooCommerce/WooCommerceTests/ViewModels/CardPresentPayments/CollectOrderPaymentUseCaseTests.swift +++ b/WooCommerce/WooCommerceTests/ViewModels/CardPresentPayments/CollectOrderPaymentUseCaseTests.swift @@ -43,7 +43,7 @@ final class CollectOrderPaymentUseCaseTests: XCTestCase { super.tearDown() } - func test_cancelling_readerIsReady_alert_triggers_onCancel_and_tracks_collectPaymentCanceled_event_and_dispatches_cancel_action() throws { + func test_cancelling_preparingReader_alert_triggers_onCancel_and_tracks_collectPaymentCanceled_event_and_dispatches_cancel_action() throws { // Given assertEmpty(stores.receivedActions) @@ -53,7 +53,7 @@ final class CollectOrderPaymentUseCaseTests: XCTestCase { self.useCase.collectPayment(onCollect: { _ in }, onCancel: { promise(()) }, onCompleted: {}) - self.alerts.cancelReaderIsReadyAlert?() + self.alerts.cancelPreparingReaderAlert?() } // Then @@ -243,6 +243,17 @@ private extension CollectOrderPaymentUseCaseTests { completion([MockCardReader.wisePad3()]) } else if case let .cancelPayment(completion) = action { completion?(.success(())) + } else if case let .collectPayment(_, _, _, onCardReaderMessage, _, _) = action { + onCardReaderMessage(.waitingForInput("")) + } + } + + stores.whenReceivingAction(ofType: SystemStatusAction.self) { action in + switch action { + case .synchronizeSystemPlugins(_, let completion): + completion(.success(())) + default: + break } } } diff --git a/WooCommerce/WooCommerceTests/ViewModels/CardPresentPayments/PaymentCaptureOrchestratorTests.swift b/WooCommerce/WooCommerceTests/ViewModels/CardPresentPayments/PaymentCaptureOrchestratorTests.swift index ef885f68028..7a416e9679d 100644 --- a/WooCommerce/WooCommerceTests/ViewModels/CardPresentPayments/PaymentCaptureOrchestratorTests.swift +++ b/WooCommerce/WooCommerceTests/ViewModels/CardPresentPayments/PaymentCaptureOrchestratorTests.swift @@ -94,7 +94,7 @@ final class PaymentCaptureOrchestratorTests: XCTestCase { } struct MockReceiptEmailParameterDeterminer: ReceiptEmailParameterDeterminer { - func receiptEmail(from order: Order, onCompletion: @escaping ((Result) -> Void)) { - onCompletion(.success(nil)) + func receiptEmail(from order: Order) -> String? { + return nil } } diff --git a/WooCommerce/WooCommerceTests/ViewModels/CardPresentPayments/PaymentReceiptEmailParameterDeterminerTests.swift b/WooCommerce/WooCommerceTests/ViewModels/CardPresentPayments/PaymentReceiptEmailParameterDeterminerTests.swift index 05bf26554a1..ed4faafa113 100644 --- a/WooCommerce/WooCommerceTests/ViewModels/CardPresentPayments/PaymentReceiptEmailParameterDeterminerTests.swift +++ b/WooCommerce/WooCommerceTests/ViewModels/CardPresentPayments/PaymentReceiptEmailParameterDeterminerTests.swift @@ -4,47 +4,18 @@ import TestKit @testable import Yosemite final class PaymentReceiptEmailParameterDeterminerTests: XCTestCase { - private var stores: MockStoresManager! - - override func setUp() { - super.setUp() - - stores = MockStoresManager(sessionManager: SessionManager.makeForTesting()) - stores.whenReceivingAction(ofType: SystemStatusAction.self) { action in - guard case let .synchronizeSystemPlugins(_, onCompletion) = action else { - return - } - - onCompletion(.success(())) - } - } - - override func tearDown() { - stores = nil - super.tearDown() - } - func test_when_only_WCPay_is_active_and_version_is_higher_than_minimum_that_sends_email_then_returns_nil() { // Given let order = Order.fake() let wcPayPlugin = SystemPlugin.fake().copy(version: "4.3.4") let cardPresentPluginsDataProvider = MockCardPresentPluginsDataProvider(wcPayPlugin: wcPayPlugin, paymentPluginsInstalledAndActiveStatus: .onlyWCPayIsInstalledAndActive) - let sut = PaymentReceiptEmailParameterDeterminer(cardPresentPluginsDataProvider: cardPresentPluginsDataProvider, stores: stores) + let sut = PaymentReceiptEmailParameterDeterminer(cardPresentPluginsDataProvider: cardPresentPluginsDataProvider) // When - let result: Result = waitFor { promise in - sut.receiptEmail(from: order) { result in - promise(result) - } - } + let email = sut.receiptEmail(from: order) // Then - guard case let .success(email) = result else { - XCTFail() - return - } - XCTAssertNil(email) } @@ -55,21 +26,12 @@ final class PaymentReceiptEmailParameterDeterminerTests: XCTestCase { let wcPayPlugin = SystemPlugin.fake().copy(version: "4.0.0") let cardPresentPluginsDataProvider = MockCardPresentPluginsDataProvider(wcPayPlugin: wcPayPlugin, paymentPluginsInstalledAndActiveStatus: .onlyWCPayIsInstalledAndActive) - let sut = PaymentReceiptEmailParameterDeterminer(cardPresentPluginsDataProvider: cardPresentPluginsDataProvider, stores: stores) + let sut = PaymentReceiptEmailParameterDeterminer(cardPresentPluginsDataProvider: cardPresentPluginsDataProvider) // When - let result: Result = waitFor { promise in - sut.receiptEmail(from: Order.fake().copy(billingAddress: billingAddress)) { result in - promise(result) - } - } + let email = sut.receiptEmail(from: Order.fake().copy(billingAddress: billingAddress)) // Then - guard case let .success(email) = result else { - XCTFail() - return - } - XCTAssertNil(email) } @@ -80,21 +42,12 @@ final class PaymentReceiptEmailParameterDeterminerTests: XCTestCase { let wcPayPlugin = SystemPlugin.fake().copy(version: "3.9.9") let cardPresentPluginsDataProvider = MockCardPresentPluginsDataProvider(wcPayPlugin: wcPayPlugin, paymentPluginsInstalledAndActiveStatus: .onlyWCPayIsInstalledAndActive) - let sut = PaymentReceiptEmailParameterDeterminer(cardPresentPluginsDataProvider: cardPresentPluginsDataProvider, stores: stores) + let sut = PaymentReceiptEmailParameterDeterminer(cardPresentPluginsDataProvider: cardPresentPluginsDataProvider) // When - let result: Result = waitFor { promise in - sut.receiptEmail(from: Order.fake().copy(billingAddress: billingAddress)) { result in - promise(result) - } - } + let returnedEmail = sut.receiptEmail(from: Order.fake().copy(billingAddress: billingAddress)) // Then - guard case let .success(returnedEmail) = result else { - XCTFail() - return - } - XCTAssertEqual(returnedEmail, receiptEmail) } @@ -103,21 +56,12 @@ final class PaymentReceiptEmailParameterDeterminerTests: XCTestCase { let receiptEmail = "test@test.com" let billingAddress = Address.fake().copy(email: receiptEmail) let cardPresentPluginsDataProvider = MockCardPresentPluginsDataProvider(paymentPluginsInstalledAndActiveStatus: .bothAreInstalledAndActive) - let sut = PaymentReceiptEmailParameterDeterminer(cardPresentPluginsDataProvider: cardPresentPluginsDataProvider, stores: stores) + let sut = PaymentReceiptEmailParameterDeterminer(cardPresentPluginsDataProvider: cardPresentPluginsDataProvider) // When - let result: Result = waitFor { promise in - sut.receiptEmail(from: Order.fake().copy(billingAddress: billingAddress)) { result in - promise(result) - } - } + let email = sut.receiptEmail(from: Order.fake().copy(billingAddress: billingAddress)) // Then - guard case let .success(email) = result else { - XCTFail() - return - } - XCTAssertNil(email) } @@ -126,21 +70,12 @@ final class PaymentReceiptEmailParameterDeterminerTests: XCTestCase { let receiptEmail = "test@test.com" let billingAddress = Address.fake().copy(email: receiptEmail) let cardPresentPluginsDataProvider = MockCardPresentPluginsDataProvider(paymentPluginsInstalledAndActiveStatus: .onlyStripeIsInstalledAndActive) - let sut = PaymentReceiptEmailParameterDeterminer(cardPresentPluginsDataProvider: cardPresentPluginsDataProvider, stores: stores) + let sut = PaymentReceiptEmailParameterDeterminer(cardPresentPluginsDataProvider: cardPresentPluginsDataProvider) // When - let result: Result = waitFor { promise in - sut.receiptEmail(from: Order.fake().copy(billingAddress: billingAddress)) { result in - promise(result) - } - } + let returnedEmail = sut.receiptEmail(from: Order.fake().copy(billingAddress: billingAddress)) // Then - guard case let .success(returnedEmail) = result else { - XCTFail() - return - } - XCTAssertEqual(returnedEmail, receiptEmail) } } diff --git a/WooCommerce/WooCommerceTests/ViewModels/CardPresentPayments/RefundSubmissionUseCaseTests.swift b/WooCommerce/WooCommerceTests/ViewModels/CardPresentPayments/RefundSubmissionUseCaseTests.swift index 34375fabfc2..5a7c3d2a3c4 100644 --- a/WooCommerce/WooCommerceTests/ViewModels/CardPresentPayments/RefundSubmissionUseCaseTests.swift +++ b/WooCommerce/WooCommerceTests/ViewModels/CardPresentPayments/RefundSubmissionUseCaseTests.swift @@ -356,7 +356,7 @@ final class RefundSubmissionUseCaseTests: XCTestCase { XCTAssertEqual(eventProperties["plugin_slug"] as? String, Mocks.paymentGatewayID) } - func test_canceling_readerIsReady_alert_tracks_interacRefundCanceled_event_when_payment_method_is_interac() throws { + func test_canceling_preparingReader_alert_tracks_interacRefundCanceled_event_when_payment_method_is_interac() throws { // Given let useCase = createUseCase(details: .init(order: .fake().copy(total: "2.28"), charge: .fake().copy(paymentMethodDetails: .interacPresent( @@ -376,42 +376,7 @@ final class RefundSubmissionUseCaseTests: XCTestCase { useCase.submitRefund(.fake(), showInProgressUI: {}, onCompletion: { result in promise(result) }) - self.alerts.cancelReaderIsReadyAlert?() - } - - // Then - XCTAssertTrue(result.isFailure) - XCTAssertEqual(result.failure as? RefundSubmissionUseCase.RefundSubmissionError, .canceledByUser) - - let indexOfEvent = try XCTUnwrap(analyticsProvider.receivedEvents.firstIndex(where: { $0 == "interac_refund_cancelled"})) - let eventProperties = try XCTUnwrap(analyticsProvider.receivedProperties[indexOfEvent]) - XCTAssertEqual(eventProperties["card_reader_model"] as? String, Mocks.cardReaderModel) - XCTAssertEqual(eventProperties["country"] as? String, "US") - XCTAssertEqual(eventProperties["plugin_slug"] as? String, Mocks.paymentGatewayID) - } - - func test_canceling_tapOrInsertCard_alert_tracks_interacRefundCanceled_event_when_payment_method_is_interac() throws { - // Given - let useCase = createUseCase(details: .init(order: .fake().copy(total: "2.28"), - charge: .fake().copy(paymentMethodDetails: .interacPresent( - details: .init(brand: .visa, - last4: "9969", - funding: .credit, - receipt: .init(accountType: .credit, - applicationPreferredName: "Stripe Credit", - dedicatedFileName: "A000000003101001")))), - amount: "2.28", - paymentGatewayAccount: createPaymentGatewayAccount(siteID: Mocks.siteID))) - mockCardPresentPaymentActions(clientSideRefundResult: .failure(RefundSubmissionUseCase.RefundSubmissionError.cardReaderDisconnected), - cancelRefundResult: .success(()), - returnCardReaderMessage: .waitingForInput("")) - - // When - let result: Result = waitFor { promise in - useCase.submitRefund(.fake(), showInProgressUI: {}, onCompletion: { result in - promise(result) - }) - self.alerts.cancelTapOrInsertCardAlert?() + self.alerts.cancelPreparingReaderAlert?() } // Then