From 04147ec4828dac69095d466bedf5784ca32b0d3d Mon Sep 17 00:00:00 2001 From: Isvvc Date: Tue, 1 Jun 2021 15:27:11 -0600 Subject: [PATCH 1/8] Add groups in-app purchase. --- Tickmate/Tickmate.xcodeproj/project.pbxproj | 6 + .../Controllers/StoreController.swift | 110 ++++++++++++++++++ .../Tickmate/Resources/Configuration.storekit | 33 ++++++ .../Supplementary Views/TextWithCaption.swift | 10 ++ Tickmate/Tickmate/Views/ContentView.swift | 2 + Tickmate/Tickmate/Views/SettingsView.swift | 35 ++++++ 6 files changed, 196 insertions(+) create mode 100644 Tickmate/Tickmate/Controllers/StoreController.swift create mode 100644 Tickmate/Tickmate/Resources/Configuration.storekit diff --git a/Tickmate/Tickmate.xcodeproj/project.pbxproj b/Tickmate/Tickmate.xcodeproj/project.pbxproj index f5f4230..fd3ebb5 100644 --- a/Tickmate/Tickmate.xcodeproj/project.pbxproj +++ b/Tickmate/Tickmate.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 59342E1C2603F3DF007E9F64 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59342E1B2603F3DF007E9F64 /* OnboardingView.swift */; }; 5939239C2654CE050085DABD /* PageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5939239B2654CE050085DABD /* PageView.swift */; }; 5939239E2655B6C90085DABD /* Animation+Push.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5939239D2655B6C90085DABD /* Animation+Push.swift */; }; + 59603D452666C85A001482C6 /* StoreController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59603D442666C85A001482C6 /* StoreController.swift */; }; 5964545225E987C9004FE184 /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5964545125E987C9004FE184 /* CloudKit.framework */; }; 597A7FDD25F6E1C900256268 /* StateEditButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 597A7FDC25F6E1C900256268 /* StateEditButton.swift */; }; 597DF5B025E5E90800DC8D28 /* Color+RGB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 597DF5AF25E5E90800DC8D28 /* Color+RGB.swift */; }; @@ -58,6 +59,8 @@ 59342E1B2603F3DF007E9F64 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; 5939239B2654CE050085DABD /* PageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageView.swift; sourceTree = ""; }; 5939239D2655B6C90085DABD /* Animation+Push.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Animation+Push.swift"; sourceTree = ""; }; + 59603D442666C85A001482C6 /* StoreController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreController.swift; sourceTree = ""; }; + 59603D462666CA98001482C6 /* Configuration.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Configuration.storekit; sourceTree = ""; }; 5964544E25E987C6004FE184 /* Tickmate.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Tickmate.entitlements; sourceTree = ""; }; 5964545125E987C9004FE184 /* CloudKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CloudKit.framework; path = System/Library/Frameworks/CloudKit.framework; sourceTree = SDKROOT; }; 597A7FDC25F6E1C900256268 /* StateEditButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateEditButton.swift; sourceTree = ""; }; @@ -119,6 +122,7 @@ 59FBCF2425E07BD8007B114F /* Assets.xcassets */, 59C23A6625E70D7000AFBC4B /* SymbolsList.swift */, 5991969125F817F7003215CC /* PresetTracks.swift */, + 59603D462666CA98001482C6 /* Configuration.storekit */, ); path = Resources; sourceTree = ""; @@ -219,6 +223,7 @@ 59F6EAA625E36D680069DF40 /* TrackController.swift */, 592850F1265EF54B00E682F5 /* GroupController.swift */, 59F3EDC125FC4F270009CDEC /* ViewControllerContainer.swift */, + 59603D442666C85A001482C6 /* StoreController.swift */, ); path = Controllers; sourceTree = ""; @@ -322,6 +327,7 @@ 59FBCF2125E07BD5007B114F /* TickmateApp.swift in Sources */, 5996360F264F128A00AAF6CA /* GroupsView.swift in Sources */, 59FBCF2D25E07BD8007B114F /* Tickmate.xcdatamodeld in Sources */, + 59603D452666C85A001482C6 /* StoreController.swift in Sources */, 5991969225F817F7003215CC /* PresetTracks.swift in Sources */, 599D9F5225EC5A4900DF3795 /* TracksView.swift in Sources */, 59F6EA9D25E369830069DF40 /* Tickmate+Convenience.swift in Sources */, diff --git a/Tickmate/Tickmate/Controllers/StoreController.swift b/Tickmate/Tickmate/Controllers/StoreController.swift new file mode 100644 index 0000000..047d6e7 --- /dev/null +++ b/Tickmate/Tickmate/Controllers/StoreController.swift @@ -0,0 +1,110 @@ +// +// StoreController.swift +// Tickmate +// +// Created by Isaac Lyons on 6/1/21. +// + +import SwiftUI +import StoreKit + +class StoreController: NSObject, ObservableObject { + + @Published private(set) var products = [SKProduct]() + + @Published var purchased = Set() + + override init() { + super.init() + SKPaymentQueue.default().add(self) + } + + var priceFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.formatterBehavior = .behavior10_4 + formatter.numberStyle = .currency + return formatter + }() + + func fetchProducts() { + let request = SKProductsRequest(productIdentifiers: Set(Products.allValues)) + request.delegate = self + request.start() + } + + @discardableResult + func purchase(_ product: SKProduct) -> Bool { + guard SKPaymentQueue.canMakePayments() else { return false } + + let payment = SKPayment(product: product) + SKPaymentQueue.default().add(payment) + return true + } + + //MARK: Products + + enum Products: String, CaseIterable { + case groups = "vc.isv.Tickmate.groups" + + static var allValues: [String] { + allCases.map { $0.rawValue } + } + } + +} + +//MARK: Products Request Delegate + +extension StoreController: SKProductsRequestDelegate { + func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) { + if !response.invalidProductIdentifiers.isEmpty { + NSLog("Invalid product identifiers found: \(response.invalidProductIdentifiers)") + } + + DispatchQueue.main.async { + if let locale = response.products.first?.priceLocale { + self.priceFormatter.locale = locale + } + withAnimation { + response.products + .map { $0.productIdentifier } + .filter { UserDefaults.standard.bool(forKey: $0) } + .forEach { self.purchased.insert($0) } + self.products = response.products + } + } + } +} + +//MARK: Payment Transaction Observer + +extension StoreController: SKPaymentTransactionObserver { + func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { + transactions.forEach { transaction in + let productID = transaction.payment.productIdentifier + + switch transaction.transactionState { + case .purchasing: + break + case .purchased, .restored: + print("Purchased \(productID)!") + UserDefaults.standard.set(true, forKey: productID) + withAnimation { + _ = purchased.insert(productID) + } + + queue.finishTransaction(transaction) + case .failed, .deferred: + if let error = transaction.error { + print("Purchase of \(productID) failed: \(error)") + } else { + print("Purchase of \(productID) failed.") + } + + queue.finishTransaction(transaction) + @unknown default: + break + } + } + } +} diff --git a/Tickmate/Tickmate/Resources/Configuration.storekit b/Tickmate/Tickmate/Resources/Configuration.storekit new file mode 100644 index 0000000..240e4a7 --- /dev/null +++ b/Tickmate/Tickmate/Resources/Configuration.storekit @@ -0,0 +1,33 @@ +{ + "identifier" : "6A8C1B93", + "nonRenewingSubscriptions" : [ + + ], + "products" : [ + { + "displayPrice" : "1.99", + "familyShareable" : false, + "internalID" : "07E2B154", + "localizations" : [ + { + "description" : "Unlock track grouping feature", + "displayName" : "Groups", + "locale" : "en_US" + } + ], + "productID" : "vc.isv.Tickmate.groups", + "referenceName" : "Groups", + "type" : "NonConsumable" + } + ], + "settings" : { + + }, + "subscriptionGroups" : [ + + ], + "version" : { + "major" : 1, + "minor" : 1 + } +} diff --git a/Tickmate/Tickmate/Supplementary Views/TextWithCaption.swift b/Tickmate/Tickmate/Supplementary Views/TextWithCaption.swift index 984078d..ae8d671 100644 --- a/Tickmate/Tickmate/Supplementary Views/TextWithCaption.swift +++ b/Tickmate/Tickmate/Supplementary Views/TextWithCaption.swift @@ -12,6 +12,16 @@ struct TextWithCaption: View { var text: String var caption: String? + init(text: String, caption: String? = nil) { + self.text = text + self.caption = caption + } + + init(_ text: String, caption: String? = nil) { + self.text = text + self.caption = caption + } + var body: some View { VStack(alignment: .leading) { Text(text) diff --git a/Tickmate/Tickmate/Views/ContentView.swift b/Tickmate/Tickmate/Views/ContentView.swift index 7ce56e8..4a24e30 100644 --- a/Tickmate/Tickmate/Views/ContentView.swift +++ b/Tickmate/Tickmate/Views/ContentView.swift @@ -32,6 +32,7 @@ struct ContentView: View { @StateObject private var trackController = TrackController() @StateObject private var groupController = GroupController() @StateObject private var vcContainer = ViewControllerContainer() + @StateObject private var storeController = StoreController() @State private var showingSettings = false @State private var showingTracks = false @@ -136,6 +137,7 @@ struct ContentView: View { SettingsView(showing: $showingSettings) } .environmentObject(trackController) + .environmentObject(storeController) } EmptyView() diff --git a/Tickmate/Tickmate/Views/SettingsView.swift b/Tickmate/Tickmate/Views/SettingsView.swift index 2230b31..bf4d01e 100644 --- a/Tickmate/Tickmate/Views/SettingsView.swift +++ b/Tickmate/Tickmate/Views/SettingsView.swift @@ -18,6 +18,7 @@ struct SettingsView: View { @AppStorage(Defaults.relativeDates.rawValue) private var relativeDates = true @EnvironmentObject private var trackController: TrackController + @EnvironmentObject private var storeController: StoreController @Binding var showing: Bool @@ -68,6 +69,37 @@ struct SettingsView: View { } } + Section(header: Text("Premium Features")) { + ForEach(storeController.products, id: \.productIdentifier) { product in + Button { + storeController.purchase(product) + } label: { + HStack { + TextWithCaption(product.localizedTitle, caption: product.localizedDescription) + .foregroundColor(.primary) + Spacer() + if storeController.purchased.contains(product.productIdentifier) { + Text("Purchased!") + .foregroundColor(.secondary) + } else { + Text(product.price, formatter: storeController.priceFormatter) + .foregroundColor(.accentColor) + } + } + } + .disabled(storeController.purchased.contains(product.productIdentifier)) + } + + #if DEBUG + Button("Reset purchases (debug feature)") { + StoreController.Products.allCases.forEach { + UserDefaults.standard.set(false, forKey: $0.rawValue) + storeController.purchased.remove($0.rawValue) + } + } + #endif + } + Section(header: Text("App Information")) { Link("Support Website", destination: URL(string: "https://github.com/Isvvc/Tickmate-iOS/issues")!) Link("Email Support", destination: URL(string: "mailto:lyons@tuta.io")!) @@ -93,6 +125,7 @@ struct SettingsView: View { }, region: .current) { timeOffset = date.date } + storeController.fetchProducts() } .onChange(of: customDayStart, perform: updateCustomDayStart) .onChange(of: timeOffset, perform: updateCustomDayStart) @@ -124,5 +157,7 @@ struct SettingsView_Previews: PreviewProvider { NavigationView { SettingsView(showing: .constant(true)) } + .environmentObject(TrackController()) + .environmentObject(StoreController()) } } From fcbc1ed82d6b8e1c0680b6c671b6c21f4256dc91 Mon Sep 17 00:00:00 2001 From: Isvvc Date: Tue, 1 Jun 2021 15:33:38 -0600 Subject: [PATCH 2/8] Disable pages if groups are not unlocked --- Tickmate/Tickmate/Views/ContentView.swift | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/Tickmate/Tickmate/Views/ContentView.swift b/Tickmate/Tickmate/Views/ContentView.swift index 4a24e30..be9a3a2 100644 --- a/Tickmate/Tickmate/Views/ContentView.swift +++ b/Tickmate/Tickmate/Views/ContentView.swift @@ -28,6 +28,7 @@ struct ContentView: View { @AppStorage(Defaults.showUngroupedTracks.rawValue) private var showUngroupedTracks = false @AppStorage(Defaults.onboardingComplete.rawValue) private var onboardingComplete: Bool = false @AppStorage(Defaults.groupPage.rawValue) private var page = 0 + @AppStorage(StoreController.Products.groups.rawValue) private var groupsUnlocked: Bool = false @StateObject private var trackController = TrackController() @StateObject private var groupController = GroupController() @@ -40,16 +41,22 @@ struct ContentView: View { @State private var showingOnboarding = false private var showingAllTracks: Bool { - showAllTracks || groups.count == 0 + showAllTracks || groups.count == 0 || !groupsUnlocked } private var showingUngroupedTracks: Bool { - showUngroupedTracks && ungroupedTracksFetchRequest.wrappedValue.count > 0 + showUngroupedTracks && ungroupedTracksFetchRequest.wrappedValue.count > 0 && groupsUnlocked + } + + private var pageCount: Int { + groupsUnlocked + ? groups.count + showAllTracks.int + showingUngroupedTracks.int + : 1 } var body: some View { NavigationView { - PageView(pageCount: groups.count + showAllTracks.int + showingUngroupedTracks.int, currentIndex: $page) { + PageView(pageCount: pageCount, currentIndex: $page) { if showingAllTracks { TicksView(scrollToBottomToggle: scrollToBottomToggle) } @@ -58,8 +65,10 @@ struct ContentView: View { TicksView(fetchRequest: ungroupedTracksFetchRequest, scrollToBottomToggle: scrollToBottomToggle) } - ForEach(groups) { group in - TicksView(group: group, scrollToBottomToggle: scrollToBottomToggle) + if groupsUnlocked { + ForEach(groups) { group in + TicksView(group: group, scrollToBottomToggle: scrollToBottomToggle) + } } } .navigationBarTitle("Tickmate", displayMode: .inline) @@ -100,7 +109,7 @@ struct ContentView: View { // There have been bugs with page numbers in the past. // This is just in case the page number gets bugged // and is scrolled past the edge. - if page > 0 || (page >= groups.count + showingAllTracks.int + showingUngroupedTracks.int) { + if page < 0 || (page >= pageCount) { page = 0 } From 20f6e672cb18301f64c9b9ffc5202d966f7f1ccd Mon Sep 17 00:00:00 2001 From: Isvvc Date: Tue, 1 Jun 2021 15:48:39 -0600 Subject: [PATCH 3/8] Disable group menus when groups are not unlocked --- Tickmate/Tickmate/Views/TrackView.swift | 11 ++++++++++- Tickmate/Tickmate/Views/TracksView.swift | 8 +++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/Tickmate/Tickmate/Views/TrackView.swift b/Tickmate/Tickmate/Views/TrackView.swift index fe24416..c0fc521 100644 --- a/Tickmate/Tickmate/Views/TrackView.swift +++ b/Tickmate/Tickmate/Views/TrackView.swift @@ -15,6 +15,8 @@ struct TrackView: View { @Environment(\.managedObjectContext) private var moc + @AppStorage(StoreController.Products.groups.rawValue) private var groupsUnlocked: Bool = false + @EnvironmentObject private var vcContainer: ViewControllerContainer @EnvironmentObject private var trackController: TrackController @@ -29,11 +31,17 @@ struct TrackView: View { @State private var showingSymbolPicker = false @State private var showDelete = false + private var groupsFooter: some View { + groupsUnlocked + ? AnyView(EmptyView()) + : AnyView(Text("Unlock the groups upgrade from the settings page")) + } + //MARK: Body var body: some View { Form { - Section { + Section(footer: groupsFooter) { Toggle("Enabled", isOn: $enabled) NavigationLink(destination: GroupsPicker(track: track, groups: groups)) { HStack { @@ -44,6 +52,7 @@ struct TrackView: View { .foregroundColor(.secondary) } } + .disabled(!groupsUnlocked) } Section(header: Text("Name")) { diff --git a/Tickmate/Tickmate/Views/TracksView.swift b/Tickmate/Tickmate/Views/TracksView.swift index 5eafcbf..dba5675 100644 --- a/Tickmate/Tickmate/Views/TracksView.swift +++ b/Tickmate/Tickmate/Views/TracksView.swift @@ -17,6 +17,8 @@ struct TracksView: View { // The environment EditMode is buggy, so using a custom @State property instead @State private var editMode = EditMode.inactive + @AppStorage(StoreController.Products.groups.rawValue) private var groupsUnlocked: Bool = false + @EnvironmentObject private var trackController: TrackController @Binding var showing: Bool @@ -32,9 +34,13 @@ struct TracksView: View { var body: some View { Form { - Section(footer: Text("Swipe left and right on the main screen to change group")) { + Section(footer: Text( + groupsUnlocked + ? "Swipe left and right on the main screen to change group" + : "Unlock the groups upgrade from the settings page")) { NavigationLink("Groups", destination: GroupsView()) } + .disabled(!groupsUnlocked) ForEach(tracks) { track in TrackCell(track: track, selection: $selection) From 436941787b84e67ecdc7ebbffc35926efcc2a6c3 Mon Sep 17 00:00:00 2001 From: Isvvc Date: Tue, 1 Jun 2021 15:58:00 -0600 Subject: [PATCH 4/8] Add footer explaining groups feature --- Tickmate/Tickmate/Views/SettingsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tickmate/Tickmate/Views/SettingsView.swift b/Tickmate/Tickmate/Views/SettingsView.swift index bf4d01e..6a42114 100644 --- a/Tickmate/Tickmate/Views/SettingsView.swift +++ b/Tickmate/Tickmate/Views/SettingsView.swift @@ -69,7 +69,7 @@ struct SettingsView: View { } } - Section(header: Text("Premium Features")) { + 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) From a7a4257551811c58cf148ef736bdae1825b8a99f Mon Sep 17 00:00:00 2001 From: Isvvc Date: Tue, 1 Jun 2021 16:01:53 -0600 Subject: [PATCH 5/8] Move new track buttons to new section It could be a bit difficult or confusing to move tracks to the bottom of the list when the new track buttons were in the same section. --- Tickmate/Tickmate/Views/TracksView.swift | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/Tickmate/Tickmate/Views/TracksView.swift b/Tickmate/Tickmate/Views/TracksView.swift index dba5675..ee5e6eb 100644 --- a/Tickmate/Tickmate/Views/TracksView.swift +++ b/Tickmate/Tickmate/Views/TracksView.swift @@ -49,18 +49,20 @@ struct TracksView: View { .onMove(perform: move) .animation(.easeInOut(duration: 0.25)) - Button("Create new track") { - let newTrack = trackController.newTrack(index: (tracks.last?.index ?? -1) + 1, context: moc) - select(track: newTrack, delay: 0.25) - } - .centered() - .foregroundColor(.accentColor) - - Button("Add preset track") { - showingPresets = true + Section { + Button("Create new track") { + let newTrack = trackController.newTrack(index: (tracks.last?.index ?? -1) + 1, context: moc) + select(track: newTrack, delay: 0.25) + } + .centered() + .foregroundColor(.accentColor) + + Button("Add preset track") { + showingPresets = true + } + .centered() + .foregroundColor(.accentColor) } - .centered() - .foregroundColor(.accentColor) } .environment(\.editMode, $editMode) .navigationTitle("Tracks") From 1b612a606f6baefc7ec50a587765b910c0fdc817 Mon Sep 17 00:00:00 2001 From: Isvvc Date: Tue, 1 Jun 2021 20:16:17 -0600 Subject: [PATCH 6/8] Add restore purchases button Add loading circle when a payment is processing Show alert when payments are restricted --- Tickmate/Tickmate.xcodeproj/project.pbxproj | 4 ++ .../Controllers/StoreController.swift | 58 ++++++++++++++++--- .../Supplementary Views/AlertItem.swift | 36 ++++++++++++ Tickmate/Tickmate/Views/SettingsView.swift | 19 +++++- 4 files changed, 107 insertions(+), 10 deletions(-) create mode 100644 Tickmate/Tickmate/Supplementary Views/AlertItem.swift 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 From 934c8755327b20a1636dac4a28b3c0af1dd3eb58 Mon Sep 17 00:00:00 2001 From: Isvvc Date: Wed, 2 Jun 2021 15:06:09 -0600 Subject: [PATCH 7/8] Update date upon entering foreground Closes #35 --- .../Controllers/TrackController.swift | 23 +++++++++++++++---- Tickmate/Tickmate/Views/ContentView.swift | 4 ++++ 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/Tickmate/Tickmate/Controllers/TrackController.swift b/Tickmate/Tickmate/Controllers/TrackController.swift index c3ef798..010349d 100644 --- a/Tickmate/Tickmate/Controllers/TrackController.swift +++ b/Tickmate/Tickmate/Controllers/TrackController.swift @@ -36,11 +36,7 @@ class TrackController: NSObject, ObservableObject { Defaults.relativeDates.rawValue: true ]) - if UserDefaults.standard.bool(forKey: Defaults.customDayStart.rawValue) { - date = Date() - UserDefaults.standard.integer(forKey: Defaults.customDayStartMinutes.rawValue).minutes - } else { - date = Date() - } + date = Date() - TrackController.dayOffset weekday = date.in(region: .current).weekday weekStartDay = UserDefaults.standard.integer(forKey: Defaults.weekStartDay.rawValue) @@ -69,6 +65,10 @@ class TrackController: NSObject, ObservableObject { } } + static var dayOffset: DateComponents { + (UserDefaults.standard.bool(forKey: Defaults.customDayStart.rawValue) ? UserDefaults.standard.integer(forKey: Defaults.customDayStartMinutes.rawValue) : 0).minutes + } + //MARK: Date Formatters private var dateFormatter: DateFormatter = { @@ -247,6 +247,19 @@ class TrackController: NSObject, ObservableObject { DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: work) } + func checkForNewDay() { + // The new date check was done differently in setCustomDayStart, but this works + // too and I don't know if one ways is necissarily better than the other. + let oldDate = TrackController.iso8601.string(from: date) + let newDate = TrackController.iso8601.string(from: Date() - TrackController.dayOffset) + if oldDate != newDate { + objectWillChange.send() + date = Date() - TrackController.dayOffset + tickControllers.values.forEach { $0.loadTicks() } + print("Updated from \(oldDate) to \(newDate)") + } + } + } //MARK: Fetched Results Controller Delegate diff --git a/Tickmate/Tickmate/Views/ContentView.swift b/Tickmate/Tickmate/Views/ContentView.swift index be9a3a2..061cd74 100644 --- a/Tickmate/Tickmate/Views/ContentView.swift +++ b/Tickmate/Tickmate/Views/ContentView.swift @@ -103,6 +103,10 @@ struct ContentView: View { .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { _ in trackController.scheduleSave(now: true) } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in + print("willEnterForeground") + trackController.checkForNewDay() + } .onAppear { groupController.trackController = trackController From 7e900573baee9bc8874413b8d8f94543399e1c9e Mon Sep 17 00:00:00 2001 From: Isvvc Date: Wed, 2 Jun 2021 15:33:59 -0600 Subject: [PATCH 8/8] Increment build number --- Tickmate/Tickmate.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tickmate/Tickmate.xcodeproj/project.pbxproj b/Tickmate/Tickmate.xcodeproj/project.pbxproj index 456d2ef..0db244e 100644 --- a/Tickmate/Tickmate.xcodeproj/project.pbxproj +++ b/Tickmate/Tickmate.xcodeproj/project.pbxproj @@ -473,7 +473,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Tickmate/Tickmate.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_ASSET_PATHS = "\"Tickmate/Preview Content\""; DEVELOPMENT_TEAM = A4525544Q4; ENABLE_PREVIEWS = YES; @@ -498,7 +498,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Tickmate/Tickmate.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_ASSET_PATHS = "\"Tickmate/Preview Content\""; DEVELOPMENT_TEAM = A4525544Q4; ENABLE_PREVIEWS = YES;