From 9b16b3a13f47760d5b51e2b5191aa20ae7e28938 Mon Sep 17 00:00:00 2001 From: Davide Date: Mon, 16 Dec 2024 23:20:29 +0100 Subject: [PATCH 01/14] Define iaps local variable --- Library/Sources/UILibrary/Views/Paywall/PaywallView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift b/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift index d518283ee..ca7a0a452 100644 --- a/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift +++ b/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift @@ -189,12 +189,14 @@ private extension PaywallView { guard !availableProducts.isEmpty else { throw AppError.emptyProducts } - iaps = try await iapManager.purchasableProducts(for: availableProducts) + let iaps = try await iapManager.purchasableProducts(for: availableProducts) pp_log(.App.iap, .info, "Suggested products: \(availableProducts)") pp_log(.App.iap, .info, "\tIAPs: \(iaps)") guard !iaps.isEmpty else { throw AppError.emptyProducts } + + self.iaps = iaps } catch { onError(error, dismissing: true) } From 4549cb2faf8c55de9ae77d4758da4df48a7ba9e9 Mon Sep 17 00:00:00 2001 From: Davide Date: Mon, 16 Dec 2024 23:31:45 +0100 Subject: [PATCH 02/14] Delay .reloadReceipt() to avoid onLaunch() lock-up --- Library/Sources/UILibrary/Business/AppContext.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Library/Sources/UILibrary/Business/AppContext.swift b/Library/Sources/UILibrary/Business/AppContext.swift index bb45007db..8acb1965a 100644 --- a/Library/Sources/UILibrary/Business/AppContext.swift +++ b/Library/Sources/UILibrary/Business/AppContext.swift @@ -98,7 +98,9 @@ private extension AppContext { pp_log(.App.profiles, .info, "\tObserve in-app events...") iapManager.observeObjects() - await iapManager.reloadReceipt() + Task { + await iapManager.reloadReceipt() + } pp_log(.App.profiles, .info, "\tObserve eligible features...") iapManager From d87a96358ecf1b0fc9abc9db543d0be8503902ee Mon Sep 17 00:00:00 2001 From: Davide Date: Mon, 16 Dec 2024 23:20:32 +0100 Subject: [PATCH 03/14] Append recurring products when .fullTV suggested --- .../CommonIAP/Domain/AppProduct+Features.swift | 11 +++++++++++ .../CommonLibrary/IAP/IAPManager+Suggestions.swift | 5 +++++ 2 files changed, 16 insertions(+) diff --git a/Library/Sources/CommonIAP/Domain/AppProduct+Features.swift b/Library/Sources/CommonIAP/Domain/AppProduct+Features.swift index 96f97e177..4422a44f3 100644 --- a/Library/Sources/CommonIAP/Domain/AppProduct+Features.swift +++ b/Library/Sources/CommonIAP/Domain/AppProduct+Features.swift @@ -81,6 +81,17 @@ extension AppProduct.Full { } } +extension AppProduct { + public var isRecurring: Bool { + switch self { + case .Full.Recurring.monthly, .Full.Recurring.yearly: + return true + default: + return false + } + } +} + // MARK: - Discontinued extension AppProduct.Features { diff --git a/Library/Sources/CommonLibrary/IAP/IAPManager+Suggestions.swift b/Library/Sources/CommonLibrary/IAP/IAPManager+Suggestions.swift index 5657dd732..ebf678032 100644 --- a/Library/Sources/CommonLibrary/IAP/IAPManager+Suggestions.swift +++ b/Library/Sources/CommonLibrary/IAP/IAPManager+Suggestions.swift @@ -64,6 +64,11 @@ extension IAPManager { } } + if list.contains(.Full.OneTime.fullTV) { + list.append(.Full.Recurring.monthly) + list.append(.Full.Recurring.yearly) + } + return list } } From 7f690d71a716a6a7cb1a07d1b9958433c02e6872 Mon Sep 17 00:00:00 2001 From: Davide Date: Mon, 16 Dec 2024 23:46:20 +0100 Subject: [PATCH 04/14] Return oneTime/recurring suggestions --- .../IAP/IAPManager+Suggestions.swift | 28 ++++---- .../UILibrary/Views/Paywall/PaywallView.swift | 67 +++++++++++++------ 2 files changed, 61 insertions(+), 34 deletions(-) diff --git a/Library/Sources/CommonLibrary/IAP/IAPManager+Suggestions.swift b/Library/Sources/CommonLibrary/IAP/IAPManager+Suggestions.swift index ebf678032..c819dbb56 100644 --- a/Library/Sources/CommonLibrary/IAP/IAPManager+Suggestions.swift +++ b/Library/Sources/CommonLibrary/IAP/IAPManager+Suggestions.swift @@ -33,42 +33,42 @@ extension IAPManager { purchasedProducts.contains(.Full.OneTime.full) || purchasedProducts.contains(.Full.OneTime.fullTV) || (purchasedProducts.contains(.Full.OneTime.iOS) && purchasedProducts.contains(.Full.OneTime.macOS)) } - public func suggestedProducts(for features: Set) -> [AppProduct] { + public func suggestedProducts(for features: Set) -> (oneTime: [AppProduct], recurring: [AppProduct])? { guard !features.isEmpty else { - return [] + return nil } guard !eligibleFeatures.isSuperset(of: features) else { - return [] + return nil } - var list: [AppProduct] = [] + var oneTime: [AppProduct] = [] let requiredFeatures = features.subtracting(eligibleFeatures) if isFullVersionPurchaser { if requiredFeatures == [.appleTV] { - list.append(.Features.appleTV) + oneTime.append(.Features.appleTV) } else { assertionFailure("Full version purchaser requiring other than [.appleTV]") } } else { // !isFullVersionPurchaser if requiredFeatures == [.appleTV] { - list.append(.Features.appleTV) - list.append(.Full.OneTime.fullTV) + oneTime.append(.Features.appleTV) + oneTime.append(.Full.OneTime.fullTV) } else if requiredFeatures.contains(.appleTV) { - list.append(.Full.OneTime.fullTV) + oneTime.append(.Full.OneTime.fullTV) } else { - list.append(.Full.OneTime.full) + oneTime.append(.Full.OneTime.full) if !eligibleFeatures.contains(.appleTV) { - list.append(.Full.OneTime.fullTV) + oneTime.append(.Full.OneTime.fullTV) } } } - if list.contains(.Full.OneTime.fullTV) { - list.append(.Full.Recurring.monthly) - list.append(.Full.Recurring.yearly) + var recurring: [AppProduct] = [] + if oneTime.contains(.Full.OneTime.fullTV) { + recurring = [.Full.Recurring.monthly, .Full.Recurring.yearly] } - return list + return (oneTime, recurring) } } diff --git a/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift b/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift index ca7a0a452..a432d143a 100644 --- a/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift +++ b/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift @@ -46,7 +46,10 @@ struct PaywallView: View { private var isFetchingProducts = true @State - private var iaps: [InAppProduct] = [] + private var oneTimeIAPs: [InAppProduct] = [] + + @State + private var recurringIAPs: [InAppProduct] = [] @State private var purchasingIdentifier: String? @@ -84,7 +87,8 @@ private extension PaywallView { var contentView: some View { Form { requiredFeaturesView - productsView + oneTimeProductsView + recurringProductsView if !iapManager.isFullVersionPurchaser { fullVersionFeaturesView } @@ -106,18 +110,35 @@ private extension PaywallView { ) } - var productsView: some View { - ForEach(iaps, id: \.productIdentifier) { - PaywallProductView( - iapManager: iapManager, - style: .recurring, - product: $0, - purchasingIdentifier: $purchasingIdentifier, - onComplete: onComplete, - onError: onError - ) + var oneTimeProductsView: some View { + oneTimeIAPs.nilIfEmpty.map { + ForEach($0, id: \.productIdentifier) { + PaywallProductView( + iapManager: iapManager, + style: .oneTime, + product: $0, + purchasingIdentifier: $purchasingIdentifier, + onComplete: onComplete, + onError: onError + ) + } + .themeSection(header: Strings.Global.Nouns.purchases) + } + } + + var recurringProductsView: some View { + recurringIAPs.nilIfEmpty.map { + ForEach($0, id: \.productIdentifier) { + PaywallProductView( + iapManager: iapManager, + style: .recurring, + product: $0, + purchasingIdentifier: $purchasingIdentifier, + onComplete: onComplete, + onError: onError + ) + } } - .themeSection(header: Strings.Global.Nouns.purchases) } var fullVersionFeaturesView: some View { @@ -185,19 +206,25 @@ private extension PaywallView { isFetchingProducts = false } do { - let availableProducts = iapManager.suggestedProducts(for: features) - guard !availableProducts.isEmpty else { + let suggestedProducts = iapManager.suggestedProducts(for: features) + guard let suggestedProducts else { throw AppError.emptyProducts } - let iaps = try await iapManager.purchasableProducts(for: availableProducts) - pp_log(.App.iap, .info, "Suggested products: \(availableProducts)") - pp_log(.App.iap, .info, "\tIAPs: \(iaps)") - guard !iaps.isEmpty else { + + let oneTimeIAPs = try await iapManager.purchasableProducts(for: suggestedProducts.oneTime) + let recurringIAPs = try await iapManager.purchasableProducts(for: suggestedProducts.recurring) + + pp_log(.App.iap, .info, "Suggested products: \(suggestedProducts)") + pp_log(.App.iap, .info, "\tOne-time: \(oneTimeIAPs)") + pp_log(.App.iap, .info, "\tRecurring: \(recurringIAPs)") + guard !(oneTimeIAPs + recurringIAPs).isEmpty else { throw AppError.emptyProducts } - self.iaps = iaps + self.oneTimeIAPs = oneTimeIAPs + self.recurringIAPs = recurringIAPs } catch { + pp_log(.App.iap, .error, "Unable to load purchasable products: \(error)") onError(error, dismissing: true) } } From 2673ad464b702905e7e4b502efd2ebddbfe232e5 Mon Sep 17 00:00:00 2001 From: Davide Date: Tue, 17 Dec 2024 00:33:21 +0100 Subject: [PATCH 05/14] Move recurring above --- .../UILibrary/Views/Paywall/PaywallView.swift | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift b/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift index a432d143a..9bf604a07 100644 --- a/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift +++ b/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift @@ -87,8 +87,8 @@ private extension PaywallView { var contentView: some View { Form { requiredFeaturesView - oneTimeProductsView recurringProductsView + oneTimeProductsView if !iapManager.isFullVersionPurchaser { fullVersionFeaturesView } @@ -110,34 +110,37 @@ private extension PaywallView { ) } - var oneTimeProductsView: some View { - oneTimeIAPs.nilIfEmpty.map { - ForEach($0, id: \.productIdentifier) { + var recurringProductsView: some View { + recurringIAPs.nilIfEmpty.map { iaps in + ForEach(iaps, id: \.productIdentifier) { PaywallProductView( iapManager: iapManager, - style: .oneTime, + style: .recurring, product: $0, purchasingIdentifier: $purchasingIdentifier, onComplete: onComplete, onError: onError ) } - .themeSection(header: Strings.Global.Nouns.purchases) + // FIXME: ### l10n + .themeSection(header: "Subscription") } } - var recurringProductsView: some View { - recurringIAPs.nilIfEmpty.map { + var oneTimeProductsView: some View { + oneTimeIAPs.nilIfEmpty.map { ForEach($0, id: \.productIdentifier) { PaywallProductView( iapManager: iapManager, - style: .recurring, + style: .oneTime, product: $0, purchasingIdentifier: $purchasingIdentifier, onComplete: onComplete, onError: onError ) } + // FIXME: ### l10n + .themeSection(header: "Lifetime") } } From 3383c79ce8b7ddf794e78b15233ed4b4d432c20e Mon Sep 17 00:00:00 2001 From: Davide Date: Tue, 17 Dec 2024 00:36:36 +0100 Subject: [PATCH 06/14] Lint --- .../Sources/LegacyV2/Strategy/CDProfileRepositoryV2.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Library/Sources/LegacyV2/Strategy/CDProfileRepositoryV2.swift b/Library/Sources/LegacyV2/Strategy/CDProfileRepositoryV2.swift index bafa7828a..35e04a6db 100644 --- a/Library/Sources/LegacyV2/Strategy/CDProfileRepositoryV2.swift +++ b/Library/Sources/LegacyV2/Strategy/CDProfileRepositoryV2.swift @@ -44,10 +44,7 @@ final class CDProfileRepositoryV2: Sendable { var hasMigratableProfiles: Bool { do { - return try context.performAndWait { [weak self] in - guard let self else { - return false - } + return try context.performAndWait { let entities = try CDProfile.fetchRequest().execute() return !entities.compactMap { ($0.encryptedJSON ?? $0.json) != nil From 97b115175f068e0b8d5da4b014888158f7e5c180 Mon Sep 17 00:00:00 2001 From: Davide Date: Tue, 17 Dec 2024 00:49:41 +0100 Subject: [PATCH 07/14] Fix tests --- .../xcschemes/Library-Package.xcscheme | 14 +++++++ .../Business/IAPManagerTests.swift | 38 +++++++++---------- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/Library/.swiftpm/xcode/xcshareddata/xcschemes/Library-Package.xcscheme b/Library/.swiftpm/xcode/xcshareddata/xcschemes/Library-Package.xcscheme index fca740694..eb7246227 100644 --- a/Library/.swiftpm/xcode/xcshareddata/xcschemes/Library-Package.xcscheme +++ b/Library/.swiftpm/xcode/xcshareddata/xcschemes/Library-Package.xcscheme @@ -147,6 +147,20 @@ ReferencedContainer = "container:"> + + + + Date: Tue, 17 Dec 2024 17:43:14 +0100 Subject: [PATCH 08/14] Externalize paywall section titles Drop unused: "views.paywall.sections.suggested_product.header" "views.paywall.sections.full_products.header" --- .../Sources/UILibrary/L10n/SwiftGen+Strings.swift | 12 ++++++------ .../UILibrary/Resources/en.lproj/Localizable.strings | 4 ++-- .../UILibrary/Views/Paywall/PaywallView.swift | 6 ++---- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift b/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift index 68786b847..c72cf8377 100644 --- a/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift +++ b/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift @@ -749,9 +749,9 @@ public enum Strings { /// Full version includes public static let header = Strings.tr("Localizable", "views.paywall.sections.all_features.header", fallback: "Full version includes") } - public enum FullProducts { - /// Full version - public static let header = Strings.tr("Localizable", "views.paywall.sections.full_products.header", fallback: "Full version") + public enum OneTime { + /// Lifetime + public static let header = Strings.tr("Localizable", "views.paywall.sections.one_time.header", fallback: "Lifetime") } public enum RequiredFeatures { /// Required features @@ -763,9 +763,9 @@ public enum Strings { /// Restore public static let header = Strings.tr("Localizable", "views.paywall.sections.restore.header", fallback: "Restore") } - public enum SuggestedProduct { - /// One-time purchase - public static let header = Strings.tr("Localizable", "views.paywall.sections.suggested_product.header", fallback: "One-time purchase") + public enum Subscription { + /// Subscription + public static let header = Strings.tr("Localizable", "views.paywall.sections.subscription.header", fallback: "Subscription") } } } diff --git a/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings b/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings index 38f04be88..11b03583a 100644 --- a/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings +++ b/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings @@ -78,8 +78,8 @@ "views.migration.alerts.delete.message" = "Do you want to discard these profiles? You will not be able to recover them later.\n\n%@"; "views.paywall.sections.required_features.header" = "Required features"; -"views.paywall.sections.suggested_product.header" = "One-time purchase"; -"views.paywall.sections.full_products.header" = "Full version"; +"views.paywall.sections.subscription.header" = "Subscription"; +"views.paywall.sections.one_time.header" = "Lifetime"; "views.paywall.sections.all_features.header" = "Full version includes"; "views.paywall.sections.restore.header" = "Restore"; "views.paywall.sections.restore.footer" = "If you bought this app or feature in the past, you can restore your purchases."; diff --git a/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift b/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift index 9bf604a07..dc0d9de02 100644 --- a/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift +++ b/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift @@ -122,8 +122,7 @@ private extension PaywallView { onError: onError ) } - // FIXME: ### l10n - .themeSection(header: "Subscription") + .themeSection(header: Strings.Views.Paywall.Sections.Subscription.header) } } @@ -139,8 +138,7 @@ private extension PaywallView { onError: onError ) } - // FIXME: ### l10n - .themeSection(header: "Lifetime") + .themeSection(header: Strings.Views.Paywall.Sections.OneTime.header) } } From aef650a3652ad6e746b254e951dd334403fe7c46 Mon Sep 17 00:00:00 2001 From: Davide Date: Tue, 17 Dec 2024 18:01:21 +0100 Subject: [PATCH 09/14] Regroup suggestions into features/full --- .../Domain/AppProduct+Features.swift | 9 ++++ .../IAP/IAPManager+Suggestions.swift | 36 ++++++++-------- .../UILibrary/L10n/SwiftGen+Strings.swift | 10 ++--- .../Resources/en.lproj/Localizable.strings | 3 +- .../Paywall/PaywallProductViewStyle.swift | 6 +-- .../UILibrary/Views/Paywall/PaywallView.swift | 42 ++++++++++--------- .../Views/Paywall/StoreKitProductView.swift | 10 ++--- 7 files changed, 61 insertions(+), 55 deletions(-) diff --git a/Library/Sources/CommonIAP/Domain/AppProduct+Features.swift b/Library/Sources/CommonIAP/Domain/AppProduct+Features.swift index 4422a44f3..7c6bc38cb 100644 --- a/Library/Sources/CommonIAP/Domain/AppProduct+Features.swift +++ b/Library/Sources/CommonIAP/Domain/AppProduct+Features.swift @@ -82,6 +82,15 @@ extension AppProduct.Full { } extension AppProduct { + public var isFullVersion: Bool { + switch self { + case .Full.OneTime.full, .Full.OneTime.fullTV, .Full.Recurring.monthly, .Full.Recurring.yearly: + return true + default: + return false + } + } + public var isRecurring: Bool { switch self { case .Full.Recurring.monthly, .Full.Recurring.yearly: diff --git a/Library/Sources/CommonLibrary/IAP/IAPManager+Suggestions.swift b/Library/Sources/CommonLibrary/IAP/IAPManager+Suggestions.swift index c819dbb56..d27558b94 100644 --- a/Library/Sources/CommonLibrary/IAP/IAPManager+Suggestions.swift +++ b/Library/Sources/CommonLibrary/IAP/IAPManager+Suggestions.swift @@ -33,42 +33,42 @@ extension IAPManager { purchasedProducts.contains(.Full.OneTime.full) || purchasedProducts.contains(.Full.OneTime.fullTV) || (purchasedProducts.contains(.Full.OneTime.iOS) && purchasedProducts.contains(.Full.OneTime.macOS)) } - public func suggestedProducts(for features: Set) -> (oneTime: [AppProduct], recurring: [AppProduct])? { - guard !features.isEmpty else { + public func suggestedProducts(for requiredFeatures: Set) -> [AppProduct]? { + guard !requiredFeatures.isEmpty else { return nil } - guard !eligibleFeatures.isSuperset(of: features) else { + guard !eligibleFeatures.isSuperset(of: requiredFeatures) else { return nil } - var oneTime: [AppProduct] = [] - let requiredFeatures = features.subtracting(eligibleFeatures) + var products: [AppProduct] = [] + let ineligibleFeatures = requiredFeatures.subtracting(eligibleFeatures) if isFullVersionPurchaser { - if requiredFeatures == [.appleTV] { - oneTime.append(.Features.appleTV) + if ineligibleFeatures == [.appleTV] { + products.append(.Features.appleTV) } else { assertionFailure("Full version purchaser requiring other than [.appleTV]") } } else { // !isFullVersionPurchaser - if requiredFeatures == [.appleTV] { - oneTime.append(.Features.appleTV) - oneTime.append(.Full.OneTime.fullTV) - } else if requiredFeatures.contains(.appleTV) { - oneTime.append(.Full.OneTime.fullTV) + if ineligibleFeatures == [.appleTV] { + products.append(.Features.appleTV) + products.append(.Full.OneTime.fullTV) + } else if ineligibleFeatures.contains(.appleTV) { + products.append(.Full.OneTime.fullTV) } else { - oneTime.append(.Full.OneTime.full) + products.append(.Full.OneTime.full) if !eligibleFeatures.contains(.appleTV) { - oneTime.append(.Full.OneTime.fullTV) + products.append(.Full.OneTime.fullTV) } } } - var recurring: [AppProduct] = [] - if oneTime.contains(.Full.OneTime.fullTV) { - recurring = [.Full.Recurring.monthly, .Full.Recurring.yearly] + if products.contains(.Full.OneTime.fullTV) { + products.insert(.Full.Recurring.monthly, at: 0) + products.insert(.Full.Recurring.yearly, at: 0) } - return (oneTime, recurring) + return products } } diff --git a/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift b/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift index c72cf8377..dce8c0343 100644 --- a/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift +++ b/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift @@ -749,9 +749,9 @@ public enum Strings { /// Full version includes public static let header = Strings.tr("Localizable", "views.paywall.sections.all_features.header", fallback: "Full version includes") } - public enum OneTime { - /// Lifetime - public static let header = Strings.tr("Localizable", "views.paywall.sections.one_time.header", fallback: "Lifetime") + public enum FullProducts { + /// Full version + public static let header = Strings.tr("Localizable", "views.paywall.sections.full_products.header", fallback: "Full version") } public enum RequiredFeatures { /// Required features @@ -763,10 +763,6 @@ public enum Strings { /// Restore public static let header = Strings.tr("Localizable", "views.paywall.sections.restore.header", fallback: "Restore") } - public enum Subscription { - /// Subscription - public static let header = Strings.tr("Localizable", "views.paywall.sections.subscription.header", fallback: "Subscription") - } } } public enum Preferences { diff --git a/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings b/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings index 11b03583a..8f68ec08b 100644 --- a/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings +++ b/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings @@ -78,8 +78,7 @@ "views.migration.alerts.delete.message" = "Do you want to discard these profiles? You will not be able to recover them later.\n\n%@"; "views.paywall.sections.required_features.header" = "Required features"; -"views.paywall.sections.subscription.header" = "Subscription"; -"views.paywall.sections.one_time.header" = "Lifetime"; +"views.paywall.sections.full_products.header" = "Full version"; "views.paywall.sections.all_features.header" = "Full version includes"; "views.paywall.sections.restore.header" = "Restore"; "views.paywall.sections.restore.footer" = "If you bought this app or feature in the past, you can restore your purchases."; diff --git a/Library/Sources/UILibrary/Views/Paywall/PaywallProductViewStyle.swift b/Library/Sources/UILibrary/Views/Paywall/PaywallProductViewStyle.swift index 8867c2d37..0d8793a8c 100644 --- a/Library/Sources/UILibrary/Views/Paywall/PaywallProductViewStyle.swift +++ b/Library/Sources/UILibrary/Views/Paywall/PaywallProductViewStyle.swift @@ -26,9 +26,7 @@ import Foundation public enum PaywallProductViewStyle { - case oneTime - - case recurring - case donation + + case paywall } diff --git a/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift b/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift index dc0d9de02..5ae79810c 100644 --- a/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift +++ b/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift @@ -46,10 +46,10 @@ struct PaywallView: View { private var isFetchingProducts = true @State - private var oneTimeIAPs: [InAppProduct] = [] + private var featureIAPs: [InAppProduct] = [] @State - private var recurringIAPs: [InAppProduct] = [] + private var fullIAPs: [InAppProduct] = [] @State private var purchasingIdentifier: String? @@ -87,8 +87,8 @@ private extension PaywallView { var contentView: some View { Form { requiredFeaturesView - recurringProductsView - oneTimeProductsView + featureProductsView + fullProductsView if !iapManager.isFullVersionPurchaser { fullVersionFeaturesView } @@ -110,35 +110,35 @@ private extension PaywallView { ) } - var recurringProductsView: some View { - recurringIAPs.nilIfEmpty.map { iaps in + var featureProductsView: some View { + featureIAPs.nilIfEmpty.map { iaps in ForEach(iaps, id: \.productIdentifier) { PaywallProductView( iapManager: iapManager, - style: .recurring, + style: .paywall, product: $0, purchasingIdentifier: $purchasingIdentifier, onComplete: onComplete, onError: onError ) } - .themeSection(header: Strings.Views.Paywall.Sections.Subscription.header) + .themeSection(header: Strings.Global.Nouns.products) } } - var oneTimeProductsView: some View { - oneTimeIAPs.nilIfEmpty.map { + var fullProductsView: some View { + fullIAPs.nilIfEmpty.map { ForEach($0, id: \.productIdentifier) { PaywallProductView( iapManager: iapManager, - style: .oneTime, + style: .paywall, product: $0, purchasingIdentifier: $purchasingIdentifier, onComplete: onComplete, onError: onError ) } - .themeSection(header: Strings.Views.Paywall.Sections.OneTime.header) + .themeSection(header: Strings.Views.Paywall.Sections.FullProducts.header) } } @@ -212,18 +212,22 @@ private extension PaywallView { throw AppError.emptyProducts } - let oneTimeIAPs = try await iapManager.purchasableProducts(for: suggestedProducts.oneTime) - let recurringIAPs = try await iapManager.purchasableProducts(for: suggestedProducts.recurring) + let featureIAPs = try await iapManager.purchasableProducts(for: suggestedProducts.filter { + !$0.isFullVersion + }) + let fullIAPs = try await iapManager.purchasableProducts(for: suggestedProducts.filter { + $0.isFullVersion + }) pp_log(.App.iap, .info, "Suggested products: \(suggestedProducts)") - pp_log(.App.iap, .info, "\tOne-time: \(oneTimeIAPs)") - pp_log(.App.iap, .info, "\tRecurring: \(recurringIAPs)") - guard !(oneTimeIAPs + recurringIAPs).isEmpty else { + pp_log(.App.iap, .info, "\tFeatures: \(featureIAPs)") + pp_log(.App.iap, .info, "\tFull version: \(fullIAPs)") + guard !(featureIAPs + fullIAPs).isEmpty else { throw AppError.emptyProducts } - self.oneTimeIAPs = oneTimeIAPs - self.recurringIAPs = recurringIAPs + self.featureIAPs = featureIAPs + self.fullIAPs = fullIAPs } catch { pp_log(.App.iap, .error, "Unable to load purchasable products: \(error)") onError(error, dismissing: true) diff --git a/Library/Sources/UILibrary/Views/Paywall/StoreKitProductView.swift b/Library/Sources/UILibrary/Views/Paywall/StoreKitProductView.swift index e38b9d052..32f380517 100644 --- a/Library/Sources/UILibrary/Views/Paywall/StoreKitProductView.swift +++ b/Library/Sources/UILibrary/Views/Paywall/StoreKitProductView.swift @@ -65,14 +65,14 @@ private extension ProductView { func withPaywallStyle(_ paywallStyle: PaywallProductViewStyle) -> some View { #if os(tvOS) switch paywallStyle { - case .oneTime, .recurring: - productViewStyle(.regular) - .listRowBackground(Color.clear) - .listRowInsets(.init()) - case .donation: productViewStyle(.compact) .padding() + + case .paywall: + productViewStyle(.regular) + .listRowBackground(Color.clear) + .listRowInsets(.init()) } #else productViewStyle(.compact) From 6cdb1abcc0c784afe0a36bdef20eda2d2f56291f Mon Sep 17 00:00:00 2001 From: Davide Date: Tue, 17 Dec 2024 18:04:35 +0100 Subject: [PATCH 10/14] Put full non-TV last --- Library/Sources/CommonLibrary/IAP/IAPManager+Suggestions.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Library/Sources/CommonLibrary/IAP/IAPManager+Suggestions.swift b/Library/Sources/CommonLibrary/IAP/IAPManager+Suggestions.swift index d27558b94..e271a07a2 100644 --- a/Library/Sources/CommonLibrary/IAP/IAPManager+Suggestions.swift +++ b/Library/Sources/CommonLibrary/IAP/IAPManager+Suggestions.swift @@ -57,10 +57,10 @@ extension IAPManager { } else if ineligibleFeatures.contains(.appleTV) { products.append(.Full.OneTime.fullTV) } else { - products.append(.Full.OneTime.full) if !eligibleFeatures.contains(.appleTV) { products.append(.Full.OneTime.fullTV) } + products.append(.Full.OneTime.full) } } From 3ca059beab2997c2f74bd480096c37c8f8255b40 Mon Sep 17 00:00:00 2001 From: Davide Date: Tue, 17 Dec 2024 18:15:19 +0100 Subject: [PATCH 11/14] Return suggestions set and sort in view --- .../IAP/IAPManager+Suggestions.swift | 20 ++++----- .../UILibrary/Views/Paywall/PaywallView.swift | 41 ++++++++++++++++--- 2 files changed, 45 insertions(+), 16 deletions(-) diff --git a/Library/Sources/CommonLibrary/IAP/IAPManager+Suggestions.swift b/Library/Sources/CommonLibrary/IAP/IAPManager+Suggestions.swift index e271a07a2..c67fcc7a1 100644 --- a/Library/Sources/CommonLibrary/IAP/IAPManager+Suggestions.swift +++ b/Library/Sources/CommonLibrary/IAP/IAPManager+Suggestions.swift @@ -33,7 +33,7 @@ extension IAPManager { purchasedProducts.contains(.Full.OneTime.full) || purchasedProducts.contains(.Full.OneTime.fullTV) || (purchasedProducts.contains(.Full.OneTime.iOS) && purchasedProducts.contains(.Full.OneTime.macOS)) } - public func suggestedProducts(for requiredFeatures: Set) -> [AppProduct]? { + public func suggestedProducts(for requiredFeatures: Set) -> Set? { guard !requiredFeatures.isEmpty else { return nil } @@ -41,32 +41,32 @@ extension IAPManager { return nil } - var products: [AppProduct] = [] + var products: Set = [] let ineligibleFeatures = requiredFeatures.subtracting(eligibleFeatures) if isFullVersionPurchaser { if ineligibleFeatures == [.appleTV] { - products.append(.Features.appleTV) + products.insert(.Features.appleTV) } else { assertionFailure("Full version purchaser requiring other than [.appleTV]") } } else { // !isFullVersionPurchaser if ineligibleFeatures == [.appleTV] { - products.append(.Features.appleTV) - products.append(.Full.OneTime.fullTV) + products.insert(.Features.appleTV) + products.insert(.Full.OneTime.fullTV) } else if ineligibleFeatures.contains(.appleTV) { - products.append(.Full.OneTime.fullTV) + products.insert(.Full.OneTime.fullTV) } else { if !eligibleFeatures.contains(.appleTV) { - products.append(.Full.OneTime.fullTV) + products.insert(.Full.OneTime.fullTV) } - products.append(.Full.OneTime.full) + products.insert(.Full.OneTime.full) } } if products.contains(.Full.OneTime.fullTV) { - products.insert(.Full.Recurring.monthly, at: 0) - products.insert(.Full.Recurring.yearly, at: 0) + products.insert(.Full.Recurring.monthly) + products.insert(.Full.Recurring.yearly) } return products diff --git a/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift b/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift index 5ae79810c..180380a74 100644 --- a/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift +++ b/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift @@ -212,12 +212,20 @@ private extension PaywallView { throw AppError.emptyProducts } - let featureIAPs = try await iapManager.purchasableProducts(for: suggestedProducts.filter { - !$0.isFullVersion - }) - let fullIAPs = try await iapManager.purchasableProducts(for: suggestedProducts.filter { - $0.isFullVersion - }) + let featureIAPs = try await iapManager.purchasableProducts(for: suggestedProducts + .filter { + !$0.isFullVersion + } + .sorted { + $0.featureRank < $1.featureRank + } + ) + let fullIAPs = try await iapManager.purchasableProducts(for: suggestedProducts + .filter(\.isFullVersion) + .sorted { + $0.fullVersionRank < $1.fullVersionRank + } + ) pp_log(.App.iap, .info, "Suggested products: \(suggestedProducts)") pp_log(.App.iap, .info, "\tFeatures: \(featureIAPs)") @@ -266,6 +274,27 @@ private extension PaywallView { } } +private extension AppProduct { + var featureRank: Int { + 0 + } + + var fullVersionRank: Int { + switch self { + case .Full.Recurring.yearly: + return 4 + case .Full.Recurring.monthly: + return 3 + case .Full.OneTime.fullTV: + return 2 + case .Full.OneTime.full: + return 1 + default: + return 0 + } + } +} + // MARK: - Previews #Preview { From a64b6ade0ca913eab870132629a3f7ff8a4559f4 Mon Sep 17 00:00:00 2001 From: Davide Date: Tue, 17 Dec 2024 18:16:49 +0100 Subject: [PATCH 12/14] Fix tests to include recurring --- .../Business/IAPManagerTests.swift | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/Library/Tests/CommonLibraryTests/Business/IAPManagerTests.swift b/Library/Tests/CommonLibraryTests/Business/IAPManagerTests.swift index c32b601aa..193a49a79 100644 --- a/Library/Tests/CommonLibraryTests/Business/IAPManagerTests.swift +++ b/Library/Tests/CommonLibraryTests/Business/IAPManagerTests.swift @@ -268,7 +268,9 @@ extension IAPManagerTests { func test_givenFree_whenRequireFeature_thenSuggestsFullAndFullTV() async { let sut = await IAPManager(products: []) - XCTAssertEqual(sut.suggestedProducts(for: [.dns])?.oneTime, [ + XCTAssertEqual(sut.suggestedProducts(for: [.dns]), [ + .Full.Recurring.yearly, + .Full.Recurring.monthly, .Full.OneTime.full, .Full.OneTime.fullTV ]) @@ -276,15 +278,19 @@ extension IAPManagerTests { func test_givenFree_whenRequireAppleTV_thenSuggestsAppleTVAndFullTV() async { let sut = await IAPManager(products: []) - XCTAssertEqual(sut.suggestedProducts(for: [.appleTV])?.oneTime, [ + XCTAssertEqual(sut.suggestedProducts(for: [.appleTV]), [ .Features.appleTV, + .Full.Recurring.yearly, + .Full.Recurring.monthly, .Full.OneTime.fullTV ]) } func test_givenFree_whenRequireFeatureAndAppleTV_thenSuggestsFullTV() async { let sut = await IAPManager(products: []) - XCTAssertEqual(sut.suggestedProducts(for: [.appleTV, .providers])?.oneTime, [ + XCTAssertEqual(sut.suggestedProducts(for: [.appleTV, .providers]), [ + .Full.Recurring.yearly, + .Full.Recurring.monthly, .Full.OneTime.fullTV ]) } @@ -296,39 +302,49 @@ extension IAPManagerTests { func test_givenCurrentPlatform_whenRequireAppleTV_thenSuggestsAppleTVAndFullTV() async { let sut = await IAPManager.withFullCurrentPlatform() - XCTAssertEqual(sut.suggestedProducts(for: [.appleTV])?.oneTime, [ + XCTAssertEqual(sut.suggestedProducts(for: [.appleTV]), [ .Features.appleTV, + .Full.Recurring.yearly, + .Full.Recurring.monthly, .Full.OneTime.fullTV ]) } func test_givenCurrentPlatform_whenRequireFeatureAndAppleTV_thenSuggestsAppleTVAndFullTV() async { let sut = await IAPManager.withFullCurrentPlatform() - XCTAssertEqual(sut.suggestedProducts(for: [.appleTV, .providers])?.oneTime, [ + XCTAssertEqual(sut.suggestedProducts(for: [.appleTV, .providers]), [ .Features.appleTV, + .Full.Recurring.yearly, + .Full.Recurring.monthly, .Full.OneTime.fullTV ]) } func test_givenOtherPlatform_whenRequireFeature_thenSuggestsFullAndFullTV() async { let sut = await IAPManager.withFullOtherPlatform() - XCTAssertEqual(sut.suggestedProducts(for: [.dns])?.oneTime, [ - .Full.OneTime.full, - .Full.OneTime.fullTV + XCTAssertEqual(sut.suggestedProducts(for: [.dns]), [ + .Full.Recurring.yearly, + .Full.Recurring.monthly, + .Full.OneTime.fullTV, + .Full.OneTime.full ]) } func test_givenOtherPlatform_whenRequireAppleTV_thenSuggestsAppleTVAndFullTV() async { let sut = await IAPManager.withFullOtherPlatform() - XCTAssertEqual(sut.suggestedProducts(for: [.appleTV])?.oneTime, [ + XCTAssertEqual(sut.suggestedProducts(for: [.appleTV]), [ .Features.appleTV, + .Full.Recurring.yearly, + .Full.Recurring.monthly, .Full.OneTime.fullTV ]) } func test_givenOtherPlatform_whenRequireFeatureAndAppleTV_thenSuggestsFullTV() async { let sut = await IAPManager.withFullOtherPlatform() - XCTAssertEqual(sut.suggestedProducts(for: [.appleTV, .providers])?.oneTime, [ + XCTAssertEqual(sut.suggestedProducts(for: [.appleTV, .providers]), [ + .Full.Recurring.yearly, + .Full.Recurring.monthly, .Full.OneTime.fullTV ]) } @@ -340,21 +356,21 @@ extension IAPManagerTests { func test_givenFull_whenRequireAppleTV_thenSuggestsAppleTV() async { let sut = await IAPManager(products: [.Full.OneTime.full]) - XCTAssertEqual(sut.suggestedProducts(for: [.appleTV])?.oneTime, [ + XCTAssertEqual(sut.suggestedProducts(for: [.appleTV]), [ .Features.appleTV ]) } func test_givenFull_whenRequireFeatureAndAppleTV_thenSuggestsAppleTV() async { let sut = await IAPManager(products: [.Full.OneTime.full]) - XCTAssertEqual(sut.suggestedProducts(for: [.appleTV, .providers])?.oneTime, [ + XCTAssertEqual(sut.suggestedProducts(for: [.appleTV, .providers]), [ .Features.appleTV ]) } func test_givenAppleTV_whenRequireFeature_thenSuggestsFull() async { let sut = await IAPManager(products: [.Features.appleTV]) - XCTAssertEqual(sut.suggestedProducts(for: [.dns])?.oneTime, [ + XCTAssertEqual(sut.suggestedProducts(for: [.dns]), [ .Full.OneTime.full ]) } @@ -366,7 +382,7 @@ extension IAPManagerTests { func test_givenAppleTV_whenRequireFeatureAndAppleTV_thenSuggestsFull() async { let sut = await IAPManager(products: [.Features.appleTV]) - XCTAssertEqual(sut.suggestedProducts(for: [.appleTV, .providers])?.oneTime, [ + XCTAssertEqual(sut.suggestedProducts(for: [.appleTV, .providers]), [ .Full.OneTime.full ]) } From 6f52b45fa901584fc706ff6843c0ff1f361f1c40 Mon Sep 17 00:00:00 2001 From: Davide Date: Tue, 17 Dec 2024 18:17:26 +0100 Subject: [PATCH 13/14] Return recurring as option --- .../Sources/CommonLibrary/IAP/IAPManager+Suggestions.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Library/Sources/CommonLibrary/IAP/IAPManager+Suggestions.swift b/Library/Sources/CommonLibrary/IAP/IAPManager+Suggestions.swift index c67fcc7a1..fd8530cc8 100644 --- a/Library/Sources/CommonLibrary/IAP/IAPManager+Suggestions.swift +++ b/Library/Sources/CommonLibrary/IAP/IAPManager+Suggestions.swift @@ -33,7 +33,7 @@ extension IAPManager { purchasedProducts.contains(.Full.OneTime.full) || purchasedProducts.contains(.Full.OneTime.fullTV) || (purchasedProducts.contains(.Full.OneTime.iOS) && purchasedProducts.contains(.Full.OneTime.macOS)) } - public func suggestedProducts(for requiredFeatures: Set) -> Set? { + public func suggestedProducts(for requiredFeatures: Set, withRecurring: Bool = true) -> Set? { guard !requiredFeatures.isEmpty else { return nil } @@ -64,7 +64,7 @@ extension IAPManager { } } - if products.contains(.Full.OneTime.fullTV) { + if withRecurring && products.contains(.Full.OneTime.fullTV) { products.insert(.Full.Recurring.monthly) products.insert(.Full.Recurring.yearly) } From 33d7649bfbfe4e50f3d0252ac73eca0cb20e29b6 Mon Sep 17 00:00:00 2001 From: Davide Date: Tue, 17 Dec 2024 18:19:08 +0100 Subject: [PATCH 14/14] Fix sorting --- Library/Sources/UILibrary/Views/Paywall/PaywallView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift b/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift index 180380a74..8b91c61b5 100644 --- a/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift +++ b/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift @@ -282,15 +282,15 @@ private extension AppProduct { var fullVersionRank: Int { switch self { case .Full.Recurring.yearly: - return 4 + return .min case .Full.Recurring.monthly: - return 3 + return 1 case .Full.OneTime.fullTV: return 2 case .Full.OneTime.full: - return 1 + return 3 default: - return 0 + return .max } } }