From 8fbccc6d80639518e1420369050c6895b7371e27 Mon Sep 17 00:00:00 2001 From: Davide Date: Thu, 7 Nov 2024 23:02:10 +0100 Subject: [PATCH] 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 --- .../Views/About/AboutRouterView.swift | 3 + .../AppUIMain/Views/About/DonateView.swift | 131 +++++++++++++++++- .../Views/About/iOS/AboutRouterView+iOS.swift | 2 +- .../Views/About/iOS/AboutView+iOS.swift | 3 +- .../About/macOS/AboutRouterView+macOS.swift | 2 +- .../Views/About/macOS/AboutView+macOS.swift | 3 +- .../Profile/macOS/ModuleListView+macOS.swift | 7 +- .../CommonLibrary/Domain/AppError.swift | 2 + .../IAP/AppProduct+Donations.swift | 2 +- .../CommonLibrary/IAP/IAPManager.swift | 4 +- .../UILibrary/L10n/AppError+L10n.swift | 3 + .../UILibrary/L10n/SwiftGen+Strings.swift | 16 +++ .../Resources/en.lproj/Localizable.strings | 5 +- ...w+Custom.swift => CustomProductView.swift} | 39 +++--- .../Views/Paywall/PaywallProductView.swift | 82 +++++++++++ .../Paywall/PaywallProductViewStyle.swift | 34 +++++ .../UILibrary/Views/Paywall/PaywallView.swift | 111 ++++++++------- .../Paywall/RestorePurchasesButton.swift | 21 ++- ...oreKit.swift => StoreKitProductView.swift} | 49 ++++--- 19 files changed, 413 insertions(+), 106 deletions(-) rename Passepartout/Library/Sources/UILibrary/Views/Paywall/{PaywallView+Custom.swift => CustomProductView.swift} (63%) create mode 100644 Passepartout/Library/Sources/UILibrary/Views/Paywall/PaywallProductView.swift create mode 100644 Passepartout/Library/Sources/UILibrary/Views/Paywall/PaywallProductViewStyle.swift rename Passepartout/Library/Sources/UILibrary/Views/Paywall/{PaywallView+StoreKit.swift => StoreKitProductView.swift} (55%) diff --git a/Passepartout/Library/Sources/AppUIMain/Views/About/AboutRouterView.swift b/Passepartout/Library/Sources/AppUIMain/Views/About/AboutRouterView.swift index 8f174b80..47d7870b 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/About/AboutRouterView.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/About/AboutRouterView.swift @@ -36,6 +36,9 @@ struct AboutRouterView: View { let tunnel: ExtendedTunnel + @State + var path = NavigationPath() + @State var navigationRoute: NavigationRoute? } diff --git a/Passepartout/Library/Sources/AppUIMain/Views/About/DonateView.swift b/Passepartout/Library/Sources/AppUIMain/Views/About/DonateView.swift index 8e8d39fd..f1857bcc 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/About/DonateView.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/About/DonateView.swift @@ -23,14 +23,137 @@ // along with Passepartout. If not, see . // +import CommonLibrary +import CommonUtils import SwiftUI -// FIXME: #830, UI for donations - 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 { - 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) } } diff --git a/Passepartout/Library/Sources/AppUIMain/Views/About/iOS/AboutRouterView+iOS.swift b/Passepartout/Library/Sources/AppUIMain/Views/About/iOS/AboutRouterView+iOS.swift index b1d08f40..916f10bb 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/About/iOS/AboutRouterView+iOS.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/About/iOS/AboutRouterView+iOS.swift @@ -30,7 +30,7 @@ import SwiftUI extension AboutRouterView { var body: some View { - NavigationStack { + NavigationStack(path: $path) { AboutView( profileManager: profileManager, navigationRoute: $navigationRoute diff --git a/Passepartout/Library/Sources/AppUIMain/Views/About/iOS/AboutView+iOS.swift b/Passepartout/Library/Sources/AppUIMain/Views/About/iOS/AboutView+iOS.swift index e8f6080a..96e249dd 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/About/iOS/AboutView+iOS.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/About/iOS/AboutView+iOS.swift @@ -33,10 +33,9 @@ extension AboutView { List { SettingsSectionGroup(profileManager: profileManager) Group { - // FIXME: #830, UI for donations -// donateLink linksLink creditsLink + donateLink } .themeSection(header: Strings.Views.About.Sections.resources) Section { diff --git a/Passepartout/Library/Sources/AppUIMain/Views/About/macOS/AboutRouterView+macOS.swift b/Passepartout/Library/Sources/AppUIMain/Views/About/macOS/AboutRouterView+macOS.swift index 6f6d2901..de33b511 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/About/macOS/AboutRouterView+macOS.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/About/macOS/AboutRouterView+macOS.swift @@ -36,7 +36,7 @@ extension AboutRouterView { navigationRoute: $navigationRoute ) } detail: { - NavigationStack { + NavigationStack(path: $path) { pushDestination(for: navigationRoute) .navigationDestination(for: NavigationRoute.self, destination: pushDestination) } diff --git a/Passepartout/Library/Sources/AppUIMain/Views/About/macOS/AboutView+macOS.swift b/Passepartout/Library/Sources/AppUIMain/Views/About/macOS/AboutView+macOS.swift index a813af88..c085c27f 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/About/macOS/AboutView+macOS.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/About/macOS/AboutView+macOS.swift @@ -32,10 +32,9 @@ extension AboutView { var listView: some View { List(selection: $navigationRoute) { Section { - // FIXME: #830, UI for donations -// donateLink linksLink creditsLink + donateLink diagnosticsLink } } diff --git a/Passepartout/Library/Sources/AppUIMain/Views/Profile/macOS/ModuleListView+macOS.swift b/Passepartout/Library/Sources/AppUIMain/Views/Profile/macOS/ModuleListView+macOS.swift index 07324af9..328f04b7 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/Profile/macOS/ModuleListView+macOS.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/Profile/macOS/ModuleListView+macOS.swift @@ -49,18 +49,15 @@ struct ModuleListView: View, Routable { NavigationLink(Strings.Global.general, value: ProfileSplitView.Detail.general) .tag(Self.generalModuleId) } - Section { + Group { ForEach(profileEditor.modules, id: \.id) { module in NavigationLink(value: ProfileSplitView.Detail.module(id: module.id)) { moduleRow(for: module) } } .onMove(perform: moveModules) - } header: { - if !profileEditor.modules.isEmpty { - Text(Strings.Global.modules) - } } + .themeSection(header: !profileEditor.modules.isEmpty ? Strings.Global.modules : nil) } .onDeleteCommand(perform: removeSelectedModule) .toolbar(content: toolbarContent) diff --git a/Passepartout/Library/Sources/CommonLibrary/Domain/AppError.swift b/Passepartout/Library/Sources/CommonLibrary/Domain/AppError.swift index 6c01e7bc..ae5aea3f 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Domain/AppError.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Domain/AppError.swift @@ -27,6 +27,8 @@ import Foundation import PassepartoutKit public enum AppError: Error { + case emptyProducts + case emptyProfileName case malformedModule(any ModuleBuilder, error: Error) diff --git a/Passepartout/Library/Sources/CommonLibrary/IAP/AppProduct+Donations.swift b/Passepartout/Library/Sources/CommonLibrary/IAP/AppProduct+Donations.swift index b91b48ed..0b29c28d 100644 --- a/Passepartout/Library/Sources/CommonLibrary/IAP/AppProduct+Donations.swift +++ b/Passepartout/Library/Sources/CommonLibrary/IAP/AppProduct+Donations.swift @@ -39,7 +39,7 @@ extension AppProduct { public static let maxi = AppProduct(donationId: "Maxi") - static let all: [AppProduct] = [ + public static let all: [AppProduct] = [ .Donations.tiny, .Donations.small, .Donations.medium, diff --git a/Passepartout/Library/Sources/CommonLibrary/IAP/IAPManager.swift b/Passepartout/Library/Sources/CommonLibrary/IAP/IAPManager.swift index d51628a6..d1391e83 100644 --- a/Passepartout/Library/Sources/CommonLibrary/IAP/IAPManager.swift +++ b/Passepartout/Library/Sources/CommonLibrary/IAP/IAPManager.swift @@ -76,7 +76,7 @@ public final class IAPManager: ObservableObject { // MARK: - Actions extension IAPManager { - public func purchasableProducts(for products: [AppProduct]) async -> [InAppProduct] { + public func purchasableProducts(for products: [AppProduct]) async throws -> [InAppProduct] { do { let inAppProducts = try await inAppHelper.fetchProducts() return products.compactMap { @@ -84,7 +84,7 @@ extension IAPManager { } } catch { pp_log(.App.iap, .error, "Unable to fetch in-app products: \(error)") - return [] + throw error } } diff --git a/Passepartout/Library/Sources/UILibrary/L10n/AppError+L10n.swift b/Passepartout/Library/Sources/UILibrary/L10n/AppError+L10n.swift index 23d4d4bf..1c80ce34 100644 --- a/Passepartout/Library/Sources/UILibrary/L10n/AppError+L10n.swift +++ b/Passepartout/Library/Sources/UILibrary/L10n/AppError+L10n.swift @@ -32,6 +32,9 @@ extension AppError: LocalizedError { public var errorDescription: String? { let V = Strings.Errors.App.self switch self { + case .emptyProducts: + return V.emptyProducts + case .emptyProfileName: return V.emptyProfileName diff --git a/Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift b/Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift index 4dc2f200..34e17d49 100644 --- a/Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift +++ b/Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift @@ -114,6 +114,8 @@ public enum Strings { public enum App { /// 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. public static let emptyProfileName = Strings.tr("Localizable", "errors.app.empty_profile_name", fallback: "Profile name is empty.") /// Profile is expired. @@ -696,6 +698,20 @@ public enum Strings { public enum Donate { /// 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 ModuleList { diff --git a/Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings b/Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings index 4dbb283b..21cca2c9 100644 --- a/Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings +++ b/Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings @@ -155,6 +155,8 @@ "views.about.credits.translations" = "Translations"; "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.sections.live" = "Live log"; @@ -252,7 +254,7 @@ "ui.connection_status.on_demand_suffix" = " (on-demand)"; "ui.profile_context.connect_to" = "Connect to..."; -// MARK: - Paywalls +// MARK: - Paywall "paywall.sections.one_time.header" = "One-time purchase"; "paywall.sections.recurring.header" = "All features"; @@ -287,6 +289,7 @@ // MARK: - Errors +"errors.app.empty_products" = "Unable to fetch products, please retry later."; "errors.app.empty_profile_name" = "Profile name is empty."; "errors.app.expired_profile" = "Profile is expired."; "errors.app.malformed_module" = "Module %@ is malformed. %@"; diff --git a/Passepartout/Library/Sources/UILibrary/Views/Paywall/PaywallView+Custom.swift b/Passepartout/Library/Sources/UILibrary/Views/Paywall/CustomProductView.swift similarity index 63% rename from Passepartout/Library/Sources/UILibrary/Views/Paywall/PaywallView+Custom.swift rename to Passepartout/Library/Sources/UILibrary/Views/Paywall/CustomProductView.swift index 2f1cb5d5..c3a3cdc8 100644 --- a/Passepartout/Library/Sources/UILibrary/Views/Paywall/PaywallView+Custom.swift +++ b/Passepartout/Library/Sources/UILibrary/Views/Paywall/CustomProductView.swift @@ -1,5 +1,5 @@ // -// PaywallView+Custom.swift +// CustomProductView.swift // Passepartout // // Created by Davide De Rosa on 11/7/24. @@ -28,34 +28,39 @@ import CommonUtils import StoreKit import SwiftUI -extension PaywallView { - struct CustomProductView: View { - let style: ProductStyle +struct CustomProductView: View { + let style: PaywallProductViewStyle - @ObservedObject - var iapManager: IAPManager + @ObservedObject + 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 { - HStack { - Text(verbatim: product.localizedTitle) - Spacer() - Button(action: purchase) { - Text(verbatim: product.localizedPrice) - } + let onError: (Error) -> Void + + var body: some View { + HStack { + Text(verbatim: product.localizedTitle) + Spacer() + Button(action: purchase) { + Text(verbatim: product.localizedPrice) } } } } -private extension PaywallView.CustomProductView { +private extension CustomProductView { func purchase() { + purchasingIdentifier = product.productIdentifier Task { + defer { + purchasingIdentifier = nil + } do { let result = try await iapManager.purchase(product) onComplete(product.productIdentifier, result) diff --git a/Passepartout/Library/Sources/UILibrary/Views/Paywall/PaywallProductView.swift b/Passepartout/Library/Sources/UILibrary/Views/Paywall/PaywallProductView.swift new file mode 100644 index 00000000..b470e3fd --- /dev/null +++ b/Passepartout/Library/Sources/UILibrary/Views/Paywall/PaywallProductView.swift @@ -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 . +// + +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, + 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 + ) + } + } +} diff --git a/Passepartout/Library/Sources/UILibrary/Views/Paywall/PaywallProductViewStyle.swift b/Passepartout/Library/Sources/UILibrary/Views/Paywall/PaywallProductViewStyle.swift new file mode 100644 index 00000000..8867c2d3 --- /dev/null +++ b/Passepartout/Library/Sources/UILibrary/Views/Paywall/PaywallProductViewStyle.swift @@ -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 . +// + +import Foundation + +public enum PaywallProductViewStyle { + case oneTime + + case recurring + + case donation +} diff --git a/Passepartout/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift b/Passepartout/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift index 5fe8f025..b7491751 100644 --- a/Passepartout/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift +++ b/Passepartout/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift @@ -29,11 +29,6 @@ import StoreKit import SwiftUI struct PaywallView: View { - enum ProductStyle { - case oneTime - - case recurring - } @EnvironmentObject private var iapManager: IAPManager @@ -45,9 +40,6 @@ struct PaywallView: View { let suggestedProduct: AppProduct? - @State - private var isFetchingProducts = true - @State private var oneTimeProduct: InAppProduct? @@ -55,7 +47,13 @@ struct PaywallView: View { private var recurringProducts: [InAppProduct] = [] @State - private var isPendingPresented = false + private var isFetchingProducts = true + + @State + private var purchasingIdentifier: String? + + @State + private var isPurchasePendingConfirmation = false @StateObject private var errorHandler: ErrorHandler = .default() @@ -65,17 +63,20 @@ struct PaywallView: View { if isFetchingProducts { ProgressView() .id(UUID()) - } else { - productsView - subscriptionFeaturesView - restoreView + } else if !recurringProducts.isEmpty { + Group { + productsView + subscriptionFeaturesView + restoreView + } + .disabled(purchasingIdentifier != nil) } } .themeForm() .toolbar(content: toolbarContent) .alert( Strings.Global.purchase, - isPresented: $isPendingPresented, + isPresented: $isPurchasePendingConfirmation, actions: pendingActions, message: pendingMessage ) @@ -100,11 +101,25 @@ private extension PaywallView { @ViewBuilder var productsView: some View { oneTimeProduct.map { - productView(.oneTime, for: $0) - .themeSection(header: Strings.Paywall.Sections.OneTime.header) + PaywallProductView( + iapManager: iapManager, + style: .oneTime, + product: $0, + purchasingIdentifier: $purchasingIdentifier, + onComplete: onComplete, + onError: onError + ) + .themeSection(header: Strings.Paywall.Sections.OneTime.header) } 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) } @@ -124,28 +139,8 @@ private extension PaywallView { } #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 { - RestorePurchasesButton() + RestorePurchasesButton(errorHandler: errorHandler) .themeSectionWithSingleRow( header: Strings.Paywall.Sections.Restore.header, footer: Strings.Paywall.Sections.Restore.footer, @@ -183,6 +178,9 @@ private extension PaywallView { private extension PaywallView { func fetchAvailableProducts() async { isFetchingProducts = true + defer { + isFetchingProducts = false + } var list: [AppProduct] = [] if let suggestedProduct { @@ -191,18 +189,23 @@ private extension PaywallView { list.append(.Full.Recurring.yearly) list.append(.Full.Recurring.monthly) - let availableProducts = await iapManager.purchasableProducts(for: list) - oneTimeProduct = availableProducts.first { - guard let suggestedProduct else { - return false + do { + let availableProducts = try await iapManager.purchasableProducts(for: list) + guard !availableProducts.isEmpty else { + 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) { @@ -211,7 +214,7 @@ private extension PaywallView { isPresented = false case .pending: - isPendingPresented = true + isPurchasePendingConfirmation = true case .cancelled: break @@ -222,7 +225,15 @@ private extension PaywallView { } 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 + } + } } } diff --git a/Passepartout/Library/Sources/UILibrary/Views/Paywall/RestorePurchasesButton.swift b/Passepartout/Library/Sources/UILibrary/Views/Paywall/RestorePurchasesButton.swift index 6eed490b..7ba5b468 100644 --- a/Passepartout/Library/Sources/UILibrary/Views/Paywall/RestorePurchasesButton.swift +++ b/Passepartout/Library/Sources/UILibrary/Views/Paywall/RestorePurchasesButton.swift @@ -24,6 +24,7 @@ // import CommonLibrary +import CommonUtils import SwiftUI public struct RestorePurchasesButton: View { @@ -31,14 +32,28 @@ public struct RestorePurchasesButton: View { @EnvironmentObject private var iapManager: IAPManager - public init() { + @ObservedObject + private var errorHandler: ErrorHandler + + public init(errorHandler: ErrorHandler) { + self.errorHandler = errorHandler } public var body: some View { - Button(Strings.Paywall.Rows.restorePurchases) { + Button(title) { 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 + } +} diff --git a/Passepartout/Library/Sources/UILibrary/Views/Paywall/PaywallView+StoreKit.swift b/Passepartout/Library/Sources/UILibrary/Views/Paywall/StoreKitProductView.swift similarity index 55% rename from Passepartout/Library/Sources/UILibrary/Views/Paywall/PaywallView+StoreKit.swift rename to Passepartout/Library/Sources/UILibrary/Views/Paywall/StoreKitProductView.swift index b74835df..48d4d9a2 100644 --- a/Passepartout/Library/Sources/UILibrary/Views/Paywall/PaywallView+StoreKit.swift +++ b/Passepartout/Library/Sources/UILibrary/Views/Paywall/StoreKitProductView.swift @@ -1,5 +1,5 @@ // -// PaywallView+StoreKit.swift +// StoreKitProductView.swift // Passepartout // // Created by Davide De Rosa on 11/7/24. @@ -28,27 +28,42 @@ import StoreKit import SwiftUI @available(iOS 17, macOS 14, *) -extension PaywallView { - struct StoreKitProductView: View { - let style: ProductStyle +struct StoreKitProductView: View { + let style: PaywallProductViewStyle - 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 { - ProductView(id: product.productIdentifier) - .productViewStyle(.compact) - .onInAppPurchaseCompletion { skProduct, result in - do { - let skResult = try result.get() - onComplete(skProduct.id, skResult.toResult) - } catch { - onError(error) - } + let onError: (Error) -> Void + + var body: some View { + ProductView(id: product.productIdentifier) + .productViewStyle(style.toStoreKitStyle) + .onInAppPurchaseStart { _ in + purchasingIdentifier = product.productIdentifier + } + .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 } } }