//
// PaywallView+Purchase.swift
// Passepartout
//
// Created by Davide De Rosa on 3/12/22.
// Copyright (c) 2023 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 PassepartoutLibrary
import SwiftUI
extension PaywallView {
struct PurchaseView: View {
fileprivate enum PurchaseState {
case purchasing(InAppProduct)
case restoring
}
@Environment(\.scenePhase) private var scenePhase
@ObservedObject private var productManager: ProductManager
@Binding private var isPresented: Bool
private let feature: LocalProduct?
@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)
// reloading
.task {
await productManager.refreshProducts()
}
.onChange(of: scenePhase) { newValue in
if newValue == .active {
Task {
await productManager.refreshProducts()
}
}
}
.themeAnimation(on: productManager.isRefreshingProducts)
}
}
}
private struct PurchaseRow: View {
var product: InAppProduct?
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)
}
}.padding([.top, .bottom])
}
}
private typealias RowModel = (product: InAppProduct, extra: String?)
// MARK: -
private extension PaywallView.PurchaseView {
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)
}
}
func productRow(_ model: RowModel) -> some View {
PurchaseRow(
product: model.product,
title: model.product.localizedTitle,
extra: model.extra,
action: {
purchaseProduct(model.product)
},
purchaseState: purchaseState
)
}
var restoreRow: some View {
PurchaseRow(
title: L10n.Paywall.Items.Restore.title,
extra: L10n.Paywall.Items.Restore.description,
action: restorePurchases,
purchaseState: purchaseState
)
}
}
private extension PaywallView.PurchaseView {
var skFeature: InAppProduct? {
guard let feature = feature else {
return nil
}
return productManager.product(withIdentifier: feature)
}
var skPlatformVersion: InAppProduct? {
#if targetEnvironment(macCatalyst)
productManager.product(withIdentifier: .fullVersion_macOS)
#else
productManager.product(withIdentifier: .fullVersion_iOS)
#endif
}
// hide full version if already bought the other platform version
var skFullVersion: InAppProduct? {
#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)
}
var platformVersionExtra: [String] {
productManager.featureProducts(excluding: [
.fullVersion,
.fullVersion_iOS,
.fullVersion_macOS
]).map {
$0.localizedTitle
}.sorted {
$0.lowercased() < $1.lowercased()
}
}
var fullVersionExtra: [String] {
productManager.featureProducts(including: [
.fullVersion_iOS,
.fullVersion_macOS
]).map {
$0.localizedTitle
}.sorted {
$0.lowercased() < $1.lowercased()
}
}
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 extension PurchaseRow {
@ViewBuilder
var actionButton: some View {
if let product {
purchaseButton(product)
} else {
restoreButton
}
}
func purchaseButton(_ product: InAppProduct) -> 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()
}
}
}
}
var restoreButton: some View {
HStack {
Button(title, action: action)
Spacer()
if case .restoring = purchaseState {
ProgressView()
}
}
}
}
// MARK: -
private extension PaywallView.PurchaseView {
func purchaseProduct(_ product: InAppProduct) {
purchaseState = .purchasing(product)
Task {
do {
let result = try await productManager.purchase(product)
switch result {
case .done:
isPresented = false
case .cancelled:
break
}
purchaseState = nil
} catch {
pp_log.error("Unable to purchase: \(error)")
ErrorHandler.shared.handle(
title: product.localizedTitle,
message: AppError(error).localizedDescription
) {
purchaseState = nil
}
}
}
}
func restorePurchases() {
purchaseState = .restoring
Task {
do {
try await productManager.restorePurchases()
isPresented = false
purchaseState = nil
} catch {
pp_log.error("Unable to restore purchases: \(error)")
ErrorHandler.shared.handle(
title: L10n.Paywall.Items.Restore.title,
message: AppError(error).localizedDescription
) {
purchaseState = nil
}
}
}
}
}