From 2f4eba0aeedc06b15eeb6aabce9d317d0711e446 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Thu, 14 Nov 2024 22:10:07 +0000 Subject: [PATCH] Rewrite everything --- .../project.pbxproj | 34 +- .../MobileBuyIntegration/AppDelegate.swift | 16 +- .../logo.imageset/Contents.json | 21 ++ .../Assets.xcassets/logo.imageset/plant.png | Bin 0 -> 44912 bytes .../MobileBuyIntegration/Cart.swift | 234 ++++++++++++++ .../MobileBuyIntegration/CartManager.swift | 23 +- .../CartViewController.swift | 3 - .../CartViewController.xib | 93 ------ .../MobileBuyIntegration/Catalog.swift | 140 +++++++++ .../Localizable.xcstrings | 28 +- .../MobileBuyIntegration/MoneyV2+Format.swift | 6 +- .../MobileBuyIntegration/ProductView.swift | 293 ++++++++++++++++++ .../ProductViewController.swift | 246 --------------- .../ProductViewController.xib | 119 ------- .../MobileBuyIntegration/SceneDelegate.swift | 199 +++++++----- .../SettingsViewController.swift | 8 +- .../StorefrontClient.swift | 45 +++ 17 files changed, 940 insertions(+), 568 deletions(-) create mode 100644 Samples/MobileBuyIntegration/MobileBuyIntegration/Assets.xcassets/logo.imageset/Contents.json create mode 100644 Samples/MobileBuyIntegration/MobileBuyIntegration/Assets.xcassets/logo.imageset/plant.png create mode 100644 Samples/MobileBuyIntegration/MobileBuyIntegration/Cart.swift delete mode 100644 Samples/MobileBuyIntegration/MobileBuyIntegration/CartViewController.xib create mode 100644 Samples/MobileBuyIntegration/MobileBuyIntegration/Catalog.swift create mode 100644 Samples/MobileBuyIntegration/MobileBuyIntegration/ProductView.swift delete mode 100644 Samples/MobileBuyIntegration/MobileBuyIntegration/ProductViewController.swift delete mode 100644 Samples/MobileBuyIntegration/MobileBuyIntegration/ProductViewController.xib 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 0000000000000000000000000000000000000000..b88ca1694e1910b49847de30001620325054b4c7 GIT binary patch literal 44912 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 literal 0 HcmV?d00001 diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/Cart.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/Cart.swift new file mode 100644 index 00000000..a133cf0a --- /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..f216cdac 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..d6f40597 --- /dev/null +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/Catalog.swift @@ -0,0 +1,140 @@ +/* +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..df6bc5f2 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/SceneDelegate.swift +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/SceneDelegate.swift @@ -24,60 +24,111 @@ 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 +166,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 +187,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } func navigateToProduct(with handle: String) { - if let pdp = self.productController { - pdp.getProductByHandle(handle) - } + ProductCache.shared.getProduct(handle: handle, completion: { product in }) if let tabBarVC = window?.rootViewController as? UITabBarController { tabBarVC.selectedIndex = 0 @@ -169,43 +216,37 @@ 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..68d8fd8c 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,45 @@ 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 + } +}