From 7f7e591616b68b76ea71826d903b99890e10e3ed Mon Sep 17 00:00:00 2001 From: Davide Date: Mon, 25 Nov 2024 19:17:18 +0100 Subject: [PATCH] Add view in "About" about purchased products (#935) Closes #897 --- .../Views/About/AboutCoordinator.swift | 15 ++- .../Views/About/AboutCoordinatorRoute.swift | 2 + .../About/iOS/AboutContentView+iOS.swift | 1 + .../About/macOS/AboutContentView+macOS.swift | 1 + .../AppUITV/Views/App/AppCoordinator.swift | 5 + .../Views/App/AppCoordinatorRoute.swift | 2 + .../AppUITV/Views/Settings/SettingsView.swift | 1 + .../CommonLibrary/Business/IAPManager.swift | 2 +- .../Strategy/InAppProcessor.swift | 18 +-- .../UILibrary/L10n/SwiftGen+Strings.swift | 26 ++++ .../UILibrary/Mock/AppContext+Mock.swift | 6 +- .../Resources/en.lproj/Localizable.strings | 8 ++ .../UILibrary/Views/About/PurchasedView.swift | 113 ++++++++++++++++++ Passepartout/Shared/Shared+App.swift | 20 ++-- 14 files changed, 193 insertions(+), 27 deletions(-) create mode 100644 Passepartout/Library/Sources/UILibrary/Views/About/PurchasedView.swift diff --git a/Passepartout/Library/Sources/AppUIMain/Views/About/AboutCoordinator.swift b/Passepartout/Library/Sources/AppUIMain/Views/About/AboutCoordinator.swift index 7b416cc1..dec62a96 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/About/AboutCoordinator.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/About/AboutCoordinator.swift @@ -77,6 +77,9 @@ extension AboutCoordinator { case .links: return Strings.Views.About.Links.title + + case .purchased: + return Strings.Views.Purchased.title } } @@ -85,19 +88,23 @@ extension AboutCoordinator { switch item { case .credits: CreditsView() - .navigationTitle(Strings.Views.About.Credits.title) + .navigationTitle(title(for: .credits)) case .diagnostics: DiagnosticsView(profileManager: profileManager, tunnel: tunnel) - .navigationTitle(Strings.Views.Diagnostics.title) + .navigationTitle(title(for: .diagnostics)) case .donate: DonateView(modifier: DonateViewModifier()) - .navigationTitle(Strings.Views.Donate.title) + .navigationTitle(title(for: .donate)) case .links: LinksView() - .navigationTitle(Strings.Views.About.Links.title) + .navigationTitle(title(for: .links)) + + case .purchased: + PurchasedView() + .navigationTitle(title(for: .purchased)) default: Text(Strings.Global.Nouns.noSelection) diff --git a/Passepartout/Library/Sources/AppUIMain/Views/About/AboutCoordinatorRoute.swift b/Passepartout/Library/Sources/AppUIMain/Views/About/AboutCoordinatorRoute.swift index 01522608..785a6e03 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/About/AboutCoordinatorRoute.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/About/AboutCoordinatorRoute.swift @@ -33,4 +33,6 @@ enum AboutCoordinatorRoute: Hashable { case donate case links + + case purchased } diff --git a/Passepartout/Library/Sources/AppUIMain/Views/About/iOS/AboutContentView+iOS.swift b/Passepartout/Library/Sources/AppUIMain/Views/About/iOS/AboutContentView+iOS.swift index 16043246..80a4a256 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/About/iOS/AboutContentView+iOS.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/About/iOS/AboutContentView+iOS.swift @@ -73,6 +73,7 @@ private extension AboutContentView { } .themeSection(header: Strings.Views.About.Sections.resources) Section { + linkContent(.purchased) linkContent(.diagnostics) Text(Strings.Global.Nouns.version) .themeTrailingValue(BundleConfiguration.mainVersionString) diff --git a/Passepartout/Library/Sources/AppUIMain/Views/About/macOS/AboutContentView+macOS.swift b/Passepartout/Library/Sources/AppUIMain/Views/About/macOS/AboutContentView+macOS.swift index 4e51819e..a21983b6 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/About/macOS/AboutContentView+macOS.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/About/macOS/AboutContentView+macOS.swift @@ -81,6 +81,7 @@ private extension AboutContentView { if !isRestricted { linkContent(.donate) } + linkContent(.purchased) linkContent(.diagnostics) } } diff --git a/Passepartout/Library/Sources/AppUITV/Views/App/AppCoordinator.swift b/Passepartout/Library/Sources/AppUITV/Views/App/AppCoordinator.swift index 82bac97d..95f875e0 100644 --- a/Passepartout/Library/Sources/AppUITV/Views/App/AppCoordinator.swift +++ b/Passepartout/Library/Sources/AppUITV/Views/App/AppCoordinator.swift @@ -116,6 +116,11 @@ private extension AppCoordinator { case .donate: DonateView(modifier: DonateViewModifier()) + case .purchased: + PurchasedView() + .resized(width: 0.5) + .themeList() + case .tunnelLog: DebugLogView(withTunnel: tunnel, parameters: Constants.shared.log) { DebugLogContentView(lines: $0) diff --git a/Passepartout/Library/Sources/AppUITV/Views/App/AppCoordinatorRoute.swift b/Passepartout/Library/Sources/AppUITV/Views/App/AppCoordinatorRoute.swift index efe85c46..98d2d2ff 100644 --- a/Passepartout/Library/Sources/AppUITV/Views/App/AppCoordinatorRoute.swift +++ b/Passepartout/Library/Sources/AppUITV/Views/App/AppCoordinatorRoute.swift @@ -32,5 +32,7 @@ enum AppCoordinatorRoute: Hashable { case donate + case purchased + case tunnelLog } diff --git a/Passepartout/Library/Sources/AppUITV/Views/Settings/SettingsView.swift b/Passepartout/Library/Sources/AppUITV/Views/Settings/SettingsView.swift index c70b6ea2..3b4a95e1 100644 --- a/Passepartout/Library/Sources/AppUITV/Views/Settings/SettingsView.swift +++ b/Passepartout/Library/Sources/AppUITV/Views/Settings/SettingsView.swift @@ -67,6 +67,7 @@ private extension SettingsView { var aboutSection: some View { Group { + NavigationLink(Strings.Views.Purchased.title, value: AppCoordinatorRoute.purchased) Text(Strings.Global.Nouns.version) .themeTrailingValue(BundleConfiguration.mainVersionString) } diff --git a/Passepartout/Library/Sources/CommonLibrary/Business/IAPManager.swift b/Passepartout/Library/Sources/CommonLibrary/Business/IAPManager.swift index dc7e21eb..3346b117 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Business/IAPManager.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Business/IAPManager.swift @@ -44,7 +44,7 @@ public final class IAPManager: ObservableObject { private(set) var userLevel: AppUserLevel - private var purchasedAppBuild: Int? + public private(set) var purchasedAppBuild: Int? public private(set) var purchasedProducts: Set diff --git a/Passepartout/Library/Sources/CommonLibrary/Strategy/InAppProcessor.swift b/Passepartout/Library/Sources/CommonLibrary/Strategy/InAppProcessor.swift index c3773239..33a5344e 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Strategy/InAppProcessor.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Strategy/InAppProcessor.swift @@ -35,28 +35,28 @@ public final class InAppProcessor: ObservableObject, Sendable { private nonisolated let _preview: (Profile) -> ProfilePreview + private nonisolated let _verify: (IAPManager, Profile) -> Set? + private nonisolated let _willRebuild: (IAPManager, Profile.Builder) throws -> Profile.Builder private nonisolated let _willInstall: (IAPManager, Profile) throws -> Profile - private nonisolated let _verify: (IAPManager, Profile) -> Set? - public init( iapManager: IAPManager, title: @escaping (Profile) -> String, isIncluded: @escaping (IAPManager, Profile) -> Bool, preview: @escaping (Profile) -> ProfilePreview, + verify: @escaping (IAPManager, Profile) -> Set?, willRebuild: @escaping (IAPManager, Profile.Builder) throws -> Profile.Builder, - willInstall: @escaping (IAPManager, Profile) throws -> Profile, - verify: @escaping (IAPManager, Profile) -> Set? + willInstall: @escaping (IAPManager, Profile) throws -> Profile ) { self.iapManager = iapManager _title = title _isIncluded = isIncluded _preview = preview + _verify = verify _willRebuild = willRebuild _willInstall = willInstall - _verify = verify } } @@ -75,13 +75,13 @@ extension InAppProcessor: ProfileProcessor { _preview(profile) } - public func willRebuild(_ builder: Profile.Builder) throws -> Profile.Builder { - try _willRebuild(iapManager, builder) - } - public func verify(_ profile: Profile) -> Set? { _verify(iapManager, profile) } + + public func willRebuild(_ builder: Profile.Builder) throws -> Profile.Builder { + try _willRebuild(iapManager, builder) + } } // MARK: - TunnelProcessor diff --git a/Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift b/Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift index 318accab..6e66f0ec 100644 --- a/Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift +++ b/Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift @@ -831,6 +831,32 @@ public enum Strings { } } } + public enum Purchased { + /// No purchases + public static let noPurchases = Strings.tr("Localizable", "views.purchased.no_purchases", fallback: "No purchases") + /// Purchased + public static let title = Strings.tr("Localizable", "views.purchased.title", fallback: "Purchased") + public enum Rows { + /// Build number + public static let buildNumber = Strings.tr("Localizable", "views.purchased.rows.build_number", fallback: "Build number") + /// Download date + public static let downloadDate = Strings.tr("Localizable", "views.purchased.rows.download_date", fallback: "Download date") + } + public enum Sections { + public enum Download { + /// First download + public static let header = Strings.tr("Localizable", "views.purchased.sections.download.header", fallback: "First download") + } + public enum Features { + /// Eligible features + public static let header = Strings.tr("Localizable", "views.purchased.sections.features.header", fallback: "Eligible features") + } + public enum Products { + /// Purchases + public static let header = Strings.tr("Localizable", "views.purchased.sections.products.header", fallback: "Purchases") + } + } + } public enum Ui { public enum ConnectionStatus { /// (on-demand) diff --git a/Passepartout/Library/Sources/UILibrary/Mock/AppContext+Mock.swift b/Passepartout/Library/Sources/UILibrary/Mock/AppContext+Mock.swift index 20223628..41f02f78 100644 --- a/Passepartout/Library/Sources/UILibrary/Mock/AppContext+Mock.swift +++ b/Passepartout/Library/Sources/UILibrary/Mock/AppContext+Mock.swift @@ -56,14 +56,14 @@ extension AppContext { preview: { ProfilePreview($0) }, + verify: { _, _ in + nil + }, willRebuild: { _, builder in builder }, willInstall: { _, profile in try profile.withProviderModules() - }, - verify: { _, _ in - nil } ) let profileManager = { diff --git a/Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings b/Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings index 1a7b9d01..459b504b 100644 --- a/Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings +++ b/Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings @@ -119,6 +119,14 @@ "views.providers.vpn.preset" = "Preset"; "views.providers.vpn.no_servers" = "No servers"; +"views.purchased.title" = "Purchased"; +"views.purchased.sections.download.header" = "First download"; +"views.purchased.sections.products.header" = "Purchases"; +"views.purchased.sections.features.header" = "Eligible features"; +"views.purchased.rows.build_number" = "Build number"; +"views.purchased.rows.download_date" = "Download date"; +"views.purchased.no_purchases" = "No purchases"; + "views.ui.connection_status.on_demand_suffix" = " (on-demand)"; "views.ui.purchase_required.purchase.help" = "Purchase required"; "views.ui.purchase_required.restricted.help" = "Feature is restricted"; diff --git a/Passepartout/Library/Sources/UILibrary/Views/About/PurchasedView.swift b/Passepartout/Library/Sources/UILibrary/Views/About/PurchasedView.swift new file mode 100644 index 00000000..75cad67f --- /dev/null +++ b/Passepartout/Library/Sources/UILibrary/Views/About/PurchasedView.swift @@ -0,0 +1,113 @@ +// +// PurchasedView.swift +// Passepartout +// +// Created by Davide De Rosa on 11/25/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 PurchasedView: View { + + @EnvironmentObject + private var iapManager: IAPManager + + @State + private var products: [InAppProduct] = [] + + public init() { + } + + public var body: some View { + listView + .themeEmpty(if: isEmpty, message: Strings.Views.Purchased.noPurchases) + .onLoad { + Task { + products = try await iapManager + .purchasableProducts(for: Array(iapManager.purchasedProducts)) + .sorted { + $0.localizedTitle < $1.localizedTitle + } + } + } + } +} + +private extension PurchasedView { + var isEmpty: Bool { + iapManager.purchasedAppBuild == nil && iapManager.purchasedProducts.isEmpty && iapManager.eligibleFeatures.isEmpty + } + + var allFeatures: [AppFeature] { + AppFeature.allCases.sorted() + } +} + +private extension PurchasedView { + var listView: some View { + List { + downloadSection + productsSection + featuresSection + } + } + + var downloadSection: some View { + iapManager.purchasedAppBuild.map { build in + Group { + Text(Strings.Views.Purchased.Rows.buildNumber) + .themeTrailingValue(build.description) + } + .themeSection(header: Strings.Views.Purchased.Sections.Download.header) + } + } + + var productsSection: some View { + Group { + ForEach(products, id: \.productIdentifier) { + Text($0.localizedTitle) + .themeTrailingValue($0.localizedPrice) + } + } + .themeSection(header: Strings.Views.Purchased.Sections.Products.header) + } + + var featuresSection: some View { + Group { + ForEach(allFeatures, id: \.self) { feature in + HStack { + Text(feature.localizedDescription) + Spacer() + ThemeImage(.marked) + .opaque(iapManager.isEligible(for: feature)) + } + } + } + .themeSection(header: Strings.Views.Purchased.Sections.Features.header) + } +} + +#Preview { + PurchasedView() + .withMockEnvironment() +} diff --git a/Passepartout/Shared/Shared+App.swift b/Passepartout/Shared/Shared+App.swift index 14462aad..7f33770c 100644 --- a/Passepartout/Shared/Shared+App.swift +++ b/Passepartout/Shared/Shared+App.swift @@ -52,6 +52,16 @@ extension IAPManager { subtitle: $0.localizedDescription(optionalStyle: .moduleTypes) ) }, + verify: { iap, profile in + do { + try iap.verify(profile) + return nil + } catch AppError.ineligibleProfile(let requiredFeatures) { + return requiredFeatures + } catch { + return nil + } + }, willRebuild: { _, builder in builder }, @@ -66,16 +76,6 @@ extension IAPManager { pp_log(.app, .error, "Unable to inject provider modules: \(error)") throw error } - }, - verify: { iap, profile in - do { - try iap.verify(profile) - return nil - } catch AppError.ineligibleProfile(let requiredFeatures) { - return requiredFeatures - } catch { - return nil - } } ) }