diff --git a/Samples/MobileBuyIntegration/.swiftlint.yml b/Samples/MobileBuyIntegration/.swiftlint.yml index 83d7c596..49aa0bca 100644 --- a/Samples/MobileBuyIntegration/.swiftlint.yml +++ b/Samples/MobileBuyIntegration/.swiftlint.yml @@ -1,5 +1,7 @@ disabled_rules: - line_length + - todo + - multiple_closures_with_trailing_closure opt_in_rules: - array_init diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration.xcodeproj/project.pbxproj b/Samples/MobileBuyIntegration/MobileBuyIntegration.xcodeproj/project.pbxproj index 1939d456..b7a60f98 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration.xcodeproj/project.pbxproj +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration.xcodeproj/project.pbxproj @@ -12,19 +12,20 @@ 4EA7F9B62A9D2B9D003276A1 /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA7F9B42A9D2B9D003276A1 /* SettingsViewController.swift */; }; 4EBBA76B2A5F0CE200193E19 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EBBA76A2A5F0CE200193E19 /* AppDelegate.swift */; }; 4EBBA76D2A5F0CE200193E19 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EBBA76C2A5F0CE200193E19 /* SceneDelegate.swift */; }; - 4EBBA76F2A5F0CE200193E19 /* ProductViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EBBA76E2A5F0CE200193E19 /* ProductViewController.swift */; }; + 4EBBA76F2A5F0CE200193E19 /* ProductView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EBBA76E2A5F0CE200193E19 /* ProductView.swift */; }; 4EBBA7742A5F0CE200193E19 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4EBBA7732A5F0CE200193E19 /* Assets.xcassets */; }; 4EBBA7772A5F0CE200193E19 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 4EBBA7752A5F0CE200193E19 /* LaunchScreen.storyboard */; }; 4EBBA7A32A5F0F5600193E19 /* Buy in Frameworks */ = {isa = PBXBuildFile; productRef = 4EBBA7A22A5F0F5600193E19 /* Buy */; }; 4EBBA7AA2A5F124F00193E19 /* StorefrontClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EBBA7A92A5F124F00193E19 /* StorefrontClient.swift */; }; - 4EBBA7AC2A5F18B900193E19 /* ProductViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 4EBBA7AB2A5F18B900193E19 /* ProductViewController.xib */; }; 4EBBA7AE2A5F1BBF00193E19 /* UIImageView+URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EBBA7AD2A5F1BBF00193E19 /* UIImageView+URL.swift */; }; 4EBBA7B02A5F222F00193E19 /* MoneyV2+Format.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EBBA7AF2A5F222F00193E19 /* MoneyV2+Format.swift */; }; 4EF54F242A6F456B00F5E407 /* CartManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF54F232A6F456B00F5E407 /* CartManager.swift */; }; 4EF54F272A6F4C4F00F5E407 /* CartViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF54F262A6F4C4F00F5E407 /* CartViewController.swift */; }; - 4EF54F312A6F63C000F5E407 /* CartViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 4EF54F2F2A6F63C000F5E407 /* CartViewController.xib */; }; + 6A0FA77E2CE4D7F7003070F8 /* MobileBuyIntegration.entitlements in Resources */ = {isa = PBXBuildFile; fileRef = 6AE865492CE3BB6500A4971C /* MobileBuyIntegration.entitlements */; }; 6A257A132AFBA78500610DA5 /* LogReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A257A122AFBA78500610DA5 /* LogReader.swift */; }; 6A257A152AFBB06300610DA5 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A257A142AFBB06300610DA5 /* Logger.swift */; }; + 6A2E77BE2CE606490067062D /* Catalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A2E77BD2CE606400067062D /* Catalog.swift */; }; + 6A2E77C02CE618720067062D /* Cart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A2E77BF2CE6186F0067062D /* Cart.swift */; }; 6A34672F2B5FFEFB007314A8 /* WebPixelEventsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A34672E2B5FFEFB007314A8 /* WebPixelEventsView.swift */; }; 6A3467332B600E64007314A8 /* LogsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A3467322B600E64007314A8 /* LogsView.swift */; }; 6A3D7ADC2B8E01460010EB27 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 6A3D7ADB2B8E01460010EB27 /* Localizable.xcstrings */; }; @@ -38,20 +39,20 @@ 4EBBA7672A5F0CE200193E19 /* MobileBuyIntegration.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MobileBuyIntegration.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4EBBA76A2A5F0CE200193E19 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 4EBBA76C2A5F0CE200193E19 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; - 4EBBA76E2A5F0CE200193E19 /* ProductViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductViewController.swift; sourceTree = ""; }; + 4EBBA76E2A5F0CE200193E19 /* ProductView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductView.swift; sourceTree = ""; }; 4EBBA7732A5F0CE200193E19 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 4EBBA7762A5F0CE200193E19 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 4EBBA7782A5F0CE200193E19 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 4EBBA7A72A5F10C400193E19 /* Storefront.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Storefront.xcconfig; sourceTree = SOURCE_ROOT; }; 4EBBA7A92A5F124F00193E19 /* StorefrontClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorefrontClient.swift; sourceTree = ""; }; - 4EBBA7AB2A5F18B900193E19 /* ProductViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ProductViewController.xib; sourceTree = ""; }; 4EBBA7AD2A5F1BBF00193E19 /* UIImageView+URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImageView+URL.swift"; sourceTree = ""; }; 4EBBA7AF2A5F222F00193E19 /* MoneyV2+Format.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MoneyV2+Format.swift"; sourceTree = ""; }; 4EF54F232A6F456B00F5E407 /* CartManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CartManager.swift; sourceTree = ""; }; 4EF54F262A6F4C4F00F5E407 /* CartViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CartViewController.swift; sourceTree = ""; }; - 4EF54F2F2A6F63C000F5E407 /* CartViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = CartViewController.xib; sourceTree = ""; }; 6A257A122AFBA78500610DA5 /* LogReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogReader.swift; sourceTree = ""; }; 6A257A142AFBB06300610DA5 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; + 6A2E77BD2CE606400067062D /* Catalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Catalog.swift; sourceTree = ""; }; + 6A2E77BF2CE6186F0067062D /* Cart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cart.swift; sourceTree = ""; }; 6A34672E2B5FFEFB007314A8 /* WebPixelEventsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebPixelEventsView.swift; sourceTree = ""; }; 6A3467322B600E64007314A8 /* LogsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogsView.swift; sourceTree = ""; }; 6A3D7ADB2B8E01460010EB27 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; @@ -116,15 +117,15 @@ 4EBBA77F2A5F0DA300193E19 /* Application */ = { isa = PBXGroup; children = ( + 6A2E77BF2CE6186F0067062D /* Cart.swift */, + 6A2E77BD2CE606400067062D /* Catalog.swift */, 4EBBA76A2A5F0CE200193E19 /* AppDelegate.swift */, 86250DE32AD5521C002E45C2 /* AppConfiguration.swift */, 4EBBA76C2A5F0CE200193E19 /* SceneDelegate.swift */, 4EF54F232A6F456B00F5E407 /* CartManager.swift */, 4EBBA7A92A5F124F00193E19 /* StorefrontClient.swift */, - 4EBBA76E2A5F0CE200193E19 /* ProductViewController.swift */, - 4EBBA7AB2A5F18B900193E19 /* ProductViewController.xib */, + 4EBBA76E2A5F0CE200193E19 /* ProductView.swift */, 4EF54F262A6F4C4F00F5E407 /* CartViewController.swift */, - 4EF54F2F2A6F63C000F5E407 /* CartViewController.xib */, 4EA7F9B42A9D2B9D003276A1 /* SettingsViewController.swift */, 6A257A122AFBA78500610DA5 /* LogReader.swift */, 6A257A142AFBB06300610DA5 /* Logger.swift */, @@ -228,10 +229,9 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 6A0FA77E2CE4D7F7003070F8 /* MobileBuyIntegration.entitlements in Resources */, 4EBBA7772A5F0CE200193E19 /* LaunchScreen.storyboard in Resources */, - 4EF54F312A6F63C000F5E407 /* CartViewController.xib in Resources */, 6A3D7ADC2B8E01460010EB27 /* Localizable.xcstrings in Resources */, - 4EBBA7AC2A5F18B900193E19 /* ProductViewController.xib in Resources */, 4EBBA7742A5F0CE200193E19 /* Assets.xcassets in Resources */, 2147F3E62B502AFD005546F3 /* checkout-sheet-kit-swift in Resources */, ); @@ -268,14 +268,16 @@ 4EBBA7AE2A5F1BBF00193E19 /* UIImageView+URL.swift in Sources */, 6A34672F2B5FFEFB007314A8 /* WebPixelEventsView.swift in Sources */, 4EBBA7B02A5F222F00193E19 /* MoneyV2+Format.swift in Sources */, + 6A2E77BE2CE606490067062D /* Catalog.swift in Sources */, 4EF54F242A6F456B00F5E407 /* CartManager.swift in Sources */, - 4EBBA76F2A5F0CE200193E19 /* ProductViewController.swift in Sources */, + 4EBBA76F2A5F0CE200193E19 /* ProductView.swift in Sources */, 4EF54F272A6F4C4F00F5E407 /* CartViewController.swift in Sources */, 4EBBA76B2A5F0CE200193E19 /* AppDelegate.swift in Sources */, 86250DE42AD5521C002E45C2 /* AppConfiguration.swift in Sources */, 4EBBA7AA2A5F124F00193E19 /* StorefrontClient.swift in Sources */, 6A257A152AFBB06300610DA5 /* Logger.swift in Sources */, 4EBBA76D2A5F0CE200193E19 /* SceneDelegate.swift in Sources */, + 6A2E77C02CE618720067062D /* Cart.swift in Sources */, 6A774DD12B58023400C8EF7E /* CountryCode+inferRegion.swift in Sources */, 4EA7F9B62A9D2B9D003276A1 /* SettingsViewController.swift in Sources */, 6A3467332B600E64007314A8 /* LogsView.swift in Sources */, @@ -426,12 +428,12 @@ DEVELOPMENT_TEAM = A7XGC83MZE; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = MobileBuyIntegration/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "Checkout Sheet Kit"; + INFOPLIST_KEY_CFBundleDisplayName = Plant; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -456,12 +458,12 @@ DEVELOPMENT_TEAM = A7XGC83MZE; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = MobileBuyIntegration/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "Checkout Sheet Kit"; + INFOPLIST_KEY_CFBundleDisplayName = Plant; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/AppDelegate.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/AppDelegate.swift index 0c169302..bee7e824 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/AppDelegate.swift +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/AppDelegate.swift @@ -30,7 +30,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate { ShopifyCheckoutSheetKit.configure { /// Checkout color scheme setting - $0.colorScheme = .automatic + $0.colorScheme = .web + + /// Customize progress bar color + $0.tintColor = ColorPalette.primaryColor + + /// Customize sheet color (matches web configuration by default) + $0.backgroundColor = ColorPalette.backgroundColor /// Enable preloading $0.preloading.enabled = true @@ -43,7 +49,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { print("[MobileBuyIntegration] Log level set to .all") - UIBarButtonItem.appearance().tintColor = .label + UIBarButtonItem.appearance().tintColor = ColorPalette.primaryColor return true } @@ -52,3 +58,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return UISceneConfiguration(name: "Default", sessionRole: connectingSceneSession.role) } } + +struct ColorPalette { + static let primaryColor = UIColor(red: 37/255, green: 96/255, blue: 79/255, alpha: 1.0) + static let successColor = UIColor(red: 31/255, green: 59/255, blue: 51/255, alpha: 1.0) + static let backgroundColor = UIColor(red: 249/255, green: 248/255, blue: 246/255, alpha: 1.0) +} diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/Assets.xcassets/logo.imageset/Contents.json b/Samples/MobileBuyIntegration/MobileBuyIntegration/Assets.xcassets/logo.imageset/Contents.json new file mode 100644 index 00000000..9cc643d5 --- /dev/null +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/Assets.xcassets/logo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "plant.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/Assets.xcassets/logo.imageset/plant.png b/Samples/MobileBuyIntegration/MobileBuyIntegration/Assets.xcassets/logo.imageset/plant.png new file mode 100644 index 00000000..b88ca169 Binary files /dev/null and b/Samples/MobileBuyIntegration/MobileBuyIntegration/Assets.xcassets/logo.imageset/plant.png differ diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/Cart.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/Cart.swift new file mode 100644 index 00000000..72d00fa3 --- /dev/null +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/Cart.swift @@ -0,0 +1,234 @@ +/* +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 { + @State var cartCompleted: Bool = false + @State var isBusy: Bool = false + + @ObservedObject var cartManager: CartManager = CartManager.shared + + var body: some View { + if let lines = cartManager.cart?.lines.nodes { + ZStack(alignment: .bottom) { + ScrollView { + VStack { + CartLines(lines: lines, isBusy: $isBusy) + } + .padding(.bottom, 80) + } + + VStack { + Button(action: presentCheckout, label: { + HStack { + Text("Checkout") + .fontWeight(.bold) + Spacer() + if let amount = cartManager.cart?.cost.totalAmount, let total = amount.formattedString() { + Text(total) + .fontWeight(.bold) + } + } + .padding() + .frame(maxWidth: .infinity) + .background(isBusy ? Color.gray : Color(ColorPalette.primaryColor)) + .cornerRadius(10) + }) + .disabled(isBusy) + .foregroundColor(.white) + .accessibilityIdentifier("checkoutButton") + .padding(.horizontal, 15) + } + .padding(.bottom, 20) + } + .onAppear { + preloadCheckout() + } + } else { + EmptyState() + } + } + + private func preloadCheckout() { + CartManager.shared.preloadCheckout() + } + + private func presentCheckout() { + guard let url = CartManager.shared.cart?.checkoutUrl else { + return + } + + ShopifyCheckoutSheetKit.present(checkout: url, from: SceneDelegate.cartController, delegate: SceneDelegate.cartController) + } +} + +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] + @State var updating: GraphQL.ID? { + didSet { + isBusy = updating != nil + } + } + @Binding var isBusy: Bool + + 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) { phase in + switch phase { + case .empty: + ProgressView() + .frame(width: 80, height: 140) + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .transition(.opacity.animation(.easeIn)) + case .failure: + Image(systemName: "photo") + .frame(width: 80, height: 140) + @unknown default: + EmptyView() + } + } + .frame(width: 80, height: 140) + .padding(.trailing, 5) + } + + VStack(alignment: .leading, spacing: 8 +) { + Text(variant?.product.title ?? "") + .font(.body) + .bold() + .lineLimit(2) + .truncationMode(.tail) + + Text(variant?.product.vendor ?? "") + .font(.body) + .foregroundColor(Color(ColorPalette.primaryColor)) + + if let price = variant?.price.formattedString() { + HStack { + Text("\(price)") + .foregroundColor(.gray) + + Spacer() + + HStack(spacing: 20) { + Button(action: { + /// Prevent multiple simulataneous calls + guard node.quantity > 1 && updating != node.id else { + return + } + + updating = node.id + + /// Invalidate the cart cache to ensure the correct item quantity is reflected on checkout + ShopifyCheckoutSheetKit.invalidate() + + CartManager.shared.updateQuantity(variant: node.id, quantity: node.quantity - 1, completionHandler: { cart in + CartManager.shared.cart = cart + updating = nil + + CartManager.shared.preloadCheckout() + }) + }, label: { + Image(systemName: "minus") + .font(.system(size: 12)) + .frame(width: 32, height: 32) + .background(Color.gray.opacity(0.1)) + .clipShape(Circle()) + }) + + VStack { + if updating == node.id { + ProgressView().progressViewStyle(CircularProgressViewStyle()) + .scaleEffect(0.8) + } else { + Text("\(node.quantity)") + .frame(width: 20) + } + }.frame(width: 20) + + Button(action: { + /// Prevent multiple simulataneous calls + guard updating != node.id else { + return + } + + updating = node.id + + /// Invalidate the cart cache to ensure the correct item quantity is reflected on checkout + ShopifyCheckoutSheetKit.invalidate() + + CartManager.shared.updateQuantity(variant: node.id, quantity: node.quantity + 1, completionHandler: { cart in + CartManager.shared.cart = cart + updating = nil + + if let url = cart?.checkoutUrl { + ShopifyCheckoutSheetKit.preload(checkout: url) + } + }) + }, label: { + Image(systemName: "plus") + .font(.system(size: 12)) + .frame(width: 32, height: 32) + .background(Color.gray.opacity(0.1)) + .clipShape(Circle()) + }) + } + .padding(.trailing, 10) + } + } + }.padding(.leading, 5) + } + .padding([.leading, .trailing], 20) + .frame(maxWidth: .infinity, alignment: .leading) + + Divider() + .background(Color.gray.opacity(0.3)) + .padding(.vertical, 2) + } + } +} diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/CartManager.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/CartManager.swift index 1c7e83bb..0688b41f 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/CartManager.swift +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/CartManager.swift @@ -26,14 +26,14 @@ import Combine import Foundation import ShopifyCheckoutSheetKit -class CartManager { +class CartManager: ObservableObject { static let shared = CartManager(client: .shared) // MARK: Properties - @Published - var cart: Storefront.Cart? + @Published var cart: Storefront.Cart? + @Published var isDirty: Bool = false private let client: StorefrontClient private let address1: String @@ -78,6 +78,20 @@ class CartManager { self.phone = phone } + public func preloadCheckout() { + /// Only preload checkout if cart is dirty, meaning it has changes since checkout was last preloaded + if let url = cart?.checkoutUrl, isDirty { + ShopifyCheckoutSheetKit.preload(checkout: url) + markCartAsReady() + } + } + + /// The cart is "ready" when ShopifyCheckoutSheetKit.preload(checkoutUrl) has been called + /// The dirty state will be set to false to prevent preloading again + func markCartAsReady() { + isDirty = false + } + // MARK: Cart Actions func addItem(variant: GraphQL.ID, completionHandler: (() -> Void)?) { @@ -85,6 +99,7 @@ class CartManager { switch result { case .success(let cart): self.cart = cart + self.isDirty = true case .failure(let error): print(error) } @@ -97,6 +112,7 @@ class CartManager { switch result { case .success(let cart): self.cart = cart + self.isDirty = true case .failure(let error): print(error) } @@ -106,6 +122,7 @@ class CartManager { func resetCart() { self.cart = nil + self.isDirty = false } typealias CartResultHandler = (Result) -> Void diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/CartViewController.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/CartViewController.swift index f479287b..cb56aaf3 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/CartViewController.swift +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/CartViewController.swift @@ -94,9 +94,6 @@ class CartItemCell: UITableViewCell { quantityLabel.widthAnchor.constraint(equalToConstant: 20).isActive = true quantityLabel.textAlignment = .center - decreaseButton.contentEdgeInsets = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16) - increaseButton.contentEdgeInsets = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16) - decreaseButton.addTarget(self, action: #selector(decreaseQuantity), for: .touchUpInside) increaseButton.addTarget(self, action: #selector(increaseQuantity), for: .touchUpInside) diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/CartViewController.xib b/Samples/MobileBuyIntegration/MobileBuyIntegration/CartViewController.xib deleted file mode 100644 index 6e16c2d1..00000000 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/CartViewController.xib +++ /dev/null @@ -1,93 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/Catalog.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/Catalog.swift new file mode 100644 index 00000000..1008a664 --- /dev/null +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/Catalog.swift @@ -0,0 +1,139 @@ +/* +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 + +struct ProductGrid: View { + @StateObject private var productCache = ProductCache.shared + @State private var selectedProduct: Storefront.Product? + @State private var showProductSheet = false + + let columns = [ + GridItem(.flexible()), + GridItem(.flexible()) + ] + + var body: some View { + ScrollView { + LazyVGrid(columns: columns, spacing: 0) { + if let products = productCache.collection, !products.isEmpty { + ForEach(products, id: \.id) { product in + ProductGridItem(product: product) + .onTapGesture { + selectProductAndShowSheet(for: product) + } + } + } else { + Text("Loading products...") + .padding() + } + } + .padding(.horizontal, 5) + } + .onAppear { + if productCache.collection == nil { + productCache.fetchCollection() + } + } + .sheet(isPresented: $showProductSheet) { + ProductSheetView(product: $selectedProduct, isPresented: $showProductSheet) + } + } + + private func selectProductAndShowSheet(for product: Storefront.Product) { + selectedProduct = product + if selectedProduct != nil { + showProductSheet = true + } + } +} + +struct ProductSheetView: View { + @Binding var product: Storefront.Product? + @Binding var isPresented: Bool + + var body: some View { + ZStack(alignment: .topTrailing) { + if let product = product { + ProductView(product: product) + } + + Button(action: { + isPresented = false + }) { + Image(systemName: "xmark") + .font(.system(size: 14)) + .padding() + .foregroundStyle(.white) + } + .padding([.top, .trailing], 16) + } + .edgesIgnoringSafeArea(.top) + } +} + +struct ProductGridItem: View { + let product: Storefront.Product + + let imageHeight = 200.0 + + var body: some View { + VStack { + if let imageURL = product.featuredImage?.url { + AsyncImage(url: imageURL) { image in + image + .resizable() + .scaledToFit() + .frame(height: imageHeight) + } placeholder: { + Rectangle() + .fill(Color.gray.opacity(0.2)) + .frame(height: imageHeight) + } + } + + VStack { + Text(product.title) + .font(.headline) + .lineLimit(1) + .padding(.top, 4) + + if let price = product.variants.nodes.first?.price { + Text( price.formattedString()!) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + .frame(alignment: .leading) + } + .frame(maxWidth: .infinity) + .padding(.bottom, 20) + } +} + +struct ProductGrid_Previews: PreviewProvider { + static var previews: some View { + ProductGrid() + } +} diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/Localizable.xcstrings b/Samples/MobileBuyIntegration/MobileBuyIntegration/Localizable.xcstrings index 85ff5146..39f94d83 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/Localizable.xcstrings +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/Localizable.xcstrings @@ -1,14 +1,32 @@ { "sourceLanguage" : "en", "strings" : { + "%@" : { + + }, + "%d" : { + + }, "✓" : { }, - "App version" : { + "Add to Cart" : { + + }, + "Added" : { + + }, + "Adding..." : { }, "By default, the app will only handle the selections above and route everything else to Safari. Enabling the \"Handle all Universal Links\" setting will route all Universal Links to this app." : { + }, + "Checkout" : { + + }, + "Checkout Sheet Kit version" : { + }, "Clear" : { @@ -33,6 +51,9 @@ }, "Handle Product URLs" : { + }, + "Loading products..." : { + }, "Logs" : { @@ -49,7 +70,7 @@ "Preload checkout" : { }, - "SDK version" : { + "Sample app version" : { }, "Settings" : { @@ -90,6 +111,9 @@ }, "Web pixel events" : { + }, + "Your cart is empty." : { + } }, "version" : "1.0" diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/MoneyV2+Format.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/MoneyV2+Format.swift index 960f976a..5a55edd6 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/MoneyV2+Format.swift +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/MoneyV2+Format.swift @@ -29,6 +29,10 @@ extension Storefront.MoneyV2 { let formatter = NumberFormatter() formatter.numberStyle = .currency formatter.currencyCode = currencyCode.rawValue - return formatter.string(from: NSDecimalNumber(decimal: amount)) + return isFree() ? "Free" : formatter.string(from: NSDecimalNumber(decimal: amount)) + } + + func isFree() -> Bool { + return amount == 0 } } diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/ProductView.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/ProductView.swift new file mode 100644 index 00000000..be0be8ba --- /dev/null +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/ProductView.swift @@ -0,0 +1,293 @@ +/* +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 UIKit +import SwiftUI +import ShopifyCheckoutSheetKit + +struct ProductView: View { + // MARK: Properties + @State private var product: Storefront.Product + @State private var handle: String? + @State private var loading = false + @State private var imageLoaded: Bool = false + @State private var showingCart = false + @State private var descriptionExpanded: Bool = false + @State private var addedToCart: Bool = false + + init(product: Storefront.Product) { + _product = State(initialValue: product) + } + + // MARK: Body + var body: some View { + ScrollView { + VStack(spacing: 16) { + if let imageURL = product.featuredImage?.url { + ZStack { + Rectangle() + .fill(Color.gray.opacity(0.2)) + .frame(height: 400) + + AsyncImage(url: imageURL) { phase in + switch phase { + case .empty: + EmptyView() + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(height: 400) + .clipped() + .opacity(imageLoaded ? 1 : 0) + .onAppear { + withAnimation(.easeIn(duration: 0.5)) { + imageLoaded = true + } + } + case .failure: + Image(systemName: "photo") + .resizable() + .frame(width: 100, height: 100) + .foregroundColor(.gray) + @unknown default: + EmptyView() + } + } + } + .frame(height: 400) + } + + VStack(alignment: .leading, spacing: 8) { + + Text(product.vendor) + .font(.body) + .fontWeight(.semibold) + .padding(.vertical) + .foregroundColor(Color(ColorPalette.primaryColor)) + + Text(product.title) + .font(.title) + + Text(product.description) + .font(.body) + .foregroundColor(.gray) + .lineLimit(descriptionExpanded ? 10 : 3) + .onTapGesture { + descriptionExpanded.toggle() + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + + if let variant = product.variants.nodes.first { + Button(action: addToCart) { + HStack { + Text(loading ? "Adding..." : (addedToCart ? "Added" : "Add to Cart")) + .font(.headline) + + if loading { + ProgressView() + .colorInvert() + } + Spacer() + + Text((variant.availableForSale ? (addedToCart ? "✓" : ( variant.price.formattedString())) : "Out of stock")!) + }.padding() + } + .background(addedToCart ? Color(ColorPalette.successColor) : Color(ColorPalette.primaryColor)) + .foregroundStyle(.white) + .cornerRadius(10) + .disabled(!variant.availableForSale || loading) + .padding(20) + } + } + } + .navigationTitle(product.collections.nodes.first?.title ?? product.title) + } + + // MARK: Methods + private func addToCart() { + guard let variant = product.variants.nodes.first else { return } + + loading = true + let start = Date() + + CartManager.shared.addItem(variant: variant.id) { + let diff = Date().timeIntervalSince(start) + let message = "Added item to cart in \(String(format: "%.0f", diff * 1000))ms" + ShopifyCheckoutSheetKit.configuration.logger.log(message) + loading = false + addedToCart = true + + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + addedToCart = false + } + } + } + + private func setProduct(_ product: Storefront.Product?) { + if let product = product { + self.product = product + self.handle = product.handle + } + } +} + +class ProductCache: ObservableObject { + static let shared = ProductCache() + @Published public var cachedProduct: Storefront.Product? + @Published public var isFetching: Bool = false + @Published public var collection: [Storefront.Product]? + + private init() {} + + func getProduct(handle: String?, completion: @escaping (Storefront.Product?) -> Void) { + if let product = cachedProduct { + completion(product) + } else { + fetchProduct(by: handle) { product in + self.cachedProduct = product + completion(product) + } + } + } + + private func fetchProduct(by handle: String?, completion: @escaping (Storefront.Product?) -> Void) { + // Simulate fetching product logic; in actual implementation use your GraphQL query + let context = Storefront.InContextDirective(country: Storefront.CountryCode.inferRegion()) + let query = Storefront.buildQuery(inContext: context) { $0 + .products(first: 1, query: handle) { $0 + .nodes { $0 + .id() + .title() + .handle() + .description() + .vendor() + .featuredImage { $0 + .url() + } + .collections(first: 1) { $0 + .nodes { $0 + .id() + .title() + } + } + .variants(first: 1) { $0 + .nodes { $0 + .id() + .title() + .availableForSale() + .price { $0 + .amount() + .currencyCode() + } + } + } + } + } + } + + StorefrontClient.shared.execute(query: query) { result in + if case .success(let query) = result { + completion(query.products.nodes.first) + } else { + completion(nil) + } + } + } + + public func fetchCollection(limit: Int32 = 20) { + let context = Storefront.InContextDirective(country: Storefront.CountryCode.inferRegion()) + let query = Storefront.buildQuery(inContext: context) { $0 + .products(first: limit) { $0 + .nodes { $0 + .id() + .title() + .handle() + .description() + .vendor() + .featuredImage { $0 + .url() + } + .collections(first: 1) { $0 + .nodes { $0 + .id() + .title() + } + } + .variants(first: 1) { $0 + .nodes { $0 + .id() + .title() + .availableForSale() + .price { $0 + .amount() + .currencyCode() + } + } + } + } + } + } + + StorefrontClient.shared.execute(query: query) { result in + if case .success(let query) = result { + DispatchQueue.main.async { + self.collection = query.products.nodes + self.cachedProduct = query.products.nodes.first + } + } + } + } +} + +struct ProductGalleryView: View { + @StateObject private var productCache = ProductCache.shared + + var body: some View { + TabView { + if productCache.collection?.isEmpty ?? true { + Text("Loading products...").padding() + } else { + ForEach(productCache.collection!, id: \.id) { product in + ProductView(product: product) + .onAppear { + ProductCache.shared.cachedProduct = product + } + } + } + } + .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) + .onAppear { + productCache.fetchCollection() + } + } +} + +struct ProductGalleryView_Previews: PreviewProvider { + static var previews: some View { + ProductGalleryView() + } +} diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/ProductViewController.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/ProductViewController.swift deleted file mode 100644 index f1f2c6c8..00000000 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/ProductViewController.swift +++ /dev/null @@ -1,246 +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 Buy -import UIKit -import ShopifyCheckoutSheetKit - -class ProductViewController: UIViewController { - - // MARK: Properties - private var handle: String? - - @IBOutlet private var image: UIImageView! - - @IBOutlet private var titleLabel: UILabel! - - @IBOutlet private var variantLabel: UILabel! - - @IBOutlet private var descriptionLabel: UILabel! - - @IBOutlet private var addToCartButton: UIButton! - - private var loading = false { - didSet { - rerender() - } - } - - private var product: Storefront.Product? { - didSet { - DispatchQueue.main.async { [weak self] in - self?.updateProductDetails() - } - } - } - - // MARK: Initializers - - convenience init(handle: String?) { - self.init(nibName: nil, bundle: nil) - - self.handle = handle - } - - // MARK: UIViewController Lifecycle - - override func viewDidLoad() { - super.viewDidLoad() - - navigationItem.rightBarButtonItem = UIBarButtonItem( - barButtonSystemItem: .refresh, - target: self, action: #selector(reloadProduct) - ) - - navigationItem.leftBarButtonItem = UIBarButtonItem( - image: UIImage(systemName: "cart"), - style: .plain, - target: self, action: #selector(openCart)) - - if #available(iOS 15.0, *) { - addToCartButton.configurationUpdateHandler = { - $0.configuration?.showsActivityIndicator = self.loading - } - } - - if let handle = handle { - getProductByHandle(handle) - } else { - reloadProduct() - } - } - - private func rerender() { - if #available(iOS 15.0, *) { - addToCartButton.configurationUpdateHandler = { - $0.configuration?.showsActivityIndicator = self.loading - } - } - updateProductDetails() - } - - private func setLoading(_ state: Bool) { - if state { - addToCartButton.isEnabled = false - loading = true - } else { - addToCartButton.isEnabled = true - loading = false - } - } - - private func setProduct(_ product: Storefront.Product?) { - if let product = product { - self.product = product - self.handle = product.handle - } - } - - // MARK: Actions - - @IBAction func addToCart() { - if let variant = product?.variants.nodes.first { - self.setLoading(true) - addToCartButton.isEnabled = false - let start = Date() - CartManager.shared.addItem(variant: variant.id) { [weak self] in - let diff = Date().timeIntervalSince(start) - let message = "Added item to cart in \(String(format: "%.0f", diff * 1000))ms" - ShopifyCheckoutSheetKit.configuration.logger.log(message) - self?.setLoading(false) - } - } - } - - public func getProductByHandle(_ handle: String) { - let context = Storefront.InContextDirective(country: Storefront.CountryCode.inferRegion()) - let query = Storefront.buildQuery(inContext: context) { $0 - .products(first: 1, query: handle) { $0 - .nodes { $0 - .id() - .title() - .handle() - .description() - .vendor() - .featuredImage { $0 - .url() - } - .variants(first: 1) { $0 - .nodes { $0 - .id() - .title() - .availableForSale() - .price { $0 - .amount() - .currencyCode() - } - } - } - } - } - } - - StorefrontClient.shared.execute(query: query) { [weak self] result in - self?.setLoading(false) - if case .success(let query) = result { - self?.setProduct(query.products.nodes.first) - } - } - } - - @IBAction private func reloadProduct() { - let query = Storefront.buildQuery(inContext: Storefront.InContextDirective(country: Storefront.CountryCode.inferRegion())) { $0 - .products(first: 250) { $0 - .nodes { $0 - .id() - .title() - .handle() - .description() - .vendor() - .featuredImage { $0 - .url() - } - .variants(first: 1) { $0 - .nodes { $0 - .id() - .title() - .availableForSale() - .price { $0 - .amount() - .currencyCode() - } - } - } - } - } - } - - StorefrontClient.shared.execute(query: query) { [weak self] result in - self?.setLoading(false) - if case .success(let query) = result { - self?.setProduct(query.products.nodes.randomElement()) - } - } - } - - @IBAction private func openCart() { - let cartViewController = CartViewController() - - if #available(iOS 13.0, *) { - cartViewController.modalPresentationStyle = .automatic - } else { - cartViewController.modalPresentationStyle = .overFullScreen - } - present(cartViewController, animated: true, completion: nil) - } - - // MARK: Private - - private func updateProductDetails() { - guard let product = self.product else { return } - - titleLabel.text = product.title - variantLabel.text = product.vendor - descriptionLabel.text = product.description - - self.navigationItem.title = product.title - - if let featuredImageURL = product.featuredImage?.url { - image.load(url: featuredImageURL) - } - - if let variant = product.variants.nodes.first { - if #available(iOS 15.0, *) { - - if variant.availableForSale { - addToCartButton.configuration? - .subtitle = variant.price.formattedString() - } else { - addToCartButton.configuration? - .subtitle = "Out of stock" - addToCartButton.isEnabled = false - } - } - } - } -} diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/ProductViewController.xib b/Samples/MobileBuyIntegration/MobileBuyIntegration/ProductViewController.xib deleted file mode 100644 index 4b4efb6d..00000000 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/ProductViewController.xib +++ /dev/null @@ -1,119 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/SceneDelegate.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/SceneDelegate.swift index 75d7cf12..5945566f 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/SceneDelegate.swift +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/SceneDelegate.swift @@ -24,60 +24,106 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SO import UIKit import SwiftUI import ShopifyCheckoutSheetKit - +import Combine + +/// A SceneDelgate is a bit of a legacy concept since the introduction of the SwiftUI App Lifecycle in iOS 14. +/// This implementation can updated to use SwiftUI's @main attribute like so: +/// +/// @main +/// struct MySwiftUIApp: App { +/// var body: some Scene { +/// WindowGroup { +/// ContentView() +/// } +/// } +/// } class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? - var cartController: CartViewController? - var productController: ProductViewController? - - func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) { - guard let windowScene = (scene as? UIWindowScene) else { return } - - let tabBarController = UITabBarController() - - /// Catalog - productController = ProductViewController() - productController?.tabBarItem.image = UIImage(systemName: "books.vertical") - productController?.tabBarItem.title = "Browse" - productController?.navigationItem.title = "Product details" - - /// Cart - cartController = CartViewController() - cartController?.tabBarItem.image = UIImage(systemName: "cart") - cartController?.tabBarItem.title = "Cart" - cartController?.navigationItem.title = "Cart" - - tabBarController.viewControllers = [ - UINavigationController( - rootViewController: productController! - ), - UINavigationController( - rootViewController: cartController! - ) - ] - - if #available(iOS 15.0, *) { - let settingsController = UIHostingController(rootView: SettingsView(appConfiguration: appConfiguration)) - settingsController.tabBarItem.image = UIImage(systemName: "gearshape.2") - settingsController.tabBarItem.title = "Settings" - - tabBarController.viewControllers?.append(UINavigationController( - rootViewController: settingsController - )) - } + public static var cartController = CheckoutViewHostingController(rootView: CartView()) + var productController: ProductView? + var productGrid: ProductGrid? - let window = UIWindow(windowScene: windowScene) - window.rootViewController = tabBarController - window.makeKeyAndVisible() + var cancellables: Set = [] - NotificationCenter.default.addObserver(self, selector: #selector(colorSchemeChanged), name: .colorSchemeChanged, object: nil) + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) { + guard let windowScene = (scene as? UIWindowScene) else { return } - window.overrideUserInterfaceStyle = ShopifyCheckoutSheetKit.configuration.colorScheme.userInterfaceStyle + let tabBarController = UITabBarController() - self.window = window - } + /// Branding Logo + /// TODO: Fetch this from the Storefront API for the configured storefront + let logoImageView = UIImageView(image: UIImage(named: "logo")) + logoImageView.contentMode = .scaleAspectFit + logoImageView.widthAnchor.constraint(equalToConstant: 90).isActive = true + logoImageView.heightAnchor.constraint(equalToConstant: 50).isActive = true + + /// Catalog grid view + productGrid = ProductGrid() + let productGridController = UIHostingController(rootView: productGrid) + productGridController.tabBarItem.image = UIImage(systemName: "square.grid.2x2") + productGridController.tabBarItem.title = "Catalog" + productGridController.navigationItem.titleView = logoImageView + + /// Product Gallery + let productView = ProductGalleryView() + let productGalleryController = UIHostingController(rootView: productView) + productGalleryController.tabBarItem.image = UIImage(systemName: "appwindow.swipe.rectangle") + productGalleryController.tabBarItem.title = "Products" + productGalleryController.navigationItem.titleView = logoImageView + + /// Cart + SceneDelegate.cartController.tabBarItem.image = UIImage(systemName: "cart") + SceneDelegate.cartController.tabBarItem.title = "Cart" + SceneDelegate.cartController.navigationItem.title = "Cart" + + subscribeToCartUpdates() + + tabBarController.viewControllers = [ + /// Catalog grid screen + UINavigationController(rootViewController: productGridController), + + /// Product gallery screen + UINavigationController(rootViewController: productGalleryController), + + /// Cart screen + UINavigationController(rootViewController: SceneDelegate.cartController) + ] + + if #available(iOS 15.0, *) { + let settingsController = UIHostingController(rootView: SettingsView(appConfiguration: appConfiguration)) + settingsController.tabBarItem.image = UIImage(systemName: "gearshape.2") + settingsController.tabBarItem.title = "Settings" + + tabBarController.viewControllers?.append(UINavigationController( + rootViewController: settingsController + )) + } + + let window = UIWindow(windowScene: windowScene) + window.rootViewController = tabBarController + window.makeKeyAndVisible() + window.tintColor = ColorPalette.primaryColor + + // Set up Notification and interface style + NotificationCenter.default.addObserver(self, selector: #selector(colorSchemeChanged), name: .colorSchemeChanged, object: nil) + window.overrideUserInterfaceStyle = ShopifyCheckoutSheetKit.configuration.colorScheme.userInterfaceStyle + + self.window = window + } + + private func subscribeToCartUpdates() { + CartManager.shared.$cart + .sink { cart in + if let cart = cart, cart.lines.nodes.count > 0 { + SceneDelegate.cartController.tabBarItem.badgeValue = "\(cart.totalQuantity)" + } else { + SceneDelegate.cartController.tabBarItem.badgeValue = nil + } + } + .store(in: &cancellables) + } func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { guard @@ -115,9 +161,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } private func presentCheckout(_ url: URL) { - if let viewController = cartController { - ShopifyCheckoutSheetKit.present(checkout: url, from: viewController, delegate: viewController) - } + ShopifyCheckoutSheetKit.present(checkout: url, from: SceneDelegate.cartController, delegate: SceneDelegate.cartController) } private func getRootViewController() -> UINavigationController? { @@ -138,9 +182,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } func navigateToProduct(with handle: String) { - if let pdp = self.productController { - pdp.getProductByHandle(handle) - } + ProductCache.shared.getProduct(handle: handle, completion: { _ in }) if let tabBarVC = window?.rootViewController as? UITabBarController { tabBarVC.selectedIndex = 0 @@ -169,43 +211,36 @@ extension Configuration.ColorScheme { } } -public struct StorefrontURL { - public let url: URL - - private let slug = "([\\w\\d_-]+)" - - init(from url: URL) { - self.url = url +class CheckoutViewHostingController: UIHostingController, CheckoutDelegate { + override init(rootView: CartView) { + super.init(rootView: rootView) } - public func isThankYouPage() -> Bool { - return url.path.range(of: "/thank[-_]you", options: .regularExpression) != nil + @objc required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") } - public func isCheckout() -> Bool { - return url.path.contains("/checkout") - } + // Implementing CheckoutDelegate methods + func checkoutDidComplete() { + CartManager.shared.resetCart() + } - public func isCart() -> Bool { - return url.path.contains("/cart") - } + func checkoutDidCancel() { + dismiss(animated: true, completion: nil) + } - public func isCollection() -> Bool { - return url.path.range(of: "/collections/\(slug)", options: .regularExpression) != nil - } + func checkoutDidFail(error: Error) { + print("Checkout failed: \(error.localizedDescription)") + // Handle checkout failure logic + } - public func isProduct() -> Bool { - return url.path.range(of: "/products/\(slug)", options: .regularExpression) != nil + func checkoutDidFail(error: ShopifyCheckoutSheetKit.CheckoutError) { + print("Checkout failed: \(error.localizedDescription)") + // Handle checkout failure logic } - public func getProductSlug() -> String? { - guard isProduct() else { return nil } - - let pattern = "/products/([\\w_-]+)" - if let match = url.path.range(of: pattern, options: .regularExpression, range: nil, locale: nil) { - let slug = url.path[match].components(separatedBy: "/").last - return slug - } - return nil + func checkoutDidEmitWebPixelEvent(event: ShopifyCheckoutSheetKit.PixelEvent) { + print("Checkout pixel event") + // Handle pixel event } } diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/SettingsViewController.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/SettingsViewController.swift index ec90226d..99bfc62b 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/SettingsViewController.swift +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/SettingsViewController.swift @@ -81,14 +81,14 @@ struct SettingsView: View { Section(header: Text("Version")) { HStack { - Text("App version") + Text("Sample app version") Spacer() Text(currentVersion()) .font(.system(size: 14)) .foregroundStyle(.gray) } HStack { - Text("SDK version") + Text("Checkout Sheet Kit version") Spacer() Text(ShopifyCheckoutSheetKit.version) .font(.system(size: 14)) @@ -154,7 +154,7 @@ extension Configuration.ColorScheme { case .automatic: return "Automatic" case .web: - return "Web Browser" + return "Web" } } @@ -170,7 +170,7 @@ extension Configuration.ColorScheme { var backgroundColor: UIColor { switch self { case .web: - return UIColor(red: 0.94, green: 0.94, blue: 0.91, alpha: 1.00) + return ColorPalette.backgroundColor default: return .systemBackground } diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/StorefrontClient.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/StorefrontClient.swift index 014a01d4..143feb00 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/StorefrontClient.swift +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/StorefrontClient.swift @@ -40,6 +40,9 @@ class StorefrontClient { } client = Graph.Client(shopDomain: domain, apiKey: token) + + /// Set the caching policy (1 hour) + client.cachePolicy = .cacheFirst(expireIn: 60 * 60) } typealias QueryResultHandler = (Result) -> Void @@ -70,3 +73,44 @@ class StorefrontClient { task.resume() } } + +public struct StorefrontURL { + public let url: URL + + private let slug = "([\\w\\d_-]+)" + + init(from url: URL) { + self.url = url + } + + public func isThankYouPage() -> Bool { + return url.path.range(of: "/thank[-_]you", options: .regularExpression) != nil + } + + public func isCheckout() -> Bool { + return url.path.contains("/checkout") + } + + public func isCart() -> Bool { + return url.path.contains("/cart") + } + + public func isCollection() -> Bool { + return url.path.range(of: "/collections/\(slug)", options: .regularExpression) != nil + } + + public func isProduct() -> Bool { + return url.path.range(of: "/products/\(slug)", options: .regularExpression) != nil + } + + public func getProductSlug() -> String? { + guard isProduct() else { return nil } + + let pattern = "/products/([\\w_-]+)" + if let match = url.path.range(of: pattern, options: .regularExpression, range: nil, locale: nil) { + let slug = url.path[match].components(separatedBy: "/").last + return slug + } + return nil + } +}