Add donations UI and in-app error handling (#833)
- Reuse same product views from paywall - Handle errors in fetch products - Hide views on fetch products error - Disable views during purchase Closes #830
This commit is contained in:
parent
83b2e6b4e0
commit
8fbccc6d80
|
@ -36,6 +36,9 @@ struct AboutRouterView: View {
|
||||||
|
|
||||||
let tunnel: ExtendedTunnel
|
let tunnel: ExtendedTunnel
|
||||||
|
|
||||||
|
@State
|
||||||
|
var path = NavigationPath()
|
||||||
|
|
||||||
@State
|
@State
|
||||||
var navigationRoute: NavigationRoute?
|
var navigationRoute: NavigationRoute?
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,14 +23,137 @@
|
||||||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import CommonLibrary
|
||||||
|
import CommonUtils
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// FIXME: #830, UI for donations
|
|
||||||
|
|
||||||
struct DonateView: View {
|
struct DonateView: View {
|
||||||
|
|
||||||
|
@EnvironmentObject
|
||||||
|
private var iapManager: IAPManager
|
||||||
|
|
||||||
|
@Environment(\.dismiss)
|
||||||
|
private var dismiss
|
||||||
|
|
||||||
|
@State
|
||||||
|
private var availableProducts: [InAppProduct] = []
|
||||||
|
|
||||||
|
@State
|
||||||
|
private var isFetchingProducts = true
|
||||||
|
|
||||||
|
@State
|
||||||
|
private var purchasingIdentifier: String?
|
||||||
|
|
||||||
|
@State
|
||||||
|
private var isThankYouPresented = false
|
||||||
|
|
||||||
|
@StateObject
|
||||||
|
private var errorHandler: ErrorHandler = .default()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
Form {
|
||||||
|
if isFetchingProducts {
|
||||||
|
ProgressView()
|
||||||
|
.id(UUID())
|
||||||
|
} else if !availableProducts.isEmpty {
|
||||||
|
donationsView
|
||||||
|
.disabled(purchasingIdentifier != nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.themeForm()
|
||||||
|
.navigationTitle(title)
|
||||||
|
.alert(
|
||||||
|
title,
|
||||||
|
isPresented: $isThankYouPresented,
|
||||||
|
actions: thankYouActions,
|
||||||
|
message: thankYouMessage
|
||||||
|
)
|
||||||
|
.task {
|
||||||
|
await fetchAvailableProducts()
|
||||||
|
}
|
||||||
|
.withErrorHandler(errorHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension DonateView {
|
||||||
|
var title: String {
|
||||||
|
Strings.Views.Donate.title
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
var donationsView: some View {
|
||||||
|
#if os(macOS)
|
||||||
|
Section {
|
||||||
|
Text(Strings.Views.Donate.Sections.Main.footer)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
ForEach(availableProducts, id: \.productIdentifier) {
|
||||||
|
PaywallProductView(
|
||||||
|
iapManager: iapManager,
|
||||||
|
style: .donation,
|
||||||
|
product: $0,
|
||||||
|
purchasingIdentifier: $purchasingIdentifier,
|
||||||
|
onComplete: onComplete,
|
||||||
|
onError: onError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.themeSection(footer: Strings.Views.Donate.Sections.Main.footer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func thankYouActions() -> some View {
|
||||||
|
Button(Strings.Global.ok) {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func thankYouMessage() -> some View {
|
||||||
|
Text(Strings.Views.Donate.Alerts.ThankYou.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: -
|
||||||
|
|
||||||
|
private extension DonateView {
|
||||||
|
func fetchAvailableProducts() async {
|
||||||
|
isFetchingProducts = true
|
||||||
|
defer {
|
||||||
|
isFetchingProducts = false
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
availableProducts = try await iapManager.purchasableProducts(for: AppProduct.Donations.all)
|
||||||
|
guard !availableProducts.isEmpty else {
|
||||||
|
throw AppError.emptyProducts
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
onError(error, dismissing: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func onComplete(_ productIdentifier: String, result: InAppPurchaseResult) {
|
||||||
|
switch result {
|
||||||
|
case .done:
|
||||||
|
isThankYouPresented = true
|
||||||
|
|
||||||
|
case .pending:
|
||||||
|
dismiss()
|
||||||
|
|
||||||
|
case .cancelled:
|
||||||
|
break
|
||||||
|
|
||||||
|
case .notFound:
|
||||||
|
fatalError("Product not found: \(productIdentifier)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func onError(_ error: Error) {
|
||||||
|
onError(error, dismissing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func onError(_ error: Error, dismissing: Bool) {
|
||||||
|
errorHandler.handle(error, title: title) {
|
||||||
|
if dismissing {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(Strings.Views.Donate.title)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,7 +30,7 @@ import SwiftUI
|
||||||
|
|
||||||
extension AboutRouterView {
|
extension AboutRouterView {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack(path: $path) {
|
||||||
AboutView(
|
AboutView(
|
||||||
profileManager: profileManager,
|
profileManager: profileManager,
|
||||||
navigationRoute: $navigationRoute
|
navigationRoute: $navigationRoute
|
||||||
|
|
|
@ -33,10 +33,9 @@ extension AboutView {
|
||||||
List {
|
List {
|
||||||
SettingsSectionGroup(profileManager: profileManager)
|
SettingsSectionGroup(profileManager: profileManager)
|
||||||
Group {
|
Group {
|
||||||
// FIXME: #830, UI for donations
|
|
||||||
// donateLink
|
|
||||||
linksLink
|
linksLink
|
||||||
creditsLink
|
creditsLink
|
||||||
|
donateLink
|
||||||
}
|
}
|
||||||
.themeSection(header: Strings.Views.About.Sections.resources)
|
.themeSection(header: Strings.Views.About.Sections.resources)
|
||||||
Section {
|
Section {
|
||||||
|
|
|
@ -36,7 +36,7 @@ extension AboutRouterView {
|
||||||
navigationRoute: $navigationRoute
|
navigationRoute: $navigationRoute
|
||||||
)
|
)
|
||||||
} detail: {
|
} detail: {
|
||||||
NavigationStack {
|
NavigationStack(path: $path) {
|
||||||
pushDestination(for: navigationRoute)
|
pushDestination(for: navigationRoute)
|
||||||
.navigationDestination(for: NavigationRoute.self, destination: pushDestination)
|
.navigationDestination(for: NavigationRoute.self, destination: pushDestination)
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,10 +32,9 @@ extension AboutView {
|
||||||
var listView: some View {
|
var listView: some View {
|
||||||
List(selection: $navigationRoute) {
|
List(selection: $navigationRoute) {
|
||||||
Section {
|
Section {
|
||||||
// FIXME: #830, UI for donations
|
|
||||||
// donateLink
|
|
||||||
linksLink
|
linksLink
|
||||||
creditsLink
|
creditsLink
|
||||||
|
donateLink
|
||||||
diagnosticsLink
|
diagnosticsLink
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -49,18 +49,15 @@ struct ModuleListView: View, Routable {
|
||||||
NavigationLink(Strings.Global.general, value: ProfileSplitView.Detail.general)
|
NavigationLink(Strings.Global.general, value: ProfileSplitView.Detail.general)
|
||||||
.tag(Self.generalModuleId)
|
.tag(Self.generalModuleId)
|
||||||
}
|
}
|
||||||
Section {
|
Group {
|
||||||
ForEach(profileEditor.modules, id: \.id) { module in
|
ForEach(profileEditor.modules, id: \.id) { module in
|
||||||
NavigationLink(value: ProfileSplitView.Detail.module(id: module.id)) {
|
NavigationLink(value: ProfileSplitView.Detail.module(id: module.id)) {
|
||||||
moduleRow(for: module)
|
moduleRow(for: module)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onMove(perform: moveModules)
|
.onMove(perform: moveModules)
|
||||||
} header: {
|
|
||||||
if !profileEditor.modules.isEmpty {
|
|
||||||
Text(Strings.Global.modules)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.themeSection(header: !profileEditor.modules.isEmpty ? Strings.Global.modules : nil)
|
||||||
}
|
}
|
||||||
.onDeleteCommand(perform: removeSelectedModule)
|
.onDeleteCommand(perform: removeSelectedModule)
|
||||||
.toolbar(content: toolbarContent)
|
.toolbar(content: toolbarContent)
|
||||||
|
|
|
@ -27,6 +27,8 @@ import Foundation
|
||||||
import PassepartoutKit
|
import PassepartoutKit
|
||||||
|
|
||||||
public enum AppError: Error {
|
public enum AppError: Error {
|
||||||
|
case emptyProducts
|
||||||
|
|
||||||
case emptyProfileName
|
case emptyProfileName
|
||||||
|
|
||||||
case malformedModule(any ModuleBuilder, error: Error)
|
case malformedModule(any ModuleBuilder, error: Error)
|
||||||
|
|
|
@ -39,7 +39,7 @@ extension AppProduct {
|
||||||
|
|
||||||
public static let maxi = AppProduct(donationId: "Maxi")
|
public static let maxi = AppProduct(donationId: "Maxi")
|
||||||
|
|
||||||
static let all: [AppProduct] = [
|
public static let all: [AppProduct] = [
|
||||||
.Donations.tiny,
|
.Donations.tiny,
|
||||||
.Donations.small,
|
.Donations.small,
|
||||||
.Donations.medium,
|
.Donations.medium,
|
||||||
|
|
|
@ -76,7 +76,7 @@ public final class IAPManager: ObservableObject {
|
||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
|
|
||||||
extension IAPManager {
|
extension IAPManager {
|
||||||
public func purchasableProducts(for products: [AppProduct]) async -> [InAppProduct] {
|
public func purchasableProducts(for products: [AppProduct]) async throws -> [InAppProduct] {
|
||||||
do {
|
do {
|
||||||
let inAppProducts = try await inAppHelper.fetchProducts()
|
let inAppProducts = try await inAppHelper.fetchProducts()
|
||||||
return products.compactMap {
|
return products.compactMap {
|
||||||
|
@ -84,7 +84,7 @@ extension IAPManager {
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
pp_log(.App.iap, .error, "Unable to fetch in-app products: \(error)")
|
pp_log(.App.iap, .error, "Unable to fetch in-app products: \(error)")
|
||||||
return []
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -32,6 +32,9 @@ extension AppError: LocalizedError {
|
||||||
public var errorDescription: String? {
|
public var errorDescription: String? {
|
||||||
let V = Strings.Errors.App.self
|
let V = Strings.Errors.App.self
|
||||||
switch self {
|
switch self {
|
||||||
|
case .emptyProducts:
|
||||||
|
return V.emptyProducts
|
||||||
|
|
||||||
case .emptyProfileName:
|
case .emptyProfileName:
|
||||||
return V.emptyProfileName
|
return V.emptyProfileName
|
||||||
|
|
||||||
|
|
|
@ -114,6 +114,8 @@ public enum Strings {
|
||||||
public enum App {
|
public enum App {
|
||||||
/// Unable to complete operation.
|
/// Unable to complete operation.
|
||||||
public static let `default` = Strings.tr("Localizable", "errors.app.default", fallback: "Unable to complete operation.")
|
public static let `default` = Strings.tr("Localizable", "errors.app.default", fallback: "Unable to complete operation.")
|
||||||
|
/// Unable to fetch products, please retry later.
|
||||||
|
public static let emptyProducts = Strings.tr("Localizable", "errors.app.empty_products", fallback: "Unable to fetch products, please retry later.")
|
||||||
/// Profile name is empty.
|
/// Profile name is empty.
|
||||||
public static let emptyProfileName = Strings.tr("Localizable", "errors.app.empty_profile_name", fallback: "Profile name is empty.")
|
public static let emptyProfileName = Strings.tr("Localizable", "errors.app.empty_profile_name", fallback: "Profile name is empty.")
|
||||||
/// Profile is expired.
|
/// Profile is expired.
|
||||||
|
@ -696,6 +698,20 @@ public enum Strings {
|
||||||
public enum Donate {
|
public enum Donate {
|
||||||
/// Make a donation
|
/// Make a donation
|
||||||
public static let title = Strings.tr("Localizable", "views.donate.title", fallback: "Make a donation")
|
public static let title = Strings.tr("Localizable", "views.donate.title", fallback: "Make a donation")
|
||||||
|
public enum Alerts {
|
||||||
|
public enum ThankYou {
|
||||||
|
/// This means a lot to me and I really hope you keep using and promoting this app.
|
||||||
|
public static let message = Strings.tr("Localizable", "views.donate.alerts.thank_you.message", fallback: "This means a lot to me and I really hope you keep using and promoting this app.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public enum Sections {
|
||||||
|
public enum Main {
|
||||||
|
/// If you want to display gratitude for my work, here are a couple amounts you can donate instantly.
|
||||||
|
///
|
||||||
|
/// You will only be charged once per donation, and you can donate multiple times.
|
||||||
|
public static let footer = Strings.tr("Localizable", "views.donate.sections.main.footer", fallback: "If you want to display gratitude for my work, here are a couple amounts you can donate instantly.\n\nYou will only be charged once per donation, and you can donate multiple times.")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
public enum Profile {
|
public enum Profile {
|
||||||
public enum ModuleList {
|
public enum ModuleList {
|
||||||
|
|
|
@ -155,6 +155,8 @@
|
||||||
"views.about.credits.translations" = "Translations";
|
"views.about.credits.translations" = "Translations";
|
||||||
|
|
||||||
"views.donate.title" = "Make a donation";
|
"views.donate.title" = "Make a donation";
|
||||||
|
"views.donate.sections.main.footer" = "If you want to display gratitude for my work, here are a couple amounts you can donate instantly.\n\nYou will only be charged once per donation, and you can donate multiple times.";
|
||||||
|
"views.donate.alerts.thank_you.message" = "This means a lot to me and I really hope you keep using and promoting this app.";
|
||||||
|
|
||||||
"views.diagnostics.title" = "Diagnostics";
|
"views.diagnostics.title" = "Diagnostics";
|
||||||
"views.diagnostics.sections.live" = "Live log";
|
"views.diagnostics.sections.live" = "Live log";
|
||||||
|
@ -252,7 +254,7 @@
|
||||||
"ui.connection_status.on_demand_suffix" = " (on-demand)";
|
"ui.connection_status.on_demand_suffix" = " (on-demand)";
|
||||||
"ui.profile_context.connect_to" = "Connect to...";
|
"ui.profile_context.connect_to" = "Connect to...";
|
||||||
|
|
||||||
// MARK: - Paywalls
|
// MARK: - Paywall
|
||||||
|
|
||||||
"paywall.sections.one_time.header" = "One-time purchase";
|
"paywall.sections.one_time.header" = "One-time purchase";
|
||||||
"paywall.sections.recurring.header" = "All features";
|
"paywall.sections.recurring.header" = "All features";
|
||||||
|
@ -287,6 +289,7 @@
|
||||||
|
|
||||||
// MARK: - Errors
|
// MARK: - Errors
|
||||||
|
|
||||||
|
"errors.app.empty_products" = "Unable to fetch products, please retry later.";
|
||||||
"errors.app.empty_profile_name" = "Profile name is empty.";
|
"errors.app.empty_profile_name" = "Profile name is empty.";
|
||||||
"errors.app.expired_profile" = "Profile is expired.";
|
"errors.app.expired_profile" = "Profile is expired.";
|
||||||
"errors.app.malformed_module" = "Module %@ is malformed. %@";
|
"errors.app.malformed_module" = "Module %@ is malformed. %@";
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// PaywallView+Custom.swift
|
// CustomProductView.swift
|
||||||
// Passepartout
|
// Passepartout
|
||||||
//
|
//
|
||||||
// Created by Davide De Rosa on 11/7/24.
|
// Created by Davide De Rosa on 11/7/24.
|
||||||
|
@ -28,34 +28,39 @@ import CommonUtils
|
||||||
import StoreKit
|
import StoreKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
extension PaywallView {
|
struct CustomProductView: View {
|
||||||
struct CustomProductView: View {
|
let style: PaywallProductViewStyle
|
||||||
let style: ProductStyle
|
|
||||||
|
|
||||||
@ObservedObject
|
@ObservedObject
|
||||||
var iapManager: IAPManager
|
var iapManager: IAPManager
|
||||||
|
|
||||||
let product: InAppProduct
|
let product: InAppProduct
|
||||||
|
|
||||||
let onComplete: (String, InAppPurchaseResult) -> Void
|
@Binding
|
||||||
|
var purchasingIdentifier: String?
|
||||||
|
|
||||||
let onError: (Error) -> Void
|
let onComplete: (String, InAppPurchaseResult) -> Void
|
||||||
|
|
||||||
var body: some View {
|
let onError: (Error) -> Void
|
||||||
HStack {
|
|
||||||
Text(verbatim: product.localizedTitle)
|
var body: some View {
|
||||||
Spacer()
|
HStack {
|
||||||
Button(action: purchase) {
|
Text(verbatim: product.localizedTitle)
|
||||||
Text(verbatim: product.localizedPrice)
|
Spacer()
|
||||||
}
|
Button(action: purchase) {
|
||||||
|
Text(verbatim: product.localizedPrice)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension PaywallView.CustomProductView {
|
private extension CustomProductView {
|
||||||
func purchase() {
|
func purchase() {
|
||||||
|
purchasingIdentifier = product.productIdentifier
|
||||||
Task {
|
Task {
|
||||||
|
defer {
|
||||||
|
purchasingIdentifier = nil
|
||||||
|
}
|
||||||
do {
|
do {
|
||||||
let result = try await iapManager.purchase(product)
|
let result = try await iapManager.purchase(product)
|
||||||
onComplete(product.productIdentifier, result)
|
onComplete(product.productIdentifier, result)
|
|
@ -0,0 +1,82 @@
|
||||||
|
//
|
||||||
|
// Empty.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 SwiftUI
|
||||||
|
|
||||||
|
public struct PaywallProductView: View {
|
||||||
|
|
||||||
|
@ObservedObject
|
||||||
|
private var iapManager: IAPManager
|
||||||
|
|
||||||
|
private let style: PaywallProductViewStyle
|
||||||
|
|
||||||
|
private let product: InAppProduct
|
||||||
|
|
||||||
|
@Binding
|
||||||
|
private var purchasingIdentifier: String?
|
||||||
|
|
||||||
|
private let onComplete: (String, InAppPurchaseResult) -> Void
|
||||||
|
|
||||||
|
private let onError: (Error) -> Void
|
||||||
|
|
||||||
|
public init(
|
||||||
|
iapManager: IAPManager,
|
||||||
|
style: PaywallProductViewStyle,
|
||||||
|
product: InAppProduct,
|
||||||
|
purchasingIdentifier: Binding<String?>,
|
||||||
|
onComplete: @escaping (String, InAppPurchaseResult) -> Void,
|
||||||
|
onError: @escaping (Error) -> Void
|
||||||
|
) {
|
||||||
|
self.iapManager = iapManager
|
||||||
|
self.style = style
|
||||||
|
self.product = product
|
||||||
|
_purchasingIdentifier = purchasingIdentifier
|
||||||
|
self.onComplete = onComplete
|
||||||
|
self.onError = onError
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
if #available(iOS 17, macOS 14, *) {
|
||||||
|
StoreKitProductView(
|
||||||
|
style: style,
|
||||||
|
product: product,
|
||||||
|
purchasingIdentifier: $purchasingIdentifier,
|
||||||
|
onComplete: onComplete,
|
||||||
|
onError: onError
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
CustomProductView(
|
||||||
|
style: style,
|
||||||
|
iapManager: iapManager,
|
||||||
|
product: product,
|
||||||
|
purchasingIdentifier: $purchasingIdentifier,
|
||||||
|
onComplete: onComplete,
|
||||||
|
onError: onError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
//
|
||||||
|
// PaywallProductViewStyle.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 Foundation
|
||||||
|
|
||||||
|
public enum PaywallProductViewStyle {
|
||||||
|
case oneTime
|
||||||
|
|
||||||
|
case recurring
|
||||||
|
|
||||||
|
case donation
|
||||||
|
}
|
|
@ -29,11 +29,6 @@ import StoreKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct PaywallView: View {
|
struct PaywallView: View {
|
||||||
enum ProductStyle {
|
|
||||||
case oneTime
|
|
||||||
|
|
||||||
case recurring
|
|
||||||
}
|
|
||||||
|
|
||||||
@EnvironmentObject
|
@EnvironmentObject
|
||||||
private var iapManager: IAPManager
|
private var iapManager: IAPManager
|
||||||
|
@ -45,9 +40,6 @@ struct PaywallView: View {
|
||||||
|
|
||||||
let suggestedProduct: AppProduct?
|
let suggestedProduct: AppProduct?
|
||||||
|
|
||||||
@State
|
|
||||||
private var isFetchingProducts = true
|
|
||||||
|
|
||||||
@State
|
@State
|
||||||
private var oneTimeProduct: InAppProduct?
|
private var oneTimeProduct: InAppProduct?
|
||||||
|
|
||||||
|
@ -55,7 +47,13 @@ struct PaywallView: View {
|
||||||
private var recurringProducts: [InAppProduct] = []
|
private var recurringProducts: [InAppProduct] = []
|
||||||
|
|
||||||
@State
|
@State
|
||||||
private var isPendingPresented = false
|
private var isFetchingProducts = true
|
||||||
|
|
||||||
|
@State
|
||||||
|
private var purchasingIdentifier: String?
|
||||||
|
|
||||||
|
@State
|
||||||
|
private var isPurchasePendingConfirmation = false
|
||||||
|
|
||||||
@StateObject
|
@StateObject
|
||||||
private var errorHandler: ErrorHandler = .default()
|
private var errorHandler: ErrorHandler = .default()
|
||||||
|
@ -65,17 +63,20 @@ struct PaywallView: View {
|
||||||
if isFetchingProducts {
|
if isFetchingProducts {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.id(UUID())
|
.id(UUID())
|
||||||
} else {
|
} else if !recurringProducts.isEmpty {
|
||||||
productsView
|
Group {
|
||||||
subscriptionFeaturesView
|
productsView
|
||||||
restoreView
|
subscriptionFeaturesView
|
||||||
|
restoreView
|
||||||
|
}
|
||||||
|
.disabled(purchasingIdentifier != nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.themeForm()
|
.themeForm()
|
||||||
.toolbar(content: toolbarContent)
|
.toolbar(content: toolbarContent)
|
||||||
.alert(
|
.alert(
|
||||||
Strings.Global.purchase,
|
Strings.Global.purchase,
|
||||||
isPresented: $isPendingPresented,
|
isPresented: $isPurchasePendingConfirmation,
|
||||||
actions: pendingActions,
|
actions: pendingActions,
|
||||||
message: pendingMessage
|
message: pendingMessage
|
||||||
)
|
)
|
||||||
|
@ -100,11 +101,25 @@ private extension PaywallView {
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
var productsView: some View {
|
var productsView: some View {
|
||||||
oneTimeProduct.map {
|
oneTimeProduct.map {
|
||||||
productView(.oneTime, for: $0)
|
PaywallProductView(
|
||||||
.themeSection(header: Strings.Paywall.Sections.OneTime.header)
|
iapManager: iapManager,
|
||||||
|
style: .oneTime,
|
||||||
|
product: $0,
|
||||||
|
purchasingIdentifier: $purchasingIdentifier,
|
||||||
|
onComplete: onComplete,
|
||||||
|
onError: onError
|
||||||
|
)
|
||||||
|
.themeSection(header: Strings.Paywall.Sections.OneTime.header)
|
||||||
}
|
}
|
||||||
ForEach(recurringProducts, id: \.productIdentifier) {
|
ForEach(recurringProducts, id: \.productIdentifier) {
|
||||||
productView(.recurring, for: $0)
|
PaywallProductView(
|
||||||
|
iapManager: iapManager,
|
||||||
|
style: .recurring,
|
||||||
|
product: $0,
|
||||||
|
purchasingIdentifier: $purchasingIdentifier,
|
||||||
|
onComplete: onComplete,
|
||||||
|
onError: onError
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.themeSection(header: Strings.Paywall.Sections.Recurring.header)
|
.themeSection(header: Strings.Paywall.Sections.Recurring.header)
|
||||||
}
|
}
|
||||||
|
@ -124,28 +139,8 @@ private extension PaywallView {
|
||||||
}
|
}
|
||||||
#endif
|
#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 {
|
var restoreView: some View {
|
||||||
RestorePurchasesButton()
|
RestorePurchasesButton(errorHandler: errorHandler)
|
||||||
.themeSectionWithSingleRow(
|
.themeSectionWithSingleRow(
|
||||||
header: Strings.Paywall.Sections.Restore.header,
|
header: Strings.Paywall.Sections.Restore.header,
|
||||||
footer: Strings.Paywall.Sections.Restore.footer,
|
footer: Strings.Paywall.Sections.Restore.footer,
|
||||||
|
@ -183,6 +178,9 @@ private extension PaywallView {
|
||||||
private extension PaywallView {
|
private extension PaywallView {
|
||||||
func fetchAvailableProducts() async {
|
func fetchAvailableProducts() async {
|
||||||
isFetchingProducts = true
|
isFetchingProducts = true
|
||||||
|
defer {
|
||||||
|
isFetchingProducts = false
|
||||||
|
}
|
||||||
|
|
||||||
var list: [AppProduct] = []
|
var list: [AppProduct] = []
|
||||||
if let suggestedProduct {
|
if let suggestedProduct {
|
||||||
|
@ -191,18 +189,23 @@ private extension PaywallView {
|
||||||
list.append(.Full.Recurring.yearly)
|
list.append(.Full.Recurring.yearly)
|
||||||
list.append(.Full.Recurring.monthly)
|
list.append(.Full.Recurring.monthly)
|
||||||
|
|
||||||
let availableProducts = await iapManager.purchasableProducts(for: list)
|
do {
|
||||||
oneTimeProduct = availableProducts.first {
|
let availableProducts = try await iapManager.purchasableProducts(for: list)
|
||||||
guard let suggestedProduct else {
|
guard !availableProducts.isEmpty else {
|
||||||
return false
|
throw AppError.emptyProducts
|
||||||
}
|
}
|
||||||
return $0.productIdentifier.hasSuffix(suggestedProduct.rawValue)
|
oneTimeProduct = availableProducts.first {
|
||||||
|
guard let suggestedProduct else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return $0.productIdentifier.hasSuffix(suggestedProduct.rawValue)
|
||||||
|
}
|
||||||
|
recurringProducts = availableProducts.filter {
|
||||||
|
$0.productIdentifier != oneTimeProduct?.productIdentifier
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
onError(error, dismissing: true)
|
||||||
}
|
}
|
||||||
recurringProducts = availableProducts.filter {
|
|
||||||
$0.productIdentifier != oneTimeProduct?.productIdentifier
|
|
||||||
}
|
|
||||||
|
|
||||||
isFetchingProducts = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func onComplete(_ productIdentifier: String, result: InAppPurchaseResult) {
|
func onComplete(_ productIdentifier: String, result: InAppPurchaseResult) {
|
||||||
|
@ -211,7 +214,7 @@ private extension PaywallView {
|
||||||
isPresented = false
|
isPresented = false
|
||||||
|
|
||||||
case .pending:
|
case .pending:
|
||||||
isPendingPresented = true
|
isPurchasePendingConfirmation = true
|
||||||
|
|
||||||
case .cancelled:
|
case .cancelled:
|
||||||
break
|
break
|
||||||
|
@ -222,7 +225,15 @@ private extension PaywallView {
|
||||||
}
|
}
|
||||||
|
|
||||||
func onError(_ error: Error) {
|
func onError(_ error: Error) {
|
||||||
errorHandler.handle(error, title: Strings.Global.purchase)
|
onError(error, dismissing: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func onError(_ error: Error, dismissing: Bool) {
|
||||||
|
errorHandler.handle(error, title: Strings.Global.purchase) {
|
||||||
|
if dismissing {
|
||||||
|
isPresented = false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -24,6 +24,7 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import CommonLibrary
|
import CommonLibrary
|
||||||
|
import CommonUtils
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
public struct RestorePurchasesButton: View {
|
public struct RestorePurchasesButton: View {
|
||||||
|
@ -31,14 +32,28 @@ public struct RestorePurchasesButton: View {
|
||||||
@EnvironmentObject
|
@EnvironmentObject
|
||||||
private var iapManager: IAPManager
|
private var iapManager: IAPManager
|
||||||
|
|
||||||
public init() {
|
@ObservedObject
|
||||||
|
private var errorHandler: ErrorHandler
|
||||||
|
|
||||||
|
public init(errorHandler: ErrorHandler) {
|
||||||
|
self.errorHandler = errorHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
Button(Strings.Paywall.Rows.restorePurchases) {
|
Button(title) {
|
||||||
Task {
|
Task {
|
||||||
try await iapManager.restorePurchases()
|
do {
|
||||||
|
try await iapManager.restorePurchases()
|
||||||
|
} catch {
|
||||||
|
errorHandler.handle(error, title: title)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extension RestorePurchasesButton {
|
||||||
|
var title: String {
|
||||||
|
Strings.Paywall.Rows.restorePurchases
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// PaywallView+StoreKit.swift
|
// StoreKitProductView.swift
|
||||||
// Passepartout
|
// Passepartout
|
||||||
//
|
//
|
||||||
// Created by Davide De Rosa on 11/7/24.
|
// Created by Davide De Rosa on 11/7/24.
|
||||||
|
@ -28,27 +28,42 @@ import StoreKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@available(iOS 17, macOS 14, *)
|
@available(iOS 17, macOS 14, *)
|
||||||
extension PaywallView {
|
struct StoreKitProductView: View {
|
||||||
struct StoreKitProductView: View {
|
let style: PaywallProductViewStyle
|
||||||
let style: ProductStyle
|
|
||||||
|
|
||||||
let product: InAppProduct
|
let product: InAppProduct
|
||||||
|
|
||||||
let onComplete: (String, InAppPurchaseResult) -> Void
|
@Binding
|
||||||
|
var purchasingIdentifier: String?
|
||||||
|
|
||||||
let onError: (Error) -> Void
|
let onComplete: (String, InAppPurchaseResult) -> Void
|
||||||
|
|
||||||
var body: some View {
|
let onError: (Error) -> Void
|
||||||
ProductView(id: product.productIdentifier)
|
|
||||||
.productViewStyle(.compact)
|
var body: some View {
|
||||||
.onInAppPurchaseCompletion { skProduct, result in
|
ProductView(id: product.productIdentifier)
|
||||||
do {
|
.productViewStyle(style.toStoreKitStyle)
|
||||||
let skResult = try result.get()
|
.onInAppPurchaseStart { _ in
|
||||||
onComplete(skProduct.id, skResult.toResult)
|
purchasingIdentifier = product.productIdentifier
|
||||||
} catch {
|
}
|
||||||
onError(error)
|
.onInAppPurchaseCompletion { skProduct, result in
|
||||||
}
|
do {
|
||||||
|
let skResult = try result.get()
|
||||||
|
onComplete(skProduct.id, skResult.toResult)
|
||||||
|
} catch {
|
||||||
|
onError(error)
|
||||||
}
|
}
|
||||||
|
purchasingIdentifier = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 17, macOS 14, *)
|
||||||
|
private extension PaywallProductViewStyle {
|
||||||
|
var toStoreKitStyle: some ProductViewStyle {
|
||||||
|
switch self {
|
||||||
|
case .oneTime, .recurring, .donation:
|
||||||
|
return .compact
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
Loading…
Reference in New Issue