-
Notifications
You must be signed in to change notification settings - Fork 108
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Experiment] Card payments facade/adaptors with UI #12760
Conversation
At this point, I think the adaptor could be used as a singleton to give us one source of truth for card reader connections. I’ve cut some corners in the POC and this still doesn’t totally prove that we can avoid UIKit use, but I _think_ the principle holds.
Note that the `showOnboarding` function is not yet called, as the AlertsHandler doesn’t look like the best API. I’m going to turn it into streams
# Conflicts: # WooCommerce/Classes/ViewRelated/Hub Menu/HubMenu.swift
Generated by 🚫 Danger |
|
||
class PointOfSalePaymentsTestViewModel: ObservableObject { | ||
let cardPresentPayments: CardPresentPayments | ||
var cancellables = Set<AnyCancellable>() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
cancellables
is not currently being used, in case that you see any odd behaviour with the expected subscription lifecycle
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks
@@ -2,13 +2,12 @@ import UIKit | |||
|
|||
/// Abstracts configuration and contents of the modal screens presented | |||
/// during operations related to Card Present Payments | |||
protocol CardPresentPaymentsModalViewModel { | |||
typealias CardPresentPaymentsModalViewModel = CardPresentPaymentsModalContent & CardPresentPaymentsModalActions |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💯 makes more sense to group the protocols like this and allows us to start decoupling from UIKit
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah. I'm not sure exactly how to handle the actions yet, but I'll probably add another actions protocol and make the existing view models implement it without using the viewController they otherwise get passed. We'll see.
|
||
/// This is really a re-implementation of the CardPresentPaymentsOnboardingPresenter, as it needs to take the calls to `showOnboardingIfRequired` and | ||
/// route the output to a SwiftUI view for display, rather than directly displaying on the viewController that's passed in. | ||
final class CardPresentPaymentsOnboardingPresenterAdaptor: CardPresentPaymentsOnboardingPresenting { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From what I understand this adapter is just a copy of the existing CardPresentPaymentsOnboardingPresenter
to be used through our SwiftUI implementation, it just ignores the usage of the UIViewController
property within showOnboardingIfRequired(:)
, that we need to declare to conform to the protocol, right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep pretty much.
It replaces the use of that UIViewController property with use of its onboardingScreenViewModelPublisher
to emit the UI events for our new code to display.
In the older implementation, the CardPresentPaymentsOnboardingPresenter
has the responsibility for actually presenting the UI, which this adaptor doesn't.
return await withTaskCancellationHandler { | ||
return await withCheckedContinuation { continuation in | ||
orderPaymentUseCase.collectPayment(using: discoveryMethod) { error in | ||
// TODO: even though we have a tri-state result type, perhaps we should throw these errors. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had a similar question when checking how we handle the collectPayment async result task within PointOfSaleDashboardView
. Should we throw errors on .failure
, or should we continue handling it as one of the result types of the collect payment state?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think trying it out and seeing what's nicer at the callsite is worthwhile on these calls... I'm not going to do it in the experiment but it would be nice to figure out our preference and be consistent.
Looking great. Any idea where we should handle the differences between |
self.cardPresentPayments = CardPresentPaymentsAdaptor(siteID: siteID) | ||
cardPresentPayments.paymentScreenEventPublisher | ||
.print("🅿️ payment event") | ||
.map { event -> InPersonPaymentsViewModel? in |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are we creating a new instance of InPersonPaymentsViewModel
every time that this event happens and is passed as an associated type? Would it be any concern if that's the case? I don't see anything that could cause troubles but I'm wondering: If it's a new instance we have to be careful when observing changes, or losing the reference may imply stop listening to changes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We are, and I'm not 100% sure if it's a concern yet... I don't think it is though.
It's poorly named – InPersonPaymentsViewModel
is essentially OnboardingScreenViewModel
– so it should be pretty simple, short lived, and safe to make new ones when the onboarding screen changes.
No, I don't think it should be in the adaptor – ideally we'll be able to use the adaptor from both the The adaptor's job is adapting from the UIKit-focus and store/action usage of the existing code, to a SwiftUI and async/await friendly API for card present payments. So we should handle the differences elsewhere. The gate between the app and POS experiences is one option. Another is the code which actually shows the UI and uses the adaptor ( Another way is to have two different |
📲 You can test the changes from this Pull Request in WooCommerce iOS by scanning the QR code below to install the corresponding build.
|
Using the NullObject pattern for the RootViewController dependency prevents some strange background UI and reduces the risk of errors
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks a lot @joshheald for digging into all different parts of the payments flow, and coming up with a solution for us to reuse the UI (not the UX for now as it looks like) with minimal changes to the existing code! LGTM to proceed as our POS payment-related service 👍
Left some questions marked with ❓ but mostly are just for clarifying. I'm slightly concerned about all the nil
rootViewController
cases that present some webview or other UI that doesn't trigger the delegate/callback toward the bottom of the PR diffs. Plus potentially missing some nil
cases, but they should only affect the POS usage and I'm sure we can figure it out.
/// The adaptor/facade is intended to be created once when we switch stores, and to be a singleton. | ||
/// We might as well make a new one when we switch stores though. | ||
init(siteID: Int64) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❓ might an initializer requirement in the protocol prevent us from passing other dependencies if needed in the future?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, agreed. I think this was just here so I could copy a simple representation to the P2 post, it's not something we need in the real protocol when we go further, AFAIK.
/// `paymentScreenEventPublisher` provides a stream of events relating to a payment, including their view models, | ||
/// for subscribers to display to the user. e.g. onboarding screens, connection progress, payment progress, card reader messages | ||
/// We may want separate streams for these depending on the way we consume it | ||
var paymentScreenEventPublisher: AnyPublisher<CardPresentPaymentEvent?, Never> { get } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice having a publisher to emit all events that we want to present UI/UX in the payment flow 💯 If we want to add support for just the card reader connection flow (in the POC pfoUAQ-HJ-p2), can we add a similar publisher like readerConnectionEventPublisher
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❓ What does a nil value in the stream mean, what's the recommended way for the observer to handle it? It might be worth mentioning in the comment if the value needs to be optional.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In this example, which is closely based on the existing UI/UX, nil
means "don't show any card payment UI", which I think we need to be able to do to remove modals which are finished with (e.g. when cancelling, or when someone taps the last button after a payment.)
That's a good question though, because it's not immediately clear what that would mean if the UI were more ambient, showing the progress in a corner of the screen or similar. In that case, we'd probably always want a state.
Perhaps an idle
event is required here...
/// - discoveryMethod: Allows specifying Tap to Pay or external reader. For POS, this would default to external. | ||
/// - Returns: `CardPresentPaymentResult` for a success, failure, or cancellation. | ||
func collectPayment(for order: Order, | ||
using discoveryMethod: CardReaderDiscoveryMethod) async -> CardPresentPaymentResult |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Really great being able to use async/await 👍
/// `paymentScreenEventPublisher` provides a stream of events relating to a payment, including their view models, | ||
/// for subscribers to display to the user. | ||
/// This is long lived, and used to display UI, not to communicate the success or failure of a particular payment | ||
// TODO: figure out whether there's any reason to use a Combine publisher or AsyncStream here. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I haven't used AsyncStream
nor seen existing usage in the app, but it could be nice to try if it fits our use case. My understanding of the major advantages of using Combine are: being able to do all kinds of operations (mapping, merging, combining, etc.), and being compatible with what we've been using in the app. Some considerations might be how to cancel and achieve the merging behavior like in paymentScreenEventPublisher
's current implementation.
// TODO: decide whether we actually want to expose this, or just internally assign `paymentScreenEventSubject` to each short-lived stream instead. | ||
var paymentScreenEventPublisher: AnyPublisher<CardPresentPaymentEvent?, Never> | ||
|
||
private let currencyFormatter: CurrencyFormatter = CurrencyFormatter(currencySettings: ServiceLocator.currencySettings) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: this could be a reason to move the initializer out of the protocol to enable dependencies other than the site ID, unless there's some reason to include the initializer. CurrencySettings
could be DI'ed for unit testing.
case .ready = readiness else { | ||
return | ||
} | ||
// TODO: perhaps we should have a more specific "success" event here. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1 to emit a specific "onboarding success/ready" event.
let cardPresentPayments: CardPresentPayments | ||
var cancellables = Set<AnyCancellable>() | ||
|
||
@Published var onboardingViewModels: InPersonPaymentsViewModel? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: just to match the value type
@Published var onboardingViewModels: InPersonPaymentsViewModel? | |
@Published var onboardingViewModel: InPersonPaymentsViewModel? |
@@ -48,7 +48,11 @@ final class CardPresentModalBluetoothRequired: CardPresentPaymentsModalViewModel | |||
} | |||
|
|||
func didTapSecondaryButton(in viewController: UIViewController?) { | |||
viewController?.dismiss(animated: true, completion: {[weak self] in | |||
guard let viewController else { | |||
return primaryAction() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❓ just wanted to make sure, the dismiss isn't needed before calling primaryAction()
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the case where a UIKit call is made for this, it probably is... but we can't dismiss, if we don't have a viewController...
The other way I'm tempted to improve this is to update the CardPresentPaymentsModalViewModel
protocol to make it handle both SwiftUI and UIKit, by providing two handlers for each button, one which requires a UIViewController
(for UIKit) and one which doesn't accept one (for SwiftUI).
@@ -601,7 +604,7 @@ private extension CollectOrderPaymentUseCase { | |||
let noticePresenter = DefaultNoticePresenter() | |||
let notice = Notice(title: Localization.failedReceiptPrintNoticeText, | |||
feedbackType: .error) | |||
noticePresenter.presentingViewController = rootViewController | |||
noticePresenter.presentingViewController = rootViewController as? UIViewController |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit ❓ : does the nil case get to show the notice, or an equivalent error event is emitted?
Same question about the nil UIViewController in presentEmailForm
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't handle receipts in this POC, because I don't think there's any need for us to use the existing code for it, and it's still in question whether they're in scope for MVP.
It's kind of tacked on to the end of the payment code... so this never gets called without a real UIViewController
.
We might be able to make the Presenting
protocol handle this a bit more gracefully.
// TODO: The use of UIViewController here is pretty problematic when using it from SwiftUI, | ||
// we need an alternative when it gets consumed through the CardPresentPaymentsAdaptor |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+100, I encountered this when trying to understand all the card reader connection UI states in #12723. It also looks like other views have a similar problem with the web view coupled with the business logic and not abstracted out. 😅
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I can't figure out a way to adapt these without changing the underlying implementation at all right now.
We should be able to ignore most of these webview launching handlers, as it's an implementation detail of the UIKit view model which we can do separately in SwiftUI... but I think we need this one to work pretty soon, because it's essentially an onboarding issue, but handled during connection.
It feels like much of this needs to be moved to the AlertProvider, where we can inject a different one for use from SwiftUI, but the else
case would need to be handled as that changes the alert shown in the connection controller.
Closing as this PR is not active anymore. |
Description
Builds on #12735 to add the onboarding and payments UI.
This adapts and reuses the existing payments code and, as much as possible, the UI.
Onboarding UI is presented without the UIKit wrapper used in the existing app.
Connection and Payments UI is reimplemented, but relies on wrapped versions of the existing view models. The wrappers only exist to make the VMs identifiable, and otherwise wouldn't be required.
TODOs
These reimplemented UIs don't have buttons yet, so you can't go all the way through the flow unless it wouldn't require a button tap.
At the moment, we're using a fake order so the payment doesn't actually work.
I'll work on those tomorrow to fully prove the concept.
Screenshots
look.ma.no.buttons.mp4