Skip to content
Draft
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
Expand Up @@ -12,7 +12,6 @@ extension CheckoutProtocol.Client {
.on(CheckoutProtocol.complete) { checkout in
// Do NOT reset the cart here — the cart drives a SwiftUI `if let` in CartView,
// and nil-ing it auto-collapses the .sheet, hiding the order confirmation page.
// Reset on user dismissal instead (see CartView .onDismiss + isCompleted).
print("[UCP] ec.complete: \(checkout.order?.id ?? "unknown")")
}
.on(CheckoutProtocol.lineItemsChange) { checkout in
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import Combine
import EmbeddedCheckoutProtocol
import ShopifyCheckoutKit
import UIKit

@MainActor
final class CartCheckoutPresentation: ObservableObject, CheckoutDelegate {
@Published var showCheckoutSheet = false

private var isCompleted = false
private let resetCart: @MainActor () -> Void

init(resetCart: @escaping @MainActor () -> Void = { CartManager.shared.resetCart() }) {
self.resetCart = resetCart
}

func present(
checkout url: URL,
using option: CheckoutPresentationOption,
from presenter: UIViewController?,
client: CheckoutProtocol.Client
) {
switch option {
case .swiftUI:
showCheckoutSheet = true
case .uiKit:
guard let presenter else { return }
ShopifyCheckoutKit.configuration.appearance = .app(.automatic)
let checkoutViewController = CheckoutViewController(
checkout: url,
delegate: self,
client: observingCompletion(on: client)
)
presenter.present(checkoutViewController, animated: true)
}
}

func observingCompletion(on client: CheckoutProtocol.Client) -> CheckoutProtocol.Client {
client.on(CheckoutProtocol.complete) { [weak self] checkout in
print("[UCP] ec.complete: \(checkout.order?.id ?? "unknown")")
self?.isCompleted = true
}
}

nonisolated func checkoutDidDismiss() {
MainActor.assumeIsolated {
print("[CheckoutKitSwiftDemo] DISMISSED")
showCheckoutSheet = false

if isCompleted {
isCompleted = false
resetCart()
}
}
}

nonisolated func checkoutDidFail(error: CheckoutError) {
MainActor.assumeIsolated {
showCheckoutSheet = false
print("[CheckoutKitSwiftDemo] FAIL - Checkout failed: \(error)")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,16 @@ typealias CartLineNode = Storefront.CartFragment.Lines.Node
struct CartView: View {
@State var cartCompleted: Bool = false
@State var isBusy: Bool = false
@State var isCompleted: Bool = false
@State var showCheckoutSheet: Bool = false
@StateObject private var checkoutPresentation = CartCheckoutPresentation()
@State private var checkoutPreload: CheckoutPreload?
@State private var preloadStateTestId = PreloadStateMarker.testId(for: .idle)

@ObservedObject var cartManager: CartManager = .shared
@ObservedObject private var preloadCacheHitLog: PreloadCacheHitLog = .shared

@AppStorage(AppStorageKeys.checkoutPresentation.rawValue)
var checkoutPresentationOption: CheckoutPresentationOption = .swiftUI

@AppStorage(AppStorageKeys.applePayStyle.rawValue)
var applePayStyle: ApplePayStyleOption = .automatic

Expand Down Expand Up @@ -87,7 +89,7 @@ struct CartView: View {
}

Button(
action: { showCheckoutSheet = true },
action: presentCheckout,
label: {
HStack {
Text("Check out")
Expand All @@ -114,30 +116,13 @@ struct CartView: View {
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
.sheet(isPresented: $showCheckoutSheet) {
.sheet(isPresented: $checkoutPresentation.showCheckoutSheet) {
if let url = cartManager.cart?.checkoutURL {
ShopifyCheckout(checkout: url)
.connect(client.on(CheckoutProtocol.complete) { checkout in
// Set the flag here; defer the cart reset until the user dismisses
// the sheet (in .onDismiss). Resetting now would nil the cart and
// SwiftUI would auto-collapse this sheet, hiding the confirmation page.
print("[UCP] ec.complete: \(checkout.order?.id ?? "unknown")")
isCompleted = true
})
.connect(checkoutPresentation.observingCompletion(on: client))
.appearance(.app(.automatic))
.onDismiss {
print("[CheckoutKitSwiftDemo] DISMISSED")
showCheckoutSheet = false

if isCompleted {
CartManager.shared.resetCart()
isCompleted = false
}
}
.onFail { error in
showCheckoutSheet = false
print("[CheckoutKitSwiftDemo] FAIL - Checkout failed: \(error)")
}
.onDismiss(checkoutPresentation.checkoutDidDismiss)
.onFail(checkoutPresentation.checkoutDidFail)
.edgesIgnoringSafeArea(.all)
}
}
Expand Down Expand Up @@ -167,9 +152,14 @@ struct CartView: View {
}

private func presentCheckout() {
guard let url = CartManager.shared.cart?.checkoutURL else { return }

CheckoutCoordinator.shared?.present(checkout: url)
guard let url = cartManager.cart?.checkoutURL else { return }

checkoutPresentation.present(
checkout: url,
using: checkoutPresentationOption,
from: CheckoutCoordinator.shared?.window?.topMostViewController(),
client: client
)
}

private func preloadCheckoutIfNeeded() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import WebKit
enum AppStorageKeys: String {
case acceleratedCheckoutsLogLevel
case checkoutKitLogLevel
case checkoutPresentation
case checkoutPreloadingEnabled
case preloadObservabilityEnabled
case buyerIdentityMode
Expand All @@ -26,9 +27,24 @@ enum WindowOpenHandlerOption: String, CaseIterable {
}
}

enum CheckoutPresentationOption: String, CaseIterable {
case swiftUI
case uiKit

var title: String {
switch self {
case .swiftUI: return "SwiftUI"
case .uiKit: return "UIKit"
}
}
}

struct SettingsView: View {
@ObservedObject var config: AppConfiguration = appConfiguration

@AppStorage(AppStorageKeys.checkoutPresentation.rawValue)
var checkoutPresentationOption: CheckoutPresentationOption = .swiftUI

@AppStorage(AppStorageKeys.checkoutKitLogLevel.rawValue)
var checkoutKitLogLevel: LogLevel = .debug {
didSet {
Expand All @@ -55,7 +71,17 @@ struct SettingsView: View {
var body: some View {
NavigationView {
List {
Section(header: Text("Features")) {
Section(
header: Text("Features"),
footer: Text("Checkout presentation applies to the Cart tab.")
) {
Picker("Checkout presentation", selection: $checkoutPresentationOption) {
ForEach(CheckoutPresentationOption.allCases, id: \.self) { option in
Text(option.title).tag(option)
}
}
.pickerStyle(.menu)

Toggle("Checkout preloading", isOn: $checkoutPreloadingEnabled)
.onChange(of: checkoutPreloadingEnabled) { _ in
ShopifyCheckoutKit.configure {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
@testable import CheckoutKitSwiftDemo
import EmbeddedCheckoutProtocol
import ShopifyCheckoutKit
import UIKit
import XCTest

@MainActor
class CartCheckoutPresentationTests: XCTestCase {
private var originalConfiguration: Configuration!

override func setUp() async throws {
try await super.setUp()
originalConfiguration = ShopifyCheckoutKit.configuration
ShopifyCheckoutKit.configuration.preloading.enabled = false
}

override func tearDown() async throws {
ShopifyCheckoutKit.configuration = originalConfiguration
try await super.tearDown()
}

func testSwiftUIPresentationOpensTheSheetWithoutPresentingUIKit() {
let presentation = makePresentation()
let presenter = CheckoutPresenterSpy()

presentation.present(checkout: makeCheckoutURL(), using: .swiftUI, from: presenter, client: .init())

XCTAssertTrue(presentation.showCheckoutSheet)
XCTAssertNil(presenter.checkoutViewController)
}

func testUIKitPresentationPresentsTheControllerWithoutOpeningTheSheet() {
let presentation = makePresentation()
let presenter = CheckoutPresenterSpy()

presentation.present(checkout: makeCheckoutURL(), using: .uiKit, from: presenter, client: .init())

XCTAssertTrue(presenter.checkoutViewController is CheckoutViewController)
XCTAssertTrue(presenter.animated)
XCTAssertFalse(presentation.showCheckoutSheet)
}

func testUIKitPresentationUsesTheSameAppearanceAsTheSwiftUISheet() {
let presentation = makePresentation()
ShopifyCheckoutKit.configuration.appearance = .storefront

presentation.present(checkout: makeCheckoutURL(), using: .uiKit, from: CheckoutPresenterSpy(), client: .init())

XCTAssertEqual(ShopifyCheckoutKit.configuration.appearance, .app(.automatic))
}

func testUIKitPresentationDoesNotOpenTheSheetWithoutAPresenter() {
let presentation = makePresentation()

presentation.present(checkout: makeCheckoutURL(), using: .uiKit, from: nil, client: .init())

XCTAssertFalse(presentation.showCheckoutSheet)
}

func testDismissingCheckoutBeforeCompletionKeepsTheCart() {
var cartResetCount = 0
let presentation = makePresentation { cartResetCount += 1 }
presentation.present(checkout: makeCheckoutURL(), using: .swiftUI, from: nil, client: .init())

presentation.checkoutDidDismiss()

XCTAssertFalse(presentation.showCheckoutSheet)
XCTAssertEqual(cartResetCount, 0)
}

func testCompletionKeepsTheConfirmationVisibleUntilDismissalAndResetsTheCartOnce() async {
var cartResetCount = 0
let presentation = makePresentation { cartResetCount += 1 }
let client = presentation.observingCompletion(on: CheckoutProtocol.Client())
presentation.present(checkout: makeCheckoutURL(), using: .swiftUI, from: nil, client: client)

_ = await client.process(makeCheckoutNotification(method: "ec.complete"))

XCTAssertTrue(presentation.showCheckoutSheet)
XCTAssertEqual(cartResetCount, 0)

presentation.checkoutDidDismiss()

XCTAssertFalse(presentation.showCheckoutSheet)
XCTAssertEqual(cartResetCount, 1)

presentation.checkoutDidDismiss()

XCTAssertEqual(cartResetCount, 1)
}

func testObservingCompletionPreservesOtherProtocolHandlers() async {
let presentation = makePresentation()
var checkoutStarted = false
let client = CheckoutProtocol.Client().on(CheckoutProtocol.start) { _ in
checkoutStarted = true
}

_ = await presentation.observingCompletion(on: client).process(makeCheckoutNotification(method: "ec.start"))

XCTAssertTrue(checkoutStarted)
}

func testFailureClosesTheSheetWithoutResettingTheCart() {
var cartResetCount = 0
let presentation = makePresentation { cartResetCount += 1 }
presentation.present(checkout: makeCheckoutURL(), using: .swiftUI, from: nil, client: .init())

presentation.checkoutDidFail(error: CheckoutError(code: .networkError, message: "Network unavailable"))

XCTAssertFalse(presentation.showCheckoutSheet)
XCTAssertEqual(cartResetCount, 0)
}

private func makePresentation(resetCart: @escaping @MainActor () -> Void = {}) -> CartCheckoutPresentation {
CartCheckoutPresentation(resetCart: resetCart)
}

private func makeCheckoutURL() -> URL {
URL(string: "https://example.com/checkouts/cn/test")!
}

private func makeCheckoutNotification(method: String) -> String {
"""
{"jsonrpc":"2.0","method":"\(method)","params":{"checkout":{"currency":"USD","id":"test-checkout","line_items":[],"links":[],"status":"completed","totals":[],"ucp":{"payment_handlers":{},"version":"\(EmbeddedCheckoutProtocol.specVersion)"}}}}
"""
}
}

@MainActor
private class CheckoutPresenterSpy: UIViewController {
var checkoutViewController: UIViewController?
var animated = false

override func present(_ viewControllerToPresent: UIViewController, animated: Bool, completion: (() -> Void)? = nil) {
checkoutViewController = viewControllerToPresent
self.animated = animated
completion?()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
@testable import CheckoutKitSwiftDemo
import XCTest

@MainActor
class CheckoutPresentationOptionTests: XCTestCase {
private var originalSelection: Any?

override func setUp() async throws {
try await super.setUp()
originalSelection = UserDefaults.standard.object(forKey: AppStorageKeys.checkoutPresentation.rawValue)
UserDefaults.standard.removeObject(forKey: AppStorageKeys.checkoutPresentation.rawValue)
}

override func tearDown() async throws {
UserDefaults.standard.set(originalSelection, forKey: AppStorageKeys.checkoutPresentation.rawValue)
try await super.tearDown()
}

func testDefaultsToSwiftUIPresentation() {
XCTAssertEqual(SettingsView().checkoutPresentationOption, .swiftUI)
XCTAssertEqual(CartView().checkoutPresentationOption, .swiftUI)
}

func testSettingsPersistsThePresentationForSettingsAndCart() {
let settings = SettingsView()

for option in CheckoutPresentationOption.allCases {
settings.checkoutPresentationOption = option

XCTAssertEqual(UserDefaults.standard.string(forKey: AppStorageKeys.checkoutPresentation.rawValue), option.rawValue)
XCTAssertEqual(SettingsView().checkoutPresentationOption, option)
XCTAssertEqual(CartView().checkoutPresentationOption, option)
}
}

func testUnknownStoredPresentationFallsBackToSwiftUI() {
UserDefaults.standard.set("unknown", forKey: AppStorageKeys.checkoutPresentation.rawValue)

XCTAssertEqual(SettingsView().checkoutPresentationOption, .swiftUI)
XCTAssertEqual(CartView().checkoutPresentationOption, .swiftUI)
}
}
Loading