diff --git a/platforms/swift/README.md b/platforms/swift/README.md index 53e7a0d2c..721ef8121 100644 --- a/platforms/swift/README.md +++ b/platforms/swift/README.md @@ -461,7 +461,7 @@ iOS handles checkout geolocation permission prompts through the system prompt. I ### Configure accelerated checkouts -Create shared configuration values and inject them into your SwiftUI hierarchy: +Create shared configuration values and apply them to your SwiftUI hierarchy: ```swift import ShopifyAcceleratedCheckouts @@ -484,13 +484,23 @@ struct YourApp: App { var body: some Scene { WindowGroup { ContentView() - .environment(\.shopifyAcceleratedCheckoutsConfiguration, checkoutConfig) + .shopifyAcceleratedCheckouts(checkoutConfig) .environment(\.shopifyApplePayConfiguration, applePayConfig) } } } ``` +Apply `.shopifyAcceleratedCheckouts(_:)` as soon as the shop configuration is available. The +modifier preserves the configuration in the SwiftUI environment and automatically prefetches the +shop settings used by the wallet buttons. Requests are deduplicated across button instances; +settings remain fresh for one hour and stale settings are served while they refresh in the +background. Existing direct environment injection remains supported. + +Set `ShopifyAcceleratedCheckouts.logLevel = .debug` to inspect privacy-safe request, cache +freshness, prefetch, and wallet button loading timings. These logs do not include shop +configuration values or checkout identifiers. + Use one customer mode at a time: ```swift @@ -528,6 +538,15 @@ AcceleratedCheckoutButtons(cartID: cartID) .connect(client) ``` +While shop settings load, the SDK renders neutral placeholders matching the height, spacing, +count, and corner radius of the configured wallet buttons. The placeholders respect Reduce Motion +and are hidden from accessibility. To provide your own loading UI, disable the SDK presentation: + +```swift +AcceleratedCheckoutButtons(cartID: cartID) + .loadingPresentation(.hidden) +``` + You can also render buttons for a single product variant: ```swift diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/AppDelegate.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/AppDelegate.swift index 479f11352..cff96c1c5 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/AppDelegate.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/AppDelegate.swift @@ -1,3 +1,4 @@ +import ShopifyAcceleratedCheckouts import ShopifyCheckoutKit import UIKit @@ -21,6 +22,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { let checkoutKitLogLevel: LogLevel = getLogLevel( key: AppStorageKeys.checkoutKitLogLevel.rawValue ) + let acceleratedCheckoutsLogLevel: LogLevel = getLogLevel( + key: AppStorageKeys.acceleratedCheckoutsLogLevel.rawValue + ) let checkoutPreloadingEnabled = UserDefaults.standard.object( forKey: AppStorageKeys.checkoutPreloadingEnabled.rawValue ) as? Bool ?? true @@ -32,8 +36,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { $0.logLevel = checkoutKitLogLevel $0.preloading.enabled = checkoutPreloadingEnabled } + ShopifyAcceleratedCheckouts.logLevel = acceleratedCheckoutsLogLevel print("[CheckoutKitSwiftDemo] CheckoutKit Log level set to \(checkoutKitLogLevel)") + print( + "[CheckoutKitSwiftDemo] Accelerated Checkouts Log level set to \(acceleratedCheckoutsLogLevel)" + ) UIBarButtonItem.appearance().tintColor = ColorPalette.primaryColor diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/SceneDelegate.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/SceneDelegate.swift index e5ffbfb19..72959a354 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/SceneDelegate.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/SceneDelegate.swift @@ -1,4 +1,5 @@ import Combine +import ShopifyAcceleratedCheckouts import ShopifyCheckoutKit import SwiftUI import UIKit @@ -17,9 +18,15 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { var cancellables: Set = [] let uiKitCartController = CartViewController() - let swiftUICartController = UIHostingController(rootView: CartView()) - let productGridController = UIHostingController(rootView: ProductGridView()) - let productGalleryController = UIHostingController(rootView: ProductGalleryView()) + let swiftUICartController = UIHostingController( + rootView: AcceleratedCheckoutsConfiguredView(content: CartView()) + ) + let productGridController = UIHostingController( + rootView: AcceleratedCheckoutsConfiguredView(content: ProductGridView()) + ) + let productGalleryController = UIHostingController( + rootView: AcceleratedCheckoutsConfiguredView(content: ProductGalleryView()) + ) let accountController = UIHostingController(rootView: AccountView()) let settingsController = UIHostingController(rootView: SettingsView()) @@ -309,6 +316,31 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } } +struct AcceleratedCheckoutsConfiguredView: View { + let content: Content + + var body: some View { + if #available(iOS 16.0, *) { + content + .shopifyAcceleratedCheckouts( + ShopifyAcceleratedCheckouts.Configuration( + storefrontDomain: InfoDictionary.shared.domain, + storefrontAccessToken: InfoDictionary.shared.accessToken + ) + ) + .environment( + \.shopifyApplePayConfiguration, + ShopifyAcceleratedCheckouts.ApplePayConfiguration( + merchantIdentifier: InfoDictionary.shared.merchantIdentifier, + contactFields: [.email, .phone] + ) + ) + } else { + content + } + } +} + extension Notification.Name { static let colorSchemeChanged = Notification.Name("colorSchemeChanged") static let navigateToAccount = Notification.Name("navigateToAccount") 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 46dadcfec..18deee4b9 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartView.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartView.swift @@ -50,20 +50,6 @@ struct CartView: View { print("[AcceleratedCheckout] Dismissed") } .connect(client) - .environment( - \.shopifyAcceleratedCheckoutsConfiguration, - ShopifyAcceleratedCheckouts.Configuration( - storefrontDomain: InfoDictionary.shared.domain, - storefrontAccessToken: InfoDictionary.shared.accessToken - ) - ) - .environment( - \.shopifyApplePayConfiguration, - ShopifyAcceleratedCheckouts.ApplePayConfiguration( - merchantIdentifier: InfoDictionary.shared.merchantIdentifier, - contactFields: [.email, .phone] - ) - ) } } diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/ProductView.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/ProductView.swift index 57d828c3e..f00ee8cbf 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/ProductView.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/ProductView.swift @@ -123,20 +123,6 @@ struct ProductView: View { .onDismiss { print("[AcceleratedCheckout] Dismissed") } - .environment( - \.shopifyAcceleratedCheckoutsConfiguration, - ShopifyAcceleratedCheckouts.Configuration( - storefrontDomain: InfoDictionary.shared.domain, - storefrontAccessToken: InfoDictionary.shared.accessToken - ) - ) - .environment( - \.shopifyApplePayConfiguration, - ShopifyAcceleratedCheckouts.ApplePayConfiguration( - merchantIdentifier: InfoDictionary.shared.merchantIdentifier, - contactFields: [.email, .phone] - ) - ) } } }.padding([.leading, .trailing], 15) diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/SettingsView.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/SettingsView.swift index 573202944..836e4df0e 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/SettingsView.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/SettingsView.swift @@ -1,5 +1,6 @@ import Combine import PassKit +import ShopifyAcceleratedCheckouts import ShopifyCheckoutKit import SwiftUI @@ -36,6 +37,13 @@ struct SettingsView: View { } } + @AppStorage(AppStorageKeys.acceleratedCheckoutsLogLevel.rawValue) + var acceleratedCheckoutsLogLevel: LogLevel = .debug { + didSet { + ShopifyAcceleratedCheckouts.logLevel = acceleratedCheckoutsLogLevel + } + } + @AppStorage(AppStorageKeys.applePayStyle.rawValue) var applePayStyle: ApplePayStyleOption = .automatic @@ -160,6 +168,21 @@ struct SettingsView: View { } .pickerStyle(.menu) + Picker( + "Accelerated Checkouts", + selection: Binding( + get: { acceleratedCheckoutsLogLevel }, + set: { acceleratedCheckoutsLogLevel = $0 } + ) + ) { + ForEach(LogLevel.allCases, id: \.self) { level in + Text( + level.rawValue.capitalized(with: Locale.current) + ).tag(level) + } + } + .pickerStyle(.menu) + NavigationLink(destination: LogsView()) { Text("Logs") } diff --git a/platforms/swift/Samples/ShopifyAcceleratedCheckoutsApp/ShopifyAcceleratedCheckoutsApp/ShopifyAcceleratedCheckoutsApp.swift b/platforms/swift/Samples/ShopifyAcceleratedCheckoutsApp/ShopifyAcceleratedCheckoutsApp/ShopifyAcceleratedCheckoutsApp.swift index 8c4f0061f..3b93e9b02 100644 --- a/platforms/swift/Samples/ShopifyAcceleratedCheckoutsApp/ShopifyAcceleratedCheckoutsApp/ShopifyAcceleratedCheckoutsApp.swift +++ b/platforms/swift/Samples/ShopifyAcceleratedCheckoutsApp/ShopifyAcceleratedCheckoutsApp/ShopifyAcceleratedCheckoutsApp.swift @@ -51,7 +51,7 @@ struct ShopifyAcceleratedCheckoutsApp: App { ShopifyAcceleratedCheckouts.logLevel = logLevel updateConfiguration() } - .environment(\.shopifyAcceleratedCheckoutsConfiguration, configuration) + .shopifyAcceleratedCheckouts(configuration) .environment(\.shopifyApplePayConfiguration, applePayConfiguration) } .environment(\.locale, Locale(identifier: locale)) diff --git a/platforms/swift/Samples/ShopifyAcceleratedCheckoutsApp/ShopifyAcceleratedCheckoutsApp/Views/Components/ButtonSet.swift b/platforms/swift/Samples/ShopifyAcceleratedCheckoutsApp/ShopifyAcceleratedCheckoutsApp/Views/Components/ButtonSet.swift index df2bf6ce5..adc250d7b 100644 --- a/platforms/swift/Samples/ShopifyAcceleratedCheckoutsApp/ShopifyAcceleratedCheckoutsApp/Views/Components/ButtonSet.swift +++ b/platforms/swift/Samples/ShopifyAcceleratedCheckoutsApp/ShopifyAcceleratedCheckoutsApp/Views/Components/ButtonSet.swift @@ -77,13 +77,6 @@ private struct CheckoutSection: View { .foregroundColor(.primary) .multilineTextAlignment(.center) - if case .loading = renderState { - VStack(spacing: 12) { - SkeletonButton(cornerRadius: 8) - SkeletonButton(cornerRadius: 8) - } - } - if case .error = renderState { VStack { Image(systemName: "exclamationmark.triangle") diff --git a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Internal/StorefrontAPI/StorefrontAPI+Queries.swift b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Internal/StorefrontAPI/StorefrontAPI+Queries.swift index 7a707088d..e99e16ce0 100644 --- a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Internal/StorefrontAPI/StorefrontAPI+Queries.swift +++ b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Internal/StorefrontAPI/StorefrontAPI+Queries.swift @@ -27,11 +27,26 @@ extension StorefrontAPI { cacheKey: "shop", url: client.url, query: { - let response = try await client.query(Operations.getShop()) - guard let shop = response.data?.shop else { - throw StorefrontAPI.Errors.payload(propertyName: "shop") + let requestStartedAt = AcceleratedCheckoutDebugTiming.now + ShopifyAcceleratedCheckouts.logger.debug( + "Shop settings Storefront API request started." + ) + + do { + let response = try await client.query(Operations.getShop()) + guard let shop = response.data?.shop else { + throw StorefrontAPI.Errors.payload(propertyName: "shop") + } + ShopifyAcceleratedCheckouts.logger.debug( + "Shop settings Storefront API request completed in \(AcceleratedCheckoutDebugTiming.elapsedMilliseconds(since: requestStartedAt)) ms." + ) + return shop + } catch { + ShopifyAcceleratedCheckouts.logger.debug( + "Shop settings Storefront API request failed after \(AcceleratedCheckoutDebugTiming.elapsedMilliseconds(since: requestStartedAt)) ms." + ) + throw error } - return shop } ) } @@ -42,47 +57,124 @@ extension StorefrontAPI { actor QueryCache { static let shared = QueryCache() - private var cache: [String: any Sendable] = [:] + static let defaultFreshnessInterval: TimeInterval = 60 * 60 + + private struct CacheEntry { + let value: any Sendable + let cachedAt: Date + } + + private let freshnessInterval: TimeInterval + private var cache: [String: CacheEntry] = [:] private var inflightRequests: [String: any Sendable] = [:] - private init() {} + init(freshnessInterval: TimeInterval = defaultFreshnessInterval) { + self.freshnessInterval = freshnessInterval + } - /// Loads data with deduplication - multiple simultaneous calls will share the same request + /// Loads data with deduplication and stale-while-revalidate caching. + /// + /// Fresh values are returned from memory. Stale values are returned immediately while + /// one shared refresh runs in the background. Failed refreshes leave the stale value intact. func load( cacheKey: String, url: URL, + date: Date = Date(), query: @Sendable @escaping () async throws -> T ) async throws -> T { let key = buildCacheKey(queryKey: cacheKey, url: url) - if let cached = cache[key] as? T { + if let entry = cache[key], let cached = entry.value as? T { + let age = max(0, date.timeIntervalSince(entry.cachedAt)) + let isFresh = age < freshnessInterval + let freshnessRemaining = max(0, freshnessInterval - age) + ShopifyAcceleratedCheckouts.logger.debug( + "Shop settings cache hit: \(isFresh ? "fresh" : "stale") " + + "(age: \(AcceleratedCheckoutDebugTiming.seconds(age)) s, " + + "freshness: \(AcceleratedCheckoutDebugTiming.seconds(freshnessInterval)) s, " + + "remaining: \(AcceleratedCheckoutDebugTiming.seconds(freshnessRemaining)) s)." + ) + if !isFresh { + refreshIfNeeded(key: key, date: date, query: query) + } return cached } if let existingTask = inflightRequests[key] as? Task { + ShopifyAcceleratedCheckouts.logger.debug( + "Shop settings cache miss: joining an in-flight request." + ) return try await existingTask.value } + let loadStartedAt = AcceleratedCheckoutDebugTiming.now + ShopifyAcceleratedCheckouts.logger.debug( + "Shop settings cache miss: starting a request." + ) let task = Task { - let result = try await query() - self.cache(result, for: key) - return result + try await query() } inflightRequests[key] = task do { let result = try await task.value + cache(result, for: key, date: date) inflightRequests.removeValue(forKey: key) + ShopifyAcceleratedCheckouts.logger.debug( + "Shop settings cache filled in \(AcceleratedCheckoutDebugTiming.elapsedMilliseconds(since: loadStartedAt)) ms; " + + "fresh for \(AcceleratedCheckoutDebugTiming.seconds(freshnessInterval)) s." + ) return result } catch { inflightRequests.removeValue(forKey: key) + ShopifyAcceleratedCheckouts.logger.debug( + "Shop settings cache fill failed after \(AcceleratedCheckoutDebugTiming.elapsedMilliseconds(since: loadStartedAt)) ms." + ) throw error } } - private func cache(_ result: some Sendable, for key: String) { - cache[key] = result + private func refreshIfNeeded( + key: String, + date: Date, + query: @Sendable @escaping () async throws -> T + ) { + guard inflightRequests[key] == nil else { + ShopifyAcceleratedCheckouts.logger.debug( + "Shop settings cache refresh already in flight; serving the stale value." + ) + return + } + + let refreshStartedAt = AcceleratedCheckoutDebugTiming.now + ShopifyAcceleratedCheckouts.logger.debug( + "Shop settings cache refresh started; serving the stale value." + ) + let task = Task { + try await query() + } + inflightRequests[key] = task + + Task { + do { + let result = try await task.value + cache(result, for: key, date: date) + ShopifyAcceleratedCheckouts.logger.debug( + "Shop settings cache refresh completed in \(AcceleratedCheckoutDebugTiming.elapsedMilliseconds(since: refreshStartedAt)) ms; " + + "fresh for \(AcceleratedCheckoutDebugTiming.seconds(freshnessInterval)) s." + ) + } catch { + ShopifyAcceleratedCheckouts.logger.debug( + "Shop settings cache refresh failed after \(AcceleratedCheckoutDebugTiming.elapsedMilliseconds(since: refreshStartedAt)) ms; retaining the stale value." + ) + } + inflightRequests.removeValue(forKey: key) + } + } + + private func cache(_ result: some Sendable, for key: String, date: Date) { + cache[key] = CacheEntry(value: result, cachedAt: date) } private func buildCacheKey(queryKey: String, url: URL) -> String { diff --git a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/ShopifyAcceleratedCheckouts+Configuration.swift b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/ShopifyAcceleratedCheckouts+Configuration.swift index 9a3a085d9..24c76ada9 100644 --- a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/ShopifyAcceleratedCheckouts+Configuration.swift +++ b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/ShopifyAcceleratedCheckouts+Configuration.swift @@ -70,3 +70,65 @@ extension EnvironmentValues { set { self[ShopifyAcceleratedCheckoutsConfigurationKey.self] = newValue } } } + +@available(iOS 16.0, *) +private struct ShopifyAcceleratedCheckoutsConfigurationModifier: ViewModifier { + let configuration: ShopifyAcceleratedCheckouts.Configuration + + func body(content: Content) -> some View { + content + .environment(\.shopifyAcceleratedCheckoutsConfiguration, configuration) + .task(id: prefetchIdentity) { + await ShopSettingsPrefetcher.prefetch(configuration: configuration) + } + } + + private var prefetchIdentity: ShopSettingsPrefetchIdentity { + ShopSettingsPrefetchIdentity( + storefrontDomain: configuration.storefrontDomain, + storefrontAccessToken: configuration.storefrontAccessToken + ) + } +} + +@available(iOS 16.0, *) +private struct ShopSettingsPrefetchIdentity: Equatable { + let storefrontDomain: String + let storefrontAccessToken: String +} + +@available(iOS 16.0, *) +enum ShopSettingsPrefetcher { + static func prefetch(configuration: ShopifyAcceleratedCheckouts.Configuration) async { + let startedAt = AcceleratedCheckoutDebugTiming.now + ShopifyAcceleratedCheckouts.logger.debug("Shop settings prefetch started.") + let storefront = StorefrontAPI( + storefrontDomain: configuration.storefrontDomain, + storefrontAccessToken: configuration.storefrontAccessToken + ) + + do { + _ = try await storefront.shop() + ShopifyAcceleratedCheckouts.logger.debug( + "Shop settings prefetch completed in \(AcceleratedCheckoutDebugTiming.elapsedMilliseconds(since: startedAt)) ms." + ) + } catch { + ShopifyAcceleratedCheckouts.logger.debug( + "Shop settings prefetch failed after \(AcceleratedCheckoutDebugTiming.elapsedMilliseconds(since: startedAt)) ms." + ) + } + } +} + +@available(iOS 16.0, *) +extension View { + /// Configures accelerated checkouts and prefetches shared shop settings. + /// + /// Apply this modifier to an ancestor view as soon as the shop configuration is known. + /// Accelerated checkout buttons reuse the prefetched result automatically. + public func shopifyAcceleratedCheckouts( + _ configuration: ShopifyAcceleratedCheckouts.Configuration + ) -> some View { + modifier(ShopifyAcceleratedCheckoutsConfigurationModifier(configuration: configuration)) + } +} diff --git a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/ShopifyAcceleratedCheckouts.swift b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/ShopifyAcceleratedCheckouts.swift index 97b0c2cb3..46cd61e48 100644 --- a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/ShopifyAcceleratedCheckouts.swift +++ b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/ShopifyAcceleratedCheckouts.swift @@ -1,3 +1,4 @@ +import Foundation import ShopifyCheckoutKit public enum ShopifyAcceleratedCheckouts { @@ -21,3 +22,23 @@ public enum ShopifyAcceleratedCheckouts { /// To modify the logLevel internal static let logger = OSLogger(prefix: name, logLevel: .warn) } + +@available(iOS 16.0, *) +enum AcceleratedCheckoutDebugTiming { + private static let clock = ContinuousClock() + + static var now: ContinuousClock.Instant { + clock.now + } + + static func elapsedMilliseconds(since start: ContinuousClock.Instant) -> String { + let components = start.duration(to: clock.now).components + let milliseconds = Double(components.seconds) * 1000 + + Double(components.attoseconds) / 1_000_000_000_000_000 + return String(format: "%.1f", milliseconds) + } + + static func seconds(_ interval: TimeInterval) -> String { + String(format: "%.1f", interval) + } +} diff --git a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/AcceleratedCheckoutButtons.swift b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/AcceleratedCheckoutButtons.swift index a6417a42b..ea958ab17 100644 --- a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/AcceleratedCheckoutButtons.swift +++ b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/AcceleratedCheckoutButtons.swift @@ -9,6 +9,18 @@ public enum RenderState: Equatable { case error(reason: String) } +@available(iOS 16.0, *) +extension ShopifyAcceleratedCheckouts { + /// Controls how accelerated checkout buttons represent their initial loading state. + public enum LoadingPresentation: Sendable, Equatable { + /// Show neutral placeholders that match the configured wallet button layout. + case automatic + + /// Render no loading UI. Use this when the containing app supplies its own loading state. + case hidden + } +} + /// Renders a Checkout buttons for a cart or product variant /// /// Note: @@ -25,6 +37,7 @@ public struct AcceleratedCheckoutButtons: View { var eventHandlers: EventHandlers = .init() var cornerRadius: CGFloat? var clientContainer: CheckoutProtocolClientContainer = .init() + var loadingPresentation: ShopifyAcceleratedCheckouts.LoadingPresentation = .automatic /// The Apple Pay button type private var applePayButtonType: PKPaymentButtonType = .plain @@ -61,30 +74,33 @@ public struct AcceleratedCheckoutButtons: View { } public var body: some View { - VStack { + VStack(spacing: WalletButtonLayout.spacing) { if let shopSettings { - VStack { - ForEach(wallets, id: \.self) { - switch $0 { - case .applePay: - ApplePayButton( - identifier: identifier, - eventHandlers: eventHandlers, - cornerRadius: cornerRadius, - buttonType: applePayButtonType, - buttonStyle: applePayButtonStyle, - client: clientContainer.client - ) - case .shopPay: - ShopPayButton( - identifier: identifier, - eventHandlers: eventHandlers, - cornerRadius: cornerRadius, - client: clientContainer.client - ) - } + ForEach(wallets, id: \.self) { + switch $0 { + case .applePay: + ApplePayButton( + identifier: identifier, + eventHandlers: eventHandlers, + cornerRadius: cornerRadius, + buttonType: applePayButtonType, + buttonStyle: applePayButtonStyle, + client: clientContainer.client + ) + case .shopPay: + ShopPayButton( + identifier: identifier, + eventHandlers: eventHandlers, + cornerRadius: cornerRadius, + client: clientContainer.client + ) } - }.environmentObject(shopSettings) + } + .environmentObject(shopSettings) + } else if currentRenderState == .loading, loadingPresentation == .automatic { + ForEach(wallets, id: \.self) { _ in + WalletButtonSkeleton(cornerRadius: cornerRadius) + } } } .task { await loadShopSettings() } @@ -95,7 +111,7 @@ public struct AcceleratedCheckoutButtons: View { private var resolvedConfiguration: ShopifyAcceleratedCheckouts.Configuration { guard let configuration else { - fatalError("Missing ShopifyAcceleratedCheckouts.Configuration. Add .environment(\\.shopifyAcceleratedCheckoutsConfiguration, ...) to an ancestor view.") + fatalError("Missing ShopifyAcceleratedCheckouts.Configuration. Add .shopifyAcceleratedCheckouts(...) or .environment(\\.shopifyAcceleratedCheckoutsConfiguration, ...) to an ancestor view.") } return configuration } @@ -103,6 +119,11 @@ public struct AcceleratedCheckoutButtons: View { private func loadShopSettings() async { guard identifier.isValid() else { return } + let startedAt = AcceleratedCheckoutDebugTiming.now + ShopifyAcceleratedCheckouts.logger.debug( + "Wallet button loading started (wallets: \(wallets.count), skeletons: \(loadingPresentation == .automatic ? wallets.count : 0))." + ) + do { currentRenderState = .loading let configuration = resolvedConfiguration @@ -113,10 +134,16 @@ public struct AcceleratedCheckoutButtons: View { let shop = try await storefront.shop() shopSettings = ShopSettings(from: shop) currentRenderState = .rendered + ShopifyAcceleratedCheckouts.logger.debug( + "Wallet buttons rendered in \(AcceleratedCheckoutDebugTiming.elapsedMilliseconds(since: startedAt)) ms (wallets: \(wallets.count))." + ) } catch { let reason = "Error loading shop settings: \(error)" ShopifyAcceleratedCheckouts.logger.error(reason) currentRenderState = .error(reason: reason) + ShopifyAcceleratedCheckouts.logger.debug( + "Wallet button loading failed after \(AcceleratedCheckoutDebugTiming.elapsedMilliseconds(since: startedAt)) ms." + ) } } } @@ -125,6 +152,19 @@ public struct AcceleratedCheckoutButtons: View { @available(iOS 16.0, *) extension AcceleratedCheckoutButtons { + /// Controls the loading UI shown while shop settings are being fetched. + /// + /// The default `.automatic` presentation renders neutral placeholders matching the + /// number, height, spacing, and corner radius of the configured wallet buttons. + /// Use `.hidden` when the containing app provides its own loading UI. + public func loadingPresentation( + _ presentation: ShopifyAcceleratedCheckouts.LoadingPresentation + ) -> AcceleratedCheckoutButtons { + var newView = self + newView.loadingPresentation = presentation + return newView + } + public func applePayButtonType(_ type: PKPaymentButtonType) -> AcceleratedCheckoutButtons { var view = self view.applePayButtonType = type diff --git a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/Wallet.swift b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/Wallet.swift index a79b188a1..b7b8c321e 100644 --- a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/Wallet.swift +++ b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/Wallet.swift @@ -36,11 +36,23 @@ final class CheckoutProtocolClientContainer: Sendable { extension View { func walletButtonStyle(bg: Color = Color.black, cornerRadius: CGFloat? = nil) -> some View { - let defaultCornerRadius: CGFloat = 8 - let radius = cornerRadius ?? defaultCornerRadius - return frame(height: 48) + let radius = WalletButtonLayout.resolvedCornerRadius(cornerRadius) + return frame(height: WalletButtonLayout.height) .background(bg) - .clipShape(RoundedRectangle(cornerRadius: radius >= 0 ? radius : defaultCornerRadius)) + .clipShape(RoundedRectangle(cornerRadius: radius)) + } +} + +enum WalletButtonLayout { + static let height: CGFloat = 48 + static let spacing: CGFloat = 8 + static let defaultCornerRadius: CGFloat = 8 + + static func resolvedCornerRadius(_ cornerRadius: CGFloat?) -> CGFloat { + guard let cornerRadius, cornerRadius >= 0 else { + return defaultCornerRadius + } + return cornerRadius } } diff --git a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/WalletButtonSkeleton.swift b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/WalletButtonSkeleton.swift new file mode 100644 index 000000000..147271895 --- /dev/null +++ b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/WalletButtonSkeleton.swift @@ -0,0 +1,26 @@ +import SwiftUI + +@available(iOS 16.0, *) +struct WalletButtonSkeleton: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var isPulsing = false + + let cornerRadius: CGFloat? + + var body: some View { + RoundedRectangle( + cornerRadius: WalletButtonLayout.resolvedCornerRadius(cornerRadius) + ) + .fill(Color(uiColor: .tertiarySystemFill)) + .frame(height: WalletButtonLayout.height) + .opacity(reduceMotion ? 0.7 : (isPulsing ? 0.5 : 1)) + .animation( + reduceMotion ? nil : .easeInOut(duration: 0.9).repeatForever(autoreverses: true), + value: isPulsing + ) + .onAppear { + isPulsing = true + } + .accessibilityHidden(true) + } +} diff --git a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Internal/StorefrontAPI/QueryCacheTests.swift b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Internal/StorefrontAPI/QueryCacheTests.swift new file mode 100644 index 000000000..6fa9f26fc --- /dev/null +++ b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Internal/StorefrontAPI/QueryCacheTests.swift @@ -0,0 +1,145 @@ +@testable import ShopifyAcceleratedCheckouts +import XCTest + +@available(iOS 17.0, *) +final class QueryCacheTests: XCTestCase { + func testConcurrentMissesShareRequest() async throws { + let cache = QueryCache() + let counter = QueryCounter() + + async let first: Int = cache.load(cacheKey: "shop", url: queryCacheTestURL) { + try await Task.sleep(for: .milliseconds(20)) + return await counter.increment() + } + async let second: Int = cache.load(cacheKey: "shop", url: queryCacheTestURL) { + try await Task.sleep(for: .milliseconds(20)) + return await counter.increment() + } + + let values = try await [first, second] + XCTAssertEqual(values, [1, 1]) + let requestCount = await counter.value + XCTAssertEqual(requestCount, 1) + } + + func testFreshValueIsReturnedWithoutAnotherRequest() async throws { + let cache = QueryCache(freshnessInterval: 3600) + let counter = QueryCounter() + let initialDate = Date(timeIntervalSinceReferenceDate: 1000) + + let first: Int = try await cache.load( + cacheKey: "shop", + url: queryCacheTestURL, + date: initialDate + ) { + await counter.increment() + } + let second: Int = try await cache.load( + cacheKey: "shop", + url: queryCacheTestURL, + date: initialDate.addingTimeInterval(3599) + ) { + await counter.increment() + } + + XCTAssertEqual(first, 1) + XCTAssertEqual(second, 1) + let requestCount = await counter.value + XCTAssertEqual(requestCount, 1) + } + + func testStaleValueReturnsWhileCacheRefreshes() async throws { + let cache = QueryCache(freshnessInterval: 3600) + let counter = QueryCounter() + let initialDate = Date(timeIntervalSinceReferenceDate: 1000) + + let first: Int = try await cache.load( + cacheKey: "shop", + url: queryCacheTestURL, + date: initialDate + ) { + await counter.increment() + } + let stale: Int = try await cache.load( + cacheKey: "shop", + url: queryCacheTestURL, + date: initialDate.addingTimeInterval(3601) + ) { + await counter.increment() + } + + XCTAssertEqual(first, 1) + XCTAssertEqual(stale, 1) + + await waitForCount(2, counter: counter) + + let refreshed: Int = try await cache.load( + cacheKey: "shop", + url: queryCacheTestURL, + date: initialDate.addingTimeInterval(3602) + ) { + await counter.increment() + } + XCTAssertEqual(refreshed, 2) + let requestCount = await counter.value + XCTAssertEqual(requestCount, 2) + } + + func testFailedRefreshKeepsStaleValue() async throws { + let cache = QueryCache(freshnessInterval: 1) + let counter = QueryCounter() + let initialDate = Date(timeIntervalSinceReferenceDate: 1000) + + let first: Int = try await cache.load( + cacheKey: "shop", + url: queryCacheTestURL, + date: initialDate + ) { + await counter.increment() + } + let stale: Int = try await cache.load( + cacheKey: "shop", + url: queryCacheTestURL, + date: initialDate.addingTimeInterval(2) + ) { + _ = await counter.increment() + throw QueryCacheTestError.refreshFailed + } + + XCTAssertEqual(first, 1) + XCTAssertEqual(stale, 1) + await waitForCount(2, counter: counter) + + let retained: Int = try await cache.load( + cacheKey: "shop", + url: queryCacheTestURL, + date: initialDate.addingTimeInterval(2) + ) { + throw QueryCacheTestError.refreshFailed + } + XCTAssertEqual(retained, 1) + } + + private func waitForCount(_ count: Int, counter: QueryCounter) async { + for _ in 0 ..< 100 { + if await counter.value >= count { return } + try? await Task.sleep(for: .milliseconds(1)) + } + XCTFail("Timed out waiting for query count \(count)") + } +} + +private let queryCacheTestURL = URL(string: "https://example.invalid/api/graphql.json")! + +private actor QueryCounter { + private(set) var value = 0 + + func increment() -> Int { + value += 1 + return value + } +} + +private enum QueryCacheTestError: Error { + case refreshFailed +} diff --git a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/AcceleratedCheckoutButtonsLoadingTests.swift b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/AcceleratedCheckoutButtonsLoadingTests.swift new file mode 100644 index 000000000..d14d42ca5 --- /dev/null +++ b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/AcceleratedCheckoutButtonsLoadingTests.swift @@ -0,0 +1,46 @@ +@testable import ShopifyAcceleratedCheckouts +import ViewInspector +import XCTest + +@available(iOS 17.0, *) +@MainActor +final class AcceleratedCheckoutButtonsLoadingTests: XCTestCase { + private let validCartID = "gid://shopify/Cart/test-cart-id" + + func testLoadingSkeletonCountMatchesWallets() throws { + let buttons = AcceleratedCheckoutButtons(cartID: validCartID) + .wallets([.shopPay, .applePay]) + + let skeletons = try buttons.inspect().findAll(WalletButtonSkeleton.self) + + XCTAssertEqual(skeletons.count, 2) + } + + func testLoadingPresentationHiddenRemovesSkeletons() throws { + let buttons = AcceleratedCheckoutButtons(cartID: validCartID) + .wallets([.shopPay, .applePay]) + .loadingPresentation(.hidden) + + let skeletons = try buttons.inspect().findAll(WalletButtonSkeleton.self) + + XCTAssertTrue(skeletons.isEmpty) + XCTAssertEqual(buttons.loadingPresentation, .hidden) + } + + func testInvalidIdentifierDoesNotRenderSkeletons() throws { + let buttons = AcceleratedCheckoutButtons(cartID: "invalid") + + let skeletons = try buttons.inspect().findAll(WalletButtonSkeleton.self) + + XCTAssertTrue(skeletons.isEmpty) + } + + func testSkeletonLayoutMatchesWalletButtons() { + XCTAssertEqual(WalletButtonLayout.height, 48) + XCTAssertEqual(WalletButtonLayout.spacing, 8) + XCTAssertEqual(WalletButtonLayout.resolvedCornerRadius(nil), 8) + XCTAssertEqual(WalletButtonLayout.resolvedCornerRadius(-1), 8) + XCTAssertEqual(WalletButtonLayout.resolvedCornerRadius(0), 0) + XCTAssertEqual(WalletButtonLayout.resolvedCornerRadius(24), 24) + } +} diff --git a/platforms/swift/api/ShopifyAcceleratedCheckouts.json b/platforms/swift/api/ShopifyAcceleratedCheckouts.json index c3cd12ff1..3caded4f5 100644 --- a/platforms/swift/api/ShopifyAcceleratedCheckouts.json +++ b/platforms/swift/api/ShopifyAcceleratedCheckouts.json @@ -880,6 +880,236 @@ } ] }, + { + "kind": "TypeDecl", + "name": "LoadingPresentation", + "printedName": "LoadingPresentation", + "children": [ + { + "kind": "Var", + "name": "automatic", + "printedName": "automatic", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(ShopifyAcceleratedCheckouts.ShopifyAcceleratedCheckouts.LoadingPresentation.Type) -> ShopifyAcceleratedCheckouts.ShopifyAcceleratedCheckouts.LoadingPresentation", + "children": [ + { + "kind": "TypeNominal", + "name": "LoadingPresentation", + "printedName": "ShopifyAcceleratedCheckouts.ShopifyAcceleratedCheckouts.LoadingPresentation", + "usr": "s:27ShopifyAcceleratedCheckoutsAAO19LoadingPresentationO" + }, + { + "kind": "TypeNominal", + "name": "Metatype", + "printedName": "ShopifyAcceleratedCheckouts.ShopifyAcceleratedCheckouts.LoadingPresentation.Type", + "children": [ + { + "kind": "TypeNominal", + "name": "LoadingPresentation", + "printedName": "ShopifyAcceleratedCheckouts.ShopifyAcceleratedCheckouts.LoadingPresentation", + "usr": "s:27ShopifyAcceleratedCheckoutsAAO19LoadingPresentationO" + } + ] + } + ] + } + ], + "declKind": "EnumElement", + "usr": "s:27ShopifyAcceleratedCheckoutsAAO19LoadingPresentationO9automaticyA2DmF", + "mangledName": "$s27ShopifyAcceleratedCheckoutsAAO19LoadingPresentationO9automaticyA2DmF", + "moduleName": "ShopifyAcceleratedCheckouts" + }, + { + "kind": "Var", + "name": "hidden", + "printedName": "hidden", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(ShopifyAcceleratedCheckouts.ShopifyAcceleratedCheckouts.LoadingPresentation.Type) -> ShopifyAcceleratedCheckouts.ShopifyAcceleratedCheckouts.LoadingPresentation", + "children": [ + { + "kind": "TypeNominal", + "name": "LoadingPresentation", + "printedName": "ShopifyAcceleratedCheckouts.ShopifyAcceleratedCheckouts.LoadingPresentation", + "usr": "s:27ShopifyAcceleratedCheckoutsAAO19LoadingPresentationO" + }, + { + "kind": "TypeNominal", + "name": "Metatype", + "printedName": "ShopifyAcceleratedCheckouts.ShopifyAcceleratedCheckouts.LoadingPresentation.Type", + "children": [ + { + "kind": "TypeNominal", + "name": "LoadingPresentation", + "printedName": "ShopifyAcceleratedCheckouts.ShopifyAcceleratedCheckouts.LoadingPresentation", + "usr": "s:27ShopifyAcceleratedCheckoutsAAO19LoadingPresentationO" + } + ] + } + ] + } + ], + "declKind": "EnumElement", + "usr": "s:27ShopifyAcceleratedCheckoutsAAO19LoadingPresentationO6hiddenyA2DmF", + "mangledName": "$s27ShopifyAcceleratedCheckoutsAAO19LoadingPresentationO6hiddenyA2DmF", + "moduleName": "ShopifyAcceleratedCheckouts" + }, + { + "kind": "Function", + "name": "__derived_enum_equals", + "printedName": "__derived_enum_equals(_:_:)", + "children": [ + { + "kind": "TypeNominal", + "name": "Bool", + "printedName": "Swift.Bool", + "usr": "s:Sb" + }, + { + "kind": "TypeNominal", + "name": "LoadingPresentation", + "printedName": "ShopifyAcceleratedCheckouts.ShopifyAcceleratedCheckouts.LoadingPresentation", + "usr": "s:27ShopifyAcceleratedCheckoutsAAO19LoadingPresentationO" + }, + { + "kind": "TypeNominal", + "name": "LoadingPresentation", + "printedName": "ShopifyAcceleratedCheckouts.ShopifyAcceleratedCheckouts.LoadingPresentation", + "usr": "s:27ShopifyAcceleratedCheckoutsAAO19LoadingPresentationO" + } + ], + "declKind": "Func", + "usr": "s:27ShopifyAcceleratedCheckoutsAAO19LoadingPresentationO21__derived_enum_equalsySbAD_ADtFZ", + "mangledName": "$s27ShopifyAcceleratedCheckoutsAAO19LoadingPresentationO21__derived_enum_equalsySbAD_ADtFZ", + "moduleName": "ShopifyAcceleratedCheckouts", + "static": true, + "implicit": true, + "declAttributes": [ + "Implements" + ], + "funcSelfKind": "NonMutating" + }, + { + "kind": "Function", + "name": "hash", + "printedName": "hash(into:)", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + }, + { + "kind": "TypeNominal", + "name": "Hasher", + "printedName": "Swift.Hasher", + "paramValueOwnership": "InOut", + "usr": "s:s6HasherV" + } + ], + "declKind": "Func", + "usr": "s:27ShopifyAcceleratedCheckoutsAAO19LoadingPresentationO4hash4intoys6HasherVz_tF", + "mangledName": "$s27ShopifyAcceleratedCheckoutsAAO19LoadingPresentationO4hash4intoys6HasherVz_tF", + "moduleName": "ShopifyAcceleratedCheckouts", + "implicit": true, + "funcSelfKind": "NonMutating" + }, + { + "kind": "Var", + "name": "hashValue", + "printedName": "hashValue", + "children": [ + { + "kind": "TypeNominal", + "name": "Int", + "printedName": "Swift.Int", + "usr": "s:Si" + } + ], + "declKind": "Var", + "usr": "s:27ShopifyAcceleratedCheckoutsAAO19LoadingPresentationO9hashValueSivp", + "mangledName": "$s27ShopifyAcceleratedCheckoutsAAO19LoadingPresentationO9hashValueSivp", + "moduleName": "ShopifyAcceleratedCheckouts", + "implicit": true, + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "Int", + "printedName": "Swift.Int", + "usr": "s:Si" + } + ], + "declKind": "Accessor", + "usr": "s:27ShopifyAcceleratedCheckoutsAAO19LoadingPresentationO9hashValueSivg", + "mangledName": "$s27ShopifyAcceleratedCheckoutsAAO19LoadingPresentationO9hashValueSivg", + "moduleName": "ShopifyAcceleratedCheckouts", + "implicit": true, + "accessorKind": "get" + } + ] + } + ], + "declKind": "Enum", + "usr": "s:27ShopifyAcceleratedCheckoutsAAO19LoadingPresentationO", + "mangledName": "$s27ShopifyAcceleratedCheckoutsAAO19LoadingPresentationO", + "moduleName": "ShopifyAcceleratedCheckouts", + "isFromExtension": true, + "isEnumExhaustive": true, + "conformances": [ + { + "kind": "Conformance", + "name": "Hashable", + "printedName": "Hashable", + "usr": "s:SH", + "mangledName": "$sSH" + }, + { + "kind": "Conformance", + "name": "Sendable", + "printedName": "Sendable", + "usr": "s:s8SendableP", + "mangledName": "$ss8SendableP" + }, + { + "kind": "Conformance", + "name": "Equatable", + "printedName": "Equatable", + "usr": "s:SQ", + "mangledName": "$sSQ" + }, + { + "kind": "Conformance", + "name": "SendableMetatype", + "printedName": "SendableMetatype", + "usr": "s:s16SendableMetatypeP", + "mangledName": "$ss16SendableMetatypeP" + }, + { + "kind": "Conformance", + "name": "Copyable", + "printedName": "Copyable", + "usr": "s:s8CopyableP", + "mangledName": "$ss8CopyableP" + }, + { + "kind": "Conformance", + "name": "Escapable", + "printedName": "Escapable", + "usr": "s:s9EscapableP", + "mangledName": "$ss9EscapableP" + } + ] + }, { "kind": "TypeDecl", "name": "RequiredContactFields", @@ -1939,6 +2169,35 @@ "Available" ] }, + { + "kind": "Function", + "name": "loadingPresentation", + "printedName": "loadingPresentation(_:)", + "children": [ + { + "kind": "TypeNominal", + "name": "AcceleratedCheckoutButtons", + "printedName": "ShopifyAcceleratedCheckouts.AcceleratedCheckoutButtons", + "usr": "s:27ShopifyAcceleratedCheckouts0B15CheckoutButtonsV" + }, + { + "kind": "TypeNominal", + "name": "LoadingPresentation", + "printedName": "ShopifyAcceleratedCheckouts.ShopifyAcceleratedCheckouts.LoadingPresentation", + "usr": "s:27ShopifyAcceleratedCheckoutsAAO19LoadingPresentationO" + } + ], + "declKind": "Func", + "usr": "s:27ShopifyAcceleratedCheckouts0B15CheckoutButtonsV19loadingPresentationyAc2AO07LoadingG0OF", + "mangledName": "$s27ShopifyAcceleratedCheckouts0B15CheckoutButtonsV19loadingPresentationyAc2AO07LoadingG0OF", + "moduleName": "ShopifyAcceleratedCheckouts", + "declAttributes": [ + "Preconcurrency", + "Custom" + ], + "isFromExtension": true, + "funcSelfKind": "NonMutating" + }, { "kind": "Function", "name": "applePayButtonType", @@ -3536,6 +3795,90 @@ } ] }, + { + "kind": "TypeDecl", + "name": "View", + "printedName": "View", + "children": [ + { + "kind": "Function", + "name": "shopifyAcceleratedCheckouts", + "printedName": "shopifyAcceleratedCheckouts(_:)", + "children": [ + { + "kind": "TypeNominal", + "name": "OpaqueTypeArchetype", + "printedName": "some SwiftUI.View", + "children": [ + { + "kind": "TypeNominal", + "name": "View", + "printedName": "SwiftUI.View", + "usr": "s:7SwiftUI4ViewP" + } + ] + }, + { + "kind": "TypeNominal", + "name": "Configuration", + "printedName": "ShopifyAcceleratedCheckouts.ShopifyAcceleratedCheckouts.Configuration", + "usr": "s:27ShopifyAcceleratedCheckoutsAAO13ConfigurationV" + } + ], + "declKind": "Func", + "usr": "s:7SwiftUI4ViewP27ShopifyAcceleratedCheckoutsE07shopifyeF0yQrA2DO13ConfigurationVF", + "mangledName": "$s7SwiftUI4ViewP27ShopifyAcceleratedCheckoutsE07shopifyeF0yQrA2DO13ConfigurationVF", + "moduleName": "ShopifyAcceleratedCheckouts", + "genericSig": "", + "declAttributes": [ + "Preconcurrency", + "Custom" + ], + "isFromExtension": true, + "funcSelfKind": "NonMutating" + } + ], + "declKind": "Protocol", + "usr": "s:7SwiftUI4ViewP", + "mangledName": "$s7SwiftUI4ViewP", + "moduleName": "SwiftUICore", + "genericSig": "", + "intro_Macosx": "10.15", + "intro_iOS": "13.0", + "intro_tvOS": "13.0", + "intro_watchOS": "6.0", + "declAttributes": [ + "Preconcurrency", + "TypeEraser", + "TypeEraser", + "OriginallyDefinedIn", + "OriginallyDefinedIn", + "OriginallyDefinedIn", + "OriginallyDefinedIn", + "Available", + "Available", + "Available", + "Available", + "Custom" + ], + "isExternal": true, + "conformances": [ + { + "kind": "Conformance", + "name": "Escapable", + "printedName": "Escapable", + "usr": "s:s9EscapableP", + "mangledName": "$ss9EscapableP" + }, + { + "kind": "Conformance", + "name": "Copyable", + "printedName": "Copyable", + "usr": "s:s8CopyableP", + "mangledName": "$ss8CopyableP" + } + ] + }, { "kind": "TypeDecl", "name": "PKPaymentAuthorizationController",