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:
Davide 2024-11-07 23:02:10 +01:00 committed by GitHub
parent 83b2e6b4e0
commit 8fbccc6d80
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 413 additions and 106 deletions

View File

@ -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?
} }

View File

@ -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 {
.navigationTitle(Strings.Views.Donate.title) 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()
}
}
} }
} }

View File

@ -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

View File

@ -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 {

View File

@ -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)
} }

View File

@ -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
} }
} }

View File

@ -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)

View File

@ -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)

View File

@ -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,

View File

@ -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
} }
} }

View File

@ -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

View File

@ -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 {

View File

@ -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. %@";

View File

@ -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,15 +28,17 @@ import CommonUtils
import StoreKit import StoreKit
import SwiftUI import SwiftUI
extension PaywallView {
struct CustomProductView: View { struct CustomProductView: View {
let style: ProductStyle let style: PaywallProductViewStyle
@ObservedObject @ObservedObject
var iapManager: IAPManager var iapManager: IAPManager
let product: InAppProduct let product: InAppProduct
@Binding
var purchasingIdentifier: String?
let onComplete: (String, InAppPurchaseResult) -> Void let onComplete: (String, InAppPurchaseResult) -> Void
let onError: (Error) -> Void let onError: (Error) -> Void
@ -51,11 +53,14 @@ extension PaywallView {
} }
} }
} }
}
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)

View File

@ -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
)
}
}
}

View File

@ -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
}

View File

@ -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 {
Group {
productsView productsView
subscriptionFeaturesView subscriptionFeaturesView
restoreView 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(
iapManager: iapManager,
style: .oneTime,
product: $0,
purchasingIdentifier: $purchasingIdentifier,
onComplete: onComplete,
onError: onError
)
.themeSection(header: Strings.Paywall.Sections.OneTime.header) .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,7 +189,11 @@ 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 {
let availableProducts = try await iapManager.purchasableProducts(for: list)
guard !availableProducts.isEmpty else {
throw AppError.emptyProducts
}
oneTimeProduct = availableProducts.first { oneTimeProduct = availableProducts.first {
guard let suggestedProduct else { guard let suggestedProduct else {
return false return false
@ -201,8 +203,9 @@ private extension PaywallView {
recurringProducts = availableProducts.filter { recurringProducts = availableProducts.filter {
$0.productIdentifier != oneTimeProduct?.productIdentifier $0.productIdentifier != oneTimeProduct?.productIdentifier
} }
} catch {
isFetchingProducts = false onError(error, dismissing: true)
}
} }
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
}
}
} }
} }

View File

@ -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 {
do {
try await iapManager.restorePurchases() try await iapManager.restorePurchases()
} catch {
errorHandler.handle(error, title: title)
} }
} }
} }
} }
}
private extension RestorePurchasesButton {
var title: String {
Strings.Paywall.Rows.restorePurchases
}
}

View File

@ -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,19 +28,24 @@ 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: ProductStyle let style: PaywallProductViewStyle
let product: InAppProduct let product: InAppProduct
@Binding
var purchasingIdentifier: String?
let onComplete: (String, InAppPurchaseResult) -> Void let onComplete: (String, InAppPurchaseResult) -> Void
let onError: (Error) -> Void let onError: (Error) -> Void
var body: some View { var body: some View {
ProductView(id: product.productIdentifier) ProductView(id: product.productIdentifier)
.productViewStyle(.compact) .productViewStyle(style.toStoreKitStyle)
.onInAppPurchaseStart { _ in
purchasingIdentifier = product.productIdentifier
}
.onInAppPurchaseCompletion { skProduct, result in .onInAppPurchaseCompletion { skProduct, result in
do { do {
let skResult = try result.get() let skResult = try result.get()
@ -48,9 +53,19 @@ extension PaywallView {
} catch { } catch {
onError(error) onError(error)
} }
purchasingIdentifier = nil
} }
} }
} }
@available(iOS 17, macOS 14, *)
private extension PaywallProductViewStyle {
var toStoreKitStyle: some ProductViewStyle {
switch self {
case .oneTime, .recurring, .donation:
return .compact
}
}
} }
private extension Product.PurchaseResult { private extension Product.PurchaseResult {