Finalize paywall UI (#831)
- Use StoreKit views when available - Offer one-time purchase - Recurring subscriptions for all features - Restore purchases Remove .siri (Shortcuts), now free. Closes #819 Closes #469
This commit is contained in:
parent
8ef1e7fbe9
commit
2c1ccbcbfd
|
@ -25,7 +25,7 @@
|
|||
|
||||
import SwiftUI
|
||||
|
||||
// FIXME: #819, UI for donations
|
||||
// FIXME: #830, UI for donations
|
||||
|
||||
struct DonateView: View {
|
||||
var body: some View {
|
||||
|
|
|
@ -33,7 +33,7 @@ extension AboutView {
|
|||
List {
|
||||
SettingsSectionGroup(profileManager: profileManager)
|
||||
Group {
|
||||
// FIXME: #819, UI for donations
|
||||
// FIXME: #830, UI for donations
|
||||
// donateLink
|
||||
linksLink
|
||||
creditsLink
|
||||
|
|
|
@ -32,7 +32,7 @@ extension AboutView {
|
|||
var listView: some View {
|
||||
List(selection: $navigationRoute) {
|
||||
Section {
|
||||
// FIXME: #819, UI for donations
|
||||
// FIXME: #830, UI for donations
|
||||
// donateLink
|
||||
linksLink
|
||||
creditsLink
|
||||
|
|
|
@ -58,11 +58,7 @@ struct VPNProviderServerCoordinator<Configuration>: View where Configuration: Pr
|
|||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
#if os(iOS)
|
||||
ThemeImage(.close)
|
||||
#else
|
||||
Text(Strings.Global.cancel)
|
||||
#endif
|
||||
ThemeCloseLabel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,8 +40,6 @@ public enum AppFeature: String, CaseIterable {
|
|||
|
||||
case routing
|
||||
|
||||
case siri
|
||||
|
||||
public static let allButAppleTV: [AppFeature] = allCases.filter {
|
||||
$0 != .appleTV
|
||||
}
|
||||
|
|
|
@ -47,6 +47,9 @@ extension AppUserLevel: AppFeatureProviding {
|
|||
extension AppProduct: AppFeatureProviding {
|
||||
var features: [AppFeature] {
|
||||
switch self {
|
||||
case .Full.Recurring.monthly, .Full.Recurring.yearly:
|
||||
return AppFeature.allCases
|
||||
|
||||
case .Features.allProviders:
|
||||
return [.providers]
|
||||
|
||||
|
@ -56,9 +59,6 @@ extension AppProduct: AppFeatureProviding {
|
|||
case .Features.networkSettings:
|
||||
return [.dns, .httpProxy, .routing]
|
||||
|
||||
case .Features.siriShortcuts:
|
||||
return [.siri]
|
||||
|
||||
case .Features.trustedNetworks:
|
||||
return [.onDemand]
|
||||
|
||||
|
|
|
@ -35,8 +35,6 @@ extension AppProduct {
|
|||
|
||||
public static let networkSettings = AppProduct(featureId: "network_settings")
|
||||
|
||||
public static let siriShortcuts = AppProduct(featureId: "siri")
|
||||
|
||||
public static let trustedNetworks = AppProduct(featureId: "trusted_networks")
|
||||
|
||||
static let all: [AppProduct] = [
|
||||
|
@ -44,7 +42,6 @@ extension AppProduct {
|
|||
.Features.appleTV,
|
||||
.Features.interactiveLogin,
|
||||
.Features.networkSettings,
|
||||
.Features.siriShortcuts,
|
||||
.Features.trustedNetworks
|
||||
]
|
||||
}
|
||||
|
@ -56,10 +53,18 @@ extension AppProduct {
|
|||
|
||||
public static let allPlatforms = AppProduct(featureId: "full_multi_version")
|
||||
|
||||
public enum Recurring {
|
||||
public static let monthly = AppProduct(featureId: "full.monthly")
|
||||
|
||||
public static let yearly = AppProduct(featureId: "full.yearly")
|
||||
}
|
||||
|
||||
static let all: [AppProduct] = [
|
||||
.Full.iOS,
|
||||
.Full.macOS,
|
||||
.Full.allPlatforms
|
||||
.Full.allPlatforms,
|
||||
.Full.Recurring.monthly,
|
||||
.Full.Recurring.yearly
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
@ -29,30 +29,28 @@ import Foundation
|
|||
|
||||
extension AppFeature: LocalizableEntity {
|
||||
public var localizedDescription: String {
|
||||
let V = Strings.Features.self
|
||||
switch self {
|
||||
case .appleTV:
|
||||
return Strings.Unlocalized.appleTV
|
||||
return V.appleTV(Strings.Unlocalized.appleTV)
|
||||
|
||||
case .dns:
|
||||
return Strings.Unlocalized.dns
|
||||
return V.dns(Strings.Unlocalized.dns)
|
||||
|
||||
case .httpProxy:
|
||||
return Strings.Unlocalized.httpProxy
|
||||
return V.httpProxy(Strings.Unlocalized.httpProxy)
|
||||
|
||||
case .interactiveLogin:
|
||||
return Strings.Features.interactiveLogin
|
||||
return V.interactiveLogin
|
||||
|
||||
case .onDemand:
|
||||
return Strings.Global.onDemand
|
||||
return V.onDemand(Strings.Global.onDemand)
|
||||
|
||||
case .providers:
|
||||
return Strings.Features.providers
|
||||
return V.providers
|
||||
|
||||
case .routing:
|
||||
return Strings.Global.routing
|
||||
|
||||
case .siri:
|
||||
return Strings.Features.siri
|
||||
return V.routing(Strings.Global.routing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -175,12 +175,30 @@ public enum Strings {
|
|||
}
|
||||
}
|
||||
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 static func appleTV(_ p1: Any) -> String {
|
||||
return Strings.tr("Localizable", "features.appleTV", String(describing: p1), fallback: "%@")
|
||||
}
|
||||
/// %@
|
||||
public static func dns(_ p1: Any) -> String {
|
||||
return Strings.tr("Localizable", "features.dns", String(describing: p1), fallback: "%@")
|
||||
}
|
||||
/// %@
|
||||
public static func httpProxy(_ p1: Any) -> String {
|
||||
return Strings.tr("Localizable", "features.httpProxy", String(describing: p1), fallback: "%@")
|
||||
}
|
||||
/// Interactive Login
|
||||
public static let interactiveLogin = Strings.tr("Localizable", "features.interactiveLogin", fallback: "Interactive Login")
|
||||
/// %@
|
||||
public static func onDemand(_ p1: Any) -> String {
|
||||
return Strings.tr("Localizable", "features.onDemand", String(describing: p1), fallback: "%@")
|
||||
}
|
||||
/// All Providers
|
||||
public static let providers = Strings.tr("Localizable", "features.providers", fallback: "All Providers")
|
||||
/// %@
|
||||
public static func routing(_ p1: Any) -> String {
|
||||
return Strings.tr("Localizable", "features.routing", String(describing: p1), fallback: "%@")
|
||||
}
|
||||
}
|
||||
public enum Global {
|
||||
/// About
|
||||
|
@ -492,6 +510,38 @@ public enum Strings {
|
|||
public static let presharedKey = Strings.tr("Localizable", "modules.wireguard.preshared_key", fallback: "Pre-shared key")
|
||||
}
|
||||
}
|
||||
public enum Paywall {
|
||||
public enum Alerts {
|
||||
public enum Pending {
|
||||
/// The purchase is pending external confirmation. The feature will be credited upon approval.
|
||||
public static let message = Strings.tr("Localizable", "paywall.alerts.pending.message", fallback: "The purchase is pending external confirmation. The feature will be credited upon approval.")
|
||||
}
|
||||
}
|
||||
public enum Rows {
|
||||
/// Restore purchases
|
||||
public static let restorePurchases = Strings.tr("Localizable", "paywall.rows.restore_purchases", fallback: "Restore purchases")
|
||||
}
|
||||
public enum Sections {
|
||||
public enum Features {
|
||||
/// Subscribe for
|
||||
public static let header = Strings.tr("Localizable", "paywall.sections.features.header", fallback: "Subscribe for")
|
||||
}
|
||||
public enum OneTime {
|
||||
/// One-time purchase
|
||||
public static let header = Strings.tr("Localizable", "paywall.sections.one_time.header", fallback: "One-time purchase")
|
||||
}
|
||||
public enum Recurring {
|
||||
/// All features
|
||||
public static let header = Strings.tr("Localizable", "paywall.sections.recurring.header", fallback: "All features")
|
||||
}
|
||||
public enum Restore {
|
||||
/// If you bought this app or feature in the past, you can restore your purchases.
|
||||
public static let footer = Strings.tr("Localizable", "paywall.sections.restore.footer", fallback: "If you bought this app or feature in the past, you can restore your purchases.")
|
||||
/// Restore
|
||||
public static let header = Strings.tr("Localizable", "paywall.sections.restore.header", fallback: "Restore")
|
||||
}
|
||||
}
|
||||
}
|
||||
public enum Placeholders {
|
||||
/// secret
|
||||
public static let secret = Strings.tr("Localizable", "placeholders.secret", fallback: "secret")
|
||||
|
|
|
@ -254,9 +254,21 @@
|
|||
|
||||
// MARK: - Paywalls
|
||||
|
||||
"features.interactive_login" = "Interactive login";
|
||||
"features.providers" = "Providers";
|
||||
"features.siri" = "Shortcuts";
|
||||
"paywall.sections.one_time.header" = "One-time purchase";
|
||||
"paywall.sections.recurring.header" = "All features";
|
||||
"paywall.sections.features.header" = "Subscribe for";
|
||||
"paywall.sections.restore.header" = "Restore";
|
||||
"paywall.sections.restore.footer" = "If you bought this app or feature in the past, you can restore your purchases.";
|
||||
"paywall.rows.restore_purchases" = "Restore purchases";
|
||||
"paywall.alerts.pending.message" = "The purchase is pending external confirmation. The feature will be credited upon approval.";
|
||||
|
||||
"features.appleTV" = "%@";
|
||||
"features.dns" = "%@";
|
||||
"features.httpProxy" = "%@";
|
||||
"features.interactiveLogin" = "Interactive Login";
|
||||
"features.onDemand" = "%@";
|
||||
"features.providers" = "All Providers";
|
||||
"features.routing" = "%@";
|
||||
|
||||
"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.";
|
||||
|
|
|
@ -278,7 +278,7 @@ struct ThemeNavigationStackModifier: ViewModifier {
|
|||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
ThemeImage(.close)
|
||||
ThemeCloseLabel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -91,6 +91,19 @@ public struct ThemeImageLabel: View {
|
|||
}
|
||||
}
|
||||
|
||||
public struct ThemeCloseLabel: View {
|
||||
public init() {
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
#if os(iOS) || os(tvOS)
|
||||
ThemeImage(.close)
|
||||
#else
|
||||
Text(Strings.Global.cancel)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
public struct ThemeCountryText: View {
|
||||
private let code: String
|
||||
|
||||
|
|
|
@ -64,6 +64,7 @@ public struct PaywallModifier: ViewModifier {
|
|||
suggestedProduct: args.product
|
||||
)
|
||||
}
|
||||
.frame(idealHeight: 400)
|
||||
}
|
||||
.onChange(of: reason) {
|
||||
switch $0 {
|
||||
|
|
|
@ -0,0 +1,67 @@
|
|||
//
|
||||
// PaywallView+Custom.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 11/7/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 StoreKit
|
||||
import SwiftUI
|
||||
|
||||
extension PaywallView {
|
||||
struct CustomProductView: View {
|
||||
let style: ProductStyle
|
||||
|
||||
@ObservedObject
|
||||
var iapManager: IAPManager
|
||||
|
||||
let product: InAppProduct
|
||||
|
||||
let onComplete: (String, InAppPurchaseResult) -> Void
|
||||
|
||||
let onError: (Error) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(verbatim: product.localizedTitle)
|
||||
Spacer()
|
||||
Button(action: purchase) {
|
||||
Text(verbatim: product.localizedPrice)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension PaywallView.CustomProductView {
|
||||
func purchase() {
|
||||
Task {
|
||||
do {
|
||||
let result = try await iapManager.purchase(product)
|
||||
onComplete(product.productIdentifier, result)
|
||||
} catch {
|
||||
onError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
//
|
||||
// PaywallView+StoreKit.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 11/7/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 CommonUtils
|
||||
import StoreKit
|
||||
import SwiftUI
|
||||
|
||||
@available(iOS 17, macOS 14, *)
|
||||
extension PaywallView {
|
||||
struct StoreKitProductView: View {
|
||||
let style: ProductStyle
|
||||
|
||||
let product: InAppProduct
|
||||
|
||||
let onComplete: (String, InAppPurchaseResult) -> Void
|
||||
|
||||
let onError: (Error) -> Void
|
||||
|
||||
var body: some View {
|
||||
ProductView(id: product.productIdentifier)
|
||||
.productViewStyle(.compact)
|
||||
.onInAppPurchaseCompletion { skProduct, result in
|
||||
do {
|
||||
let skResult = try result.get()
|
||||
onComplete(skProduct.id, skResult.toResult)
|
||||
} catch {
|
||||
onError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Product.PurchaseResult {
|
||||
var toResult: InAppPurchaseResult {
|
||||
switch self {
|
||||
case .success:
|
||||
return .done
|
||||
|
||||
case .pending:
|
||||
return .pending
|
||||
|
||||
case .userCancelled:
|
||||
return .cancelled
|
||||
|
||||
default:
|
||||
return .cancelled
|
||||
}
|
||||
}
|
||||
}
|
|
@ -24,9 +24,16 @@
|
|||
//
|
||||
|
||||
import CommonLibrary
|
||||
import CommonUtils
|
||||
import StoreKit
|
||||
import SwiftUI
|
||||
|
||||
struct PaywallView: View {
|
||||
enum ProductStyle {
|
||||
case oneTime
|
||||
|
||||
case recurring
|
||||
}
|
||||
|
||||
@EnvironmentObject
|
||||
private var iapManager: IAPManager
|
||||
|
@ -38,15 +45,112 @@ struct PaywallView: View {
|
|||
|
||||
let suggestedProduct: AppProduct?
|
||||
|
||||
// FIXME: #819, UI for paywall
|
||||
@State
|
||||
private var isFetchingProducts = true
|
||||
|
||||
@State
|
||||
private var oneTimeProduct: InAppProduct?
|
||||
|
||||
@State
|
||||
private var recurringProducts: [InAppProduct] = []
|
||||
|
||||
@State
|
||||
private var isPendingPresented = false
|
||||
|
||||
@StateObject
|
||||
private var errorHandler: ErrorHandler = .default()
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
Text(String(describing: feature).capitalized)
|
||||
Spacer()
|
||||
Form {
|
||||
if isFetchingProducts {
|
||||
ProgressView()
|
||||
.id(UUID())
|
||||
} else {
|
||||
productsView
|
||||
subscriptionFeaturesView
|
||||
restoreView
|
||||
}
|
||||
}
|
||||
.themeForm()
|
||||
.toolbar(content: toolbarContent)
|
||||
.navigationTitle(Strings.Global.purchase)
|
||||
.alert(
|
||||
Strings.Global.purchase,
|
||||
isPresented: $isPendingPresented,
|
||||
actions: pendingActions,
|
||||
message: pendingMessage
|
||||
)
|
||||
.task(id: feature) {
|
||||
await fetchAvailableProducts()
|
||||
}
|
||||
.withErrorHandler(errorHandler)
|
||||
}
|
||||
}
|
||||
|
||||
private extension PaywallView {
|
||||
var title: String {
|
||||
Strings.Global.purchase
|
||||
}
|
||||
|
||||
var subscriptionFeatures: [AppFeature] {
|
||||
AppFeature.allCases.sorted {
|
||||
$0.localizedDescription < $1.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var productsView: some View {
|
||||
oneTimeProduct.map {
|
||||
productView(.oneTime, for: $0)
|
||||
.themeSection(header: Strings.Paywall.Sections.OneTime.header)
|
||||
}
|
||||
ForEach(recurringProducts, id: \.productIdentifier) {
|
||||
productView(.recurring, for: $0)
|
||||
}
|
||||
.themeSection(header: Strings.Paywall.Sections.Recurring.header)
|
||||
}
|
||||
|
||||
#if os(iOS) || os(tvOS)
|
||||
var subscriptionFeaturesView: some View {
|
||||
ForEach(subscriptionFeatures, id: \.id) { feature in
|
||||
Text(feature.localizedDescription)
|
||||
}
|
||||
.themeSection(header: Strings.Paywall.Sections.Features.header)
|
||||
}
|
||||
#else
|
||||
var subscriptionFeaturesView: some View {
|
||||
Table(subscriptionFeatures) {
|
||||
TableColumn(Strings.Paywall.Sections.Features.header, value: \.localizedDescription)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder
|
||||
func productView(_ style: ProductStyle, for product: InAppProduct) -> some View {
|
||||
if #available(iOS 17, macOS 14, *) {
|
||||
StoreKitProductView(
|
||||
style: style,
|
||||
product: product,
|
||||
onComplete: onComplete,
|
||||
onError: onError
|
||||
)
|
||||
} else {
|
||||
CustomProductView(
|
||||
style: style,
|
||||
iapManager: iapManager,
|
||||
product: product,
|
||||
onComplete: onComplete,
|
||||
onError: onError
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var restoreView: some View {
|
||||
RestorePurchasesButton()
|
||||
.themeSectionWithSingleRow(
|
||||
header: Strings.Paywall.Sections.Restore.header,
|
||||
footer: Strings.Paywall.Sections.Restore.footer,
|
||||
above: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -58,11 +162,77 @@ private extension PaywallView {
|
|||
Button {
|
||||
isPresented = false
|
||||
} label: {
|
||||
ThemeImage(.close)
|
||||
ThemeCloseLabel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// var purchaseView: some View {
|
||||
// }
|
||||
func pendingActions() -> some View {
|
||||
Button(Strings.Global.ok) {
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
|
||||
func pendingMessage() -> some View {
|
||||
Text(Strings.Paywall.Alerts.Pending.message)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private extension PaywallView {
|
||||
func fetchAvailableProducts() async {
|
||||
isFetchingProducts = true
|
||||
|
||||
var list: [AppProduct] = []
|
||||
if let suggestedProduct {
|
||||
list.append(suggestedProduct)
|
||||
}
|
||||
list.append(.Full.Recurring.yearly)
|
||||
list.append(.Full.Recurring.monthly)
|
||||
|
||||
let availableProducts = await iapManager.purchasableProducts(for: list)
|
||||
oneTimeProduct = availableProducts.first {
|
||||
guard let suggestedProduct else {
|
||||
return false
|
||||
}
|
||||
return $0.productIdentifier.hasSuffix(suggestedProduct.rawValue)
|
||||
}
|
||||
recurringProducts = availableProducts.filter {
|
||||
$0.productIdentifier != oneTimeProduct?.productIdentifier
|
||||
}
|
||||
|
||||
isFetchingProducts = false
|
||||
}
|
||||
|
||||
func onComplete(_ productIdentifier: String, result: InAppPurchaseResult) {
|
||||
switch result {
|
||||
case .done:
|
||||
isPresented = false
|
||||
|
||||
case .pending:
|
||||
isPendingPresented = true
|
||||
|
||||
case .cancelled:
|
||||
break
|
||||
|
||||
case .notFound:
|
||||
fatalError("Product not found: \(productIdentifier)")
|
||||
}
|
||||
}
|
||||
|
||||
func onError(_ error: Error) {
|
||||
errorHandler.handle(error, title: Strings.Global.purchase)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview {
|
||||
PaywallView(
|
||||
isPresented: .constant(true),
|
||||
feature: .appleTV,
|
||||
suggestedProduct: .Features.appleTV
|
||||
)
|
||||
.withMockEnvironment()
|
||||
}
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
//
|
||||
// RestorePurchasesButton.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 11/6/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 SwiftUI
|
||||
|
||||
public struct RestorePurchasesButton: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var iapManager: IAPManager
|
||||
|
||||
public init() {
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Button(Strings.Paywall.Rows.restorePurchases) {
|
||||
Task {
|
||||
try await iapManager.restorePurchases()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -100,11 +100,7 @@ private extension InteractiveCoordinator {
|
|||
}
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(action: cancel) {
|
||||
#if os(iOS)
|
||||
ThemeImage(.close)
|
||||
#else
|
||||
Text(Strings.Global.cancel)
|
||||
#endif
|
||||
ThemeCloseLabel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -87,7 +87,6 @@ extension IAPManagerTests {
|
|||
func test_givenPurchasedFeatures_thenIsOnlyEligibleForFeatures() async {
|
||||
let reader = MockAppReceiptReader()
|
||||
await reader.setReceipt(withBuild: defaultBuildNumber, products: [
|
||||
.Features.siriShortcuts,
|
||||
.Features.networkSettings
|
||||
])
|
||||
let sut = IAPManager(receiptReader: reader)
|
||||
|
@ -97,7 +96,6 @@ extension IAPManagerTests {
|
|||
XCTAssertTrue(sut.isEligible(for: .httpProxy))
|
||||
XCTAssertFalse(sut.isEligible(for: .onDemand))
|
||||
XCTAssertTrue(sut.isEligible(for: .routing))
|
||||
XCTAssertTrue(sut.isEligible(for: .siri))
|
||||
XCTAssertFalse(sut.isEligible(for: AppFeature.allButAppleTV))
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue