mirror of
https://github.com/passepartoutvpn/passepartout-apple.git
synced 2025-01-12 11:39:04 +00:00
d1c98006d3
Infinite loop on init(), but horrible practice in general. - DonateView - PaywallView+Purchase Also show a ProgressView while rows are loading. DO NOT animate on .products value because animation won't work if products are empty and stay empty after refresh. Instead, observe .isRefreshingProducts. Lastly, to avoid annoying animation when products are actually available, do not refresh products if non-empty. They certainly do not change during the application lifecycle.
317 lines
9.4 KiB
Swift
317 lines
9.4 KiB
Swift
//
|
|
// 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 <http://www.gnu.org/licenses/>.
|
|
//
|
|
|
|
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<Bool>, 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()
|
|
}
|
|
}.animation(.default, value: 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(
|
|
header: Text(L10n.Paywall.title),
|
|
footer: Text(L10n.Paywall.Sections.Products.footer)
|
|
) {
|
|
if !productManager.isRefreshingProducts {
|
|
ForEach(productRowModels, id: \.product.productIdentifier, content: productRow)
|
|
} else {
|
|
ProgressView()
|
|
}
|
|
restoreRow
|
|
}
|
|
}
|
|
|
|
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 {
|
|
actionButton
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.bottom, 5.0)
|
|
// .border(.black, width: 1.0)
|
|
|
|
extra.map {
|
|
Text($0)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
.multilineTextAlignment(.leading)
|
|
// .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)
|
|
.foregroundColor(themeSecondaryColor)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var restoreButton: some View {
|
|
HStack {
|
|
Button(title, action: action)
|
|
Spacer()
|
|
if case .restoring = purchaseState {
|
|
ProgressView()
|
|
}
|
|
}
|
|
}
|
|
}
|