From bd238e57430c6838cd1302c41b49d9828a9f5e2c Mon Sep 17 00:00:00 2001 From: Trey t Date: Sun, 17 Jul 2022 10:26:00 -0500 Subject: [PATCH] iap - wip --- Configuration.storekit | 102 ++++++ Feels.xcodeproj/project.pbxproj | 34 +- .../xcschemes/Feels (iOS).xcscheme | 3 + Shared/Date+Extensions.swift | 6 + Shared/FeelsApp.swift | 5 +- Shared/IAPManager.swift | 296 ++++++++++++++++++ Shared/Models/IAPManager.swift | 12 - Shared/View+Extensions.swift | 16 + Shared/views/DayView/DayView.swift | 11 +- Shared/views/MonthView/MonthView.swift | 12 +- Shared/views/PurchaseButtonView.swift | 236 ++++++++++++++ Shared/views/SettingsView/SettingsView.swift | 29 +- Shared/views/StatusInfoView.swift | 143 +++++++++ Shared/views/YearView/YearView.swift | 9 + en.lproj/Localizable.strings | 5 + 15 files changed, 897 insertions(+), 22 deletions(-) create mode 100644 Configuration.storekit create mode 100644 Shared/IAPManager.swift delete mode 100644 Shared/Models/IAPManager.swift create mode 100644 Shared/View+Extensions.swift create mode 100644 Shared/views/PurchaseButtonView.swift create mode 100644 Shared/views/StatusInfoView.swift diff --git a/Configuration.storekit b/Configuration.storekit new file mode 100644 index 0000000..d0da896 --- /dev/null +++ b/Configuration.storekit @@ -0,0 +1,102 @@ +{ + "identifier" : "00CCEDCC", + "nonRenewingSubscriptions" : [ + + ], + "products" : [ + + ], + "settings" : { + + }, + "subscriptionGroups" : [ + { + "id" : "2CFE4C4F", + "localizations" : [ + + ], + "name" : "group1", + "subscriptions" : [ + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "0.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "44A8029E", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "weekly desc", + "displayName" : "Weekly", + "locale" : "en_US" + } + ], + "productID" : "com.88oakapps.ifeel.IAP.subscription.weekly", + "recurringSubscriptionPeriod" : "P1W", + "referenceName" : "Weekly", + "subscriptionGroupID" : "2CFE4C4F", + "type" : "RecurringSubscription" + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "0.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "C011E06B", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "montly desc", + "displayName" : "Monthly", + "locale" : "en_US" + } + ], + "productID" : "com.88oakapps.ifeel.IAP.subscription.monthly", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "Monthly", + "subscriptionGroupID" : "2CFE4C4F", + "type" : "RecurringSubscription" + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "4.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "32967821", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "yearly desc", + "displayName" : "Yearly", + "locale" : "en_US" + } + ], + "productID" : "com.88oakapps.ifeel.IAP.subscription.yearly", + "recurringSubscriptionPeriod" : "P1Y", + "referenceName" : "Yearly", + "subscriptionGroupID" : "2CFE4C4F", + "type" : "RecurringSubscription" + } + ] + } + ], + "version" : { + "major" : 1, + "minor" : 2 + } +} diff --git a/Feels.xcodeproj/project.pbxproj b/Feels.xcodeproj/project.pbxproj index e29b3c7..a4e91eb 100644 --- a/Feels.xcodeproj/project.pbxproj +++ b/Feels.xcodeproj/project.pbxproj @@ -122,6 +122,13 @@ 1CB101C827B81CAC00D1C033 /* MoodMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CB101C627B81CAC00D1C033 /* MoodMetrics.swift */; }; 1CB4D09628779F9B00902A56 /* IAPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CB4D09528779F9B00902A56 /* IAPManager.swift */; }; 1CB4D09728779F9B00902A56 /* IAPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CB4D09528779F9B00902A56 /* IAPManager.swift */; }; + 1CB4D0992877A14100902A56 /* View+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CB4D0982877A14100902A56 /* View+Extensions.swift */; }; + 1CB4D09A2877A14100902A56 /* View+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CB4D0982877A14100902A56 /* View+Extensions.swift */; }; + 1CB4D09C2877A36400902A56 /* PurchaseButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CB4D09B2877A36400902A56 /* PurchaseButtonView.swift */; }; + 1CB4D09D2877A36400902A56 /* PurchaseButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CB4D09B2877A36400902A56 /* PurchaseButtonView.swift */; }; + 1CB4D0A028787D8A00902A56 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CB4D09F28787D8A00902A56 /* StoreKit.framework */; }; + 1CB4D0A22878B69100902A56 /* StatusInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CB4D0A12878B69100902A56 /* StatusInfoView.swift */; }; + 1CB4D0A32878B69100902A56 /* StatusInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CB4D0A12878B69100902A56 /* StatusInfoView.swift */; }; 1CC469AA278F30A0003E0C6E /* BGTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC469A9278F30A0003E0C6E /* BGTask.swift */; }; 1CC469AC27907D48003E0C6E /* DayChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC469AB27907D48003E0C6E /* DayChartView.swift */; }; 1CD90B07278C7DE0001C4FEA /* Tests_iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CD90B06278C7DE0001C4FEA /* Tests_iOS.swift */; }; @@ -276,6 +283,11 @@ 1CB101C427B62A2D00D1C033 /* EmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyView.swift; sourceTree = ""; }; 1CB101C627B81CAC00D1C033 /* MoodMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoodMetrics.swift; sourceTree = ""; }; 1CB4D09528779F9B00902A56 /* IAPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAPManager.swift; sourceTree = ""; }; + 1CB4D0982877A14100902A56 /* View+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = ""; }; + 1CB4D09B2877A36400902A56 /* PurchaseButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseButtonView.swift; sourceTree = ""; }; + 1CB4D09E28787B3C00902A56 /* Configuration.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Configuration.storekit; sourceTree = ""; }; + 1CB4D09F28787D8A00902A56 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.5.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; }; + 1CB4D0A12878B69100902A56 /* StatusInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusInfoView.swift; sourceTree = ""; }; 1CC03FA627B5865600B530AF /* Shared 2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Shared 2.xcdatamodel"; sourceTree = ""; }; 1CC469A9278F30A0003E0C6E /* BGTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGTask.swift; sourceTree = ""; }; 1CC469AB27907D48003E0C6E /* DayChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayChartView.swift; sourceTree = ""; }; @@ -320,6 +332,7 @@ files = ( 1C747CC9279F06EB00762CBD /* CloudKitSyncMonitor in Frameworks */, 1CD90B6C278C7F78001C4FEA /* CloudKit.framework in Frameworks */, + 1CB4D0A028787D8A00902A56 /* StoreKit.framework in Frameworks */, 1C2618FA2795E41D00FDC148 /* Charts in Frameworks */, 1C414C2E27DB1B9B00BC1720 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */, ); @@ -515,22 +528,24 @@ 1C04489227C2CAB700D22444 /* CustomizeView */, 1C04489127C2CAB100D22444 /* CustomWidget */, 1CC469AB27907D48003E0C6E /* DayChartView.swift */, + 1C04488F27C2CA9C00D22444 /* DayView */, 1CB101C427B62A2D00D1C033 /* EmptyView.swift */, - 1C04489427C2CAD100D22444 /* YearView */, + 1C414C0E27D51FB500BC1720 /* EntryListView.swift */, 1CAD602D27A5C1C800C520BD /* GraphView.swift */, 1CAD603027A5C1C800C520BD /* HeaderPercView.swift */, 1CAD603327A5C1C800C520BD /* HeaderStatsView.swift */, - 1C04488F27C2CA9C00D22444 /* DayView */, + 1C7352B827DD02760024B5D2 /* ImagePickerGridView.swift */, 1C361F0B27C0356B00E832FC /* MainTabView.swift */, 1C4DAA7327CC263F00C25D2B /* MonthView */, + 1CB4D09B2877A36400902A56 /* PurchaseButtonView.swift */, 1C1B6E6827FD4E8F00181E70 /* SampleEntryView.swift */, 1C04489327C2CABF00D22444 /* SettingsView */, 1C04489527C2CB1A00D22444 /* Sharing */, 1C358FB427B0ADF3002C83A6 /* SharingTemplates */, 1CAD602B27A5C1C800C520BD /* SmallRollUpHeaderView.swift */, + 1CB4D0A12878B69100902A56 /* StatusInfoView.swift */, 1CAD603D27A6ECCD00C520BD /* SwitchableView.swift */, - 1C414C0E27D51FB500BC1720 /* EntryListView.swift */, - 1C7352B827DD02760024B5D2 /* ImagePickerGridView.swift */, + 1C04489427C2CAD100D22444 /* YearView */, ); path = Views; sourceTree = ""; @@ -538,6 +553,7 @@ 1CD90AE5278C7DDF001C4FEA = { isa = PBXGroup; children = ( + 1CB4D09E28787B3C00902A56 /* Configuration.storekit */, 1C0DAB47279DB0FB003B1F21 /* Localizable.strings */, 1CD90B6A278C7F75001C4FEA /* Feels (iOS).entitlements */, 1CD90B70278C8000001C4FEA /* Feels (iOS)Dev.entitlements */, @@ -570,6 +586,8 @@ 1C2162ED27C15191004353D1 /* MoodEntryFunctions.swift */, 1C683FC92792281400745862 /* Stats.swift */, 1C4FF3BA27BEDDF000BE8F34 /* ShowBasedOnVoteLogics.swift */, + 1CB4D0982877A14100902A56 /* View+Extensions.swift */, + 1CB4D09528779F9B00902A56 /* IAPManager.swift */, 1CA03771279A291F00D26164 /* Onboarding */, 1C26190127960CDA00FDC148 /* Protocols */, 1CAD602A27A5C1C800C520BD /* Views */, @@ -621,6 +639,7 @@ 1CD90B46278C7E7A001C4FEA /* Frameworks */ = { isa = PBXGroup; children = ( + 1CB4D09F28787D8A00902A56 /* StoreKit.framework */, 1CD90B6B278C7F78001C4FEA /* CloudKit.framework */, 1CD90B47278C7E7A001C4FEA /* WidgetKit.framework */, 1CD90B49278C7E7A001C4FEA /* SwiftUI.framework */, @@ -656,7 +675,6 @@ 1C718C7227F611E300A8F9FE /* StupidAssCustomWidgetObservableObject.swift */, 1C358FAC27ADD0C3002C83A6 /* Theme.swift */, 1C5F4977279C945E0092F1B4 /* UserDefaultsStore.swift */, - 1CB4D09528779F9B00902A56 /* IAPManager.swift */, ); path = Models; sourceTree = ""; @@ -871,6 +889,7 @@ 1CA03773279A293D00D26164 /* OnboardingTime.swift in Sources */, 1CAD603927A5C1C800C520BD /* HeaderPercView.swift in Sources */, 1C0A3C8F27FD445000FF37FF /* OnboardingCustomizeOne.swift in Sources */, + 1CB4D0A22878B69100902A56 /* StatusInfoView.swift in Sources */, 1CAD603C27A5C1C800C520BD /* HeaderStatsView.swift in Sources */, 1CAD603827A5C1C800C520BD /* AddMoodHeaderView.swift in Sources */, 1CA0377C279B605000D26164 /* OnboardingWrapup.swift in Sources */, @@ -913,6 +932,7 @@ 1CAD603527A5C1C800C520BD /* SettingsView.swift in Sources */, 1CD90B53278C7E7A001C4FEA /* FeelsWidget.intentdefinition in Sources */, 1CC469AC27907D48003E0C6E /* DayChartView.swift in Sources */, + 1CB4D0992877A14100902A56 /* View+Extensions.swift in Sources */, 1C26190327960CE500FDC148 /* ChartDataBuildable.swift in Sources */, 1CB101C527B62A2D00D1C033 /* EmptyView.swift in Sources */, 1CB101C727B81CAC00D1C033 /* MoodMetrics.swift in Sources */, @@ -924,6 +944,7 @@ 1C658D7727C0744D003231EE /* PersistenceUPDATE.swift in Sources */, 1C7352B927DD02760024B5D2 /* ImagePickerGridView.swift in Sources */, 1C358FB327B0ADA4002C83A6 /* SharingTemplate.swift in Sources */, + 1CB4D09C2877A36400902A56 /* PurchaseButtonView.swift in Sources */, 1C358FB827B0AEE3002C83A6 /* LongestStreakTemplate.swift in Sources */, 1C1AFF4127F895C00067F9DC /* ShapePickerView.swift in Sources */, 1C358FB127B0AD87002C83A6 /* SharingListView.swift in Sources */, @@ -1019,6 +1040,7 @@ 1C4FF3C827BEE09E00BE8F34 /* PersistenceADD.swift in Sources */, 1C2162F527C16061004353D1 /* MoodImagable.swift in Sources */, 1C2162EC27C14FC5004353D1 /* Date+Extensions.swift in Sources */, + 1CB4D0A32878B69100902A56 /* StatusInfoView.swift in Sources */, 1C2C5B2B27DEBE260092A308 /* EventLogger.swift in Sources */, 1C4FF3C127BEE06900BE8F34 /* PersistenceGET.swift in Sources */, 1C361F0D27C03BDF00E832FC /* OnboardingData.swift in Sources */, @@ -1029,6 +1051,8 @@ 1C04489627C2DB0100D22444 /* Theme.swift in Sources */, 1C361F0F27C03C0E00E832FC /* LocalNotification.swift in Sources */, 1C10E24E27A1AB110047948B /* UserDefaultsStore.swift in Sources */, + 1CB4D09D2877A36400902A56 /* PurchaseButtonView.swift in Sources */, + 1CB4D09A2877A14100902A56 /* View+Extensions.swift in Sources */, 1C718C7427F611E300A8F9FE /* StupidAssCustomWidgetObservableObject.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Feels.xcodeproj/xcshareddata/xcschemes/Feels (iOS).xcscheme b/Feels.xcodeproj/xcshareddata/xcschemes/Feels (iOS).xcscheme index 6ae13ec..a35d4bd 100644 --- a/Feels.xcodeproj/xcshareddata/xcschemes/Feels (iOS).xcscheme +++ b/Feels.xcodeproj/xcshareddata/xcschemes/Feels (iOS).xcscheme @@ -60,6 +60,9 @@ ReferencedContainer = "container:Feels.xcodeproj"> + + String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "MMM dd, yyyy" + return dateFormatter.string(from: self) + } } diff --git a/Shared/FeelsApp.swift b/Shared/FeelsApp.swift index 040a93f..e7890df 100644 --- a/Shared/FeelsApp.swift +++ b/Shared/FeelsApp.swift @@ -15,7 +15,8 @@ struct FeelsApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate let persistenceController = PersistenceController.shared - + @StateObject var iapManager = IAPManager() + init() { BGTaskScheduler.shared.cancelAllTaskRequests() BGTaskScheduler.shared.register(forTaskWithIdentifier: BGTask.updateDBMissingID, using: nil) { (task) in @@ -33,6 +34,7 @@ struct FeelsApp: App { yearView: YearView(viewModel: YearViewModel()), customizeView: CustomizeView()) .environment(\.managedObjectContext, persistenceController.viewContext) + .environmentObject(iapManager) }.onChange(of: scenePhase) { phase in if phase == .background { //BGTask.scheduleBackgroundProcessing() @@ -41,6 +43,7 @@ struct FeelsApp: App { if phase == .active { UIApplication.shared.applicationIconBadgeNumber = 0 + iapManager.refresh() } } } diff --git a/Shared/IAPManager.swift b/Shared/IAPManager.swift new file mode 100644 index 0000000..76eec3e --- /dev/null +++ b/Shared/IAPManager.swift @@ -0,0 +1,296 @@ +/* + See LICENSE folder for this sample’s licensing information. + + Abstract: + The store class is responsible for requesting products from the App Store and starting purchases. + */ + +import Foundation +import StoreKit +import SwiftUI + +typealias Transaction = StoreKit.Transaction +typealias RenewalInfo = StoreKit.Product.SubscriptionInfo.RenewalInfo +typealias RenewalState = StoreKit.Product.SubscriptionInfo.RenewalState + +public enum StoreError: Error { + case failedVerification +} + +class IAPManager: ObservableObject { + @Published private(set) var showIAP = false + @Published private(set) var subscriptions = [Product: (status: [Product.SubscriptionInfo.Status], renewalInfo: RenewalInfo)?]() + + public var sortedSubscriptionKeysByPriceOptions: [Product] { + subscriptions.keys.sorted(by: { + $0.price < $1.price + }) + } + + public var currentSubscription: Product? { + let sortedProducts = subscriptions.keys.sorted(by: { + $0.price > $1.price + }) + + // first see if we have a product + sub that is set to autorenew + for product in sortedProducts { + if let _value = subscriptions[product]??.renewalInfo { + if _value.willAutoRenew { + return product + } + } + } + + // if no auto renew then return + // highest priced that has a sub status + for product in sortedProducts { + if let _ = subscriptions[product]??.status { + return product + } + } + + return nil + } + + // for all products return the one that is set + // to auto renew + public var nextRenewllOption: Product? { + if let currentSubscription = currentSubscription, + let info = subscriptions[currentSubscription], + let status = info?.status { + for aStatus in status { + if let renewalInfo = try? checkVerified(aStatus.renewalInfo), + renewalInfo.willAutoRenew { + if let renewToProduct = subscriptions.first(where: { + $0.key.id == renewalInfo.autoRenewPreference + })?.key { + return renewToProduct + } + } + } + } + return nil + } + + var updateListenerTask: Task? = nil + + private let iapIdentifiers = Set([ + "com.88oakapps.ifeel.IAP.subscription.weekly", + "com.88oakapps.ifeel.IAP.subscription.monthly", + "com.88oakapps.ifeel.IAP.subscription.yearly" + ]) + + init() { + //Start a transaction listener as close to app launch as possible so you don't miss any transactions. + updateListenerTask = listenForTransactions() + + refresh() + } + + deinit { + updateListenerTask?.cancel() + } + + func refresh() { + Task { + //During store initialization, request products from the App Store. + await requestProducts() + + //Deliver products that the customer purchases. + await updateCustomerProductStatus() + + decideShowIAP() + } + } + + func decideShowIAP() { + guard !subscriptions.isEmpty else { + return + } + var tmpShowIAP = true + for (_, value) in self.subscriptions { + if value != nil { + tmpShowIAP = false + return + } + } + DispatchQueue.main.async { + self.showIAP = tmpShowIAP + } + } + + func listenForTransactions() -> Task { + return Task.detached { + //Iterate through any transactions that don't come from a direct call to `purchase()`. + for await result in Transaction.updates { + do { + let transaction = try self.checkVerified(result) + + //Deliver products to the user. + await self.updateCustomerProductStatus() + + //Always finish a transaction. + await transaction.finish() + } catch { + //StoreKit has a transaction that fails verification. Don't deliver content to the user. + print("Transaction failed verification") + } + } + } + } + + // fetch all available iap from remote and store locally + // in subscriptions + @MainActor + func requestProducts() async { + do { + subscriptions.removeAll() + + //Request products from the App Store using the identifiers that the Products.plist file defines. + let storeProducts = try await Product.products(for: iapIdentifiers) + + //Filter the products into categories based on their type. + for product in storeProducts { + switch product.type { + case .consumable: + break + case .nonConsumable: + break + case .autoRenewable: + subscriptions.updateValue(nil, forKey: product) + case .nonRenewable: + break + default: + //Ignore this product. + print("Unknown product") + } + } + + } catch { + print("Failed product request from the App Store server: \(error)") + } + } + + @MainActor + func updateCustomerProductStatus() async { + var purchasedSubscriptions: [Product] = [] + + // Iterate through all of the user's purchased products. + for await result in Transaction.currentEntitlements { + do { + //Check whether the transaction is verified. If it isn’t, catch `failedVerification` error. + let transaction = try checkVerified(result) + + //Check the `productType` of the transaction and get the corresponding product from the store. + switch transaction.productType { + case .nonConsumable: + break + case .nonRenewable: + break + case .autoRenewable: + print("subscriptions2 \(subscriptions)") + if let subscription = subscriptions.first(where: { + $0.key.id == transaction.productID + }) { + purchasedSubscriptions.append(subscription.key) + } + default: + break + } + } catch { + print() + } + } + print("purchasedSubscriptions \(purchasedSubscriptions)") + if !purchasedSubscriptions.isEmpty { + self.showIAP = false + } + + for sub in purchasedSubscriptions { + guard let statuses = try? await sub.subscription?.status else { + return + } + + for status in statuses { + switch status.state { + case .expired, .revoked: + continue + default: + if let renewalInfo = try? checkVerified(status.renewalInfo) { + subscriptions.updateValue((statuses, renewalInfo), forKey: sub) + } + } + } + } + } + + func purchase(_ product: Product) async throws -> Transaction? { + //Begin purchasing the `Product` the user selects. + let result = try await product.purchase() + + switch result { + case .success(let verification): + //Check whether the transaction is verified. If it isn't, + //this function rethrows the verification error. + let transaction = try checkVerified(verification) + + //The transaction is verified. Deliver content to the user. + await updateCustomerProductStatus() + + //Always finish a transaction. + await transaction.finish() + + decideShowIAP() + + return transaction + case .userCancelled, .pending: + return nil + default: + return nil + } + } + + func isPurchased(_ product: Product) async throws -> Bool { + //Determine whether the user purchases a given product. + switch product.type { + case .nonRenewable: + return false + case .nonConsumable: + return false + case .autoRenewable: + return subscriptions.keys.contains(product) + default: + return false + } + } + + func checkVerified(_ result: VerificationResult) throws -> T { + //Check whether the JWS passes StoreKit verification. + switch result { + case .unverified: + //StoreKit parses the JWS, but it fails verification. + throw StoreError.failedVerification + case .verified(let safe): + //The result is verified. Return the unwrapped value. + return safe + } + } + + func sortByPrice(_ products: [Product]) -> [Product] { + products.sorted(by: { return $0.price < $1.price }) + } + + func colorForIAPButton(iapIdentifier: String) -> Color { + if iapIdentifier == "com.88oakapps.ifeel.IAP.subscription.weekly" { + return DefaultMoodTint.color(forMood: .horrible) + } + else if iapIdentifier == "com.88oakapps.ifeel.IAP.subscription.monthly" { + return DefaultMoodTint.color(forMood: .average) + } + else if iapIdentifier == "com.88oakapps.ifeel.IAP.subscription.yearly" { + return DefaultMoodTint.color(forMood: .great) + } + + return .blue + } +} diff --git a/Shared/Models/IAPManager.swift b/Shared/Models/IAPManager.swift deleted file mode 100644 index 9847385..0000000 --- a/Shared/Models/IAPManager.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// IAPManager.swift -// Feels -// -// Created by Trey Tartt on 7/7/22. -// - -import Foundation - -class IAPManager { - public private(set) var isSubscribed = true -} diff --git a/Shared/View+Extensions.swift b/Shared/View+Extensions.swift new file mode 100644 index 0000000..0805257 --- /dev/null +++ b/Shared/View+Extensions.swift @@ -0,0 +1,16 @@ +// +// View+Extensions.swift +// Feels +// +// Created by Trey Tartt on 7/7/22. +// + +import SwiftUI + +extension View { + func hasScrollEnabled(_ value: Bool) -> some View { + self.onAppear { + UITableView.appearance().isScrollEnabled = value + } + } +} diff --git a/Shared/views/DayView/DayView.swift b/Shared/views/DayView/DayView.swift index 5b3e768..c85260c 100644 --- a/Shared/views/DayView/DayView.swift +++ b/Shared/views/DayView/DayView.swift @@ -38,6 +38,7 @@ struct DayView: View { @State private var showUpdateEntryAlert = false @StateObject private var onboardingData = OnboardingDataDataManager.shared @StateObject private var filteredDays = DaysFilterClass.shared + @EnvironmentObject var iapManager: IAPManager @ObservedObject var viewModel: DayViewViewModel @@ -79,6 +80,13 @@ struct DayView: View { showUpdateEntryAlert = false }) } + + if iapManager.showIAP { + VStack { + Spacer() + PurchaseButtonView(height: 175, iapManager: iapManager) + } + } } } @@ -101,7 +109,7 @@ struct DayView: View { } } } - .padding([.leading, .trailing, .bottom]) + .padding([.leading, .trailing]) .background( theme.currentTheme.bg .edgesIgnoringSafeArea(.all) @@ -161,6 +169,7 @@ struct DayView: View { } ) } + .disabled(iapManager.showIAP) .background( theme.currentTheme.secondaryBGColor ) diff --git a/Shared/views/MonthView/MonthView.swift b/Shared/views/MonthView/MonthView.swift index ced5976..05720a9 100644 --- a/Shared/views/MonthView/MonthView.swift +++ b/Shared/views/MonthView/MonthView.swift @@ -22,7 +22,7 @@ struct MonthView: View { // store a value that gets changed when user updates custom colors to update the view since the moodTint doesn't change @AppStorage(UserDefaultsStore.Keys.customMoodTintUpdateNumber.rawValue, store: GroupUserDefaults.groupDefaults) private var customMoodTintUpdateNumber: Int = 0 - + @EnvironmentObject var iapManager: IAPManager @StateObject private var selectedDetail = StupidAssDetailViewObservableObject() @State private var showingSheet = false @StateObject private var onboardingData = OnboardingDataDataManager.shared @@ -73,12 +73,20 @@ struct MonthView: View { } .padding([.leading, .trailing]) } + .disabled(iapManager.showIAP) + } + + if iapManager.showIAP { + VStack { + Spacer() + PurchaseButtonView(height: 175, iapManager: iapManager) + } } } .onAppear(perform: { EventLogger.log(event: "show_month_view") }) - .padding([.top, .bottom]) + .padding([.top]) .background( theme.currentTheme.bg .edgesIgnoringSafeArea(.all) diff --git a/Shared/views/PurchaseButtonView.swift b/Shared/views/PurchaseButtonView.swift new file mode 100644 index 0000000..fbca2ba --- /dev/null +++ b/Shared/views/PurchaseButtonView.swift @@ -0,0 +1,236 @@ +// +// PurchaseButtonView.swift +// Feels +// +// Created by Trey Tartt on 7/7/22. +// + +import SwiftUI +import StoreKit + +struct PurchaseButtonView: View { + enum ViewStatus: String { + case needToBuy + case error + case success + case subscribed + } + @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system + @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor + var iapManager: IAPManager + + private let height: Float + private let showManageSubClosure: (() -> Void)? + + @State var isPurchasing: Bool = false + + @State private var viewStatus: ViewStatus = .needToBuy { + didSet { + isPurchasing = false + } + } + + public init(height: Float, iapManager: IAPManager, showManageSubClosure: (() -> Void)? = nil) { + self.height = height + self.showManageSubClosure = showManageSubClosure + self.iapManager = iapManager + } + + var body: some View { + ZStack { + switch viewStatus { + case .needToBuy, .error: + buyOptionsView + .frame(height: CGFloat(height)) + .background(theme.currentTheme.secondaryBGColor) + case .success: + subscribedView + .frame(height: CGFloat(height)) + .background(theme.currentTheme.secondaryBGColor) + case .subscribed: + subscribedView + .frame(height: CGFloat(height)) + .background(theme.currentTheme.secondaryBGColor) + } + } + .onAppear{ + if let _ = iapManager.currentSubscription { + viewStatus = .subscribed + } + } + } + + private var buyOptionsSetingsView: some View { + GeometryReader { metrics in + VStack(spacing: 20) { + Text(String(localized: "purchase_view_title")) + .foregroundColor(textColor) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + .frame(height: 50) + .padding([.leading, .trailing]) + VStack(alignment: .leading) { + ForEach(iapManager.sortedSubscriptionKeysByPriceOptions, id: \.self) { product in + HStack { + Button(action: { + purchase(product: product) + }, label: { + Text("\(product.displayPrice)\n\(product.displayName)") + .foregroundColor(.white) + .bold() + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + }) + .frame(maxWidth: .infinity) + .frame(height: 50) + .padding() + .background(RoundedRectangle(cornerRadius: 10).fill(iapManager.colorForIAPButton(iapIdentifier: product.id))) + } + } + } + .padding([.leading, .leading]) + } + } + } + + private var buyOptionsView: some View { + VStack { + ZStack { + VStack(spacing: 20) { + Text(String(localized: "purchase_view_title")) + .font(.body) + .bold() + .foregroundColor(textColor) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + + HStack { + ForEach(iapManager.sortedSubscriptionKeysByPriceOptions) { product in + Button(action: { + purchase(product: product) + }, label: { + Text("\(product.displayPrice)\n\(product.displayName)") + .foregroundColor(.white) + .bold() + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + }) + .padding() + .frame(maxWidth: .infinity) + .background(RoundedRectangle(cornerRadius: 10).fill(iapManager.colorForIAPButton(iapIdentifier: product.id))) + } + } + } + .padding([.leading, .trailing]) + .frame(minWidth: 0, maxWidth: .infinity) + + } + .background(.ultraThinMaterial) + .frame(minWidth: 0, maxWidth: .infinity) + } + .background(.clear) + } + + private var successView: some View { + HStack { + Text("it worked") + } + .background(.green) + } + + private var errorView: some View { + HStack { + Text("something broke") + } + .background(.red) + } + + private var subscribedView: some View { + VStack(alignment: .leading) { + Text(String(localized: "purchase_view_current_subscription")) + .font(.title3) + .padding(.leading) + + Divider() + + if let currentProduct = iapManager.currentSubscription, + let value = iapManager.subscriptions[currentProduct] { + HStack { + VStack (alignment: .leading, spacing: 10) { + Text(currentProduct.displayName) + .font(.title3) + Text(currentProduct.displayPrice) + .font(.title3) + }.padding([.leading, .trailing]) + + ForEach(value!.status, id: \.self) { singleStatus in + StatusInfoView(product: currentProduct, status: singleStatus) + .padding([.leading]) + .font(.body) + } + } + } + + Button(action: { + showManageSubClosure?() + }, label: { + Text(String(localized: "purchase_view_cancel")) + .foregroundColor(.red) + }) + .frame(maxWidth: .infinity) + .padding([.bottom]) + .multilineTextAlignment(.center) + + Divider() + + showOtherSubOptions + } + } + + private var showOtherSubOptions: some View { + VStack (spacing: 10) { + HStack { + ForEach(iapManager.sortedSubscriptionKeysByPriceOptions, id: \.self) { product in + if product.id != iapManager.nextRenewllOption?.id { + Button(action: { + purchase(product: product) + }, label: { + Text("\(product.displayPrice)\n\(product.displayName)") + .foregroundColor(.white) + .font(.headline) + }) + .contentShape(Rectangle()) + .padding() + .frame(maxWidth: .infinity) + .background(RoundedRectangle(cornerRadius: 10).fill(iapManager.colorForIAPButton(iapIdentifier: product.id))) + } + } + } + } + .padding([.leading, .trailing]) + } + + private var purchasingView: some View { + HStack { + Text("purcasing") + } + .background(.yellow) + } + + private func purchase(product: Product) { + isPurchasing = true + Task { + do { + if let _ = try await iapManager.purchase(product) { + viewStatus = .success + } + } catch { + viewStatus = .error + } + } + } +} + +struct PurchaseButtonView_Previews: PreviewProvider { + static var previews: some View { + PurchaseButtonView(height: 175, iapManager: IAPManager()) + } +} diff --git a/Shared/views/SettingsView/SettingsView.swift b/Shared/views/SettingsView/SettingsView.swift index c3127bd..bac9726 100644 --- a/Shared/views/SettingsView/SettingsView.swift +++ b/Shared/views/SettingsView/SettingsView.swift @@ -8,10 +8,12 @@ import SwiftUI import CloudKitSyncMonitor import UniformTypeIdentifiers +import StoreKit struct SettingsView: View { @Environment(\.dismiss) var dismiss - + @EnvironmentObject var iapManager: IAPManager + @State private var showingExporter = false @State private var showingImporter = false @State private var importContent = "" @@ -35,6 +37,7 @@ struct SettingsView: View { .padding() // cloudKitEnable + subscriptionInfoView canDelete showOnboardingButton specialThanksCell @@ -136,6 +139,19 @@ struct SettingsView: View { } } + private var subscriptionInfoView: some View { + ZStack { + theme.currentTheme.secondaryBGColor + PurchaseButtonView(height: iapManager.currentSubscription != nil ? 300 : 175, iapManager: iapManager, showManageSubClosure: { + Task { + await + self.showManageSubscription() + } + }) + } + .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) + } + private var closeButtonView: some View { HStack{ Spacer() @@ -483,6 +499,17 @@ struct SettingsView: View { .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } + + func showManageSubscription() async { + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene{ + do { + try await StoreKit.AppStore.showManageSubscriptions(in: windowScene) + iapManager.refresh() + } catch { + print("Sheet can not be opened") + } + } + } } struct TextFile: FileDocument { diff --git a/Shared/views/StatusInfoView.swift b/Shared/views/StatusInfoView.swift new file mode 100644 index 0000000..983448d --- /dev/null +++ b/Shared/views/StatusInfoView.swift @@ -0,0 +1,143 @@ +// +// StatusInfoView.swift +// Feels +// +// Created by Trey Tartt on 7/8/22. +// + +import SwiftUI +import StoreKit + +struct StatusInfoView: View { + @EnvironmentObject var iapManager: IAPManager + + let product: Product + let status: Product.SubscriptionInfo.Status + + var body: some View { + ScrollView { + Text(statusDescription()) + .frame(maxWidth: .infinity, alignment: .center) + .font(.body) + } + .frame(maxWidth: .infinity) + .frame(height: 75) + } + + //Build a string description of the subscription status to display to the user. + fileprivate func statusDescription() -> String { + guard case .verified(let renewalInfo) = status.renewalInfo, + case .verified(let transaction) = status.transaction else { + return "The App Store could not verify your subscription status." + } + + var description = "" + + switch status.state { + case .subscribed: + let renewToProduct: Product? + let renewalInfo: RenewalInfo? + + renewalInfo = try? iapManager.checkVerified(status.renewalInfo) + renewToProduct = iapManager.subscriptions.first(where: { + $0.key.id == renewalInfo?.autoRenewPreference + })?.key + + description = subscribedDescription(expirationDate: transaction.expirationDate, + willRenew: renewalInfo?.willAutoRenew ?? false, + willRenewTo: renewToProduct) + case .expired: + if let expirationDate = transaction.expirationDate, + let expirationReason = renewalInfo.expirationReason { + description = expirationDescription(expirationReason, expirationDate: expirationDate) + } + case .revoked: + if let revokedDate = transaction.revocationDate { + description = "The App Store refunded your subscription to \(product.displayName) on \(revokedDate.formattedDate())." + } + case .inGracePeriod: + description = gracePeriodDescription(renewalInfo) + case .inBillingRetryPeriod: + description = billingRetryDescription() + default: + break + } + return description + } + + fileprivate func billingRetryDescription() -> String { + var description = "The App Store could not confirm your billing information for \(product.displayName)." + description += " Please verify your billing information to resume service." + return description + } + + fileprivate func gracePeriodDescription(_ renewalInfo: RenewalInfo) -> String { + var description = "The App Store could not confirm your billing information for \(product.displayName)." + if let untilDate = renewalInfo.gracePeriodExpirationDate { + description += " Please verify your billing information to continue service after \(untilDate.formattedDate())" + } + + return description + } + + fileprivate func subscribedDescription(expirationDate: Date?, willRenew: Bool, willRenewTo: Product?) -> String { + var description = "You are currently subscribed to \(product.displayName)" + + if let expirationDate = expirationDate { + description += ", which will expire on \(expirationDate.formattedDate())," + } + + if willRenew { + if let willRenewTo = willRenewTo { + if willRenewTo == product { + description += " and will auto renew." + } else { + description += " and will auto renew to \(willRenewTo.displayName) at \(willRenewTo.displayPrice)." + } + } + } else { + description += " and will NOT auto renew." + } + + return description + } + + fileprivate func renewalDescription(_ renewalInfo: RenewalInfo, _ expirationDate: Date) -> String { + var description = "" + + if let newProductID = renewalInfo.autoRenewPreference { + if let newProduct = iapManager.subscriptions.first(where: { $0.key.id == newProductID }) { + description += "\nYour subscription to \(newProduct.key.displayName)" + description += " will begin when your current subscription expires on \(expirationDate.formattedDate())." + } + } else if renewalInfo.willAutoRenew { + description += "\nWill auto renew on: \(expirationDate.formattedDate())." + } + + return description + } + + //Build a string description of the `expirationReason` to display to the user. + fileprivate func expirationDescription(_ expirationReason: RenewalInfo.ExpirationReason, expirationDate: Date) -> String { + var description = "" + + switch expirationReason { + case .autoRenewDisabled: + if expirationDate > Date() { + description += "Your subscription to \(product.displayName) will expire on \(expirationDate.formattedDate())." + } else { + description += "Your subscription to \(product.displayName) expired on \(expirationDate.formattedDate())." + } + case .billingError: + description = "Your subscription to \(product.displayName) was not renewed due to a billing error." + case .didNotConsentToPriceIncrease: + description = "Your subscription to \(product.displayName) was not renewed due to a price increase that you disapproved." + case .productUnavailable: + description = "Your subscription to \(product.displayName) was not renewed because the product is no longer available." + default: + description = "Your subscription to \(product.displayName) was not renewed." + } + + return description + } +} diff --git a/Shared/views/YearView/YearView.swift b/Shared/views/YearView/YearView.swift index 5264e82..a8c0192 100644 --- a/Shared/views/YearView/YearView.swift +++ b/Shared/views/YearView/YearView.swift @@ -22,6 +22,7 @@ struct YearView: View { @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor + @EnvironmentObject var iapManager: IAPManager @StateObject public var viewModel: YearViewModel @StateObject private var filteredDays = DaysFilterClass.shared //[ @@ -52,8 +53,16 @@ struct YearView: View { ScrollView { gridView } + .disabled(iapManager.showIAP) .padding(.bottom, 5) } + + if iapManager.showIAP { + VStack { + Spacer() + PurchaseButtonView(height: 175, iapManager: iapManager) + } + } } .onAppear(perform: { self.viewModel.filterEntries(startDate: Date(timeIntervalSince1970: 0), endDate: Date()) diff --git a/en.lproj/Localizable.strings b/en.lproj/Localizable.strings index 7ed0d81..fbe8e08 100644 --- a/en.lproj/Localizable.strings +++ b/en.lproj/Localizable.strings @@ -108,6 +108,11 @@ "rude_notif_body_yesterday_two" = "Don't be an ass, rate yesterday"; "rude_notif_body_yesterday_three" = "Rate yesterday ... or else ☠️"; +"purchase_view_title" = "How long do you want to support your feelings?"; +"purchase_view_other_options" = "Other Options"; +"purchase_view_cancel" = "Cancel"; +"purchase_view_current_subscription" = "Current Subscription"; + /* not used */ "onboarding_title_title" = "What would you like the reminder to say?"; "onboarding_title_type_your_own" = "-- or type your own--";