mirror of
https://github.com/passepartoutvpn/passepartout-apple.git
synced 2025-01-18 22:49:10 +00:00
parent
07b4e786c3
commit
7f7e591616
@ -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)
|
||||
|
@ -33,4 +33,6 @@ enum AboutCoordinatorRoute: Hashable {
|
||||
case donate
|
||||
|
||||
case links
|
||||
|
||||
case purchased
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -81,6 +81,7 @@ private extension AboutContentView {
|
||||
if !isRestricted {
|
||||
linkContent(.donate)
|
||||
}
|
||||
linkContent(.purchased)
|
||||
linkContent(.diagnostics)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -32,5 +32,7 @@ enum AppCoordinatorRoute: Hashable {
|
||||
|
||||
case donate
|
||||
|
||||
case purchased
|
||||
|
||||
case tunnelLog
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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 = {
|
||||
|
@ -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";
|
||||
|
@ -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()
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user