From 8dba8567b6bf9e7513d5de76b87ebd02a946a23a Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Mon, 14 Sep 2026 15:11:32 +0100 Subject: [PATCH] [Swift][Sample]: Add a checkout presentation picker Assisted-By: devx/e9549c39-4038-46fb-b955-39538bc9441f --- .../Sources/CheckoutProtocolClient.swift | 1 - .../Cart/CartCheckoutPresentation.swift | 63 ++++++++ .../Sources/Scenes/Cart/CartView.swift | 44 +++--- .../Sources/Scenes/SettingsView.swift | 28 +++- .../Cart/CartCheckoutPresentationTests.swift | 140 ++++++++++++++++++ .../CheckoutPresentationOptionTests.swift | 42 ++++++ 6 files changed, 289 insertions(+), 29 deletions(-) create mode 100644 platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartCheckoutPresentation.swift create mode 100644 platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/Scenes/Cart/CartCheckoutPresentationTests.swift create mode 100644 platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/Scenes/CheckoutPresentationOptionTests.swift diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/CheckoutProtocolClient.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/CheckoutProtocolClient.swift index 4dd91c0e0..a9474b9f9 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/CheckoutProtocolClient.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/CheckoutProtocolClient.swift @@ -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 diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartCheckoutPresentation.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartCheckoutPresentation.swift new file mode 100644 index 000000000..57aea96f4 --- /dev/null +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartCheckoutPresentation.swift @@ -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)") + } + } +} diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartView.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartView.swift index 1a30b4535..0c6014a90 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartView.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartView.swift @@ -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 @@ -87,7 +89,7 @@ struct CartView: View { } Button( - action: { showCheckoutSheet = true }, + action: presentCheckout, label: { HStack { Text("Check out") @@ -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) } } @@ -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() { diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/SettingsView.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/SettingsView.swift index fc1ec2699..1c1e73cb2 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/SettingsView.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/SettingsView.swift @@ -7,6 +7,7 @@ import WebKit enum AppStorageKeys: String { case acceleratedCheckoutsLogLevel case checkoutKitLogLevel + case checkoutPresentation case checkoutPreloadingEnabled case preloadObservabilityEnabled case buyerIdentityMode @@ -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 { @@ -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 { diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/Scenes/Cart/CartCheckoutPresentationTests.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/Scenes/Cart/CartCheckoutPresentationTests.swift new file mode 100644 index 000000000..b334e6d6d --- /dev/null +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/Scenes/Cart/CartCheckoutPresentationTests.swift @@ -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?() + } +} diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/Scenes/CheckoutPresentationOptionTests.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/Scenes/CheckoutPresentationOptionTests.swift new file mode 100644 index 000000000..d93418096 --- /dev/null +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/Scenes/CheckoutPresentationOptionTests.swift @@ -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) + } +}