// // PaywallView+Purchase.swift // Passepartout // // Created by Davide De Rosa on 3/12/22. // Copyright (c) 2022 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 SwiftUI import StoreKit import PassepartoutCore extension PaywallView { struct PurchaseView: View { private enum AlertType: Identifiable { case purchaseFailed(SKProduct, Error) case restoreFailed(Error) var id: Int { switch self { case .purchaseFailed: return 1 case .restoreFailed: return 2 } } } fileprivate enum PurchaseState { case purchasing(SKProduct) case restoring } private typealias RowModel = (product: SKProduct, extra: String?) @Environment(\.scenePhase) private var scenePhase @ObservedObject private var productManager: ProductManager @Binding private var isPresented: Bool private let feature: LocalProduct? @State private var alertType: AlertType? @State private var purchaseState: PurchaseState? init(isPresented: Binding, feature: LocalProduct? = nil) { productManager = .shared _isPresented = isPresented self.feature = feature } var body: some View { List { productsSection .disabled(purchaseState != nil) }.navigationTitle(Unlocalized.appName) .alert(item: $alertType, content: presentedAlert) // reloading .onAppear { productManager.refreshProducts() }.onChange(of: scenePhase) { newValue in if newValue == .active { productManager.refreshProducts() } }.themeAnimation(on: productManager.isRefreshingProducts) } private func presentedAlert(_ alertType: AlertType) -> Alert { switch alertType { case .purchaseFailed(let product, let error): return Alert( title: Text(product.localizedTitle), message: Text(error.localizedDescription), dismissButton: .default(Text(L10n.Global.Strings.ok)) { purchaseState = nil } ) case .restoreFailed(let error): return Alert( title: Text(L10n.Paywall.Items.Restore.title), message: Text(error.localizedDescription), dismissButton: .default(Text(L10n.Global.Strings.ok)) { purchaseState = nil } ) } } private var productsSection: some View { Section { if !productManager.isRefreshingProducts { ForEach(productRowModels, id: \.product.productIdentifier, content: productRow) } else { ProgressView() } restoreRow } header: { Text(L10n.Paywall.title) } footer: { Text(L10n.Paywall.Sections.Products.footer) } } private func productRow(_ model: RowModel) -> some View { PurchaseRow( product: model.product, title: model.product.localizedTitle, extra: model.extra, action: { purchaseProduct(model.product) }, purchaseState: purchaseState ) } private var restoreRow: some View { PurchaseRow( title: L10n.Paywall.Items.Restore.title, extra: L10n.Paywall.Items.Restore.description, action: restorePurchases, purchaseState: purchaseState ) } } } extension PaywallView.PurchaseView { private func purchaseProduct(_ product: SKProduct) { purchaseState = .purchasing(product) productManager.purchase(product) { switch $0 { case .success(let result): switch result { case .done: isPresented = false case .cancelled: break } purchaseState = nil case .failure(let error): pp_log.error("Unable to purchase: \(error)") alertType = .purchaseFailed(product, error) } } } private func restorePurchases() { purchaseState = .restoring productManager.restorePurchases { if let error = $0 { pp_log.error("Unable to restore purchases: \(error)") alertType = .restoreFailed(error) return } isPresented = false purchaseState = nil } } } extension PaywallView.PurchaseView { private var skFeature: SKProduct? { guard let feature = feature else { return nil } return productManager.product(withIdentifier: feature) } private var skPlatformVersion: SKProduct? { #if targetEnvironment(macCatalyst) return productManager.product(withIdentifier: .fullVersion_macOS) #else return productManager.product(withIdentifier: .fullVersion_iOS) #endif } // hide full version if already bought the other platform version private var skFullVersion: SKProduct? { #if targetEnvironment(macCatalyst) guard !productManager.hasPurchased(.fullVersion_iOS) else { return nil } #else guard !productManager.hasPurchased(.fullVersion_macOS) else { return nil } #endif return productManager.product(withIdentifier: .fullVersion) } private var platformVersionExtra: [String] { return productManager.featureProducts(excluding: [ .fullVersion, .fullVersion_iOS, .fullVersion_macOS ]).map { $0.localizedTitle }.sorted { $0.lowercased() < $1.lowercased() } } private var fullVersionExtra: [String] { return productManager.featureProducts(including: [ .fullVersion_iOS, .fullVersion_macOS ]).map { $0.localizedTitle }.sorted { $0.lowercased() < $1.lowercased() } } private var productRowModels: [RowModel] { var models: [RowModel] = [] skPlatformVersion.map { let extra = platformVersionExtra.joined(separator: "\n") models.append(($0, extra)) } skFullVersion.map { let extra = fullVersionExtra.joined(separator: "\n") models.append(($0, extra)) } skFeature.map { models.append(($0, nil)) } return models } } private struct PurchaseRow: View { var product: SKProduct? let title: String let extra: String? let action: () -> Void let purchaseState: PaywallView.PurchaseView.PurchaseState? var body: some View { VStack(alignment: .leading) { actionButton .padding(.bottom, 5) extra.map { Text($0) .frame(maxHeight: .infinity) // .xxxThemeTruncation() } }.padding([.top, .bottom]) } @ViewBuilder private var actionButton: some View { if let product = product { purchaseButton(product) } else { restoreButton } } private func purchaseButton(_ product: SKProduct) -> some View { HStack { Button(title, action: action) Spacer() if case .purchasing(let pending) = purchaseState, pending.productIdentifier == product.productIdentifier { ProgressView() } else { product.localizedPrice.map { Text($0) .themeSecondaryTextStyle() } } } } private var restoreButton: some View { HStack { Button(title, action: action) Spacer() if case .restoring = purchaseState { ProgressView() } } } }