diff --git a/LeCountdown.xcodeproj/project.pbxproj b/LeCountdown.xcodeproj/project.pbxproj index b045c52..383e018 100644 --- a/LeCountdown.xcodeproj/project.pbxproj +++ b/LeCountdown.xcodeproj/project.pbxproj @@ -165,6 +165,7 @@ C4E5D67829B88BB5008E7465 /* DelaySoundPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E5D67329B88734008E7465 /* DelaySoundPlayer.swift */; }; C4E5D67A29B8C5A1008E7465 /* VolumeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E5D67929B8C5A1008E7465 /* VolumeView.swift */; }; C4E5D67C29B8D4A5008E7465 /* Low_Tom_Disto_Earth.wav in Resources */ = {isa = PBXBuildFile; fileRef = C4E5D67B29B8D4A5008E7465 /* Low_Tom_Disto_Earth.wav */; }; + C4E5D68029B8FD93008E7465 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E5D67F29B8FD93008E7465 /* Store.swift */; }; C4F8B1532987FE6F005C86A5 /* LaunchWidgetLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = C438C7D72981216200BF3EF9 /* LaunchWidgetLiveActivity.swift */; }; C4F8B15729891271005C86A5 /* Conductor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F8B15629891271005C86A5 /* Conductor.swift */; }; C4F8B15929891528005C86A5 /* forest_stream.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = C4F8B15829891528005C86A5 /* forest_stream.mp3 */; }; @@ -379,6 +380,7 @@ C4E5D67329B88734008E7465 /* DelaySoundPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DelaySoundPlayer.swift; sourceTree = ""; }; C4E5D67929B8C5A1008E7465 /* VolumeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeView.swift; sourceTree = ""; }; C4E5D67B29B8D4A5008E7465 /* Low_Tom_Disto_Earth.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = Low_Tom_Disto_Earth.wav; sourceTree = ""; }; + C4E5D67F29B8FD93008E7465 /* Store.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = ""; }; C4F8B15629891271005C86A5 /* Conductor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Conductor.swift; sourceTree = ""; }; C4F8B15829891528005C86A5 /* forest_stream.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = forest_stream.mp3; sourceTree = ""; }; C4F8B15E298961A7005C86A5 /* ReorderableForEach.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReorderableForEach.swift; sourceTree = ""; }; @@ -679,6 +681,7 @@ isa = PBXGroup; children = ( C4BA2B56299FFA4F00CB4FBA /* AppGuard.swift */, + C4E5D67F29B8FD93008E7465 /* Store.swift */, C4BA2B5E299FFC8300CB4FBA /* StoreView.swift */, ); path = Subscription; @@ -997,6 +1000,7 @@ C498E5A1298D543900E90DE0 /* LiveTimer.swift in Sources */, C4BA2B6329A3C34600CB4FBA /* Stat.swift in Sources */, C438C80F29828B8600BF3EF9 /* ActivitiesView.swift in Sources */, + C4E5D68029B8FD93008E7465 /* Store.swift in Sources */, C4F8B187298AC234005C86A5 /* Activity+CoreDataProperties.swift in Sources */, C438C80D2982847300BF3EF9 /* CoreDataRequests.swift in Sources */, C4742B5F2984205000D5D950 /* ViewModifiers.swift in Sources */, diff --git a/LeCountdown/Subscription/AppGuard.swift b/LeCountdown/Subscription/AppGuard.swift index 4e10656..b26d1c3 100644 --- a/LeCountdown/Subscription/AppGuard.swift +++ b/LeCountdown/Subscription/AppGuard.swift @@ -1,6 +1,6 @@ // -// Guard.swift -// Poker Analytics 6 +// AppGuard.swift +// LeCountdown // // Created by Laurent Morvillier on 20/04/2022. // diff --git a/LeCountdown/Subscription/Store.swift b/LeCountdown/Subscription/Store.swift new file mode 100644 index 0000000..868272a --- /dev/null +++ b/LeCountdown/Subscription/Store.swift @@ -0,0 +1,111 @@ +// +// Store.swift +// Poker Analytics 6 +// +// Created by Laurent Morvillier on 20/04/2022. +// + +import Foundation +import StoreKit + +//public enum StoreError: Error { +// case failedVerification +//} + +protocol StoreDelegate { + func productsReceived() + func errorDidOccur(error: Error) +} + +//extension Notification.Name { +// static let StoreEventHappened = Notification.Name("storePurchaseSucceeded") +//} + +class Store: ObservableObject { + + @Published private(set) var products: [Product] = [] + + @Published private(set) var purchasedTransactions = Set() + + var delegate: StoreDelegate? = nil + + var updateListenerTask: Task? = nil + + init() { + + 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 currentPlan = AppGuard.main.currentPlan + let identifiers: [String] = [StorePlan.unlimited.rawValue] + products = try await Product.products(for: identifiers) + Logger.log("products = \(self.products.count)") + self.delegate?.productsReceived() + } 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 AppGuard.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) async throws -> StoreKit.Transaction? { + // Begin a purchase. + let result = try await product.purchase() + + switch result { + case .success(let verificationResult): + + let transaction = try await AppGuard.main.processTransactionResult(verificationResult) + + // Always finish a transaction. + await transaction.finish() + + DispatchQueue.main.asyncAfter(deadline: DispatchTime(uptimeNanoseconds: 200000), execute: { + NotificationCenter.default.post(name: Notification.Name.StoreEventHappened, object: nil) + }) + + return transaction + case .userCancelled, .pending: + return nil + default: + return nil + } + } + +} diff --git a/LeCountdown/Subscription/StoreView.swift b/LeCountdown/Subscription/StoreView.swift index 32b3ec4..e27df01 100644 --- a/LeCountdown/Subscription/StoreView.swift +++ b/LeCountdown/Subscription/StoreView.swift @@ -6,15 +6,138 @@ // import SwiftUI +import StoreKit -struct StoreView: View { +fileprivate enum Feature: Int, Identifiable, CaseIterable { + case unlimitedTimers + case allSounds + case longSounds + case allStats + + var id: Int { self.rawValue } + + var localizedString: String { + switch self { + case .unlimitedTimers: return NSLocalizedString("Unlimited timers and stopwatches", comment: "") + case .allSounds: return NSLocalizedString("Access all the sound library", comment: "") + case .longSounds: return NSLocalizedString("Access long version of sounds", comment: "") + case .allStats: return NSLocalizedString("See all your activities in detail", comment: "") + } + } + +} + +struct StoreView: View, StoreDelegate { + + @StateObject var store: Store = Store() + + @State var errorMessage: String? = nil + + init() { + + self.store.delegate = self + +// if SKPaymentQueue.canMakePayments() { +// self._store = Store(delegate: self) +// } else { +// self.errorMessage = NSLocalizedString("In-app purchase disabled", comment: "") +// } + + } + + var body: some View { + + if let product = self.store.products.first { + + PlanView(productName: product.displayName, + price: product.displayPrice) { + self._purchase() + } + + } else { + ProgressView() + .progressViewStyle(.circular) + } + + } + + fileprivate func _purchase() { + + if let product = self.store.products.first { + Task { + try await store.purchase(product) + } + } + + } + + // MARK: - StoreDelegate + + func productsReceived() { + + } + + func errorDidOccur(error: Error) { + + } + +} + +struct PlanView: View { + + var productName: String + var price: String + + var actionHandler: () -> () + var body: some View { - Text("Hello Store!") + + VStack { + + Text(productName) + .font(.title) + .fontWeight(.bold) + .padding() + + Group { + ForEach(Feature.allCases) { feature in + HStack { + Text(feature.localizedString) + Spacer() + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.accentColor) + }.fontWeight(.medium) + } + } + .padding(.horizontal, 24.0) + .padding(.vertical, 2.0) + + Spacer() + + Button { + self.actionHandler() + } label: { + HStack { + Spacer() + VStack { + Text("Purchase") + Text(self.price).font(.title3) + } + Spacer() + } + } + .font(.title) + .buttonStyle(.borderedProminent).fontWeight(.medium) + + }.padding() } + } struct StoreView_Previews: PreviewProvider { static var previews: some View { - StoreView() + PlanView(productName: "Pro version", + price: "$0.99 / month", + actionHandler: {}) } } diff --git a/LeCountdown/fr.lproj/Localizable.strings b/LeCountdown/fr.lproj/Localizable.strings index 1e475df..cf7271c 100644 --- a/LeCountdown/fr.lproj/Localizable.strings +++ b/LeCountdown/fr.lproj/Localizable.strings @@ -246,3 +246,4 @@ "Timer %@ started" = "Le minuteur %@ a démarré"; "The timer has not been found in the app" = "Le minuteur n'a pas été trouvé dans l'app"; +"In-app purchase disabled" = "Les achats in-app sont désactivés. Veuillez les activer si vous souhaitez vous abonner";