Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import SwiftUI
import WooFoundation

struct PaymentMethodsView: View {
/// Set this closure with UIKit dismiss code. Needed because we need access to the UIHostingController `dismiss` method.
Expand All @@ -23,6 +24,8 @@ struct PaymentMethodsView: View {

@State private var showingPurchaseCardReaderView = false

@State private var showingScanToPayView = false

private let learnMoreViewModel = LearnMoreViewModel.inPersonPayments(source: .paymentMethods)

/// Environment safe areas
Expand Down Expand Up @@ -88,7 +91,9 @@ struct PaymentMethodsView: View {
if viewModel.showScanToPayRow {
Divider()

MethodRow(icon: .scanToPayIcon, title: Localization.scanToPay, accessibilityID: Accessibility.scanToPayMethod) {}
MethodRow(icon: .scanToPayIcon, title: Localization.scanToPay, accessibilityID: Accessibility.scanToPayMethod) {
showingScanToPayView = true
}
}
}
.padding(.horizontal)
Expand Down Expand Up @@ -150,6 +155,13 @@ struct PaymentMethodsView: View {
}
}
}
.fullScreenCover(isPresented: $showingScanToPayView) {
ScanToPayView(viewModel: ScanToPayViewModel(paymentURL: viewModel.paymentLink)) {
dismiss()
viewModel.performScanToPayFinishedTasks()
}
.background(FullScreenCoverClearBackgroundView())
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,10 @@ final class PaymentMethodsViewModel: ObservableObject {
trackFlowCompleted(method: .paymentLink, cardReaderType: .none)
}

func performScanToPayFinishedTasks() {
presentNoticeSubject.send(.created)
}

/// Track the flow cancel scenario.
///
func userDidCancelFlow() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import SwiftUI

struct ScanToPayView: View {
let viewModel: ScanToPayViewModel
let onSuccess: (() -> Void)

@Environment(\.dismiss) var dismiss

var body: some View {
ZStack {
Color.black.opacity(Layout.backgroundOpacity).edgesIgnoringSafeArea(.all)
VStack {
VStack(alignment: .center, spacing: Layout.scanToPayBoxSpacing) {
if let qrCodeImage = viewModel.generateQRCodeImage() {
Text(Localization.title)
.foregroundColor(.white)
Image(uiImage: qrCodeImage)
.interpolation(.none)
.resizable()
.frame(width: Layout.qrCodeWidth, height: Layout.qrCodeHeight)
DoneButton() {
dismiss()
DispatchQueue.main.asyncAfter(deadline: .now() + Constants.onSuccessCallDelayAfterDismiss) {
onSuccess()
}
}
.buttonStyle(PrimaryButtonStyle())
} else {
Text(Localization.errorMessage)
.foregroundColor(.white)
.multilineTextAlignment(.center)
DoneButton() {
dismiss()
}
}
}
.padding(Layout.scanToPayBoxSpacing)
.frame(maxWidth: .infinity, alignment: .center)
.background(Color(.gray(.shade70)))
.cornerRadius(Layout.scanToPayBoxCornerRadius)

}
.padding(Layout.scanToPayBoxOutterPadding)
.frame(maxWidth: .infinity, alignment: .center)
}
}

private struct DoneButton: View {
let onButtonTapped: (() -> Void)
var body: some View {
Button(Localization.doneButtontitle) {
onButtonTapped()
}
.buttonStyle(PrimaryButtonStyle())
}
}
}

extension ScanToPayView {
enum Constants {
static let onSuccessCallDelayAfterDismiss: TimeInterval = 1
}
enum Localization {
static let title = NSLocalizedString("Scan QR and follow instructions", comment: "Title text on the Scan to Pay screen")
static let doneButtontitle = NSLocalizedString("Done", comment: "Button title to close the Scan to Pay screen")
static let errorMessage = NSLocalizedString("Error generating QR Code. Please try again later",
comment: "Error message in the Scan to Pay screen when the code cannot be generated.")
}

enum Layout {
static let backgroundOpacity: CGFloat = 0.5
static let scanToPayBoxSpacing: CGFloat = 20
static let qrCodeWidth: CGFloat = 270
static let qrCodeHeight: CGFloat = 300
static let scanToPayBoxCornerRadius: CGFloat = 8
static let scanToPayBoxOutterPadding: CGFloat = 50
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import Foundation
import CoreImage.CIFilterBuiltins
import UIKit

struct ScanToPayViewModel {
private let paymentURL: URL?

init(paymentURL: URL?) {
self.paymentURL = paymentURL
}

func generateQRCodeImage() -> UIImage? {
guard let paymentURLString = paymentURL?.absoluteString else {
return nil
}

let context = CIContext()
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(paymentURLString.utf8)

guard let outputImage = filter.outputImage,
let cgImage = context.createCGImage(outputImage, from: outputImage.extent) else {
return nil
}

return UIImage(cgImage: cgImage)
}
Comment on lines +12 to +27
Copy link
Contributor

@iamgabrielma iamgabrielma May 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a blocker: Should we abstract and extract this method somewhere else? Let's say an extension of UIImage, or as an Utility method in WooFoundation, then change the signature to something like the following generateQRCodeFromPaymentDetails(paymentURLString: URL, message: String) -> UIImage?

This way the view model doesn't have to know about the QR generation implementation details, we just pass the URL, the message, and let the system return it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I also considered that and preferred leaving it in the view model. The rationale is that at the moment we only generate a QR code here, and the function is simple enough to keep it in the view model. Furthermore, generating an image that we're going to display in the view from model data matches the responsibility of a view model.

If more logic is added to the QR code generation, e.g., adding the woo logo to it, I will extract it to its own class/extension.

}
20 changes: 18 additions & 2 deletions WooCommerce/WooCommerce.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -612,9 +612,9 @@
03EF250228C615A5006A033E /* InPersonPaymentsMenuViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EF250128C615A5006A033E /* InPersonPaymentsMenuViewModel.swift */; };
03EF250428C6283B006A033E /* InPersonPaymentsMenuViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EF250328C6283B006A033E /* InPersonPaymentsMenuViewModelTests.swift */; };
03EF250628C75838006A033E /* PurchaseCardReaderWebViewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EF250528C75838006A033E /* PurchaseCardReaderWebViewViewModel.swift */; };
03F5CB832A0C3A1A0026877A /* AnimatedPlaceholder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F5CB822A0C3A1A0026877A /* AnimatedPlaceholder.swift */; };
03F5CAFF2A0BA37C0026877A /* JustInTimeMessageModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F5CAFE2A0BA37C0026877A /* JustInTimeMessageModal.swift */; };
03F5CB012A0BA3D40026877A /* ModalOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F5CB002A0BA3D40026877A /* ModalOverlay.swift */; };
03F5CB832A0C3A1A0026877A /* AnimatedPlaceholder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F5CB822A0C3A1A0026877A /* AnimatedPlaceholder.swift */; };
03FBDA9D263AD49200ACE257 /* CouponListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03FBDA9C263AD49100ACE257 /* CouponListViewController.swift */; };
03FBDAA3263AED2F00ACE257 /* CouponListViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 03FBDAA2263AED2F00ACE257 /* CouponListViewController.xib */; };
03FBDAF2263EE47C00ACE257 /* CouponListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03FBDAF1263EE47C00ACE257 /* CouponListViewModel.swift */; };
Expand Down Expand Up @@ -1575,6 +1575,8 @@
B958B4DA2983E3F40010286B /* OrderDurationRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = B958B4D92983E3F40010286B /* OrderDurationRecorder.swift */; };
B96B536B2816ECFC00F753E6 /* CardPresentPluginsDataProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96B536A2816ECFC00F753E6 /* CardPresentPluginsDataProviderTests.swift */; };
B96D6C0729081AE5003D2DC0 /* InAppPurchasesForWPComPlansManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96D6C0629081AE5003D2DC0 /* InAppPurchasesForWPComPlansManager.swift */; };
B99686E02A13C8CC00D1AF62 /* ScanToPayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B99686DF2A13C8CC00D1AF62 /* ScanToPayView.swift */; };
B99686E32A13C98200D1AF62 /* ScanToPayViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B99686E22A13C98200D1AF62 /* ScanToPayViewModel.swift */; };
B9B0391628A6824200DC1C83 /* PermanentNoticePresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9B0391528A6824200DC1C83 /* PermanentNoticePresenter.swift */; };
B9B0391828A6838400DC1C83 /* PermanentNoticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9B0391728A6838400DC1C83 /* PermanentNoticeView.swift */; };
B9B0391A28A68ADE00DC1C83 /* ConstraintsUpdatingHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9B0391928A68ADE00DC1C83 /* ConstraintsUpdatingHostingController.swift */; };
Expand Down Expand Up @@ -2889,9 +2891,9 @@
03EF250128C615A5006A033E /* InPersonPaymentsMenuViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InPersonPaymentsMenuViewModel.swift; sourceTree = "<group>"; };
03EF250328C6283B006A033E /* InPersonPaymentsMenuViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InPersonPaymentsMenuViewModelTests.swift; sourceTree = "<group>"; };
03EF250528C75838006A033E /* PurchaseCardReaderWebViewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseCardReaderWebViewViewModel.swift; sourceTree = "<group>"; };
03F5CB822A0C3A1A0026877A /* AnimatedPlaceholder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedPlaceholder.swift; sourceTree = "<group>"; };
03F5CAFE2A0BA37C0026877A /* JustInTimeMessageModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JustInTimeMessageModal.swift; sourceTree = "<group>"; };
03F5CB002A0BA3D40026877A /* ModalOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalOverlay.swift; sourceTree = "<group>"; };
03F5CB822A0C3A1A0026877A /* AnimatedPlaceholder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedPlaceholder.swift; sourceTree = "<group>"; };
03FBDA9C263AD49100ACE257 /* CouponListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CouponListViewController.swift; sourceTree = "<group>"; };
03FBDAA2263AED2F00ACE257 /* CouponListViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = CouponListViewController.xib; sourceTree = "<group>"; };
03FBDAF1263EE47C00ACE257 /* CouponListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CouponListViewModel.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3853,6 +3855,8 @@
B958B4D92983E3F40010286B /* OrderDurationRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderDurationRecorder.swift; sourceTree = "<group>"; };
B96B536A2816ECFC00F753E6 /* CardPresentPluginsDataProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentPluginsDataProviderTests.swift; sourceTree = "<group>"; };
B96D6C0629081AE5003D2DC0 /* InAppPurchasesForWPComPlansManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppPurchasesForWPComPlansManager.swift; sourceTree = "<group>"; };
B99686DF2A13C8CC00D1AF62 /* ScanToPayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanToPayView.swift; sourceTree = "<group>"; };
B99686E22A13C98200D1AF62 /* ScanToPayViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanToPayViewModel.swift; sourceTree = "<group>"; };
B9B0391528A6824200DC1C83 /* PermanentNoticePresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermanentNoticePresenter.swift; sourceTree = "<group>"; };
B9B0391728A6838400DC1C83 /* PermanentNoticeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PermanentNoticeView.swift; sourceTree = "<group>"; };
B9B0391928A68ADE00DC1C83 /* ConstraintsUpdatingHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConstraintsUpdatingHostingController.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -8409,6 +8413,15 @@
path = DurationRecorder;
sourceTree = "<group>";
};
B99686E12A13C8D200D1AF62 /* ScanToPay */ = {
isa = PBXGroup;
children = (
B99686DF2A13C8CC00D1AF62 /* ScanToPayView.swift */,
B99686E22A13C98200D1AF62 /* ScanToPayViewModel.swift */,
);
path = ScanToPay;
sourceTree = "<group>";
};
B9B0391B28A690DA00DC1C83 /* PermanentNotice */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -8916,6 +8929,7 @@
CE1CCB4920570B05000EE3AC /* Orders */ = {
isa = PBXGroup;
children = (
B99686E12A13C8D200D1AF62 /* ScanToPay */,
CEE006022077D0F80079161F /* Cells */,
268FD44827580A92008FDF9B /* Collect Payments */,
CE35F1092343E482007B2A6B /* Order Details */,
Expand Down Expand Up @@ -11267,6 +11281,7 @@
CCD2F51C26D697860010E679 /* ShippingLabelServicePackageListViewModel.swift in Sources */,
03076D38290C223E008EE839 /* WooNavigationSheet.swift in Sources */,
022CE91A29BB143000F210E0 /* ProductSelectorNavigationView.swift in Sources */,
B99686E02A13C8CC00D1AF62 /* ScanToPayView.swift in Sources */,
E107FCE326C13A0D00BAF51B /* InPersonPaymentsSupportLink.swift in Sources */,
2662D90626E1571900E25611 /* ListSelector.swift in Sources */,
74D0A5302139CF1300E2919F /* String+Helpers.swift in Sources */,
Expand Down Expand Up @@ -11964,6 +11979,7 @@
45B9C63E23A8E50D007FC4C5 /* ProductPriceSettingsViewController.swift in Sources */,
318853362639FC9C00F66A9C /* PaymentSettingsFlowPresentingViewController.swift in Sources */,
452FE64B25657EC100EB54A0 /* LinkedProductsViewController.swift in Sources */,
B99686E32A13C98200D1AF62 /* ScanToPayViewModel.swift in Sources */,
450C6EEA286F4334002DB168 /* SitePlugin+Woo.swift in Sources */,
45B9C64123A9139A007FC4C5 /* Product+PriceSettingsViewModels.swift in Sources */,
038BC38129C4B8AC00EAF565 /* SetUpTapToPayTryPaymentPromptViewModel.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,33 @@ final class PaymentMethodsViewModelTests: XCTestCase {
XCTAssertTrue(receivedCompleted)
}

func test_view_model_attempts_created_notice_after_scan_to_pay() {
// Given
let noticeSubject = PassthroughSubject<SimplePaymentsNotice, Never>()
let dependencies = Dependencies(presentNoticeSubject: noticeSubject)
let viewModel = PaymentMethodsViewModel(formattedTotal: "$12.00",
flow: .simplePayment,
isTapToPayOnIPhoneEnabled: false,
dependencies: dependencies)

// When
let receivedCompleted: Bool = waitFor { promise in
noticeSubject.sink { intent in
switch intent {
case .error, .completed:
promise(false)
case .created:
promise(true)
}
}
.store(in: &self.subscriptions)
viewModel.performScanToPayFinishedTasks()
}

// Then
XCTAssertTrue(receivedCompleted)
}

func test_view_model_attempts_completed_notice_after_collecting_payment() {
// Given
let storage = MockStorageManager()
Expand Down
4 changes: 4 additions & 0 deletions WooFoundation/WooFoundation.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
AE948D0D28CF6D50009F3246 /* DateStartAndEndTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE948D0C28CF6D50009F3246 /* DateStartAndEndTests.swift */; };
B97190D1292CF3BC0065E413 /* Result+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B97190D0292CF3BC0065E413 /* Result+Extensions.swift */; };
B987B06F284540D300C53CF6 /* CurrencyCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = B987B06E284540D300C53CF6 /* CurrencyCode.swift */; };
B99686DE2A13B38B00D1AF62 /* FullScreenCoverClearBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B99686DD2A13B38B00D1AF62 /* FullScreenCoverClearBackgroundView.swift */; };
B9C9C63F283E703C001B879F /* WooFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B9C9C635283E703C001B879F /* WooFoundation.framework */; };
B9C9C659283E7195001B879F /* NSDecimalNumber+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9C9C658283E7195001B879F /* NSDecimalNumber+Helpers.swift */; };
B9C9C65D283E71C8001B879F /* CurrencyFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9C9C65B283E71C8001B879F /* CurrencyFormatter.swift */; };
Expand Down Expand Up @@ -82,6 +83,7 @@
AE948D0C28CF6D50009F3246 /* DateStartAndEndTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DateStartAndEndTests.swift; sourceTree = "<group>"; };
B97190D0292CF3BC0065E413 /* Result+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Result+Extensions.swift"; sourceTree = "<group>"; };
B987B06E284540D300C53CF6 /* CurrencyCode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CurrencyCode.swift; sourceTree = "<group>"; };
B99686DD2A13B38B00D1AF62 /* FullScreenCoverClearBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenCoverClearBackgroundView.swift; sourceTree = "<group>"; };
B9AED558283E7553002A2668 /* Yosemite.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Yosemite.framework; sourceTree = BUILT_PRODUCTS_DIR; };
B9AED55B283E755A002A2668 /* Hardware.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Hardware.framework; sourceTree = BUILT_PRODUCTS_DIR; };
B9C9C635283E703C001B879F /* WooFoundation.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = WooFoundation.framework; sourceTree = BUILT_PRODUCTS_DIR; };
Expand Down Expand Up @@ -153,6 +155,7 @@
686BE910288EE09B00967C86 /* Utilities */ = {
isa = PBXGroup;
children = (
B99686DD2A13B38B00D1AF62 /* FullScreenCoverClearBackgroundView.swift */,
686BE911288EE0D300967C86 /* TypedPredicates.swift */,
03597A9328F85686005E4A98 /* UTMParameters.swift */,
03597A9A28F87BFC005E4A98 /* WooCommerceComUTMProvider.swift */,
Expand Down Expand Up @@ -499,6 +502,7 @@
26AF1F5528B8362800937BA9 /* UIColor+ColorStudio.swift in Sources */,
26AF1F5428B8362800937BA9 /* ColorStudio.swift in Sources */,
68FBC5B328926B2C00A05461 /* Collection+Extensions.swift in Sources */,
B99686DE2A13B38B00D1AF62 /* FullScreenCoverClearBackgroundView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import SwiftUI

/// Use this view to clear the background of a view in SwiftUI after it's presented with `fullScreenCover`
///
/// Use it as follows:
///
///```
/// .fullScreenCover(isPresented: $showingFooView) {
/// FooView()
/// .background(FullScreenCoverClearBackgroundView())
/// }
/// ```
///
public struct FullScreenCoverClearBackgroundView: UIViewRepresentable {
public init() {}

public func makeUIView(context: Context) -> UIView {
return InnerView()
}

public func updateUIView(_ uiView: UIView, context: Context) {
}

private class InnerView: UIView {
override func didMoveToWindow() {
super.didMoveToWindow()

superview?.superview?.backgroundColor = .clear
}
}
}