diff --git a/Passepartout.xcodeproj/xcshareddata/xcschemes/Passepartout.xcscheme b/Passepartout.xcodeproj/xcshareddata/xcschemes/Passepartout.xcscheme index 0e471e804..21b0fa825 100644 --- a/Passepartout.xcodeproj/xcshareddata/xcschemes/Passepartout.xcscheme +++ b/Passepartout.xcodeproj/xcshareddata/xcschemes/Passepartout.xcscheme @@ -103,7 +103,7 @@ Void - @State - private var requiresPurchase = false - - @State - private var requiredFeatures: Set = [] - @State private var paywallReason: PaywallReason? @@ -72,11 +66,8 @@ struct ProfileCoordinator: View { var body: some View { contentView - .modifier(PaywallModifier(reason: $paywallReason)) - .modifier(PurchaseAlertModifier( - isPresented: $requiresPurchase, - paywallReason: $paywallReason, - requiredFeatures: requiredFeatures, + .modifier(PaywallModifier( + reason: $paywallReason, okTitle: Strings.Views.Profile.Alerts.Purchase.Buttons.ok, okAction: onDismiss )) @@ -144,8 +135,7 @@ private extension ProfileCoordinator { do { try iapManager.verify(savedProfile) } catch AppError.ineligibleProfile(let requiredFeatures) { - self.requiredFeatures = requiredFeatures - requiresPurchase = true + paywallReason = .init(requiredFeatures, needsConfirmation: true) return } onDismiss() @@ -156,7 +146,7 @@ private extension ProfileCoordinator { do { try iapManager.verify(profileEditor.activeModules) } catch AppError.ineligibleProfile(let requiredFeatures) { - paywallReason = .purchase(requiredFeatures) + paywallReason = .init(requiredFeatures) return } try await profileEditor.save(to: profileManager) diff --git a/Passepartout/Library/Sources/AppUIMain/Views/Profile/StorageSection.swift b/Passepartout/Library/Sources/AppUIMain/Views/Profile/StorageSection.swift index 1b8c5248c..3f32c881f 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/Profile/StorageSection.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/Profile/StorageSection.swift @@ -74,25 +74,20 @@ private extension StorageSection { } var purchaseSharingButton: some View { - EmptyView() - .modifier(PurchaseButtonModifier( - Strings.Modules.General.Rows.Shared.purchase, - feature: .sharing, - suggesting: nil, - showsIfRestricted: false, - paywallReason: $paywallReason - )) + PurchaseRequiredButton( + Strings.Modules.General.Rows.Shared.purchase, + features: [.sharing], + paywallReason: $paywallReason + ) } var purchaseTVButton: some View { - EmptyView() - .modifier(PurchaseButtonModifier( - Strings.Modules.General.Rows.Appletv.purchase, - feature: .appleTV, - suggesting: .Features.appleTV, - showsIfRestricted: false, - paywallReason: $paywallReason - )) + PurchaseRequiredButton( + Strings.Modules.General.Rows.Appletv.purchase, + features: [.appleTV], + suggestedProduct: .Features.appleTV, + paywallReason: $paywallReason + ) } var header: String { diff --git a/Passepartout/Library/Sources/AppUITV/Views/App/AppCoordinator.swift b/Passepartout/Library/Sources/AppUITV/Views/App/AppCoordinator.swift index 8d7330272..82bac97db 100644 --- a/Passepartout/Library/Sources/AppUITV/Views/App/AppCoordinator.swift +++ b/Passepartout/Library/Sources/AppUITV/Views/App/AppCoordinator.swift @@ -36,12 +36,6 @@ public struct AppCoordinator: View, AppCoordinatorConforming { private let registry: Registry - @State - private var requiresPurchase = false - - @State - private var requiredFeatures: Set = [] - @State private var paywallReason: PaywallReason? @@ -74,13 +68,8 @@ public struct AppCoordinator: View, AppCoordinatorConforming { } } .navigationDestination(for: AppCoordinatorRoute.self, destination: pushDestination) - .withErrorHandler(errorHandler) .modifier(PaywallModifier(reason: $paywallReason)) - .modifier(PurchaseAlertModifier( - isPresented: $requiresPurchase, - paywallReason: $paywallReason, - requiredFeatures: requiredFeatures - )) + .withErrorHandler(errorHandler) } } } @@ -147,8 +136,7 @@ private extension AppCoordinator { } func onPurchaseRequired(_ features: Set) { - requiredFeatures = features - requiresPurchase = true + paywallReason = .init(features, needsConfirmation: true) } } diff --git a/Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift b/Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift index de4f0804f..d5cc7f2f7 100644 --- a/Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift +++ b/Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift @@ -705,6 +705,12 @@ public enum Strings { } public enum Paywall { public enum Alerts { + public enum Confirmation { + /// This profile requires paid features to work. + public static let message = Strings.tr("Localizable", "views.paywall.alerts.confirmation.message", fallback: "This profile requires paid features to work.") + /// Purchase required + public static let title = Strings.tr("Localizable", "views.paywall.alerts.confirmation.title", fallback: "Purchase required") + } public enum Pending { /// The purchase is pending external confirmation. The feature will be credited upon approval. public static let message = Strings.tr("Localizable", "views.paywall.alerts.pending.message", fallback: "The purchase is pending external confirmation. The feature will be credited upon approval.") @@ -830,12 +836,6 @@ public enum Strings { /// (on-demand) public static let onDemandSuffix = Strings.tr("Localizable", "views.ui.connection_status.on_demand_suffix", fallback: " (on-demand)") } - public enum PurchaseAlert { - /// This profile requires paid features to work. - public static let message = Strings.tr("Localizable", "views.ui.purchase_alert.message", fallback: "This profile requires paid features to work.") - /// Purchase required - public static let title = Strings.tr("Localizable", "views.ui.purchase_alert.title", fallback: "Purchase required") - } public enum PurchaseRequired { public enum Purchase { /// Purchase required diff --git a/Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings b/Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings index 73aa2d4a4..d8c87f667 100644 --- a/Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings +++ b/Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings @@ -63,6 +63,8 @@ "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."; "views.paywall.rows.restore_purchases" = "Restore purchases"; +"views.paywall.alerts.confirmation.title" = "Purchase required"; +"views.paywall.alerts.confirmation.message" = "This profile requires paid features to work."; "views.paywall.alerts.restricted.title" = "Restricted"; "views.paywall.alerts.restricted.message" = "Some features are unavailable in this build."; "views.paywall.alerts.pending.message" = "The purchase is pending external confirmation. The feature will be credited upon approval."; @@ -93,8 +95,6 @@ "views.providers.vpn.no_servers" = "No servers"; "views.ui.connection_status.on_demand_suffix" = " (on-demand)"; -"views.ui.purchase_alert.title" = "Purchase required"; -"views.ui.purchase_alert.message" = "This profile requires paid features to work."; "views.ui.purchase_required.purchase.help" = "Purchase required"; "views.ui.purchase_required.restricted.help" = "Feature is restricted"; diff --git a/Passepartout/Library/Sources/UILibrary/Theme/UI/Theme+Modifiers.swift b/Passepartout/Library/Sources/UILibrary/Theme/UI/Theme+Modifiers.swift index 702319b28..e44ac7dfe 100644 --- a/Passepartout/Library/Sources/UILibrary/Theme/UI/Theme+Modifiers.swift +++ b/Passepartout/Library/Sources/UILibrary/Theme/UI/Theme+Modifiers.swift @@ -32,7 +32,20 @@ import SwiftUI // MARK: Shortcuts -public enum ThemeModalSize { +public struct ThemeModalOptions: Hashable { + public var size: ThemeModalSize = .medium + + public var isFixedWidth = false + + public var isFixedHeight = false + + public var isInteractive = true + + public init() { + } +} + +public enum ThemeModalSize: Hashable { case small case medium @@ -45,36 +58,24 @@ public enum ThemeModalSize { extension View { public func themeModal( isPresented: Binding, - size: ThemeModalSize = .medium, - isFixedWidth: Bool = false, - isFixedHeight: Bool = false, - isInteractive: Bool = true, + options: ThemeModalOptions? = nil, content: @escaping () -> Content ) -> some View where Content: View { modifier(ThemeBooleanModalModifier( isPresented: isPresented, - size: size, - isFixedWidth: isFixedWidth, - isFixedHeight: isFixedHeight, - isInteractive: isInteractive, + options: options ?? ThemeModalOptions(), modal: content )) } public func themeModal( item: Binding, - size: ThemeModalSize = .medium, - isFixedWidth: Bool = false, - isFixedHeight: Bool = false, - isInteractive: Bool = true, + options: ThemeModalOptions? = nil, content: @escaping (T) -> Content ) -> some View where Content: View, T: Identifiable { modifier(ThemeItemModalModifier( item: item, - size: size, - isFixedWidth: isFixedWidth, - isFixedHeight: isFixedHeight, - isInteractive: isInteractive, + options: options ?? ThemeModalOptions(), modal: content )) } @@ -282,18 +283,12 @@ struct ThemeBooleanModalModifier: ViewModifier where Modal: View { @Binding var isPresented: Bool - let size: ThemeModalSize - - let isFixedWidth: Bool - - let isFixedHeight: Bool - - let isInteractive: Bool + let options: ThemeModalOptions let modal: () -> Modal func body(content: Content) -> some View { - let modalSize = theme.modalSize(size) + let modalSize = theme.modalSize(options.size) _ = modalSize return content .sheet(isPresented: $isPresented) { @@ -301,12 +296,12 @@ struct ThemeBooleanModalModifier: ViewModifier where Modal: View { #if os(macOS) .frame( minWidth: modalSize.width, - maxWidth: isFixedWidth ? modalSize.width : nil, + maxWidth: options.isFixedWidth ? modalSize.width : nil, minHeight: modalSize.height, - maxHeight: isFixedHeight ? modalSize.height : nil + maxHeight: options.isFixedHeight ? modalSize.height : nil ) #endif - .interactiveDismissDisabled(!isInteractive) + .interactiveDismissDisabled(!options.isInteractive) .themeLockScreen() } } @@ -320,18 +315,12 @@ struct ThemeItemModalModifier: ViewModifier where Modal: View, T: Iden @Binding var item: T? - let size: ThemeModalSize - - let isFixedWidth: Bool - - let isFixedHeight: Bool - - let isInteractive: Bool + let options: ThemeModalOptions let modal: (T) -> Modal func body(content: Content) -> some View { - let modalSize = theme.modalSize(size) + let modalSize = theme.modalSize(options.size) _ = modalSize return content .sheet(item: $item) { @@ -339,12 +328,12 @@ struct ThemeItemModalModifier: ViewModifier where Modal: View, T: Iden #if os(macOS) .frame( minWidth: modalSize.width, - maxWidth: isFixedWidth ? modalSize.width : nil, + maxWidth: options.isFixedWidth ? modalSize.width : nil, minHeight: modalSize.height, - maxHeight: isFixedHeight ? modalSize.height : nil + maxHeight: options.isFixedHeight ? modalSize.height : nil ) #endif - .interactiveDismissDisabled(!isInteractive) + .interactiveDismissDisabled(!options.isInteractive) .themeLockScreen() } } diff --git a/Passepartout/Library/Sources/CommonIAP/Domain/PaywallReason.swift b/Passepartout/Library/Sources/UILibrary/Views/UI/PaywallModifier+Reason.swift similarity index 57% rename from Passepartout/Library/Sources/CommonIAP/Domain/PaywallReason.swift rename to Passepartout/Library/Sources/UILibrary/Views/UI/PaywallModifier+Reason.swift index 57f44175b..ad23b301c 100644 --- a/Passepartout/Library/Sources/CommonIAP/Domain/PaywallReason.swift +++ b/Passepartout/Library/Sources/UILibrary/Views/UI/PaywallModifier+Reason.swift @@ -1,5 +1,5 @@ // -// PaywallReason.swift +// PaywallModifier+Reason.swift // Passepartout // // Created by Davide De Rosa on 9/14/24. @@ -23,8 +23,27 @@ // along with Passepartout. If not, see . // +import CommonIAP import Foundation -public enum PaywallReason: Hashable { - case purchase(Set, AppProduct? = nil) +public typealias PaywallReason = PaywallModifier.Reason + +extension PaywallModifier { + public struct Reason: Hashable { + public let requiredFeatures: Set + + public let suggestedProduct: AppProduct? + + public let needsConfirmation: Bool + + public init( + _ requiredFeatures: Set, + suggestedProduct: AppProduct? = nil, + needsConfirmation: Bool = false + ) { + self.requiredFeatures = requiredFeatures + self.suggestedProduct = suggestedProduct + self.needsConfirmation = needsConfirmation + } + } } diff --git a/Passepartout/Library/Sources/UILibrary/Views/UI/PaywallModifier.swift b/Passepartout/Library/Sources/UILibrary/Views/UI/PaywallModifier.swift index 22e3afb4b..b793ffe13 100644 --- a/Passepartout/Library/Sources/UILibrary/Views/UI/PaywallModifier.swift +++ b/Passepartout/Library/Sources/UILibrary/Views/UI/PaywallModifier.swift @@ -34,97 +34,161 @@ public struct PaywallModifier: ViewModifier { @Binding private var reason: PaywallReason? + private let okTitle: String? + + private let okAction: (() -> Void)? + + @State + private var isConfirming = false + @State - private var isPresentingRestricted = false + private var isRestricted = false @State - private var paywallArguments: PaywallArguments? + private var isPurchasing = false - public init(reason: Binding) { + public init( + reason: Binding, + okTitle: String? = nil, + okAction: (() -> Void)? = nil + ) { _reason = reason + self.okTitle = okTitle + self.okAction = okAction } public func body(content: Content) -> some View { content + .alert( + Strings.Views.Paywall.Alerts.Confirmation.title, + isPresented: $isConfirming, + actions: confirmationActions, + message: confirmationMessage + ) .alert( Strings.Views.Paywall.Alerts.Restricted.title, - isPresented: $isPresentingRestricted, - actions: { - Button(Strings.Global.Nouns.ok) { - reason = nil - isPresentingRestricted = false - } - }, - message: { - Text(restrictedMessage) - } + isPresented: $isRestricted, + actions: restrictedActions, + message: restrictedMessage ) - .themeModal(item: $paywallArguments) { args in - NavigationStack { - PaywallView( - isPresented: isPresentingPurchase, - features: iapManager.excludingEligible(from: args.features), - suggestedProduct: args.product - ) + .themeModal(isPresented: $isPurchasing, content: modalDestination) + .onChange(of: isRestricted) { + if !$0 { + reason = nil + } + } + .onChange(of: isPurchasing) { + if !$0 { + reason = nil } - .frame(idealHeight: 500) } .onChange(of: reason) { - switch $0 { - case .purchase(let features, let product): - guard !iapManager.isRestricted else { - isPresentingRestricted = true - return + guard let reason = $0 else { + return + } + if !iapManager.isRestricted { + if reason.needsConfirmation { + isConfirming = true + } else { + isPurchasing = true } - paywallArguments = PaywallArguments(features: features, product: product) - - default: - break + } else { + isRestricted = true } } } } private extension PaywallModifier { - var isPresentingPurchase: Binding { - Binding { - paywallArguments != nil - } set: { - if !$0 { - // make sure to reset this to allow paywall to appear again + var ineligibleFeatures: [String] { + guard let reason else { + return [] + } + return iapManager + .excludingEligible(from: reason.requiredFeatures) + .map(\.localizedDescription) + .sorted() + } + + func alertMessage(startingWith header: String, features: [String]) -> String { + header + "\n\n" + features + .joined(separator: "\n") + } +} + +private extension IAPManager { + func excludingEligible(from features: Set) -> Set { + features.filter { + !isEligible(for: $0) + } + } +} + +// MARK: - Confirmation alert + +private extension PaywallModifier { + + @ViewBuilder + func confirmationActions() -> some View { + Button(Strings.Global.Actions.purchase) { + // IMPORTANT: retain reason because it serves paywall content + isPurchasing = true + } + if let okTitle { + Button(okTitle) { reason = nil - paywallArguments = nil + okAction?() } } + Button(Strings.Global.Actions.cancel, role: .cancel) { + reason = nil + } } - var restrictedMessage: String { - guard case .purchase(let features, _) = reason else { - return "" - } - let msg = Strings.Views.Paywall.Alerts.Restricted.message - return msg + "\n\n" + iapManager - .excludingEligible(from: features) - .map(\.localizedDescription) - .sorted() - .joined(separator: "\n") + func confirmationMessage() -> some View { + Text(confirmationMessageString) + } + + var confirmationMessageString: String { + alertMessage( + startingWith: Strings.Views.Paywall.Alerts.Confirmation.message, + features: ineligibleFeatures + ) } } -private struct PaywallArguments: Identifiable { - let features: Set +// MARK: - Restricted alert - let product: AppProduct? +private extension PaywallModifier { + func restrictedActions() -> some View { + Button(Strings.Global.Nouns.ok) { + // + } + } - var id: [String] { - features.map(\.id) + func restrictedMessage() -> some View { + Text(restrictedMessageString) + } + + var restrictedMessageString: String { + alertMessage( + startingWith: Strings.Views.Paywall.Alerts.Restricted.message, + features: ineligibleFeatures + ) } } -private extension IAPManager { - func excludingEligible(from features: Set) -> Set { - features.filter { - !isEligible(for: $0) +// MARK: - Paywall + +private extension PaywallModifier { + func modalDestination() -> some View { + reason.map { + PaywallView( + isPresented: $isPurchasing, + features: iapManager.excludingEligible(from: $0.requiredFeatures), + suggestedProduct: $0.suggestedProduct + ) + .themeNavigationStack() } } } diff --git a/Passepartout/Library/Sources/UILibrary/Views/UI/PurchaseAlertModifier.swift b/Passepartout/Library/Sources/UILibrary/Views/UI/PurchaseAlertModifier.swift deleted file mode 100644 index a4771ab68..000000000 --- a/Passepartout/Library/Sources/UILibrary/Views/UI/PurchaseAlertModifier.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// PurchaseAlertModifier.swift -// Passepartout -// -// Created by Davide De Rosa on 11/23/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 . -// - -import CommonIAP -import CommonUtils -import SwiftUI - -public struct PurchaseAlertModifier: ViewModifier { - - @Binding - private var isPresented: Bool - - @Binding - private var paywallReason: PaywallReason? - - private let requiredFeatures: Set - - private let okTitle: String? - - private let okAction: (() -> Void)? - - public init( - isPresented: Binding, - paywallReason: Binding, - requiredFeatures: Set, - okTitle: String? = nil, - okAction: (() -> Void)? = nil - ) { - _isPresented = isPresented - _paywallReason = paywallReason - self.requiredFeatures = requiredFeatures - self.okTitle = okTitle - self.okAction = okAction - } - - public func body(content: Content) -> some View { - content - .alert(Strings.Views.Ui.PurchaseAlert.title, isPresented: $isPresented) { - Button(Strings.Global.Actions.purchase) { - setLater(.purchase(requiredFeatures, nil)) { - paywallReason = $0 - } - } - if let okTitle { - Button(okTitle) { - okAction?() - } - } - Button(Strings.Global.Actions.cancel, role: .cancel, action: {}) - } message: { - Text(purchaseMessage) - } - } -} - -private extension PurchaseAlertModifier { - var purchaseMessage: String { - let msg = Strings.Views.Ui.PurchaseAlert.message - return msg + "\n\n" + requiredFeatures - .map(\.localizedDescription) - .joined(separator: "\n") - } -} diff --git a/Passepartout/Library/Sources/UILibrary/Views/UI/PurchaseButtonModifier.swift b/Passepartout/Library/Sources/UILibrary/Views/UI/PurchaseButtonModifier.swift deleted file mode 100644 index ed028d82d..000000000 --- a/Passepartout/Library/Sources/UILibrary/Views/UI/PurchaseButtonModifier.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// PurchaseButtonModifier.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 . -// - -import CommonLibrary -import SwiftUI - -public struct PurchaseButtonModifier: ViewModifier { - - @EnvironmentObject - private var iapManager: IAPManager - - private let title: String - - private let label: String? - - private let feature: AppFeature - - private let suggestedProduct: AppProduct? - - private let showsIfRestricted: Bool - - @Binding - private var paywallReason: PaywallReason? - - public init( - _ title: String, - label: String? = nil, - feature: AppFeature, - suggesting suggestedProduct: AppProduct?, - showsIfRestricted: Bool, - paywallReason: Binding - ) { - self.title = title - self.label = label - self.feature = feature - self.suggestedProduct = suggestedProduct - self.showsIfRestricted = showsIfRestricted - _paywallReason = paywallReason - } - - public func body(content: Content) -> some View { - if iapManager.isEligible(for: feature) { - content - } else if !iapManager.isRestricted { - purchaseView - } else if showsIfRestricted { - content - } - } -} - -private extension PurchaseButtonModifier { - var purchaseView: some View { - HStack { - if let label { - Text(label) - Spacer() - } - purchaseButton - } - } - - var purchaseButton: some View { - Button(title) { - paywallReason = .purchase([feature], suggestedProduct) - } - } -} diff --git a/Passepartout/Library/Sources/UILibrary/Views/UI/PurchaseRequiredButton.swift b/Passepartout/Library/Sources/UILibrary/Views/UI/PurchaseRequiredButton.swift index 9c15df69c..740868416 100644 --- a/Passepartout/Library/Sources/UILibrary/Views/UI/PurchaseRequiredButton.swift +++ b/Passepartout/Library/Sources/UILibrary/Views/UI/PurchaseRequiredButton.swift @@ -28,61 +28,108 @@ import CommonUtils import PassepartoutKit import SwiftUI -public struct PurchaseRequiredButton: View { - - @EnvironmentObject - private var theme: Theme +public struct PurchaseRequiredButton: View where Content: View { @EnvironmentObject private var iapManager: IAPManager - private let features: Set? + let features: Set? - @Binding - private var paywallReason: PaywallReason? + let suggestedProduct: AppProduct? - public init(for requiring: AppFeatureRequiring?, paywallReason: Binding) { - features = requiring?.features - _paywallReason = paywallReason - } + @Binding + var paywallReason: PaywallReason? - public init(features: Set?, paywallReason: Binding) { - self.features = features - _paywallReason = paywallReason - } + @ViewBuilder + let content: (_ isRestricted: Bool, _ action: @escaping () -> Void) -> Content public var body: some View { - Button { - guard let features, !isEligible else { - return - } - setLater(.purchase(features)) { - paywallReason = $0 - } - } label: { - ThemeImage(iapManager.isRestricted ? .warning : .upgrade) - .help(helpMessage) - } -#if os(iOS) - .buttonStyle(.plain) -#else - .imageScale(.large) - .buttonStyle(.borderless) -#endif - .foregroundStyle(theme.upgradeColor) - .opaque(!isEligible) + content(iapManager.isRestricted, onTap) + .opaque(!isEligible) } } private extension PurchaseRequiredButton { + func onTap() { + guard let features, !isEligible else { + return + } + setLater(.init(features, suggestedProduct: suggestedProduct)) { + paywallReason = $0 + } + } + var isEligible: Bool { if let features { return iapManager.isEligible(for: features) } return true } +} + +// MARK: - Initializers - var helpMessage: String { - iapManager.isRestricted ? Strings.Views.Ui.PurchaseRequired.Restricted.help : Strings.Views.Ui.PurchaseRequired.Purchase.help +extension PurchaseRequiredButton where Content == Button { + public init( + _ title: String, + features: Set?, + suggestedProduct: AppProduct? = nil, + paywallReason: Binding + ) { + self.features = features + self.suggestedProduct = suggestedProduct + _paywallReason = paywallReason + content = { _, action in + Button(title, action: action) + } + } +} + +extension PurchaseRequiredButton where Content == PurchaseRequiredImageButtonContent { + public init( + for requiring: AppFeatureRequiring?, + suggestedProduct: AppProduct? = nil, + paywallReason: Binding + ) { + self.init( + features: requiring?.features, + suggestedProduct: suggestedProduct, + paywallReason: paywallReason + ) + } + + public init( + features: Set?, + suggestedProduct: AppProduct? = nil, + paywallReason: Binding + ) { + self.features = features + self.suggestedProduct = suggestedProduct + _paywallReason = paywallReason + content = { + PurchaseRequiredImageButtonContent(isRestricted: $0, action: $1) + } + } +} + +public struct PurchaseRequiredImageButtonContent: View { + + @EnvironmentObject + private var theme: Theme + + let isRestricted: Bool + + let action: () -> Void + + public var body: some View { + Button(action: action) { + ThemeImage(isRestricted ? .warning : .upgrade) + .foregroundStyle(theme.upgradeColor) + .help(isRestricted ? Strings.Views.Ui.PurchaseRequired.Restricted.help : Strings.Views.Ui.PurchaseRequired.Purchase.help) + } + .buttonStyle(.plain) +#if os(macOS) + .imageScale(.large) +#endif } }