diff --git a/Tickmate/Tickmate.xcodeproj/project.pbxproj b/Tickmate/Tickmate.xcodeproj/project.pbxproj index fd3ebb5..456d2ef 100644 --- a/Tickmate/Tickmate.xcodeproj/project.pbxproj +++ b/Tickmate/Tickmate.xcodeproj/project.pbxproj @@ -37,6 +37,7 @@ 59F0980225E081CC00667971 /* TicksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59F0980125E081CC00667971 /* TicksView.swift */; }; 59F3EDBB25FC46770009CDEC /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 59F3EDBA25FC46770009CDEC /* Introspect */; }; 59F3EDC225FC4F270009CDEC /* ViewControllerContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59F3EDC125FC4F270009CDEC /* ViewControllerContainer.swift */; }; + 59F6170926671C2100CC78E1 /* AlertItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59F6170826671C2100CC78E1 /* AlertItem.swift */; }; 59F6EA9D25E369830069DF40 /* Tickmate+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59F6EA9C25E369830069DF40 /* Tickmate+Convenience.swift */; }; 59F6EAA425E36D510069DF40 /* TickController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59F6EAA325E36D510069DF40 /* TickController.swift */; }; 59F6EAA725E36D680069DF40 /* TrackController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59F6EAA625E36D680069DF40 /* TrackController.swift */; }; @@ -81,6 +82,7 @@ 59EF633625E5DC93003E7259 /* TextWithCaption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextWithCaption.swift; sourceTree = ""; }; 59F0980125E081CC00667971 /* TicksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TicksView.swift; sourceTree = ""; }; 59F3EDC125FC4F270009CDEC /* ViewControllerContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewControllerContainer.swift; sourceTree = ""; }; + 59F6170826671C2100CC78E1 /* AlertItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertItem.swift; sourceTree = ""; }; 59F6EA9C25E369830069DF40 /* Tickmate+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Tickmate+Convenience.swift"; sourceTree = ""; }; 59F6EAA325E36D510069DF40 /* TickController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TickController.swift; sourceTree = ""; }; 59F6EAA625E36D680069DF40 /* TrackController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackController.swift; sourceTree = ""; }; @@ -138,6 +140,7 @@ 592CE887264F19B400668684 /* View+Centered.swift */, 5939239B2654CE050085DABD /* PageView.swift */, 5939239D2655B6C90085DABD /* Animation+Push.swift */, + 59F6170826671C2100CC78E1 /* AlertItem.swift */, ); path = "Supplementary Views"; sourceTree = ""; @@ -330,6 +333,7 @@ 59603D452666C85A001482C6 /* StoreController.swift in Sources */, 5991969225F817F7003215CC /* PresetTracks.swift in Sources */, 599D9F5225EC5A4900DF3795 /* TracksView.swift in Sources */, + 59F6170926671C2100CC78E1 /* AlertItem.swift in Sources */, 59F6EA9D25E369830069DF40 /* Tickmate+Convenience.swift in Sources */, 59A0D8932645CF1600E01E61 /* View+DismissKeyboard.swift in Sources */, 59EF633725E5DC93003E7259 /* TextWithCaption.swift in Sources */, diff --git a/Tickmate/Tickmate/Controllers/StoreController.swift b/Tickmate/Tickmate/Controllers/StoreController.swift index 047d6e7..5e57611 100644 --- a/Tickmate/Tickmate/Controllers/StoreController.swift +++ b/Tickmate/Tickmate/Controllers/StoreController.swift @@ -12,7 +12,15 @@ class StoreController: NSObject, ObservableObject { @Published private(set) var products = [SKProduct]() - @Published var purchased = Set() + @Published private(set) var purchased = Set() + @Published private(set) var purchasing = Set() + @Published var restored: AlertItem? + + private var isRestoringPurchases = false + + var isAuthorizedForPayments: Bool { + SKPaymentQueue.canMakePayments() + } override init() { super.init() @@ -32,15 +40,24 @@ class StoreController: NSObject, ObservableObject { request.start() } - @discardableResult - func purchase(_ product: SKProduct) -> Bool { - guard SKPaymentQueue.canMakePayments() else { return false } + func purchase(_ product: SKProduct) { + guard isAuthorizedForPayments else { return } let payment = SKPayment(product: product) SKPaymentQueue.default().add(payment) - return true } + func restorePurchases() { + isRestoringPurchases = true + SKPaymentQueue.default().restoreCompletedTransactions() + } + + #if DEBUG + func removePurchased(id: String) { + purchased.remove(id) + } + #endif + //MARK: Products enum Products: String, CaseIterable { @@ -85,16 +102,20 @@ extension StoreController: SKPaymentTransactionObserver { switch transaction.transactionState { case .purchasing: - break + withAnimation { + _ = purchasing.insert(productID) + } case .purchased, .restored: print("Purchased \(productID)!") UserDefaults.standard.set(true, forKey: productID) withAnimation { - _ = purchased.insert(productID) + purchasing.remove(productID) + purchased.insert(productID) } queue.finishTransaction(transaction) case .failed, .deferred: + purchasing.remove(productID) if let error = transaction.error { print("Purchase of \(productID) failed: \(error)") } else { @@ -106,5 +127,28 @@ extension StoreController: SKPaymentTransactionObserver { break } } + + if isRestoringPurchases { + let restoredProducts = transactions + .filter { $0.transactionState == .restored } + .compactMap { transaction in products.first(where: { $0.productIdentifier == transaction.payment.productIdentifier }) } + if !restoredProducts.isEmpty { + let alertBody = restoredProducts + .map { $0.localizedTitle } + .joined(separator: ", ") + restored = AlertItem(title: "Purchases restored!", message: alertBody) + isRestoringPurchases = false + } + } + } + + func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) { + // If there were purchases that were restored, isRestoringPurchases, + // would be set to false in paymentQueue(_:, updatedTransactions:), so + // if it's still true here, that means there were no purchases to restore. + if isRestoringPurchases { + restored = AlertItem(title: "No purchases to restore") + isRestoringPurchases = false + } } } diff --git a/Tickmate/Tickmate/Supplementary Views/AlertItem.swift b/Tickmate/Tickmate/Supplementary Views/AlertItem.swift new file mode 100644 index 0000000..123713c --- /dev/null +++ b/Tickmate/Tickmate/Supplementary Views/AlertItem.swift @@ -0,0 +1,36 @@ +// +// AlertItem.swift +// Tickmate +// +// Created by Isaac Lyons on 6/1/21. +// + +import SwiftUI + +class AlertItem: Identifiable { + var title: String + var message: String? + + init(title: String, message: String? = nil) { + self.title = title + self.message = message + } + + var alert: Alert { + if let message = message { + return Alert(title: Text(title), message: Text(message)) + } + + return Alert(title: Text(title)) + } + + static func alert(for alertItem: AlertItem) -> Alert { + alertItem.alert + } +} + +extension View { + func alert(alertItem: Binding) -> some View { + self.alert(item: alertItem) { $0.alert } + } +} diff --git a/Tickmate/Tickmate/Views/SettingsView.swift b/Tickmate/Tickmate/Views/SettingsView.swift index 6a42114..881f1da 100644 --- a/Tickmate/Tickmate/Views/SettingsView.swift +++ b/Tickmate/Tickmate/Views/SettingsView.swift @@ -23,6 +23,7 @@ struct SettingsView: View { @Binding var showing: Bool @State private var timeOffset: Date = Date() + @State private var showingRestrictedPaymentsAlert = false var body: some View { Form { @@ -72,7 +73,9 @@ struct SettingsView: View { Section(header: Text("Premium Features"), footer: Text("Groups allow you to swipe left and right between different sets of tracks from the main screen")) { ForEach(storeController.products, id: \.productIdentifier) { product in Button { - storeController.purchase(product) + storeController.isAuthorizedForPayments + ? storeController.purchase(product) + : (showingRestrictedPaymentsAlert = true) } label: { HStack { TextWithCaption(product.localizedTitle, caption: product.localizedDescription) @@ -81,20 +84,30 @@ struct SettingsView: View { if storeController.purchased.contains(product.productIdentifier) { Text("Purchased!") .foregroundColor(.secondary) + } else if storeController.purchasing.contains(product.productIdentifier) { + ProgressView() } else { Text(product.price, formatter: storeController.priceFormatter) - .foregroundColor(.accentColor) + .foregroundColor(storeController.isAuthorizedForPayments ? .accentColor : .secondary) } } } .disabled(storeController.purchased.contains(product.productIdentifier)) } + .alert(isPresented: $showingRestrictedPaymentsAlert) { + Alert(title: Text("Access restricted"), message: Text("You don't have permission to make purchases on this account.")) + } + + Button("Restore purchases") { + storeController.restorePurchases() + } + .alert(alertItem: $storeController.restored) #if DEBUG Button("Reset purchases (debug feature)") { StoreController.Products.allCases.forEach { UserDefaults.standard.set(false, forKey: $0.rawValue) - storeController.purchased.remove($0.rawValue) + storeController.removePurchased(id: $0.rawValue) } } #endif