Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add recurring products to paywall #1019

Merged
merged 14 commits into from
Dec 17, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,20 @@
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "UIAccessibility"
BuildableName = "UIAccessibility"
BlueprintName = "UIAccessibility"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
Expand Down
20 changes: 20 additions & 0 deletions Library/Sources/CommonIAP/Domain/AppProduct+Features.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,26 @@ 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:
return true
default:
return false
}
}
}

// MARK: - Discontinued

extension AppProduct.Features {
Expand Down
39 changes: 22 additions & 17 deletions Library/Sources/CommonLibrary/IAP/IAPManager+Suggestions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,37 +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<AppFeature>) -> [AppProduct] {
guard !features.isEmpty else {
return []
public func suggestedProducts(for requiredFeatures: Set<AppFeature>, withRecurring: Bool = true) -> Set<AppProduct>? {
guard !requiredFeatures.isEmpty else {
return nil
}
guard !eligibleFeatures.isSuperset(of: features) else {
return []
guard !eligibleFeatures.isSuperset(of: requiredFeatures) else {
return nil
}

var list: [AppProduct] = []
let requiredFeatures = features.subtracting(eligibleFeatures)
var products: Set<AppProduct> = []
let ineligibleFeatures = requiredFeatures.subtracting(eligibleFeatures)

if isFullVersionPurchaser {
if requiredFeatures == [.appleTV] {
list.append(.Features.appleTV)
if ineligibleFeatures == [.appleTV] {
products.insert(.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)
} else if requiredFeatures.contains(.appleTV) {
list.append(.Full.OneTime.fullTV)
if ineligibleFeatures == [.appleTV] {
products.insert(.Features.appleTV)
products.insert(.Full.OneTime.fullTV)
} else if ineligibleFeatures.contains(.appleTV) {
products.insert(.Full.OneTime.fullTV)
} else {
list.append(.Full.OneTime.full)
if !eligibleFeatures.contains(.appleTV) {
list.append(.Full.OneTime.fullTV)
products.insert(.Full.OneTime.fullTV)
}
products.insert(.Full.OneTime.full)
}
}

return list
if withRecurring && products.contains(.Full.OneTime.fullTV) {
products.insert(.Full.Recurring.monthly)
products.insert(.Full.Recurring.yearly)
}

return products
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion Library/Sources/UILibrary/Business/AppContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 0 additions & 4 deletions Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -763,10 +763,6 @@ 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 Preferences {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@
"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.all_features.header" = "Full version includes";
"views.paywall.sections.restore.header" = "Restore";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,7 @@
import Foundation

public enum PaywallProductViewStyle {
case oneTime

case recurring

case donation

case paywall
}
101 changes: 82 additions & 19 deletions Library/Sources/UILibrary/Views/Paywall/PaywallView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ struct PaywallView: View {
private var isFetchingProducts = true

@State
private var iaps: [InAppProduct] = []
private var featureIAPs: [InAppProduct] = []

@State
private var fullIAPs: [InAppProduct] = []

@State
private var purchasingIdentifier: String?
Expand Down Expand Up @@ -84,7 +87,8 @@ private extension PaywallView {
var contentView: some View {
Form {
requiredFeaturesView
productsView
featureProductsView
fullProductsView
if !iapManager.isFullVersionPurchaser {
fullVersionFeaturesView
}
Expand All @@ -106,18 +110,36 @@ 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 featureProductsView: some View {
featureIAPs.nilIfEmpty.map { iaps in
ForEach(iaps, id: \.productIdentifier) {
PaywallProductView(
iapManager: iapManager,
style: .paywall,
product: $0,
purchasingIdentifier: $purchasingIdentifier,
onComplete: onComplete,
onError: onError
)
}
.themeSection(header: Strings.Global.Nouns.products)
}
}

var fullProductsView: some View {
fullIAPs.nilIfEmpty.map {
ForEach($0, id: \.productIdentifier) {
PaywallProductView(
iapManager: iapManager,
style: .paywall,
product: $0,
purchasingIdentifier: $purchasingIdentifier,
onComplete: onComplete,
onError: onError
)
}
.themeSection(header: Strings.Views.Paywall.Sections.FullProducts.header)
}
.themeSection(header: Strings.Global.Nouns.purchases)
}

var fullVersionFeaturesView: some View {
Expand Down Expand Up @@ -185,17 +207,37 @@ 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
}
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 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)")
pp_log(.App.iap, .info, "\tFull version: \(fullIAPs)")
guard !(featureIAPs + fullIAPs).isEmpty else {
throw AppError.emptyProducts
}

self.featureIAPs = featureIAPs
self.fullIAPs = fullIAPs
} catch {
pp_log(.App.iap, .error, "Unable to load purchasable products: \(error)")
onError(error, dismissing: true)
}
}
Expand Down Expand Up @@ -232,6 +274,27 @@ private extension PaywallView {
}
}

private extension AppProduct {
var featureRank: Int {
0
}

var fullVersionRank: Int {
switch self {
case .Full.Recurring.yearly:
return .min
case .Full.Recurring.monthly:
return 1
case .Full.OneTime.fullTV:
return 2
case .Full.OneTime.full:
return 3
default:
return .max
}
}
}

// MARK: - Previews

#Preview {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading