diff --git a/PadelClub.xcodeproj/project.pbxproj b/PadelClub.xcodeproj/project.pbxproj index e1d853a..e18d6d0 100644 --- a/PadelClub.xcodeproj/project.pbxproj +++ b/PadelClub.xcodeproj/project.pbxproj @@ -23,6 +23,11 @@ C4A47D7B2B73C0F900ADC637 /* TournamentV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D7A2B73C0F900ADC637 /* TournamentV2.swift */; }; C4A47D7D2B73CDC300ADC637 /* ClubV1.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D7C2B73CDC300ADC637 /* ClubV1.swift */; }; C4A47D872B7BA36D00ADC637 /* UserCreationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D862B7BA36D00ADC637 /* UserCreationView.swift */; }; + C4A47D8A2B7BBB6500ADC637 /* SubscriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D892B7BBB6500ADC637 /* SubscriptionView.swift */; }; + C4A47D902B7BBBEC00ADC637 /* StoreManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D8D2B7BBBEC00ADC637 /* StoreManager.swift */; }; + C4A47D912B7BBBEC00ADC637 /* Guard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D8E2B7BBBEC00ADC637 /* Guard.swift */; }; + C4A47D922B7BBBEC00ADC637 /* StoreItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D8F2B7BBBEC00ADC637 /* StoreItem.swift */; }; + C4A47D9F2B7D0BCE00ADC637 /* StepperView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D9E2B7D0BCE00ADC637 /* StepperView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -76,6 +81,11 @@ C4A47D7A2B73C0F900ADC637 /* TournamentV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TournamentV2.swift; sourceTree = ""; }; C4A47D7C2B73CDC300ADC637 /* ClubV1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClubV1.swift; sourceTree = ""; }; C4A47D862B7BA36D00ADC637 /* UserCreationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreationView.swift; sourceTree = ""; }; + C4A47D892B7BBB6500ADC637 /* SubscriptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionView.swift; sourceTree = ""; }; + C4A47D8D2B7BBBEC00ADC637 /* StoreManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StoreManager.swift; sourceTree = ""; }; + C4A47D8E2B7BBBEC00ADC637 /* Guard.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Guard.swift; sourceTree = ""; }; + C4A47D8F2B7BBBEC00ADC637 /* StoreItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StoreItem.swift; sourceTree = ""; }; + C4A47D9E2B7D0BCE00ADC637 /* StepperView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepperView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -193,6 +203,8 @@ C4A47D722B72881500ADC637 /* Views */ = { isa = PBXGroup; children = ( + C4A47DA02B7D0BD800ADC637 /* Components */, + C4A47D882B7BBB5000ADC637 /* Subscription */, C4A47D852B7BA33F00ADC637 /* User */, C425D4022B6D249D002A7B48 /* ContentView.swift */, C4A47D732B72881F00ADC637 /* ClubView.swift */, @@ -218,6 +230,25 @@ path = User; sourceTree = ""; }; + C4A47D882B7BBB5000ADC637 /* Subscription */ = { + isa = PBXGroup; + children = ( + C4A47D892B7BBB6500ADC637 /* SubscriptionView.swift */, + C4A47D8E2B7BBBEC00ADC637 /* Guard.swift */, + C4A47D8D2B7BBBEC00ADC637 /* StoreManager.swift */, + C4A47D8F2B7BBBEC00ADC637 /* StoreItem.swift */, + ); + path = Subscription; + sourceTree = ""; + }; + C4A47DA02B7D0BD800ADC637 /* Components */ = { + isa = PBXGroup; + children = ( + C4A47D9E2B7D0BCE00ADC637 /* StepperView.swift */, + ); + path = Components; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -372,7 +403,10 @@ buildActionMask = 2147483647; files = ( C4A47D872B7BA36D00ADC637 /* UserCreationView.swift in Sources */, + C4A47D9F2B7D0BCE00ADC637 /* StepperView.swift in Sources */, + C4A47D8A2B7BBB6500ADC637 /* SubscriptionView.swift in Sources */, C4A47D742B72881F00ADC637 /* ClubView.swift in Sources */, + C4A47D902B7BBBEC00ADC637 /* StoreManager.swift in Sources */, C4A47D5A2B6D383C00ADC637 /* Tournament.swift in Sources */, C4A47D7B2B73C0F900ADC637 /* TournamentV2.swift in Sources */, C4A47D5E2B6D38EC00ADC637 /* DataStore.swift in Sources */, @@ -381,6 +415,8 @@ C425D4032B6D249D002A7B48 /* ContentView.swift in Sources */, C425D4012B6D249D002A7B48 /* PadelClubApp.swift in Sources */, C4A47D772B73789100ADC637 /* TournamentV1.swift in Sources */, + C4A47D922B7BBBEC00ADC637 /* StoreItem.swift in Sources */, + C4A47D912B7BBBEC00ADC637 /* Guard.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/PadelClub/Views/Components/StepperView.swift b/PadelClub/Views/Components/StepperView.swift new file mode 100644 index 0000000..562cb2f --- /dev/null +++ b/PadelClub/Views/Components/StepperView.swift @@ -0,0 +1,64 @@ +// +// StepperView.swift +// PadelClub +// +// Created by Laurent Morvillier on 14/02/2024. +// + +import SwiftUI + + + +struct StepperView: View { + + @Binding var count: Int + + var minimum: Int? = nil + + var body: some View { + HStack { + Button(action: { + self._add() + }, label: { + Image(systemName: "plus.circle") + }) + Button(action: { + self._subtract() + }, label: { + Image(systemName: "minus.circle") + }) + + }.padding(4.0) + } + + fileprivate func _add() { + self.count += 1 + } + + fileprivate func _subtract() { + self.count -= 1 + if let minimum, self.count < minimum { + self.count = minimum + } + } + +} + +struct StepperTestView: View { + + @State var quantity: Int = 5 + + var body: some View { + Text(quantity.formatted()) + StepperView(count: self.$quantity, minimum: 0) + } + +} + +#Preview { + StepperTestView() +} + +#Preview { + StepperView(count: .constant(1)) +} diff --git a/PadelClub/Views/ContentView.swift b/PadelClub/Views/ContentView.swift index 671ab8b..dfec084 100644 --- a/PadelClub/Views/ContentView.swift +++ b/PadelClub/Views/ContentView.swift @@ -34,13 +34,19 @@ struct ContentView: View { } .toolbar(content: { ToolbarItem { - NavigationLink { UserCreationView() } label: { Image(systemName: "person.circle.fill") } - + } + + ToolbarItem { + NavigationLink { + SubscriptionView() + } label: { + Image(systemName: "tennisball.circle.fill") + } } }) .navigationTitle("Home") diff --git a/PadelClub/Views/Subscription/Guard.swift b/PadelClub/Views/Subscription/Guard.swift new file mode 100644 index 0000000..75f7e7a --- /dev/null +++ b/PadelClub/Views/Subscription/Guard.swift @@ -0,0 +1,137 @@ +// +// Guard.swift +// Poker Analytics 6 +// +// Created by Laurent Morvillier on 20/04/2022. +// + +import Foundation +import StoreKit +import LeStorage + +@available(iOS 15, *) +@objc class Guard : NSObject { + + static var main: Guard = Guard() + + @Published private(set) var purchasedTransactions = Set() + + var currentBestPlan: StoreKit.Transaction? = nil + + var updateListenerTask: Task? = nil + + override init() { + + super.init() + + self.updateListenerTask = self.listenForTransactions() + + Task { + do { + try await self.refreshPurchasedProducts() + } catch { + Logger.error(error) + } + } + } + + func refreshPurchasedProducts() async throws { + + // Iterate through the user's purchased products. + for await verificationResult in Transaction.currentEntitlements { + let transaction = try await self.processTransactionResult(verificationResult) + DispatchQueue.main.async { + NotificationCenter.default.post(name: Notification.Name.StoreEventHappened, object: nil) + } + await transaction.finish() + } + } + + func listenForTransactions() -> Task { + return Task.detached { + //Iterate through any transactions which didn't come from a direct call to `purchase()`. + for await result in Transaction.updates { + do { + let transaction = try self.checkVerified(result) + + //Deliver content to the user. + await self.updatePurchasedIdentifiers(transaction) + + //Always finish a transaction. + await transaction.finish() + } catch { + Logger.error(error) + //StoreKit has a receipt it can read but it failed verification. Don't deliver content to the user. + print("Transaction failed verification") + } + } + } + } + + func checkVerified(_ result: VerificationResult) throws -> T { + //Check if the transaction passes StoreKit verification. + switch result { + case .unverified: + //StoreKit has parsed the JWS but failed verification. Don't deliver content to the user. + throw StoreError.failedVerification + case .verified(let safe): + //If the transaction is verified, unwrap and return it. + return safe + } + } + + @MainActor + func updatePurchasedIdentifiers(_ transaction: StoreKit.Transaction) async { + if transaction.revocationDate == nil { + // If the App Store has not revoked the transaction, add it to the list of `purchasedIdentifiers`. + purchasedTransactions.insert(transaction) + } else { + // If the App Store has revoked this transaction, remove it from the list of `purchasedIdentifiers`. + purchasedTransactions.remove(transaction) + } + + self._updateBestPlan() + } + + func processTransactionResult(_ result: VerificationResult) async throws -> StoreKit.Transaction { + + let transaction = try checkVerified(result) + + // Deliver content to the user. + await updatePurchasedIdentifiers(transaction) + + return transaction + } + + var currentPlan: StoreItem { + + #if DEBUG + return .monthly + #else + if let currentBestPlan = self.currentBestPlan, let plan = StorePlan(rawValue: currentBestPlan.productID) { + return plan + } + if let vf = Preferences.verifiedTransaction(), + vf.expiryDate > Date(), vf.graceDate > Date(), + let plan = StorePlan(rawValue: vf.productId) { + return plan + } + return .none + #endif + } + + fileprivate func _updateBestPlan() { + + if let yearly = self.purchasedTransactions.first(where: { $0.productID == StoreItem.yearly.rawValue }) { + self.currentBestPlan = yearly + } else if let monthly = self.purchasedTransactions.first(where: { $0.productID == StoreItem.monthly.rawValue }) { + self.currentBestPlan = monthly + } else if let tournamentUnit = self.purchasedTransactions.first(where: { $0.productID == StoreItem.tournament.rawValue }) { + self.currentBestPlan = tournamentUnit + } else { + self.currentBestPlan = nil + } + + } + +} diff --git a/PadelClub/Views/Subscription/StoreItem.swift b/PadelClub/Views/Subscription/StoreItem.swift new file mode 100644 index 0000000..a2b2bcf --- /dev/null +++ b/PadelClub/Views/Subscription/StoreItem.swift @@ -0,0 +1,30 @@ +// +// StorePlan.swift +// Poker Analytics 6 +// +// Created by Laurent Morvillier on 22/04/2022. +// + +import Foundation + +enum StoreItem: String, Identifiable, CaseIterable { + case tournament = "app.padelclub.tournament.unit" + case monthly = "app.padelclub.tournament.monthly" + case yearly = "app.padelclub.tournament.yearly" + + var id: String { return self.rawValue } + + var title: String { return "Tournoi illimités" } + + var formattedPrice: String { return "119.99€ / an" } + + var price: Double { return 19.99 } + + var isUnit: Bool { + switch self { + case .tournament: return true + default: return false + } + } + +} diff --git a/PadelClub/Views/Subscription/StoreManager.swift b/PadelClub/Views/Subscription/StoreManager.swift new file mode 100644 index 0000000..377154e --- /dev/null +++ b/PadelClub/Views/Subscription/StoreManager.swift @@ -0,0 +1,129 @@ +// +// Store.swift +// Poker Analytics 6 +// +// Created by Laurent Morvillier on 20/04/2022. +// + +import Foundation +import StoreKit +import LeStorage + +public enum StoreError: Error { + case failedVerification +} + +protocol StoreDelegate { + func productsReceived(products: [Product]) + func errorDidOccur(error: Error) +} + +extension Notification.Name { + static let StoreEventHappened = Notification.Name("storePurchaseSucceeded") +} + +@available(iOS 15, *) +class StoreManager { + +// @Published private(set) var products: [Product] = [] + + @Published private(set) var purchasedTransactions = Set() + + var delegate: StoreDelegate? = nil + + var updateListenerTask: Task? = nil + + init(delegate: StoreDelegate?) { + + self.delegate = delegate + self.updateListenerTask = listenForTransactions() + + Task { + //Initialize the store by starting a product request. + await self.requestProducts() + } + } + + deinit { + self.updateListenerTask?.cancel() + } + +// func indexOf(identifier: String) -> Int? { +// return self.products.map { $0.id }.firstIndex(of: identifier) +// } + + @MainActor + func requestProducts() async { + do { + let identifiers: [String] = StoreItem.allCases.map { $0.rawValue } + var products = try await Product.products(for: identifiers) + products = products.sorted { p1, p2 in + return p2.price > p1.price + } + + Logger.log("products = \(products.count)") + self.delegate?.productsReceived(products: products) + } catch { + self.delegate?.errorDidOccur(error: error) + Logger.error(error) + } + } + + func listenForTransactions() -> Task { + return Task.detached { + //Iterate through any transactions which didn't come from a direct call to `purchase()`. + for await result in Transaction.updates { + do { + + let transaction = try await Guard.main.processTransactionResult(result) + + //Always finish a transaction. + await transaction.finish() + } catch { + self.delegate?.errorDidOccur(error: error) + + //StoreKit has a receipt it can read but it failed verification. Don't deliver content to the user. + print("Transaction failed verification") + } + } + } + } + + func purchase(_ product: Product, quantity: Int? = nil) async throws -> StoreKit.Transaction? { + Logger.log("Store purchase started...") + + var options: Set = [] + let uuid: UUID = try Store.main.currentUserUUID() + let tokenOption = Product.PurchaseOption.appAccountToken(uuid) + options.insert(tokenOption) + + if let quantity = quantity { + let quantityOption = Product.PurchaseOption.quantity(quantity) + options.insert(quantityOption) + } + + let result = try await product.purchase(options: options) + + Logger.log("Store purchase ended with result: \(result)") + + switch result { + case .success(let verificationResult): + + let transaction = try await Guard.main.processTransactionResult(verificationResult) + + // Always finish a transaction. + await transaction.finish() + + DispatchQueue.main.asyncAfter(deadline: DispatchTime(uptimeNanoseconds: 100000), execute: { + NotificationCenter.default.post(name: Notification.Name.StoreEventHappened, object: nil) + }) + + return transaction + case .userCancelled, .pending: + return nil + default: + return nil + } + } + +} diff --git a/PadelClub/Views/Subscription/SubscriptionView.swift b/PadelClub/Views/Subscription/SubscriptionView.swift new file mode 100644 index 0000000..dd011de --- /dev/null +++ b/PadelClub/Views/Subscription/SubscriptionView.swift @@ -0,0 +1,142 @@ +// +// SubscriptionView.swift +// PadelClub +// +// Created by Laurent Morvillier on 13/02/2024. +// + +import SwiftUI +import StoreKit + +class SubscriptionModel: ObservableObject, StoreDelegate { + + var storeManager: StoreManager? = nil + @Published var error: Error? = nil + + @Published var isLoading: Bool = false + @Published var selectedProduct: Product? = nil + @Published var quantity: Int = 1 + @Published var products: [Product] = [] + + func load() { + self.isLoading = true + self.storeManager = StoreManager(delegate: self) + } + + func productsReceived(products: [Product]) { + self.isLoading = false + self.products = products + } + + func errorDidOccur(error: Error) { + self.isLoading = false + self.error = error + } + + func purchase() { + if let product = self.selectedProduct { + Task { + let quantity: Int? = (product.id == StoreItem.tournament.rawValue) ? self.quantity : nil + let _ = try await self.storeManager?.purchase(product, quantity: quantity) + } + } + } + +} + +struct SubscriptionView: View { + + @ObservedObject var model: SubscriptionModel = SubscriptionModel() + + @State var test = 5 + + var body: some View { + Form { + + Section { + Text(test.formatted()) + StepperView(count: self.$test) + } + + List { + ForEach(self.model.products) { product in + if let item = StoreItem(rawValue: product.id) { + ProductView(product: product, + item: item, + quantity: self.$model.quantity) + .onTapGesture { + self.model.selectedProduct = product + } + } else { + Text("Missing item") + } + } + } + + Section { + Button { + self._purchase() + } label: { + VStack { + Text("Purchase") + if let product = self.model.selectedProduct { + Text(product.displayName) + Text(product.displayPrice) + } + } + } + } + + } + .navigationTitle("Subscriptions") + .onAppear { + self._load() + } + } + + fileprivate func _purchase() { + self.model.purchase() + } + + fileprivate func _load() { + + } + +} + +struct ProductView: View { + + var product: Product + var item: StoreItem + @Binding var quantity: Int + + var body: some View { + HStack { + Image(systemName: "star.circle.fill") + .font(.title) + .foregroundColor(.blue) + VStack(alignment: .leading) { + if item.isUnit == true { + Text("\(self.quantity) \(item.title)") + let total = item.price * Double(self.quantity) + + Text(total.formatted()).foregroundColor(.blue) + + StepperView(count: self.$quantity, minimum: 1) + } else { + Text(item.title) + Text(item.formattedPrice).foregroundColor(.blue) + } + } + Spacer() + Image(systemName: "checkmark").foregroundColor(.blue) + } + } + +} + +#Preview { + NavigationStack { + SubscriptionView() + } +}