From a8a0fa6867250167f154cb8ccce04ed1804b20bf Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Wed, 6 Mar 2024 13:32:19 +0000 Subject: [PATCH] SwiftUI improvements (#133) Improvements to SwiftUI --- README.md | 66 ++++++-- .../project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../Localizable.xcstrings | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../xcshareddata/swiftpm/Package.resolved | 13 +- .../SwiftUIExample.xcodeproj/project.pbxproj | 10 +- .../xcshareddata/swiftpm/Package.resolved | 8 +- .../SwiftUIExample/SwiftUIExample/App.swift | 24 +++ .../SwiftUIExample/CartView.swift | 147 ++++++++++++++++++ .../SwiftUIExample/CatalogView.swift | 118 +++++++------- .../SwiftUIExample/ProductViewModel.swift | 8 +- .../SwiftUIExample/SwiftUIExampleApp.swift | 33 ---- .../SwiftUIExampleUITests.swift | 75 +++++++++ .../CheckoutViewController.swift | 101 +++++++++++- .../CheckoutWebView.swift | 6 +- .../CheckoutWebViewController.swift | 1 + .../Configuration.swift | 3 + .../ShopifyCheckoutSheetKit.swift | 14 +- .../SwiftUITests.swift | 146 +++++++++++++++++ 20 files changed, 646 insertions(+), 139 deletions(-) create mode 100644 Samples/SwiftUIExample/SwiftUIExample/CartView.swift delete mode 100644 Samples/SwiftUIExample/SwiftUIExample/SwiftUIExampleApp.swift create mode 100644 Samples/SwiftUIExample/SwiftUIExampleUITests/SwiftUIExampleUITests.swift create mode 100644 Tests/ShopifyCheckoutSheetKitTests/SwiftUITests.swift diff --git a/README.md b/README.md index ac9935e6..44edb76e 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ pod "ShopifyCheckoutSheetKit", "~> 2" For more information on CocoaPods, please see their [getting started guide](https://guides.cocoapods.org/using/getting-started.html). -### Basic Usage +### Programmatic Usage Once the SDK has been added as a dependency, you can import the library: @@ -85,25 +85,46 @@ class MyViewController: UIViewController { } ``` -Alternatively, with SwiftUI: +### SwiftUI Usage ```swift import SwiftUI import ShopifyCheckoutSheetKit struct ContentView: View { - @State private var isPresented = false - let url: URL - let delegate: CheckoutDelegate? - - var body: some View { - Button("Checkout") { - self.isPresented = true - } - .sheet(isPresented: $isPresented) { - CheckoutViewController.Representable(url: url, delegate: delegate) - } + @State var isPresented = false + @State var checkoutURL: URL? + + var body: some View { + Button("Checkout") { + isPresented = true + } + .sheet(isPresented: $isPresented) { + if let url = checkoutURL { + CheckoutSheet(url: url) + /// Configuration + .title("Checkout") + .colorScheme(.automatic) + .tintColor(.blue) + .backgroundColor(.white) + + /// Lifecycle events + .onCancel { + isPresented = false + } + .onComplete { event in + handleCompletedEvent(event) + } + .onFail { error in + handleError(error) + } + .onPixelEvent { event in + handlePixelEvent(event) + } + .edgesIgnoringSafeArea(.all) + } } + } } ``` @@ -158,8 +179,6 @@ ShopifyCheckoutSheetKit.configuration.backgroundColor = UIColor(red: 0.09, green ShopifyCheckoutSheetKit.configuration.backgroundColor = .systemBackground ``` -### Localization - #### `title` By default, the Checkout Sheet Kit will look for a `shopify_checkout_sheet_title` key in a `Localizable.xcstrings` file to set the sheet title, otherwise it will fallback to "Checkout" across all locales. @@ -197,6 +216,23 @@ Here is an example of a `Localizable.xcstrings` containing translations for 2 lo } ``` +#### SwiftUI Configuration + +Similarly, configuration modifiers are available to set the configuration of your checkout when using SwiftUI: + +```swift +CheckoutSheet(checkout: checkoutURL) + .title("Checkout") + .colorScheme(.automatic) + .tintColor(.blue) + .backgroundColor(.black) +``` + +> [!NOTE] +> Note that if the values of your SwiftUI configuration are **variable** and you are using `preload()`, +> you will need to call `preload()` each time your variables change to ensure that the checkout cache +> has been invalidated, for checkout to be loaded with the new configuration. + ### Preloading Initializing a checkout session requires communicating with Shopify servers and, depending on the network weather and the quality of the buyer's connection, can result in undesirable wait time for the buyer. To help optimize and deliver the best experience, the SDK provides a preloading hint that allows app developers to signal and initialize the checkout session in the background and ahead of time. diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration.xcodeproj/project.pbxproj b/Samples/MobileBuyIntegration/MobileBuyIntegration.xcodeproj/project.pbxproj index 69a18028..818ddbb4 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration.xcodeproj/project.pbxproj +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration.xcodeproj/project.pbxproj @@ -497,7 +497,7 @@ repositoryURL = "https://github.com/Shopify/mobile-buy-sdk-ios"; requirement = { kind = exactVersion; - version = 11.1.0; + version = 11.3.0; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Samples/MobileBuyIntegration/MobileBuyIntegration.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 47823df4..ba826584 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Shopify/mobile-buy-sdk-ios", "state" : { - "revision" : "e6e85dcf8f9eb95baaa8336ad3d7967ea8c36ade", - "version" : "11.1.0" + "revision" : "3a6ecdce6b9e8f356078fbdc2b738ac32cd18153", + "version" : "11.3.0" } }, { diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/Localizable.xcstrings b/Samples/MobileBuyIntegration/MobileBuyIntegration/Localizable.xcstrings index df71e467..10e9ab42 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/Localizable.xcstrings +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/Localizable.xcstrings @@ -24,7 +24,7 @@ } } } - }, + } }, "version" : "1.0" } diff --git a/Samples/Samples.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Samples/Samples.xcworkspace/xcshareddata/swiftpm/Package.resolved index ba826584..47823df4 100644 --- a/Samples/Samples.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Samples/Samples.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Shopify/mobile-buy-sdk-ios", "state" : { - "revision" : "3a6ecdce6b9e8f356078fbdc2b738ac32cd18153", - "version" : "11.3.0" + "revision" : "e6e85dcf8f9eb95baaa8336ad3d7967ea8c36ade", + "version" : "11.1.0" } }, { diff --git a/Samples/SimpleAppIntegration/SimpleAppIntegration.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Samples/SimpleAppIntegration/SimpleAppIntegration.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index deb15a9b..9fad30c7 100644 --- a/Samples/SimpleAppIntegration/SimpleAppIntegration.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Samples/SimpleAppIntegration/SimpleAppIntegration.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,12 +1,21 @@ { "pins" : [ + { + "identity" : "mobile-buy-sdk-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Shopify/mobile-buy-sdk-ios", + "state" : { + "revision" : "e6e85dcf8f9eb95baaa8336ad3d7967ea8c36ade", + "version" : "11.3.0" + } + }, { "identity" : "swiftlintplugin", "kind" : "remoteSourceControl", "location" : "https://github.com/lukepistrol/SwiftLintPlugin", "state" : { - "revision" : "b1090ecd269dddd96bda0df24ca3f1aa78f33578", - "version" : "0.52.4" + "revision" : "ea6d3ca895b49910f790e98e4b4ca658e0fe490e", + "version" : "0.54.0" } } ], diff --git a/Samples/SwiftUIExample/SwiftUIExample.xcodeproj/project.pbxproj b/Samples/SwiftUIExample/SwiftUIExample.xcodeproj/project.pbxproj index 3e69013f..75784c65 100644 --- a/Samples/SwiftUIExample/SwiftUIExample.xcodeproj/project.pbxproj +++ b/Samples/SwiftUIExample/SwiftUIExample.xcodeproj/project.pbxproj @@ -7,11 +7,13 @@ objects = { /* Begin PBXBuildFile section */ + 6A2DBC202B90ECBA00761222 /* SwiftUIExampleUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A2DBC1F2B90ECBA00761222 /* SwiftUIExampleUITests.swift */; }; 6A2DBC222B90ECBA00761222 /* SwiftUIExampleUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A2DBC212B90ECBA00761222 /* SwiftUIExampleUITestsLaunchTests.swift */; }; 6A71777C2B90DEB000ED3B99 /* ShopifyCheckoutSheetKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6A71777B2B90DEB000ED3B99 /* ShopifyCheckoutSheetKit */; }; 6A71777E2B90DECF00ED3B99 /* Buy in Frameworks */ = {isa = PBXBuildFile; productRef = 6A71777D2B90DECF00ED3B99 /* Buy */; }; 6AC6C3902B9089B60007EA2E /* ProductViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AC6C38F2B9089B60007EA2E /* ProductViewModel.swift */; }; 6ACED4192B909C5C00AC6947 /* ShopifyCheckoutSheetKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6ACED4182B909C5C00AC6947 /* ShopifyCheckoutSheetKit */; }; + 6ACED41B2B90A7FE00AC6947 /* CartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ACED41A2B90A7FE00AC6947 /* CartView.swift */; }; 86C569C32B15DA2B00F26028 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86C569C22B15DA2B00F26028 /* App.swift */; }; 86C569C52B15DA2B00F26028 /* CatalogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86C569C42B15DA2B00F26028 /* CatalogView.swift */; }; 86C569C72B15DA2D00F26028 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 86C569C62B15DA2D00F26028 /* Assets.xcassets */; }; @@ -31,8 +33,10 @@ /* Begin PBXFileReference section */ 6A2DBC1D2B90ECBA00761222 /* SwiftUIExampleUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SwiftUIExampleUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 6A2DBC1F2B90ECBA00761222 /* SwiftUIExampleUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIExampleUITests.swift; sourceTree = ""; }; 6A2DBC212B90ECBA00761222 /* SwiftUIExampleUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIExampleUITestsLaunchTests.swift; sourceTree = ""; }; 6AC6C38F2B9089B60007EA2E /* ProductViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductViewModel.swift; sourceTree = ""; }; + 6ACED41A2B90A7FE00AC6947 /* CartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CartView.swift; sourceTree = ""; }; 8652C4602B1659E600A770F9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 8666476C2B1642730039400B /* Storefront.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Storefront.xcconfig; sourceTree = ""; }; 86C569BF2B15DA2B00F26028 /* SwiftUIExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftUIExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -67,6 +71,7 @@ 6A2DBC1E2B90ECBA00761222 /* SwiftUIExampleUITests */ = { isa = PBXGroup; children = ( + 6A2DBC1F2B90ECBA00761222 /* SwiftUIExampleUITests.swift */, 6A2DBC212B90ECBA00761222 /* SwiftUIExampleUITestsLaunchTests.swift */, ); path = SwiftUIExampleUITests; @@ -117,6 +122,7 @@ 86C569C42B15DA2B00F26028 /* CatalogView.swift */, 86C569C62B15DA2D00F26028 /* Assets.xcassets */, 6AC6C38F2B9089B60007EA2E /* ProductViewModel.swift */, + 6ACED41A2B90A7FE00AC6947 /* CartView.swift */, ); path = SwiftUIExample; sourceTree = ""; @@ -252,6 +258,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 6A2DBC202B90ECBA00761222 /* SwiftUIExampleUITests.swift in Sources */, 6A2DBC222B90ECBA00761222 /* SwiftUIExampleUITestsLaunchTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -260,6 +267,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 6ACED41B2B90A7FE00AC6947 /* CartView.swift in Sources */, 86C569C52B15DA2B00F26028 /* CatalogView.swift in Sources */, 86C569ED2B15DB0A00F26028 /* StorefrontClient.swift in Sources */, 86C569C32B15DA2B00F26028 /* App.swift in Sources */, @@ -551,7 +559,7 @@ repositoryURL = "https://github.com/Shopify/mobile-buy-sdk-ios"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 11.1.0; + minimumVersion = 11.3.0; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Samples/SwiftUIExample/SwiftUIExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Samples/SwiftUIExample/SwiftUIExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5ff85b2b..e39572f0 100644 --- a/Samples/SwiftUIExample/SwiftUIExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Samples/SwiftUIExample/SwiftUIExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,12 +1,12 @@ { "pins" : [ { - "identity" : "swiftlintplugin", + "identity" : "mobile-buy-sdk-ios", "kind" : "remoteSourceControl", - "location" : "https://github.com/lukepistrol/SwiftLintPlugin", + "location" : "https://github.com/Shopify/mobile-buy-sdk-ios", "state" : { - "revision" : "ea6d3ca895b49910f790e98e4b4ca658e0fe490e", - "version" : "0.54.0" + "revision" : "3a6ecdce6b9e8f356078fbdc2b738ac32cd18153", + "version" : "11.3.0" } } ], diff --git a/Samples/SwiftUIExample/SwiftUIExample/App.swift b/Samples/SwiftUIExample/SwiftUIExample/App.swift index f4f623b9..18623cc8 100644 --- a/Samples/SwiftUIExample/SwiftUIExample/App.swift +++ b/Samples/SwiftUIExample/SwiftUIExample/App.swift @@ -52,6 +52,30 @@ struct RootTabView: View { .accessibilityIdentifier("catalogTabIcon") Text("Catalog") } + + NavigationView { + CartView(checkoutURL: $checkoutURL, isShowingCheckout: $isShowingCheckout, cart: $cartManager.cart) + .navigationTitle("Cart") + .navigationBarTitleDisplayMode(.inline) + .padding(20) + .toolbar { + if cartManager.cart?.lines != nil { + ToolbarItem(placement: .navigationBarTrailing) { + Text("Clear") + .font(.body) + .foregroundStyle(Color.accentColor) + .onTapGesture { + cartManager.resetCart() + } + } + } + } + } + .tabItem { + SwiftUI.Image(systemName: "cart") + .accessibilityIdentifier("cartTabIcon") + Text("Cart") + } } } } diff --git a/Samples/SwiftUIExample/SwiftUIExample/CartView.swift b/Samples/SwiftUIExample/SwiftUIExample/CartView.swift new file mode 100644 index 00000000..85934b7a --- /dev/null +++ b/Samples/SwiftUIExample/SwiftUIExample/CartView.swift @@ -0,0 +1,147 @@ +/* +MIT License + +Copyright 2023 - Present, Shopify Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +import Buy +import SwiftUI +import ShopifyCheckoutSheetKit + +struct CartView: View { + @Binding var checkoutURL: URL? + @Binding var isShowingCheckout: Bool + @Binding var cart: Storefront.Cart? + + var body: some View { + if let lines = cart?.lines.nodes { + ScrollView { + VStack { + CartLines(lines: lines) + } + + Spacer() + + VStack { + Button(action: { + isShowingCheckout = true + }, label: { + Text("Checkout") + .padding() + .frame(maxWidth: .infinity) + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(10) + .bold() + }) + .accessibilityIdentifier("checkoutButton") + .sheet(isPresented: $isShowingCheckout) { + if let url = checkoutURL { + CheckoutSheet(checkout: url) + /// Configuration + .title("SwiftUI") + .colorScheme(.automatic) + .tintColor(UIColor(red: 0.33, green: 0.20, blue: 0.92, alpha: 1.00)) + /// Lifecycle events + .onCancel { + isShowingCheckout = false + } + .onPixelEvent { event in + switch event { + case .standardEvent(let event): + print("WebPixel - (standard)", event.name!) + case .customEvent(let event): + print("WebPixel - (custom)", event.name!) + } + } + .onComplete { checkout in + print("Checkout completed - Order id: \(String(describing: checkout.orderDetails?.id))") + } + .onFail { error in + print(error) + } + .edgesIgnoringSafeArea(.all) + .accessibility(identifier: "CheckoutSheet") + } + } + .padding(.top, 15) + .padding(.horizontal, 5) + } + + Spacer() + }.padding(10) + } else { + EmptyState() + } + } +} + +struct EmptyState: View { + var body: some View { + VStack(alignment: .center) { + SwiftUI.Image(systemName: "cart") + .resizable() + .frame(width: 30, height: 30) + .foregroundColor(.gray) + .padding(.bottom, 6) + Text("Your cart is empty.") + .font(.caption) + } + } +} + +struct CartLines: View { + var lines: [BaseCartLine] + + var body: some View { + ForEach(lines, id: \.id) { node in + let variant = node.merchandise as? Storefront.ProductVariant + + HStack { + if let imageUrl = variant?.product.featuredImage?.url { + AsyncImage(url: imageUrl) { image in + image.image?.resizable().aspectRatio(contentMode: .fit) + } + .frame(width: 60, height: 90) + } + + VStack(alignment: .leading, spacing: 1) { + Text(variant?.product.title ?? "") + .font(.body) + .bold() + .lineLimit(2) + .truncationMode(.tail) + + Text(variant?.product.vendor ?? "") + .font(.body) + .foregroundColor(.blue) + + if let price = variant?.price.formattedString() { + Text("\(price) - Quantity: \(node.quantity)") + .font(.caption) + .foregroundColor(.gray) + } + }.padding(.leading, 5) + } + .padding([.leading, .trailing], 10) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} diff --git a/Samples/SwiftUIExample/SwiftUIExample/CatalogView.swift b/Samples/SwiftUIExample/SwiftUIExample/CatalogView.swift index e351c4e9..f7790162 100644 --- a/Samples/SwiftUIExample/SwiftUIExample/CatalogView.swift +++ b/Samples/SwiftUIExample/SwiftUIExample/CatalogView.swift @@ -25,42 +25,6 @@ import Buy import SwiftUI import ShopifyCheckoutSheetKit -public struct CheckoutSheet: View { - @Binding var checkoutURL: URL? - @Binding var isShowingCheckout: Bool - - let delegate: EventHandler = EventHandler() - - public var body: some View { - CheckoutViewController.Representable(checkout: $checkoutURL, delegate: delegate) - .onReceive(delegate.$didCancel, perform: { didCancel in - if didCancel { - delegate.checkoutDidCancel() - isShowingCheckout = false - } - }) - .onAppear { - delegate.dismissCheckout = { [self] in - self.isShowingCheckout = false - } - } - .edgesIgnoringSafeArea(.all) - } -} - -class EventHandler: NSObject, CheckoutDelegate { - var dismissCheckout: (() -> Void)? - @Published var didCancel = false - - func checkoutDidCancel() { - didCancel.toggle() - } - - func checkoutDidComplete() {} - func checkoutDidFail(error: CheckoutError) {} - func checkoutDidEmitWebPixelEvent(event: ShopifyCheckoutSheetKit.PixelEvent) {} -} - struct CatalogView: View { var cartManager: CartManager @@ -125,38 +89,55 @@ struct CatalogView: View { Button(action: { isAddingToCart = true - if let variantId = product.variants.nodes.first?.id { - cartManager.addItem(variant: variantId) { cart in - isAddingToCart = false - checkoutURL = cart?.checkoutUrl - isShowingCheckout = true + if let variantId = product.variants.nodes.first?.id { + cartManager.addItem(variant: variantId) { cart in + isAddingToCart = false + checkoutURL = cart?.checkoutUrl + } } - } - }, label: { - if isAddingToCart { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - .padding() - .frame(maxWidth: 400) - .background(Color.blue) - .foregroundColor(.white) - .cornerRadius(10) - } else { - Text("Buy now") - .font(.headline) - .padding() - .frame(maxWidth: 400) - .background(Color.blue) - .foregroundColor(.white) - .cornerRadius(10) - } - }) - .accessibilityIdentifier("addToCartButton") - .sheet(isPresented: $isShowingCheckout) { - CheckoutSheet(checkoutURL: $checkoutURL, isShowingCheckout: $isShowingCheckout) + }, label: { + if isAddingToCart { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .padding() + .frame(maxWidth: 400) + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(10) + } else { + Text("Add to cart") + .font(.headline) + .padding() + .frame(maxWidth: 400) + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(10) + } + }) + .accessibilityIdentifier("addToCartButton") + .sheet(isPresented: $isShowingCart) { + NavigationView { + CartView( + checkoutURL: $checkoutURL, + isShowingCheckout: $isShowingCheckout, + cart: $cart + ) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + SwiftUI.Image(systemName: "multiply.circle.fill") + .resizable() + .frame(width: 25, height: 25) + .foregroundColor(Color(UIColor.lightGray)) + .onTapGesture { + isShowingCart = false + } + } + } + .navigationBarTitleDisplayMode(.inline) + .navigationTitle("Cart") + } + }.padding() } - .padding() - } } else { ProgressView() } @@ -166,6 +147,11 @@ struct CatalogView: View { .font(.headline) } ToolbarItemGroup { + BadgeButton(badgeCount: Int(cartManager.cart?.totalQuantity ?? 0), action: { + isShowingCart = true + }) + .accessibilityIdentifier("cartIcon") + Button(action: { onAppear() }, label: { diff --git a/Samples/SwiftUIExample/SwiftUIExample/ProductViewModel.swift b/Samples/SwiftUIExample/SwiftUIExample/ProductViewModel.swift index 057ac167..75da8dcc 100644 --- a/Samples/SwiftUIExample/SwiftUIExample/ProductViewModel.swift +++ b/Samples/SwiftUIExample/SwiftUIExample/ProductViewModel.swift @@ -34,7 +34,13 @@ public class CartManager: ObservableObject { // MARK: Properties @Published - var cart: Storefront.Cart? + var cart: Storefront.Cart? { + didSet { + if let url = cart?.checkoutUrl { + ShopifyCheckoutSheetKit.preload(checkout: url) + } + } + } // MARK: Cart Actions diff --git a/Samples/SwiftUIExample/SwiftUIExample/SwiftUIExampleApp.swift b/Samples/SwiftUIExample/SwiftUIExample/SwiftUIExampleApp.swift deleted file mode 100644 index 05aaea83..00000000 --- a/Samples/SwiftUIExample/SwiftUIExample/SwiftUIExampleApp.swift +++ /dev/null @@ -1,33 +0,0 @@ -/* -MIT License - -Copyright 2023 - Present, Shopify Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -*/ - -import SwiftUI - -@main -struct SwiftUIExampleApp: App { - var body: some Scene { - WindowGroup { - ContentView() - } - } -} diff --git a/Samples/SwiftUIExample/SwiftUIExampleUITests/SwiftUIExampleUITests.swift b/Samples/SwiftUIExample/SwiftUIExampleUITests/SwiftUIExampleUITests.swift new file mode 100644 index 00000000..2c0cf6f0 --- /dev/null +++ b/Samples/SwiftUIExample/SwiftUIExampleUITests/SwiftUIExampleUITests.swift @@ -0,0 +1,75 @@ +/* +MIT License + +Copyright 2023 - Present, Shopify Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +import XCTest + +final class SwiftUIExampleUITests: XCTestCase { + + override func setUpWithError() throws { + continueAfterFailure = false + } + + private func openCheckoutFromCartSheet(_ app: XCUIApplication) { + app.buttons["addToCartButton"].tap() + app.buttons["cartIcon"].tap() + app.buttons["checkoutButton"].tap() + } + + private func openCheckoutFromCartView(_ app: XCUIApplication) { + app.buttons["addToCartButton"].tap() + app.buttons["cartTabIcon"].tap() + app.buttons["checkoutButton"].tap() + } + + private func expectCheckoutToContain(_ element: XCUIElement) { + let exists = NSPredicate(format: "exists == true") + expectation(for: exists, evaluatedWith: element, handler: nil) + waitForExpectations(timeout: 10, handler: nil) + XCTAssertTrue(element.exists) + } + + func testCheckoutSheetHasCustomTitle() { + let app = XCUIApplication() + + app.launch() + + openCheckoutFromCartSheet(app) + + XCTAssertTrue(app.staticTexts["SwiftUI"].exists) + + expectCheckoutToContain(app.staticTexts["Contact"]) + expectCheckoutToContain(app.staticTexts["Delivery"]) + } + + func testCheckoutViewHasCustomTitle() { + let app = XCUIApplication() + + app.launch() + + openCheckoutFromCartView(app) + + XCTAssertTrue(app.staticTexts["SwiftUI"].exists) + expectCheckoutToContain(app.staticTexts["Contact"]) + expectCheckoutToContain(app.staticTexts["Delivery"]) + } +} diff --git a/Sources/ShopifyCheckoutSheetKit/CheckoutViewController.swift b/Sources/ShopifyCheckoutSheetKit/CheckoutViewController.swift index 8d2cf5fb..49ae740e 100644 --- a/Sources/ShopifyCheckoutSheetKit/CheckoutViewController.swift +++ b/Sources/ShopifyCheckoutSheetKit/CheckoutViewController.swift @@ -26,9 +26,7 @@ import SwiftUI public class CheckoutViewController: UINavigationController { public init(checkout url: URL, delegate: CheckoutDelegate? = nil) { - let rootViewController = CheckoutWebViewController( - checkoutURL: url, delegate: delegate - ) + let rootViewController = CheckoutWebViewController(checkoutURL: url, delegate: delegate) rootViewController.notifyPresented() super.init(rootViewController: rootViewController) presentationController?.delegate = rootViewController @@ -40,7 +38,9 @@ public class CheckoutViewController: UINavigationController { } } +/// Deprecated extension CheckoutViewController { + @available(*, deprecated, message: "Use \"CheckoutSheet\" instead.") public struct Representable: UIViewControllerRepresentable { @Binding var checkoutURL: URL? @@ -59,3 +59,98 @@ extension CheckoutViewController { } } } + +public struct CheckoutSheet: UIViewControllerRepresentable, CheckoutConfigurable { + public typealias UIViewControllerType = CheckoutViewController + + var checkoutURL: URL + var delegate = CheckoutDelegateWrapper() + + public init(checkout url: URL) { + self.checkoutURL = url + + /// Programatic usage of the library will invalidate the cache each time the configuration changes. + /// This should not happen in the case of SwiftUI, where the config can change each time a modifier function runs. + ShopifyCheckoutSheetKit.invalidateOnConfigurationChange = false + } + + public func makeUIViewController(context: Self.Context) -> CheckoutViewController { + return CheckoutViewController(checkout: checkoutURL, delegate: delegate) + } + + public func updateUIViewController(_ uiViewController: CheckoutViewController, context: Self.Context) {} + + /// Lifecycle methods + + @discardableResult public func onCancel(_ action: @escaping () -> Void) -> Self { + delegate.onCancel = action + return self + } + + @discardableResult public func onComplete(_ action: @escaping (CheckoutCompletedEvent) -> Void) -> Self { + delegate.onComplete = action + return self + } + + @discardableResult public func onFail(_ action: @escaping (CheckoutError) -> Void) -> Self { + delegate.onFail = action + return self + } + + @discardableResult public func onPixelEvent(_ action: @escaping (PixelEvent) -> Void) -> Self { + delegate.onPixelEvent = action + return self + } +} + +public class CheckoutDelegateWrapper: CheckoutDelegate { + var onComplete: ((CheckoutCompletedEvent) -> Void)? + var onCancel: (() -> Void)? + var onFail: ((CheckoutError) -> Void)? + var onPixelEvent: ((PixelEvent) -> Void)? + + public func checkoutDidFail(error: CheckoutError) { + onFail?(error) + } + + public func checkoutDidEmitWebPixelEvent(event: PixelEvent) { + onPixelEvent?(event) + } + + public func checkoutDidComplete(event: CheckoutCompletedEvent) { + onComplete?(event) + } + + public func checkoutDidCancel() { + onCancel?() + } +} + +public protocol CheckoutConfigurable { + func backgroundColor(_ color: UIColor) -> Self + func colorScheme(_ colorScheme: ShopifyCheckoutSheetKit.Configuration.ColorScheme) -> Self + func tintColor(_ color: UIColor) -> Self + func title(_ title: String) -> Self +} + +extension CheckoutConfigurable { + @discardableResult public func backgroundColor(_ color: UIColor) -> Self { + ShopifyCheckoutSheetKit.configuration.backgroundColor = color + return self + } + + @discardableResult public func colorScheme(_ colorScheme: ShopifyCheckoutSheetKit.Configuration.ColorScheme) -> Self { + ShopifyCheckoutSheetKit.configuration.colorScheme = colorScheme + return self + } + + @discardableResult public func tintColor(_ color: UIColor) -> Self { + ShopifyCheckoutSheetKit.configuration.tintColor = color + return self + } + + @discardableResult public func title(_ title: String) -> Self { + ShopifyCheckoutSheetKit.configuration.title = title + return self + } +} diff --git a/Sources/ShopifyCheckoutSheetKit/CheckoutWebView.swift b/Sources/ShopifyCheckoutSheetKit/CheckoutWebView.swift index f2bc9ccc..96fcf6ba 100644 --- a/Sources/ShopifyCheckoutSheetKit/CheckoutWebView.swift +++ b/Sources/ShopifyCheckoutSheetKit/CheckoutWebView.swift @@ -35,11 +35,13 @@ protocol CheckoutWebViewDelegate: AnyObject { } class CheckoutWebView: WKWebView { - private static var cache: CacheEntry? + static var preloadingActivatedByClient: Bool = false - // a ref to the view is needed when preload is deactivated in order to detatch bridge + + /// A reference to the view is needed when preload is deactivated in order to detatch the bridge static weak var uncacheableViewRef: CheckoutWebView? + var isBridgeAttached = false static func `for`(checkout url: URL) -> CheckoutWebView { diff --git a/Sources/ShopifyCheckoutSheetKit/CheckoutWebViewController.swift b/Sources/ShopifyCheckoutSheetKit/CheckoutWebViewController.swift index 8f7ef3a8..c8559e48 100644 --- a/Sources/ShopifyCheckoutSheetKit/CheckoutWebViewController.swift +++ b/Sources/ShopifyCheckoutSheetKit/CheckoutWebViewController.swift @@ -153,6 +153,7 @@ class CheckoutWebViewController: UIViewController, UIAdaptivePresentationControl if !CheckoutWebView.preloadingActivatedByClient { CheckoutWebView.invalidate() } + delegate?.checkoutDidCancel() } } diff --git a/Sources/ShopifyCheckoutSheetKit/Configuration.swift b/Sources/ShopifyCheckoutSheetKit/Configuration.swift index e6036513..ac10aeaa 100644 --- a/Sources/ShopifyCheckoutSheetKit/Configuration.swift +++ b/Sources/ShopifyCheckoutSheetKit/Configuration.swift @@ -41,6 +41,9 @@ public struct Configuration { public var tintColor: UIColor = UIColor(red: 0.09, green: 0.45, blue: 0.69, alpha: 1.00) + @available(*, renamed: "tintColor", message: "spinnerColor has been superseded by tintColor") + public var spinnerColor: UIColor = UIColor(red: 0.09, green: 0.45, blue: 0.69, alpha: 1.00) + public var backgroundColor: UIColor = .systemBackground public var logger: Logger = NoOpLogger() diff --git a/Sources/ShopifyCheckoutSheetKit/ShopifyCheckoutSheetKit.swift b/Sources/ShopifyCheckoutSheetKit/ShopifyCheckoutSheetKit.swift index 6fe703bc..110a2b08 100644 --- a/Sources/ShopifyCheckoutSheetKit/ShopifyCheckoutSheetKit.swift +++ b/Sources/ShopifyCheckoutSheetKit/ShopifyCheckoutSheetKit.swift @@ -26,10 +26,14 @@ import UIKit /// The version of the `ShopifyCheckoutSheetKit` library. public let version = "2.0.0" +internal var invalidateOnConfigurationChange = true + /// The configuration options for the `ShopifyCheckoutSheetKit` library. public var configuration = Configuration() { didSet { - CheckoutWebView.invalidate() + if invalidateOnConfigurationChange { + CheckoutWebView.invalidate() + } } } @@ -40,7 +44,9 @@ public func configure(_ block: (inout Configuration) -> Void) { /// Preloads the checkout for faster presentation. public func preload(checkout url: URL) { - guard configuration.preloading.enabled else { return } + guard configuration.preloading.enabled else { + return + } CheckoutWebView.preloadingActivatedByClient = true CheckoutWebView.for(checkout: url).load(checkout: url, isPreload: true) @@ -50,7 +56,3 @@ public func preload(checkout url: URL) { public func present(checkout url: URL, from: UIViewController, delegate: CheckoutDelegate? = nil) { from.present(CheckoutViewController(checkout: url, delegate: delegate), animated: true) } - -public func presentRepresentable(checkout url: URL, delegate: CheckoutDelegate? = nil) { - -} diff --git a/Tests/ShopifyCheckoutSheetKitTests/SwiftUITests.swift b/Tests/ShopifyCheckoutSheetKitTests/SwiftUITests.swift new file mode 100644 index 00000000..23b3af00 --- /dev/null +++ b/Tests/ShopifyCheckoutSheetKitTests/SwiftUITests.swift @@ -0,0 +1,146 @@ +/* +MIT License + +Copyright 2023 - Present, Shopify Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +import XCTest +@testable import ShopifyCheckoutSheetKit + +class CheckoutViewControllerTests: XCTestCase { + var checkoutURL: URL! + var delegate: CheckoutDelegateWrapper! + var checkoutViewController: CheckoutViewController! + + override func setUp() { + super.setUp() + checkoutURL = URL(string: "https://www.shopify.com") + delegate = CheckoutDelegateWrapper() + checkoutViewController = CheckoutViewController(checkout: checkoutURL, delegate: delegate) + } + + func testInit() { + XCTAssertNotNil(checkoutViewController) + } +} + +class CheckoutSheetTests: XCTestCase { + var checkoutURL: URL! + var checkoutSheet: CheckoutSheet! + + override func setUp() { + super.setUp() + checkoutURL = URL(string: "https://www.shopify.com") + checkoutSheet = CheckoutSheet(checkout: checkoutURL) + } + + /// Lifecycle events + + func testOnCancel() { + var cancelActionCalled = false + + checkoutSheet.onCancel { + cancelActionCalled = true + } + checkoutSheet.delegate.checkoutDidCancel() + XCTAssertTrue(cancelActionCalled) + } + + func testOnComplete() { + var actionCalled = false + var actionData: CheckoutCompletedEvent? + let event = CheckoutCompletedEvent() + + checkoutSheet.onComplete { event in + actionCalled = true + actionData = event + } + checkoutSheet.delegate.checkoutDidComplete(event: event) + XCTAssertTrue(actionCalled) + XCTAssertNotNil(actionData) + } + + func testOnFail() { + var actionCalled = false + var actionData: CheckoutError? + let error: CheckoutError = .checkoutUnavailable(message: "error") + + checkoutSheet.onFail { failure in + actionCalled = true + actionData = failure + + } + checkoutSheet.delegate.checkoutDidFail(error: error) + XCTAssertTrue(actionCalled) + XCTAssertNotNil(actionData) + } + + func testOnPixelEvent() { + var actionCalled = false + var actionData: PixelEvent? + let standardEvent = StandardEvent(context: nil, id: "testId", name: "checkout_started", timestamp: "2022-01-01T00:00:00Z", data: nil) + let pixelEvent = PixelEvent.standardEvent(standardEvent) + + checkoutSheet.onPixelEvent { event in + actionCalled = true + actionData = event + } + checkoutSheet.delegate.checkoutDidEmitWebPixelEvent(event: pixelEvent) + XCTAssertTrue(actionCalled) + XCTAssertNotNil(actionData) + } +} + +class CheckoutConfigurableTests: XCTestCase { + var checkoutURL: URL! + var checkoutSheet: CheckoutSheet! + + override func setUp() { + super.setUp() + checkoutURL = URL(string: "https://www.shopify.com") + checkoutSheet = CheckoutSheet(checkout: checkoutURL) + } + + /// Configuration modifiers + + func testBackgroundColor() { + let color = UIColor.red + checkoutSheet.backgroundColor(color) + XCTAssertEqual(ShopifyCheckoutSheetKit.configuration.backgroundColor, color) + } + + func testColorScheme() { + let colorScheme = ShopifyCheckoutSheetKit.Configuration.ColorScheme.light + checkoutSheet.colorScheme(colorScheme) + XCTAssertEqual(ShopifyCheckoutSheetKit.configuration.colorScheme, colorScheme) + } + + func testTintColor() { + let color = UIColor.blue + checkoutSheet.tintColor(color) + XCTAssertEqual(ShopifyCheckoutSheetKit.configuration.tintColor, color) + } + + func testTitle() { + let title = "Test Title" + checkoutSheet.title(title) + XCTAssertEqual(ShopifyCheckoutSheetKit.configuration.title, title) + } +}