Add view in "About" about purchased products (#935)

Closes #897
This commit is contained in:
Davide 2024-11-25 19:17:18 +01:00 committed by GitHub
parent 07b4e786c3
commit 7f7e591616
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 193 additions and 27 deletions

View File

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

View File

@ -33,4 +33,6 @@ enum AboutCoordinatorRoute: Hashable {
case donate
case links
case purchased
}

View File

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

View File

@ -81,6 +81,7 @@ private extension AboutContentView {
if !isRestricted {
linkContent(.donate)
}
linkContent(.purchased)
linkContent(.diagnostics)
}
}

View File

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

View File

@ -32,5 +32,7 @@ enum AppCoordinatorRoute: Hashable {
case donate
case purchased
case tunnelLog
}

View File

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

View File

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

View File

@ -35,28 +35,28 @@ public final class InAppProcessor: ObservableObject, Sendable {
private nonisolated let _preview: (Profile) -> ProfilePreview
private nonisolated let _verify: (IAPManager, Profile) -> Set<AppFeature>?
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<AppFeature>?
public init(
iapManager: IAPManager,
title: @escaping (Profile) -> String,
isIncluded: @escaping (IAPManager, Profile) -> Bool,
preview: @escaping (Profile) -> ProfilePreview,
verify: @escaping (IAPManager, Profile) -> Set<AppFeature>?,
willRebuild: @escaping (IAPManager, Profile.Builder) throws -> Profile.Builder,
willInstall: @escaping (IAPManager, Profile) throws -> Profile,
verify: @escaping (IAPManager, Profile) -> Set<AppFeature>?
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<AppFeature>? {
_verify(iapManager, profile)
}
public func willRebuild(_ builder: Profile.Builder) throws -> Profile.Builder {
try _willRebuild(iapManager, builder)
}
}
// MARK: - TunnelProcessor

View File

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

View File

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

View File

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

View File

@ -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 <http://www.gnu.org/licenses/>.
//
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()
}

View File

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