Simulate in-app purchases (#818)
Integrate in-app helper into IAPManager and simulate purchases with an in-memory receipt.
This commit is contained in:
parent
9351ceeb6a
commit
d5ac785bb8
|
@ -63,6 +63,7 @@ struct OnDemandView: View, ModuleDraftEditing {
|
||||||
.modifier(PurchaseButtonModifier(
|
.modifier(PurchaseButtonModifier(
|
||||||
Strings.Modules.OnDemand.purchase,
|
Strings.Modules.OnDemand.purchase,
|
||||||
feature: .onDemand,
|
feature: .onDemand,
|
||||||
|
suggesting: nil,
|
||||||
showsIfRestricted: false,
|
showsIfRestricted: false,
|
||||||
paywallReason: $paywallReason
|
paywallReason: $paywallReason
|
||||||
))
|
))
|
||||||
|
|
|
@ -59,6 +59,7 @@ private extension AppleTVSection {
|
||||||
.modifier(PurchaseButtonModifier(
|
.modifier(PurchaseButtonModifier(
|
||||||
Strings.Modules.General.Rows.AppleTv.purchase,
|
Strings.Modules.General.Rows.AppleTv.purchase,
|
||||||
feature: .appleTV,
|
feature: .appleTV,
|
||||||
|
suggesting: .Features.appleTV,
|
||||||
showsIfRestricted: true,
|
showsIfRestricted: true,
|
||||||
paywallReason: $paywallReason
|
paywallReason: $paywallReason
|
||||||
))
|
))
|
||||||
|
@ -72,7 +73,7 @@ private extension AppleTVSection {
|
||||||
let purchaseDesc = {
|
let purchaseDesc = {
|
||||||
Strings.Modules.General.Sections.AppleTv.Footer.Purchase._2
|
Strings.Modules.General.Sections.AppleTv.Footer.Purchase._2
|
||||||
}
|
}
|
||||||
switch iapManager.paywallReason(forFeature: .appleTV) {
|
switch iapManager.paywallReason(forFeature: .appleTV, suggesting: nil) {
|
||||||
case .purchase:
|
case .purchase:
|
||||||
desc.append(expirationDesc())
|
desc.append(expirationDesc())
|
||||||
desc.append(purchaseDesc())
|
desc.append(purchaseDesc())
|
||||||
|
|
|
@ -104,13 +104,13 @@ private extension ProfileCoordinator {
|
||||||
func onNewModule(_ moduleType: ModuleType) {
|
func onNewModule(_ moduleType: ModuleType) {
|
||||||
switch moduleType {
|
switch moduleType {
|
||||||
case .dns:
|
case .dns:
|
||||||
paywallReason = iapManager.paywallReason(forFeature: .dns)
|
paywallReason = iapManager.paywallReason(forFeature: .dns, suggesting: nil)
|
||||||
|
|
||||||
case .httpProxy:
|
case .httpProxy:
|
||||||
paywallReason = iapManager.paywallReason(forFeature: .httpProxy)
|
paywallReason = iapManager.paywallReason(forFeature: .httpProxy, suggesting: nil)
|
||||||
|
|
||||||
case .ip:
|
case .ip:
|
||||||
paywallReason = iapManager.paywallReason(forFeature: .routing)
|
paywallReason = iapManager.paywallReason(forFeature: .routing, suggesting: nil)
|
||||||
|
|
||||||
case .openVPN, .wireGuard:
|
case .openVPN, .wireGuard:
|
||||||
break
|
break
|
||||||
|
|
|
@ -137,6 +137,7 @@ private extension ProviderContentModifier {
|
||||||
.modifier(PurchaseButtonModifier(
|
.modifier(PurchaseButtonModifier(
|
||||||
Strings.Providers.Picker.purchase,
|
Strings.Providers.Picker.purchase,
|
||||||
feature: .providers,
|
feature: .providers,
|
||||||
|
suggesting: nil,
|
||||||
showsIfRestricted: true,
|
showsIfRestricted: true,
|
||||||
paywallReason: $paywallReason
|
paywallReason: $paywallReason
|
||||||
))
|
))
|
||||||
|
|
|
@ -31,6 +31,9 @@ extension AppProduct {
|
||||||
|
|
||||||
public static let appleTV = AppProduct(featureId: "appletv")
|
public static let appleTV = AppProduct(featureId: "appletv")
|
||||||
|
|
||||||
|
// FIXME: #585, add in-app product
|
||||||
|
public static let interactiveLogin = AppProduct(featureId: "interactive_login")
|
||||||
|
|
||||||
public static let networkSettings = AppProduct(featureId: "network_settings")
|
public static let networkSettings = AppProduct(featureId: "network_settings")
|
||||||
|
|
||||||
public static let siriShortcuts = AppProduct(featureId: "siri")
|
public static let siriShortcuts = AppProduct(featureId: "siri")
|
||||||
|
@ -40,6 +43,7 @@ extension AppProduct {
|
||||||
static let all: [AppProduct] = [
|
static let all: [AppProduct] = [
|
||||||
.Features.allProviders,
|
.Features.allProviders,
|
||||||
.Features.appleTV,
|
.Features.appleTV,
|
||||||
|
.Features.interactiveLogin,
|
||||||
.Features.networkSettings,
|
.Features.networkSettings,
|
||||||
.Features.siriShortcuts,
|
.Features.siriShortcuts,
|
||||||
.Features.trustedNetworks
|
.Features.trustedNetworks
|
||||||
|
|
|
@ -27,12 +27,12 @@ import CommonUtils
|
||||||
import Foundation
|
import Foundation
|
||||||
import PassepartoutKit
|
import PassepartoutKit
|
||||||
|
|
||||||
// FIXME: #424, reload receipt + objectWillChange on purchase/transactions
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public final class IAPManager: ObservableObject {
|
public final class IAPManager: ObservableObject {
|
||||||
private let customUserLevel: AppUserLevel?
|
private let customUserLevel: AppUserLevel?
|
||||||
|
|
||||||
|
private let inAppHelper: any AppProductHelper
|
||||||
|
|
||||||
private let receiptReader: any AppReceiptReader
|
private let receiptReader: any AppReceiptReader
|
||||||
|
|
||||||
private let unrestrictedFeatures: Set<AppFeature>
|
private let unrestrictedFeatures: Set<AppFeature>
|
||||||
|
@ -49,17 +49,56 @@ public final class IAPManager: ObservableObject {
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
customUserLevel: AppUserLevel? = nil,
|
customUserLevel: AppUserLevel? = nil,
|
||||||
|
inAppHelper: any AppProductHelper,
|
||||||
receiptReader: any AppReceiptReader,
|
receiptReader: any AppReceiptReader,
|
||||||
unrestrictedFeatures: Set<AppFeature> = [],
|
unrestrictedFeatures: Set<AppFeature> = [],
|
||||||
productsAtBuild: BuildProducts<AppProduct>? = nil
|
productsAtBuild: BuildProducts<AppProduct>? = nil
|
||||||
) {
|
) {
|
||||||
self.customUserLevel = customUserLevel
|
self.customUserLevel = customUserLevel
|
||||||
|
self.inAppHelper = inAppHelper
|
||||||
self.receiptReader = receiptReader
|
self.receiptReader = receiptReader
|
||||||
self.unrestrictedFeatures = unrestrictedFeatures
|
self.unrestrictedFeatures = unrestrictedFeatures
|
||||||
self.productsAtBuild = productsAtBuild
|
self.productsAtBuild = productsAtBuild
|
||||||
userLevel = .undefined
|
userLevel = .undefined
|
||||||
purchasedProducts = []
|
purchasedProducts = []
|
||||||
eligibleFeatures = []
|
eligibleFeatures = []
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await inAppHelper.fetchProducts()
|
||||||
|
} catch {
|
||||||
|
pp_log(.app, .error, "Unable to fetch in-app products: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func products(for identifiers: Set<AppProduct>) async -> [InAppProduct] {
|
||||||
|
let raw = identifiers.map(\.rawValue)
|
||||||
|
return await inAppHelper.products
|
||||||
|
.values
|
||||||
|
.filter {
|
||||||
|
raw.contains($0.productIdentifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func purchase(_ inAppProduct: InAppProduct) async throws -> InAppPurchaseResult {
|
||||||
|
guard let product = AppProduct(rawValue: inAppProduct.productIdentifier) else {
|
||||||
|
return .notFound
|
||||||
|
}
|
||||||
|
return try await purchase(product)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func purchase(_ product: AppProduct) async throws -> InAppPurchaseResult {
|
||||||
|
let result = try await inAppHelper.purchase(productWithIdentifier: product)
|
||||||
|
if result == .done {
|
||||||
|
await reloadReceipt()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
public func restorePurchases() async throws {
|
||||||
|
try await inAppHelper.restorePurchases()
|
||||||
|
await reloadReceipt()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func reloadReceipt() async {
|
public func reloadReceipt() async {
|
||||||
|
@ -166,11 +205,11 @@ extension IAPManager {
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
public func paywallReason(forFeature feature: AppFeature) -> PaywallReason? {
|
public func paywallReason(forFeature feature: AppFeature, suggesting product: AppProduct?) -> PaywallReason? {
|
||||||
if isEligible(for: feature) {
|
if isEligible(for: feature) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return isRestricted ? .restricted : .purchase(feature)
|
return isRestricted ? .restricted : .purchase(feature, product)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func isPayingUser() -> Bool {
|
public func isPayingUser() -> Bool {
|
||||||
|
|
|
@ -28,5 +28,5 @@ import Foundation
|
||||||
public enum PaywallReason: Hashable {
|
public enum PaywallReason: Hashable {
|
||||||
case restricted
|
case restricted
|
||||||
|
|
||||||
case purchase(AppFeature)
|
case purchase(AppFeature, AppProduct?)
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,10 +27,17 @@ import CommonUtils
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public actor MockAppProductHelper: AppProductHelper {
|
public actor MockAppProductHelper: AppProductHelper {
|
||||||
|
private let build: Int
|
||||||
|
|
||||||
public private(set) var products: [AppProduct: InAppProduct]
|
public private(set) var products: [AppProduct: InAppProduct]
|
||||||
|
|
||||||
public init() {
|
public nonisolated let receiptReader: MockAppReceiptReader
|
||||||
|
|
||||||
|
// set .max to skip entitled products
|
||||||
|
public init(build: Int = .max) {
|
||||||
|
self.build = build
|
||||||
products = [:]
|
products = [:]
|
||||||
|
receiptReader = MockAppReceiptReader()
|
||||||
}
|
}
|
||||||
|
|
||||||
public nonisolated var canMakePurchases: Bool {
|
public nonisolated var canMakePurchases: Bool {
|
||||||
|
@ -42,14 +49,16 @@ public actor MockAppProductHelper: AppProductHelper {
|
||||||
$0[$1] = InAppProduct(
|
$0[$1] = InAppProduct(
|
||||||
productIdentifier: $1.rawValue,
|
productIdentifier: $1.rawValue,
|
||||||
localizedTitle: $1.rawValue,
|
localizedTitle: $1.rawValue,
|
||||||
localizedPrice: "10.0",
|
localizedPrice: "€10.0",
|
||||||
native: $1
|
native: $1
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
await receiptReader.setReceipt(withBuild: build, products: [])
|
||||||
}
|
}
|
||||||
|
|
||||||
public func purchase(productWithIdentifier productIdentifier: AppProduct) async throws -> InAppPurchaseResult {
|
public func purchase(productWithIdentifier productIdentifier: AppProduct) async throws -> InAppPurchaseResult {
|
||||||
.done
|
await receiptReader.addPurchase(with: productIdentifier)
|
||||||
|
return .done
|
||||||
}
|
}
|
||||||
|
|
||||||
public func restorePurchases() async throws {
|
public func restorePurchases() async throws {
|
||||||
|
|
|
@ -35,13 +35,38 @@ public actor MockAppReceiptReader: AppReceiptReader {
|
||||||
|
|
||||||
public func setReceipt(withBuild build: Int, products: [AppProduct], cancelledProducts: Set<AppProduct> = []) {
|
public func setReceipt(withBuild build: Int, products: [AppProduct], cancelledProducts: Set<AppProduct> = []) {
|
||||||
receipt = InAppReceipt(originalBuildNumber: build, purchaseReceipts: products.map {
|
receipt = InAppReceipt(originalBuildNumber: build, purchaseReceipts: products.map {
|
||||||
.init(productIdentifier: $0.rawValue,
|
.init(
|
||||||
|
productIdentifier: $0.rawValue,
|
||||||
cancellationDate: cancelledProducts.contains($0) ? Date() : nil,
|
cancellationDate: cancelledProducts.contains($0) ? Date() : nil,
|
||||||
originalPurchaseDate: nil)
|
originalPurchaseDate: nil
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public func receipt(for userLevel: AppUserLevel) -> InAppReceipt? {
|
public func receipt(for userLevel: AppUserLevel) -> InAppReceipt? {
|
||||||
receipt
|
receipt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func addPurchase(with product: AppProduct) {
|
||||||
|
guard let receipt else {
|
||||||
|
fatalError("Call setReceipt() first")
|
||||||
|
}
|
||||||
|
var purchaseReceipts = receipt.purchaseReceipts ?? []
|
||||||
|
purchaseReceipts.append(product.purchaseReceipt)
|
||||||
|
let newReceipt = InAppReceipt(
|
||||||
|
originalBuildNumber: receipt.originalBuildNumber,
|
||||||
|
purchaseReceipts: purchaseReceipts
|
||||||
|
)
|
||||||
|
self.receipt = newReceipt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension AppProduct {
|
||||||
|
var purchaseReceipt: InAppReceipt.PurchaseReceipt {
|
||||||
|
.init(
|
||||||
|
productIdentifier: rawValue,
|
||||||
|
cancellationDate: nil,
|
||||||
|
originalPurchaseDate: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -83,12 +83,12 @@ public final class StoreKitHelper<PID>: InAppHelper where PID: RawRepresentable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: #424, implement purchase
|
// FIXME: #585, implement purchase
|
||||||
public func purchase(productWithIdentifier productIdentifier: ProductIdentifier) async throws -> InAppPurchaseResult {
|
public func purchase(productWithIdentifier productIdentifier: ProductIdentifier) async throws -> InAppPurchaseResult {
|
||||||
fatalError("purchase")
|
fatalError("purchase")
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: #424, implement restore purchases
|
// FIXME: #585, implement restore purchases
|
||||||
public func restorePurchases() async throws {
|
public func restorePurchases() async throws {
|
||||||
fatalError("restorePurchases")
|
fatalError("restorePurchases")
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,58 @@
|
||||||
|
//
|
||||||
|
// AppFeature+L10n.swift
|
||||||
|
// Passepartout
|
||||||
|
//
|
||||||
|
// Created by Davide De Rosa on 11/5/24.
|
||||||
|
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
|
||||||
|
//
|
||||||
|
// https://github.com/passepartoutvpn
|
||||||
|
//
|
||||||
|
// This file is part of Passepartout.
|
||||||
|
//
|
||||||
|
// Passepartout is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// Passepartout is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU General Public License
|
||||||
|
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
//
|
||||||
|
|
||||||
|
import CommonLibrary
|
||||||
|
import CommonUtils
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension AppFeature: LocalizableEntity {
|
||||||
|
public var localizedDescription: String {
|
||||||
|
switch self {
|
||||||
|
case .appleTV:
|
||||||
|
return Strings.Unlocalized.appleTV
|
||||||
|
|
||||||
|
case .dns:
|
||||||
|
return Strings.Unlocalized.dns
|
||||||
|
|
||||||
|
case .httpProxy:
|
||||||
|
return Strings.Unlocalized.httpProxy
|
||||||
|
|
||||||
|
case .interactiveLogin:
|
||||||
|
return Strings.Features.interactiveLogin
|
||||||
|
|
||||||
|
case .onDemand:
|
||||||
|
return Strings.Global.onDemand
|
||||||
|
|
||||||
|
case .providers:
|
||||||
|
return Strings.Features.providers
|
||||||
|
|
||||||
|
case .routing:
|
||||||
|
return Strings.Global.routing
|
||||||
|
|
||||||
|
case .siri:
|
||||||
|
return Strings.Features.siri
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -174,6 +174,14 @@ public enum Strings {
|
||||||
public static let tls = Strings.tr("Localizable", "errors.tunnel.tls", fallback: "TLS failed")
|
public static let tls = Strings.tr("Localizable", "errors.tunnel.tls", fallback: "TLS failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
public enum Features {
|
||||||
|
/// Interactive login
|
||||||
|
public static let interactiveLogin = Strings.tr("Localizable", "features.interactive_login", fallback: "Interactive login")
|
||||||
|
/// Providers
|
||||||
|
public static let providers = Strings.tr("Localizable", "features.providers", fallback: "Providers")
|
||||||
|
/// Shortcuts
|
||||||
|
public static let siri = Strings.tr("Localizable", "features.siri", fallback: "Shortcuts")
|
||||||
|
}
|
||||||
public enum Global {
|
public enum Global {
|
||||||
/// About
|
/// About
|
||||||
public static let about = Strings.tr("Localizable", "global.about", fallback: "About")
|
public static let about = Strings.tr("Localizable", "global.about", fallback: "About")
|
||||||
|
|
|
@ -35,6 +35,7 @@ extension AppContext {
|
||||||
public static func mock(withRegistry registry: Registry) -> AppContext {
|
public static func mock(withRegistry registry: Registry) -> AppContext {
|
||||||
let iapManager = IAPManager(
|
let iapManager = IAPManager(
|
||||||
customUserLevel: nil,
|
customUserLevel: nil,
|
||||||
|
inAppHelper: MockAppProductHelper(),
|
||||||
receiptReader: MockAppReceiptReader(),
|
receiptReader: MockAppReceiptReader(),
|
||||||
unrestrictedFeatures: [
|
unrestrictedFeatures: [
|
||||||
.interactiveLogin,
|
.interactiveLogin,
|
||||||
|
|
|
@ -252,6 +252,10 @@
|
||||||
|
|
||||||
// MARK: - Paywalls
|
// MARK: - Paywalls
|
||||||
|
|
||||||
|
"features.interactive_login" = "Interactive login";
|
||||||
|
"features.providers" = "Providers";
|
||||||
|
"features.siri" = "Shortcuts";
|
||||||
|
|
||||||
"modules.general.sections.apple_tv.footer.purchase.1" = "TV profiles expire after %d minutes.";
|
"modules.general.sections.apple_tv.footer.purchase.1" = "TV profiles expire after %d minutes.";
|
||||||
"modules.general.sections.apple_tv.footer.purchase.2" = "Purchase to drop the restriction.";
|
"modules.general.sections.apple_tv.footer.purchase.2" = "Purchase to drop the restriction.";
|
||||||
"modules.general.rows.apple_tv.purchase" = "Drop time restriction";
|
"modules.general.rows.apple_tv.purchase" = "Drop time restriction";
|
||||||
|
|
|
@ -77,6 +77,7 @@ public struct OpenVPNCredentialsView: View {
|
||||||
.modifier(PurchaseButtonModifier(
|
.modifier(PurchaseButtonModifier(
|
||||||
Strings.Modules.Openvpn.Credentials.Interactive.purchase,
|
Strings.Modules.Openvpn.Credentials.Interactive.purchase,
|
||||||
feature: .interactiveLogin,
|
feature: .interactiveLogin,
|
||||||
|
suggesting: .Features.interactiveLogin,
|
||||||
showsIfRestricted: false,
|
showsIfRestricted: false,
|
||||||
paywallReason: $paywallReason
|
paywallReason: $paywallReason
|
||||||
))
|
))
|
||||||
|
|
|
@ -35,7 +35,7 @@ public struct PaywallModifier: ViewModifier {
|
||||||
private var isPresentingRestricted = false
|
private var isPresentingRestricted = false
|
||||||
|
|
||||||
@State
|
@State
|
||||||
private var paywallFeature: AppFeature?
|
private var paywallArguments: PaywallArguments?
|
||||||
|
|
||||||
public init(reason: Binding<PaywallReason?>) {
|
public init(reason: Binding<PaywallReason?>) {
|
||||||
_reason = reason
|
_reason = reason
|
||||||
|
@ -56,9 +56,13 @@ public struct PaywallModifier: ViewModifier {
|
||||||
Text(Strings.Alerts.Iap.Restricted.message)
|
Text(Strings.Alerts.Iap.Restricted.message)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.themeModal(item: $paywallFeature) { feature in
|
.themeModal(item: $paywallArguments) { args in
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
PaywallView(isPresented: isPresentingPurchase, feature: feature)
|
PaywallView(
|
||||||
|
isPresented: isPresentingPurchase,
|
||||||
|
feature: args.feature,
|
||||||
|
suggestedProduct: args.product
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: reason) {
|
.onChange(of: reason) {
|
||||||
|
@ -66,8 +70,8 @@ public struct PaywallModifier: ViewModifier {
|
||||||
case .restricted:
|
case .restricted:
|
||||||
isPresentingRestricted = true
|
isPresentingRestricted = true
|
||||||
|
|
||||||
case .purchase(let feature):
|
case .purchase(let feature, let product):
|
||||||
paywallFeature = feature
|
paywallArguments = PaywallArguments(feature: feature, product: product)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
|
@ -79,11 +83,23 @@ public struct PaywallModifier: ViewModifier {
|
||||||
private extension PaywallModifier {
|
private extension PaywallModifier {
|
||||||
var isPresentingPurchase: Binding<Bool> {
|
var isPresentingPurchase: Binding<Bool> {
|
||||||
Binding {
|
Binding {
|
||||||
paywallFeature != nil
|
paywallArguments != nil
|
||||||
} set: {
|
} set: {
|
||||||
if !$0 {
|
if !$0 {
|
||||||
paywallFeature = nil
|
// make sure to reset this to allow paywall to appear again
|
||||||
|
reason = nil
|
||||||
|
paywallArguments = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct PaywallArguments: Identifiable {
|
||||||
|
let feature: AppFeature
|
||||||
|
|
||||||
|
let product: AppProduct?
|
||||||
|
|
||||||
|
var id: String {
|
||||||
|
feature.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -36,6 +36,8 @@ struct PaywallView: View {
|
||||||
|
|
||||||
let feature: AppFeature
|
let feature: AppFeature
|
||||||
|
|
||||||
|
let suggestedProduct: AppProduct?
|
||||||
|
|
||||||
// FIXME: #585, implement payments
|
// FIXME: #585, implement payments
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
|
|
|
@ -37,6 +37,8 @@ public struct PurchaseButtonModifier: ViewModifier {
|
||||||
|
|
||||||
private let feature: AppFeature
|
private let feature: AppFeature
|
||||||
|
|
||||||
|
private let suggestedProduct: AppProduct?
|
||||||
|
|
||||||
private let showsIfRestricted: Bool
|
private let showsIfRestricted: Bool
|
||||||
|
|
||||||
@Binding
|
@Binding
|
||||||
|
@ -46,18 +48,20 @@ public struct PurchaseButtonModifier: ViewModifier {
|
||||||
_ title: String,
|
_ title: String,
|
||||||
label: String? = nil,
|
label: String? = nil,
|
||||||
feature: AppFeature,
|
feature: AppFeature,
|
||||||
|
suggesting suggestedProduct: AppProduct?,
|
||||||
showsIfRestricted: Bool,
|
showsIfRestricted: Bool,
|
||||||
paywallReason: Binding<PaywallReason?>
|
paywallReason: Binding<PaywallReason?>
|
||||||
) {
|
) {
|
||||||
self.title = title
|
self.title = title
|
||||||
self.label = label
|
self.label = label
|
||||||
self.feature = feature
|
self.feature = feature
|
||||||
|
self.suggestedProduct = suggestedProduct
|
||||||
self.showsIfRestricted = showsIfRestricted
|
self.showsIfRestricted = showsIfRestricted
|
||||||
_paywallReason = paywallReason
|
_paywallReason = paywallReason
|
||||||
}
|
}
|
||||||
|
|
||||||
public func body(content: Content) -> some View {
|
public func body(content: Content) -> some View {
|
||||||
switch iapManager.paywallReason(forFeature: feature) {
|
switch iapManager.paywallReason(forFeature: feature, suggesting: suggestedProduct) {
|
||||||
case .purchase:
|
case .purchase:
|
||||||
purchaseView
|
purchaseView
|
||||||
|
|
||||||
|
@ -85,7 +89,7 @@ private extension PurchaseButtonModifier {
|
||||||
|
|
||||||
var purchaseButton: some View {
|
var purchaseButton: some View {
|
||||||
Button(title) {
|
Button(title) {
|
||||||
paywallReason = .purchase(feature)
|
paywallReason = .purchase(feature, suggestedProduct)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,7 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
@testable import CommonLibrary
|
@testable import CommonLibrary
|
||||||
|
import CommonUtils
|
||||||
import Foundation
|
import Foundation
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
|
@ -259,3 +260,20 @@ extension IAPManagerTests {
|
||||||
XCTAssertTrue(sut.isEligible(for: .appleTV))
|
XCTAssertTrue(sut.isEligible(for: .appleTV))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extension IAPManager {
|
||||||
|
convenience init(
|
||||||
|
customUserLevel: AppUserLevel? = nil,
|
||||||
|
receiptReader: any AppReceiptReader,
|
||||||
|
unrestrictedFeatures: Set<AppFeature> = [],
|
||||||
|
productsAtBuild: BuildProducts<AppProduct>? = nil
|
||||||
|
) {
|
||||||
|
self.init(
|
||||||
|
customUserLevel: customUserLevel,
|
||||||
|
inAppHelper: MockAppProductHelper(),
|
||||||
|
receiptReader: receiptReader,
|
||||||
|
unrestrictedFeatures: unrestrictedFeatures,
|
||||||
|
productsAtBuild: productsAtBuild
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -37,9 +37,18 @@ extension AppContext {
|
||||||
let tunnelEnvironment: TunnelEnvironment = .shared
|
let tunnelEnvironment: TunnelEnvironment = .shared
|
||||||
let registry: Registry = .shared
|
let registry: Registry = .shared
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
let inAppHelper = MockAppProductHelper()
|
||||||
|
let receiptReader = inAppHelper.receiptReader
|
||||||
|
#else
|
||||||
|
let inAppHelper = StoreKitHelper(identifiers: AppProduct.all)
|
||||||
|
let receiptReader = KvittoReceiptReader()
|
||||||
|
#endif
|
||||||
|
|
||||||
let iapManager = IAPManager(
|
let iapManager = IAPManager(
|
||||||
customUserLevel: Configuration.IAPManager.customUserLevel,
|
customUserLevel: Configuration.IAPManager.customUserLevel,
|
||||||
receiptReader: KvittoReceiptReader(),
|
inAppHelper: inAppHelper,
|
||||||
|
receiptReader: receiptReader,
|
||||||
// FIXME: #662, omit unrestrictedFeatures on release!
|
// FIXME: #662, omit unrestrictedFeatures on release!
|
||||||
unrestrictedFeatures: [.interactiveLogin],
|
unrestrictedFeatures: [.interactiveLogin],
|
||||||
productsAtBuild: Configuration.IAPManager.productsAtBuild
|
productsAtBuild: Configuration.IAPManager.productsAtBuild
|
||||||
|
|
Loading…
Reference in New Issue