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..d5ab72d87 --- /dev/null +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartCheckoutPresentation.swift @@ -0,0 +1,117 @@ +import Combine +import ShopifyCheckoutKit +import SwiftUI +import UIKit + +@MainActor +final class CartCheckoutPresentation: ObservableObject, CheckoutDelegate { + @Published var showCheckoutSheet = false + + @AppStorage(AppStorageKeys.windowOpenHandler.rawValue) + private var windowOpenHandler: WindowOpenHandlerOption = .default + + private var isCompleted = false + private var selectedAddressIDs: [String: String] = [:] + private var selectedDeliveryMethodIDs: [String: String] = [:] + 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? + ) { + 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) + presenter.present(checkoutViewController, animated: true) + } + } + + /// Records a completed checkout. The cart is reset on dismissal so the confirmation page stays visible: + /// resetting now would nil the cart and SwiftUI would auto-collapse the sheet. + func recordCompletion() { + isCompleted = true + } + + func resetCompletedCart() { + if isCompleted { + isCompleted = false + resetCart() + } + selectedAddressIDs = [:] + selectedDeliveryMethodIDs = [:] + } + + func checkoutDidStart(_ event: CheckoutStartEvent) { + print("[CheckoutKitSwiftDemo] Started: \(event.checkout.id)") + } + + func checkoutDidUpdate(_ event: CheckoutUpdateEvent) { + let updatedAddressIDs = Self.selectedAddressIDs(in: event.checkout) + if updatedAddressIDs != selectedAddressIDs { + print("[CheckoutKitSwiftDemo] Selected address changed") + selectedAddressIDs = updatedAddressIDs + } + + let updatedDeliveryMethodIDs = Self.selectedDeliveryMethodIDs(in: event.checkout) + if updatedDeliveryMethodIDs != selectedDeliveryMethodIDs { + print("[CheckoutKitSwiftDemo] Selected delivery method changed") + selectedDeliveryMethodIDs = updatedDeliveryMethodIDs + } + + print("[CheckoutKitSwiftDemo] Updated: \(event.checkout.id)") + } + + func checkoutDidComplete(_ event: CheckoutCompleteEvent) { + print("[CheckoutKitSwiftDemo] Completed: \(event.checkout.order?.id ?? "unknown")") + recordCompletion() + } + + func checkoutAction(for link: CheckoutLink) -> CheckoutLinkAction { + print("[CheckoutKitSwiftDemo] Checkout link clicked: \(link.url)") + switch windowOpenHandler { + case .default: + print("[CheckoutKitSwiftDemo] Checkout link delegated to Checkout Kit: \(link.url)") + return .open + case .externalApp: + print("[CheckoutKitSwiftDemo] Checkout link handled by sample: \(link.url)") + UIApplication.shared.open(link.url) + return .handled + } + } + + func checkoutDidDismiss() { + print("[CheckoutKitSwiftDemo] Dismissed") + showCheckoutSheet = false + resetCompletedCart() + } + + func checkoutDidFail(_ event: CheckoutFailureEvent) { + showCheckoutSheet = false + print("[CheckoutKitSwiftDemo] Failed: \(event.error)") + } + + private static func selectedAddressIDs(in checkout: ShopifyCheckoutKit.Checkout) -> [String: String] { + (checkout.fulfillment?.methods ?? []).reduce(into: [:]) { selections, method in + guard method.type == "shipping", let destinationID = method.selectedDestinationID else { return } + selections[method.id] = destinationID + } + } + + private static func selectedDeliveryMethodIDs(in checkout: ShopifyCheckoutKit.Checkout) -> [String: String] { + (checkout.fulfillment?.methods ?? []).reduce(into: [:]) { selections, method in + for group in method.groups ?? [] { + guard let optionID = group.selectedOptionID else { continue } + selections["\(method.id):\(group.id)"] = optionID + } + } + } +} 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 c3c2e9cf2..df8b3404a 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartView.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartView.swift @@ -8,12 +8,9 @@ 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) - @State private var selectedAddressIDs: [String: String] = [:] - @State private var selectedDeliveryMethodIDs: [String: String] = [:] @ObservedObject var cartManager: CartManager = .shared @ObservedObject private var preloadCacheHitLog: PreloadCacheHitLog = .shared @@ -21,8 +18,8 @@ struct CartView: View { @AppStorage(AppStorageKeys.applePayStyle.rawValue) var applePayStyle: ApplePayStyleOption = .automatic - @AppStorage(AppStorageKeys.windowOpenHandler.rawValue) - var windowOpenHandler: WindowOpenHandlerOption = .default + @AppStorage(AppStorageKeys.checkoutPresentation.rawValue) + var checkoutPresentationOption: CheckoutPresentationOption = .swiftUI @AppStorage(AppStorageKeys.checkoutPreloadingEnabled.rawValue) var checkoutPreloadingEnabled = true @@ -62,24 +59,24 @@ struct CartView: View { .onStart { event in print("[AcceleratedCheckout] Started: \(event.checkout.id)") } - .onUpdate(handleCheckoutUpdate) + .onUpdate(checkoutPresentation.checkoutDidUpdate) .onComplete { event in print("[AcceleratedCheckout] Completed: \(event.checkout.order?.id ?? "unknown")") - isCompleted = true + checkoutPresentation.recordCompletion() } - .onLinkClick(handleCheckoutLink) + .onLinkClick(checkoutPresentation.checkoutAction(for:)) .onFail { error in print("[AcceleratedCheckout] Failed: \(error)") } .onDismiss { print("[AcceleratedCheckout] Dismissed") - resetCompletedCart() + checkoutPresentation.resetCompletedCart() } } } Button( - action: { showCheckoutSheet = true }, + action: presentCheckout, label: { HStack { Text("Check out") @@ -106,32 +103,16 @@ 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) .appearance(.app(.automatic)) - .onStart { event in - print("[CheckoutKitSwiftDemo] Started: \(event.checkout.id)") - } - .onUpdate(handleCheckoutUpdate) - .onLinkClick(handleCheckoutLink) - .onComplete { event 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("[CheckoutKitSwiftDemo] Completed: \(event.checkout.order?.id ?? "unknown")") - isCompleted = true - } - .onDismiss { - print("[CheckoutKitSwiftDemo] Dismissed") - showCheckoutSheet = false - - resetCompletedCart() - } - .onFail { event in - showCheckoutSheet = false - print("[CheckoutKitSwiftDemo] Failed: \(event.error)") - } + .onStart(checkoutPresentation.checkoutDidStart) + .onUpdate(checkoutPresentation.checkoutDidUpdate) + .onLinkClick(checkoutPresentation.checkoutAction(for:)) + .onComplete(checkoutPresentation.checkoutDidComplete) + .onDismiss(checkoutPresentation.checkoutDidDismiss) + .onFail(checkoutPresentation.checkoutDidFail) .edgesIgnoringSafeArea(.all) } } @@ -161,9 +142,13 @@ struct CartView: View { } private func presentCheckout() { - guard let url = CartManager.shared.cart?.checkoutURL else { return } + guard let url = cartManager.cart?.checkoutURL else { return } - CheckoutCoordinator.shared?.present(checkout: url) + checkoutPresentation.present( + checkout: url, + using: checkoutPresentationOption, + from: CheckoutCoordinator.shared?.window?.topMostViewController() + ) } private func preloadCheckoutIfNeeded() { @@ -177,60 +162,6 @@ struct CartView: View { ShopifyCheckoutKit.configuration.logger.log("Preload state changed to \(state)") } } - - private func resetCompletedCart() { - if isCompleted { - CartManager.shared.resetCart() - isCompleted = false - } - selectedAddressIDs = [:] - selectedDeliveryMethodIDs = [:] - } - - private func handleCheckoutUpdate(_ event: CheckoutUpdateEvent) { - let updatedAddressIDs = selectedAddressIDs(in: event.checkout) - if updatedAddressIDs != selectedAddressIDs { - print("[CheckoutKitSwiftDemo] Selected address changed") - selectedAddressIDs = updatedAddressIDs - } - - let updatedDeliveryMethodIDs = selectedDeliveryMethodIDs(in: event.checkout) - if updatedDeliveryMethodIDs != selectedDeliveryMethodIDs { - print("[CheckoutKitSwiftDemo] Selected delivery method changed") - selectedDeliveryMethodIDs = updatedDeliveryMethodIDs - } - - print("[CheckoutKitSwiftDemo] Updated: \(event.checkout.id)") - } - - private func handleCheckoutLink(_ link: CheckoutLink) -> CheckoutLinkAction { - print("[CheckoutKitSwiftDemo] Checkout link clicked: \(link.url)") - switch windowOpenHandler { - case .default: - print("[CheckoutKitSwiftDemo] Checkout link delegated to Checkout Kit: \(link.url)") - return .open - case .externalApp: - print("[CheckoutKitSwiftDemo] Checkout link handled by sample: \(link.url)") - UIApplication.shared.open(link.url) - return .handled - } - } - - private func selectedAddressIDs(in checkout: ShopifyCheckoutKit.Checkout) -> [String: String] { - (checkout.fulfillment?.methods ?? []).reduce(into: [:]) { selections, method in - guard method.type == "shipping", let destinationID = method.selectedDestinationID else { return } - selections[method.id] = destinationID - } - } - - private func selectedDeliveryMethodIDs(in checkout: ShopifyCheckoutKit.Checkout) -> [String: String] { - (checkout.fulfillment?.methods ?? []).reduce(into: [:]) { selections, method in - for group in method.groups ?? [] { - guard let optionID = group.selectedOptionID else { continue } - selections["\(method.id):\(group.id)"] = optionID - } - } - } } struct EmptyState: View { diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/SettingsView.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/SettingsView.swift index 7f4d484cd..5c04479b5 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/SettingsView.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/SettingsView.swift @@ -6,6 +6,7 @@ import WebKit enum AppStorageKeys: String { case acceleratedCheckoutsLogLevel case checkoutKitLogLevel + case checkoutPresentation case checkoutPreloadingEnabled case preloadObservabilityEnabled case buyerIdentityMode @@ -31,9 +32,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 { @@ -57,7 +73,22 @@ 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.") + ) { + HStack { + Text("Checkout presentation") + Spacer() + Picker("Checkout presentation", selection: $checkoutPresentationOption) { + ForEach(CheckoutPresentationOption.allCases, id: \.self) { option in + Text(option.title).tag(option) + } + } + .labelsHidden() + .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..46c17cdf1 --- /dev/null +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/Scenes/Cart/CartCheckoutPresentationTests.swift @@ -0,0 +1,126 @@ +@testable import CheckoutKitSwiftDemo +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) + + XCTAssertTrue(presentation.showCheckoutSheet) + XCTAssertNil(presenter.checkoutViewController) + } + + func testUIKitPresentationPresentsTheControllerWithoutOpeningTheSheet() { + let presentation = makePresentation() + let presenter = CheckoutPresenterSpy() + + presentation.present(checkout: makeCheckoutURL(), using: .uiKit, from: presenter) + + 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()) + + XCTAssertEqual(ShopifyCheckoutKit.configuration.appearance, .app(.automatic)) + } + + func testUIKitPresentationDoesNotOpenTheSheetWithoutAPresenter() { + let presentation = makePresentation() + + presentation.present(checkout: makeCheckoutURL(), using: .uiKit, from: nil) + + XCTAssertFalse(presentation.showCheckoutSheet) + } + + func testDismissingCheckoutBeforeCompletionKeepsTheCart() { + var cartResetCount = 0 + let presentation = makePresentation { cartResetCount += 1 } + presentation.present(checkout: makeCheckoutURL(), using: .swiftUI, from: nil) + + presentation.checkoutDidDismiss() + + XCTAssertFalse(presentation.showCheckoutSheet) + XCTAssertEqual(cartResetCount, 0) + } + + func testCompletionKeepsTheConfirmationVisibleUntilDismissalAndResetsTheCartOnce() { + var cartResetCount = 0 + let presentation = makePresentation { cartResetCount += 1 } + presentation.present(checkout: makeCheckoutURL(), using: .swiftUI, from: nil) + + presentation.checkoutDidComplete(CheckoutCompleteEvent(checkout: makeCheckout())) + + XCTAssertTrue(presentation.showCheckoutSheet) + XCTAssertEqual(cartResetCount, 0) + + presentation.checkoutDidDismiss() + + XCTAssertFalse(presentation.showCheckoutSheet) + XCTAssertEqual(cartResetCount, 1) + + presentation.checkoutDidDismiss() + + XCTAssertEqual(cartResetCount, 1) + } + + func testFailureClosesTheSheetWithoutResettingTheCart() { + var cartResetCount = 0 + let presentation = makePresentation { cartResetCount += 1 } + presentation.present(checkout: makeCheckoutURL(), using: .swiftUI, from: nil) + + presentation.checkoutDidFail( + CheckoutFailureEvent(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 makeCheckout() -> ShopifyCheckoutKit.Checkout { + ShopifyCheckoutKit.Checkout(id: "test-checkout", status: .completed, currency: "USD", lineItems: [], totals: []) + } +} + +@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) + } +} diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/README.md b/platforms/swift/Samples/CheckoutKitSwiftDemo/README.md index de939cb13..98c955929 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/README.md +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/README.md @@ -61,7 +61,7 @@ Do not edit files in `Generated/` by hand. Update `.graphql` files and regenerat 1. `Network.swift` creates an `ApolloClient` that points at the configured Storefront API endpoint and attaches the Storefront access token. 2. `StorefrontClient.swift` and `CartManager.swift` call Apollo using generated operation types such as `Storefront.CartCreateMutation` and `Storefront.GetCartQuery`. 3. `AcceleratedCheckoutsConfiguredView.swift` injects the storefront, Apple Pay, locale, contact-field, buyer contact, and shipping-country configuration used by accelerated checkout buttons. -4. `CheckoutCoordinator.swift` presents `cart.checkoutUrl` with `ShopifyCheckoutKit`, while `CartView.swift` handles `.onStart`, `.onUpdate`, `.onComplete`, and `.onLinkClick`. +4. `CheckoutCoordinator.swift` presents `cart.checkoutUrl` with `ShopifyCheckoutKit`. The Cart tab presents checkout with `ShopifyCheckout` (SwiftUI) or a directly constructed `CheckoutViewController` (UIKit), selected under **Settings → Features → Checkout presentation**; `CartCheckoutPresentation.swift` handles the lifecycle callbacks for both. 5. Apollo decodes responses into generated Swift types, so schema or operation changes surface as compile errors. ## Setup @@ -156,6 +156,7 @@ All commands are run from the **repo root** (`checkout-kit/`): | `CheckoutKitSwiftDemo/Sources/Scenes/AcceleratedCheckoutsSettingsView.swift` | Dedicated accelerated checkout and Apple Pay settings screen. | | `CheckoutKitSwiftDemo/Sources/App/CartManager.swift` | Cart state and Storefront API mutations. | | `CheckoutKitSwiftDemo/Sources/App/CheckoutCoordinator.swift` | Checkout presentation. | -| `CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartView.swift` | Public checkout callbacks for regular and accelerated checkout. | +| `CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartView.swift` | Cart tab with regular and accelerated checkout entrypoints. | +| `CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartCheckoutPresentation.swift` | SwiftUI or UIKit checkout presentation and public checkout callbacks for the Cart tab. | | `Package.resolved` | Dependency pins copied into the generated Xcode project's workspace. | | `project.yml` | Generates the Xcode project, Info.plist, and Associated Domains entitlements through XcodeGen. |