mirror of
https://github.com/passepartoutvpn/passepartout-apple.git
synced 2025-02-27 18:22:30 +00:00
parent
bad9e8b58e
commit
cb530d8a65
@ -168,15 +168,15 @@ extension AppCoordinator {
|
|||||||
}
|
}
|
||||||
enterDetail(of: profile)
|
enterDetail(of: profile)
|
||||||
},
|
},
|
||||||
onEditProviderEntity: {
|
onMigrateProfiles: {
|
||||||
|
modalRoute = .migrateProfiles
|
||||||
|
},
|
||||||
|
onProviderEntityRequired: {
|
||||||
guard let pair = $0.selectedProvider else {
|
guard let pair = $0.selectedProvider else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
present(.editProviderEntity($0, pair.module, pair.selection))
|
present(.editProviderEntity($0, pair.module, pair.selection))
|
||||||
},
|
},
|
||||||
onMigrateProfiles: {
|
|
||||||
modalRoute = .migrateProfiles
|
|
||||||
},
|
|
||||||
onPurchaseRequired: { features in
|
onPurchaseRequired: { features in
|
||||||
setLater(.purchase(features)) {
|
setLater(.purchase(features)) {
|
||||||
paywallReason = $0
|
paywallReason = $0
|
||||||
|
@ -137,7 +137,7 @@ private extension InstalledProfileView {
|
|||||||
.selectedProvider
|
.selectedProvider
|
||||||
.map { _, selection in
|
.map { _, selection in
|
||||||
Button {
|
Button {
|
||||||
flow?.onEditProviderEntity(profile!)
|
flow?.onProviderEntityRequired(profile!)
|
||||||
} label: {
|
} label: {
|
||||||
providerSelectorLabel(with: selection)
|
providerSelectorLabel(with: selection)
|
||||||
}
|
}
|
||||||
@ -202,7 +202,7 @@ private struct ToggleButton: View {
|
|||||||
interactiveManager: interactiveManager,
|
interactiveManager: interactiveManager,
|
||||||
errorHandler: errorHandler,
|
errorHandler: errorHandler,
|
||||||
onProviderEntityRequired: {
|
onProviderEntityRequired: {
|
||||||
flow?.onEditProviderEntity($0)
|
flow?.onProviderEntityRequired($0)
|
||||||
},
|
},
|
||||||
onPurchaseRequired: {
|
onPurchaseRequired: {
|
||||||
flow?.onPurchaseRequired($0)
|
flow?.onPurchaseRequired($0)
|
||||||
|
@ -96,7 +96,7 @@ private extension ProfileContainerView {
|
|||||||
InteractiveCoordinator(style: .modal, manager: interactiveManager) {
|
InteractiveCoordinator(style: .modal, manager: interactiveManager) {
|
||||||
errorHandler.handle(
|
errorHandler.handle(
|
||||||
$0,
|
$0,
|
||||||
title: Strings.Global.Nouns.connection,
|
title: interactiveManager.editor.profile.name,
|
||||||
message: Strings.Views.App.Errors.tunnel
|
message: Strings.Views.App.Errors.tunnel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -71,7 +71,7 @@ private extension ProfileContextMenu {
|
|||||||
interactiveManager: interactiveManager,
|
interactiveManager: interactiveManager,
|
||||||
errorHandler: errorHandler,
|
errorHandler: errorHandler,
|
||||||
onProviderEntityRequired: {
|
onProviderEntityRequired: {
|
||||||
flow?.onEditProviderEntity($0)
|
flow?.onProviderEntityRequired($0)
|
||||||
},
|
},
|
||||||
onPurchaseRequired: {
|
onPurchaseRequired: {
|
||||||
flow?.onPurchaseRequired($0)
|
flow?.onPurchaseRequired($0)
|
||||||
@ -90,7 +90,7 @@ private extension ProfileContextMenu {
|
|||||||
.selectedProvider
|
.selectedProvider
|
||||||
.map { _ in
|
.map { _ in
|
||||||
Button(Strings.Views.App.ProfileContext.connectTo) {
|
Button(Strings.Views.App.ProfileContext.connectTo) {
|
||||||
flow?.onEditProviderEntity(profile!)
|
flow?.onProviderEntityRequired(profile!)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -30,9 +30,9 @@ import PassepartoutKit
|
|||||||
struct ProfileFlow {
|
struct ProfileFlow {
|
||||||
let onEditProfile: (ProfilePreview) -> Void
|
let onEditProfile: (ProfilePreview) -> Void
|
||||||
|
|
||||||
let onEditProviderEntity: (Profile) -> Void
|
|
||||||
|
|
||||||
let onMigrateProfiles: () -> Void
|
let onMigrateProfiles: () -> Void
|
||||||
|
|
||||||
|
let onProviderEntityRequired: (Profile) -> Void
|
||||||
|
|
||||||
let onPurchaseRequired: (Set<AppFeature>) -> Void
|
let onPurchaseRequired: (Set<AppFeature>) -> Void
|
||||||
}
|
}
|
||||||
|
@ -135,7 +135,7 @@ private extension ProfileRowView {
|
|||||||
interactiveManager: interactiveManager,
|
interactiveManager: interactiveManager,
|
||||||
errorHandler: errorHandler,
|
errorHandler: errorHandler,
|
||||||
onProviderEntityRequired: {
|
onProviderEntityRequired: {
|
||||||
flow?.onEditProviderEntity($0)
|
flow?.onProviderEntityRequired($0)
|
||||||
},
|
},
|
||||||
onPurchaseRequired: {
|
onPurchaseRequired: {
|
||||||
flow?.onPurchaseRequired($0)
|
flow?.onPurchaseRequired($0)
|
||||||
|
@ -59,7 +59,7 @@ struct TunnelRestartButton<Label>: View where Label: View {
|
|||||||
} catch {
|
} catch {
|
||||||
errorHandler.handle(
|
errorHandler.handle(
|
||||||
error,
|
error,
|
||||||
title: Strings.Global.Nouns.connection,
|
title: profile.name,
|
||||||
message: Strings.Views.App.Errors.tunnel
|
message: Strings.Views.App.Errors.tunnel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -73,15 +73,13 @@ struct ProfileCoordinator: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
contentView
|
contentView
|
||||||
.modifier(PaywallModifier(reason: $paywallReason))
|
.modifier(PaywallModifier(reason: $paywallReason))
|
||||||
.alert(Strings.Views.Profile.Alerts.Purchase.title, isPresented: $requiresPurchase) {
|
.modifier(PurchaseAlertModifier(
|
||||||
Button(Strings.Global.Actions.purchase) {
|
isPresented: $requiresPurchase,
|
||||||
paywallReason = .purchase(requiredFeatures, nil)
|
paywallReason: $paywallReason,
|
||||||
}
|
requiredFeatures: requiredFeatures,
|
||||||
Button(Strings.Views.Profile.Alerts.Purchase.Buttons.ok, action: onDismiss)
|
okTitle: Strings.Views.Profile.Alerts.Purchase.Buttons.ok,
|
||||||
Button(Strings.Global.Actions.cancel, role: .cancel, action: {})
|
okAction: onDismiss
|
||||||
} message: {
|
))
|
||||||
Text(purchaseMessage)
|
|
||||||
}
|
|
||||||
.withErrorHandler(errorHandler)
|
.withErrorHandler(errorHandler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -117,13 +115,6 @@ private extension ProfileCoordinator {
|
|||||||
)
|
)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
var purchaseMessage: String {
|
|
||||||
let msg = Strings.Views.Profile.Alerts.Purchase.message
|
|
||||||
return msg + "\n\n" + requiredFeatures
|
|
||||||
.map(\.localizedDescription)
|
|
||||||
.joined(separator: "\n")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension ProfileCoordinator {
|
private extension ProfileCoordinator {
|
||||||
|
@ -24,8 +24,10 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import CommonLibrary
|
import CommonLibrary
|
||||||
|
import CommonUtils
|
||||||
import PassepartoutKit
|
import PassepartoutKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UILibrary
|
||||||
|
|
||||||
public struct AppCoordinator: View, AppCoordinatorConforming {
|
public struct AppCoordinator: View, AppCoordinatorConforming {
|
||||||
private let profileManager: ProfileManager
|
private let profileManager: ProfileManager
|
||||||
@ -34,6 +36,18 @@ public struct AppCoordinator: View, AppCoordinatorConforming {
|
|||||||
|
|
||||||
private let registry: Registry
|
private let registry: Registry
|
||||||
|
|
||||||
|
@State
|
||||||
|
private var requiresPurchase = false
|
||||||
|
|
||||||
|
@State
|
||||||
|
private var requiredFeatures: Set<AppFeature> = []
|
||||||
|
|
||||||
|
@State
|
||||||
|
private var paywallReason: PaywallReason?
|
||||||
|
|
||||||
|
@StateObject
|
||||||
|
private var errorHandler: ErrorHandler = .default()
|
||||||
|
|
||||||
public init(profileManager: ProfileManager, tunnel: ExtendedTunnel, registry: Registry) {
|
public init(profileManager: ProfileManager, tunnel: ExtendedTunnel, registry: Registry) {
|
||||||
self.profileManager = profileManager
|
self.profileManager = profileManager
|
||||||
self.tunnel = tunnel
|
self.tunnel = tunnel
|
||||||
@ -60,13 +74,28 @@ public struct AppCoordinator: View, AppCoordinatorConforming {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationDestination(for: AppCoordinatorRoute.self, destination: pushDestination)
|
.navigationDestination(for: AppCoordinatorRoute.self, destination: pushDestination)
|
||||||
|
.withErrorHandler(errorHandler)
|
||||||
|
.modifier(PaywallModifier(reason: $paywallReason))
|
||||||
|
.modifier(PurchaseAlertModifier(
|
||||||
|
isPresented: $requiresPurchase,
|
||||||
|
paywallReason: $paywallReason,
|
||||||
|
requiredFeatures: requiredFeatures
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension AppCoordinator {
|
private extension AppCoordinator {
|
||||||
var profileView: some View {
|
var profileView: some View {
|
||||||
ProfileView(profileManager: profileManager, tunnel: tunnel)
|
ProfileView(
|
||||||
|
profileManager: profileManager,
|
||||||
|
tunnel: tunnel,
|
||||||
|
errorHandler: errorHandler,
|
||||||
|
flow: .init(
|
||||||
|
onProviderEntityRequired: onProviderEntityRequired,
|
||||||
|
onPurchaseRequired: onPurchaseRequired
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// var searchView: some View {
|
// var searchView: some View {
|
||||||
@ -109,6 +138,20 @@ private extension AppCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extension AppCoordinator {
|
||||||
|
func onProviderEntityRequired(_ profile: Profile) {
|
||||||
|
errorHandler.handle(
|
||||||
|
title: profile.name,
|
||||||
|
message: Strings.Alerts.Providers.MissingServer.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func onPurchaseRequired(_ features: Set<AppFeature>) {
|
||||||
|
requiredFeatures = features
|
||||||
|
requiresPurchase = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: -
|
// MARK: -
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
|
34
Passepartout/Library/Sources/AppUITV/Views/App/AppFlow.swift
Normal file
34
Passepartout/Library/Sources/AppUITV/Views/App/AppFlow.swift
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
//
|
||||||
|
// AppFlow.swift
|
||||||
|
// Passepartout
|
||||||
|
//
|
||||||
|
// Created by Davide De Rosa on 11/23/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 Foundation
|
||||||
|
import PassepartoutKit
|
||||||
|
|
||||||
|
struct AppFlow {
|
||||||
|
let onProviderEntityRequired: (Profile) -> Void
|
||||||
|
|
||||||
|
let onPurchaseRequired: (Set<AppFeature>) -> Void
|
||||||
|
}
|
@ -53,6 +53,8 @@ struct ActiveProfileView: View {
|
|||||||
@ObservedObject
|
@ObservedObject
|
||||||
var errorHandler: ErrorHandler
|
var errorHandler: ErrorHandler
|
||||||
|
|
||||||
|
var flow: AppFlow?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: .zero) {
|
VStack(spacing: .zero) {
|
||||||
VStack {
|
VStack {
|
||||||
@ -167,15 +169,13 @@ private extension ActiveProfileView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: -
|
|
||||||
|
|
||||||
private extension ActiveProfileView {
|
private extension ActiveProfileView {
|
||||||
func onProviderEntityRequired(_ profile: Profile) {
|
func onProviderEntityRequired(_ profile: Profile) {
|
||||||
// FIXME: #913, TV missing provider entity
|
flow?.onProviderEntityRequired(profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
func onPurchaseRequired(_ features: Set<AppFeature>) {
|
func onPurchaseRequired(_ features: Set<AppFeature>) {
|
||||||
// FIXME: #913, TV purchase required
|
flow?.onPurchaseRequired(features)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -45,6 +45,8 @@ struct ProfileListView: View {
|
|||||||
@ObservedObject
|
@ObservedObject
|
||||||
var errorHandler: ErrorHandler
|
var errorHandler: ErrorHandler
|
||||||
|
|
||||||
|
var flow: AppFlow?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
headerView
|
headerView
|
||||||
@ -100,15 +102,13 @@ private extension ProfileListView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: -
|
|
||||||
|
|
||||||
private extension ProfileListView {
|
private extension ProfileListView {
|
||||||
func onProviderEntityRequired(_ profile: Profile) {
|
func onProviderEntityRequired(_ profile: Profile) {
|
||||||
// FIXME: #913, TV missing provider entity
|
flow?.onProviderEntityRequired(profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
func onPurchaseRequired(_ features: Set<AppFeature>) {
|
func onPurchaseRequired(_ features: Set<AppFeature>) {
|
||||||
// FIXME: #913, TV purchase required
|
flow?.onPurchaseRequired(features)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -29,7 +29,7 @@ import PassepartoutKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UILibrary
|
import UILibrary
|
||||||
|
|
||||||
struct ProfileView: View, TunnelInstallationProviding {
|
struct ProfileView: View, Routable, TunnelInstallationProviding {
|
||||||
enum Field: Hashable {
|
enum Field: Hashable {
|
||||||
case connect
|
case connect
|
||||||
|
|
||||||
@ -47,6 +47,11 @@ struct ProfileView: View, TunnelInstallationProviding {
|
|||||||
@ObservedObject
|
@ObservedObject
|
||||||
var tunnel: ExtendedTunnel
|
var tunnel: ExtendedTunnel
|
||||||
|
|
||||||
|
@ObservedObject
|
||||||
|
var errorHandler: ErrorHandler
|
||||||
|
|
||||||
|
var flow: AppFlow?
|
||||||
|
|
||||||
@State
|
@State
|
||||||
var showsSidePanel = false
|
var showsSidePanel = false
|
||||||
|
|
||||||
@ -56,9 +61,6 @@ struct ProfileView: View, TunnelInstallationProviding {
|
|||||||
@StateObject
|
@StateObject
|
||||||
private var interactiveManager = InteractiveManager()
|
private var interactiveManager = InteractiveManager()
|
||||||
|
|
||||||
@StateObject
|
|
||||||
private var errorHandler: ErrorHandler = .default()
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
HStack(spacing: .zero) {
|
HStack(spacing: .zero) {
|
||||||
@ -80,7 +82,6 @@ struct ProfileView: View, TunnelInstallationProviding {
|
|||||||
.ignoresSafeArea(edges: .horizontal)
|
.ignoresSafeArea(edges: .horizontal)
|
||||||
.background(theme.primaryColor.opacity(0.6).gradient)
|
.background(theme.primaryColor.opacity(0.6).gradient)
|
||||||
.themeAnimation(on: showsSidePanel, category: .profiles)
|
.themeAnimation(on: showsSidePanel, category: .profiles)
|
||||||
.withErrorHandler(errorHandler)
|
|
||||||
.defaultFocus($focusedField, .switchProfile)
|
.defaultFocus($focusedField, .switchProfile)
|
||||||
.onChange(of: tunnel.status, onTunnelStatus)
|
.onChange(of: tunnel.status, onTunnelStatus)
|
||||||
.onChange(of: tunnel.currentProfile, onTunnelCurrentProfile)
|
.onChange(of: tunnel.currentProfile, onTunnelCurrentProfile)
|
||||||
@ -104,7 +105,8 @@ private extension ProfileView {
|
|||||||
isSwitching: $showsSidePanel,
|
isSwitching: $showsSidePanel,
|
||||||
focusedField: $focusedField,
|
focusedField: $focusedField,
|
||||||
interactiveManager: interactiveManager,
|
interactiveManager: interactiveManager,
|
||||||
errorHandler: errorHandler
|
errorHandler: errorHandler,
|
||||||
|
flow: flow
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,7 +128,7 @@ private extension ProfileView {
|
|||||||
InteractiveCoordinator(style: .inline(withCancel: false), manager: interactiveManager) {
|
InteractiveCoordinator(style: .inline(withCancel: false), manager: interactiveManager) {
|
||||||
errorHandler.handle(
|
errorHandler.handle(
|
||||||
$0,
|
$0,
|
||||||
title: Strings.Global.Nouns.connection,
|
title: interactiveManager.editor.profile.name,
|
||||||
message: Strings.Views.App.Errors.tunnel
|
message: Strings.Views.App.Errors.tunnel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -144,7 +146,8 @@ private extension ProfileView {
|
|||||||
tunnel: tunnel,
|
tunnel: tunnel,
|
||||||
focusedField: $focusedField,
|
focusedField: $focusedField,
|
||||||
interactiveManager: interactiveManager,
|
interactiveManager: interactiveManager,
|
||||||
errorHandler: errorHandler
|
errorHandler: errorHandler,
|
||||||
|
flow: flow
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -189,6 +192,7 @@ private extension ProfileView {
|
|||||||
ProfileView(
|
ProfileView(
|
||||||
profileManager: .mock,
|
profileManager: .mock,
|
||||||
tunnel: .mock,
|
tunnel: .mock,
|
||||||
|
errorHandler: .default(),
|
||||||
showsSidePanel: true
|
showsSidePanel: true
|
||||||
)
|
)
|
||||||
.withMockEnvironment()
|
.withMockEnvironment()
|
||||||
@ -198,6 +202,7 @@ private extension ProfileView {
|
|||||||
ProfileView(
|
ProfileView(
|
||||||
profileManager: ProfileManager(profiles: []),
|
profileManager: ProfileManager(profiles: []),
|
||||||
tunnel: .mock,
|
tunnel: .mock,
|
||||||
|
errorHandler: .default(),
|
||||||
showsSidePanel: true
|
showsSidePanel: true
|
||||||
)
|
)
|
||||||
.withMockEnvironment()
|
.withMockEnvironment()
|
||||||
|
@ -32,6 +32,10 @@ extension View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func setLater<T>(_ value: T?, millis: Int = 50, block: @escaping (T?) -> Void) {
|
||||||
|
globalSetLater(value, millis: millis, block: block)
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
public func `if`(_ condition: Bool) -> some View {
|
public func `if`(_ condition: Bool) -> some View {
|
||||||
if condition {
|
if condition {
|
||||||
@ -63,14 +67,6 @@ extension View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func setLater<T>(_ value: T?, millis: Int = 50, block: @escaping (T?) -> Void) {
|
|
||||||
Task {
|
|
||||||
block(nil)
|
|
||||||
try await Task.sleep(for: .milliseconds(millis))
|
|
||||||
block(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ViewModifier {
|
extension ViewModifier {
|
||||||
@ -79,6 +75,18 @@ extension ViewModifier {
|
|||||||
Self._printChanges()
|
Self._printChanges()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func setLater<T>(_ value: T?, millis: Int = 50, block: @escaping (T?) -> Void) {
|
||||||
|
globalSetLater(value, millis: millis, block: block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func globalSetLater<T>(_ value: T?, millis: Int = 50, block: @escaping (T?) -> Void) {
|
||||||
|
Task {
|
||||||
|
block(nil)
|
||||||
|
try await Task.sleep(for: .milliseconds(millis))
|
||||||
|
block(value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
|
@ -21,6 +21,12 @@ public enum Strings {
|
|||||||
public static let ok = Strings.tr("Localizable", "alerts.import.passphrase.ok", fallback: "Decrypt")
|
public static let ok = Strings.tr("Localizable", "alerts.import.passphrase.ok", fallback: "Decrypt")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
public enum Providers {
|
||||||
|
public enum MissingServer {
|
||||||
|
/// No provider server selected. Please select a destination server on your iOS/macOS device.
|
||||||
|
public static let message = Strings.tr("Localizable", "alerts.providers.missing_server.message", fallback: "No provider server selected. Please select a destination server on your iOS/macOS device.")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
public enum Entities {
|
public enum Entities {
|
||||||
public enum Dns {
|
public enum Dns {
|
||||||
@ -770,10 +776,6 @@ public enum Strings {
|
|||||||
public enum Profile {
|
public enum Profile {
|
||||||
public enum Alerts {
|
public enum Alerts {
|
||||||
public enum Purchase {
|
public enum Purchase {
|
||||||
/// This profile requires paid features to work.
|
|
||||||
public static let message = Strings.tr("Localizable", "views.profile.alerts.purchase.message", fallback: "This profile requires paid features to work.")
|
|
||||||
/// Purchase required
|
|
||||||
public static let title = Strings.tr("Localizable", "views.profile.alerts.purchase.title", fallback: "Purchase required")
|
|
||||||
public enum Buttons {
|
public enum Buttons {
|
||||||
/// Save anyway
|
/// Save anyway
|
||||||
public static let ok = Strings.tr("Localizable", "views.profile.alerts.purchase.buttons.ok", fallback: "Save anyway")
|
public static let ok = Strings.tr("Localizable", "views.profile.alerts.purchase.buttons.ok", fallback: "Save anyway")
|
||||||
@ -828,6 +830,12 @@ public enum Strings {
|
|||||||
/// (on-demand)
|
/// (on-demand)
|
||||||
public static let onDemandSuffix = Strings.tr("Localizable", "views.ui.connection_status.on_demand_suffix", fallback: " (on-demand)")
|
public static let onDemandSuffix = Strings.tr("Localizable", "views.ui.connection_status.on_demand_suffix", fallback: " (on-demand)")
|
||||||
}
|
}
|
||||||
|
public enum PurchaseAlert {
|
||||||
|
/// This profile requires paid features to work.
|
||||||
|
public static let message = Strings.tr("Localizable", "views.ui.purchase_alert.message", fallback: "This profile requires paid features to work.")
|
||||||
|
/// Purchase required
|
||||||
|
public static let title = Strings.tr("Localizable", "views.ui.purchase_alert.title", fallback: "Purchase required")
|
||||||
|
}
|
||||||
public enum PurchaseRequired {
|
public enum PurchaseRequired {
|
||||||
public enum Purchase {
|
public enum Purchase {
|
||||||
/// Purchase required
|
/// Purchase required
|
||||||
|
@ -78,9 +78,7 @@
|
|||||||
|
|
||||||
"views.profile.rows.add_module" = "Add module";
|
"views.profile.rows.add_module" = "Add module";
|
||||||
"views.profile.module_list.section.footer" = "Drag modules to rearrange them, as their order determines priority.";
|
"views.profile.module_list.section.footer" = "Drag modules to rearrange them, as their order determines priority.";
|
||||||
"views.profile.alerts.purchase.title" = "Purchase required";
|
|
||||||
"views.profile.alerts.purchase.buttons.ok" = "Save anyway";
|
"views.profile.alerts.purchase.buttons.ok" = "Save anyway";
|
||||||
"views.profile.alerts.purchase.message" = "This profile requires paid features to work.";
|
|
||||||
|
|
||||||
"views.providers.no_provider" = "None";
|
"views.providers.no_provider" = "None";
|
||||||
"views.providers.select_provider" = "Select a provider";
|
"views.providers.select_provider" = "Select a provider";
|
||||||
@ -95,6 +93,8 @@
|
|||||||
"views.providers.vpn.no_servers" = "No servers";
|
"views.providers.vpn.no_servers" = "No servers";
|
||||||
|
|
||||||
"views.ui.connection_status.on_demand_suffix" = " (on-demand)";
|
"views.ui.connection_status.on_demand_suffix" = " (on-demand)";
|
||||||
|
"views.ui.purchase_alert.title" = "Purchase required";
|
||||||
|
"views.ui.purchase_alert.message" = "This profile requires paid features to work.";
|
||||||
"views.ui.purchase_required.purchase.help" = "Purchase required";
|
"views.ui.purchase_required.purchase.help" = "Purchase required";
|
||||||
"views.ui.purchase_required.restricted.help" = "Feature is restricted";
|
"views.ui.purchase_required.restricted.help" = "Feature is restricted";
|
||||||
|
|
||||||
@ -286,6 +286,7 @@
|
|||||||
|
|
||||||
"alerts.import.passphrase.message" = "Enter passphrase for '%@'.";
|
"alerts.import.passphrase.message" = "Enter passphrase for '%@'.";
|
||||||
"alerts.import.passphrase.ok" = "Decrypt";
|
"alerts.import.passphrase.ok" = "Decrypt";
|
||||||
|
"alerts.providers.missing_server.message" = "No provider server selected. Please select a destination server on your iOS/macOS device.";
|
||||||
|
|
||||||
// MARK: Global (App errors)
|
// MARK: Global (App errors)
|
||||||
|
|
||||||
|
@ -61,7 +61,9 @@ struct PaywallView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
paywallView
|
paywallView
|
||||||
.themeProgress(if: isFetchingProducts)
|
.themeProgress(if: isFetchingProducts)
|
||||||
|
#if !os(tvOS)
|
||||||
.toolbar(content: toolbarContent)
|
.toolbar(content: toolbarContent)
|
||||||
|
#endif
|
||||||
.alert(
|
.alert(
|
||||||
Strings.Global.Actions.purchase,
|
Strings.Global.Actions.purchase,
|
||||||
isPresented: $isPurchasePendingConfirmation,
|
isPresented: $isPurchasePendingConfirmation,
|
||||||
@ -80,6 +82,12 @@ private extension PaywallView {
|
|||||||
Strings.Global.Actions.purchase
|
Strings.Global.Actions.purchase
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var otherFeatures: [AppFeature] {
|
||||||
|
AppFeature.allCases.filter {
|
||||||
|
!features.contains($0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var paywallView: some View {
|
var paywallView: some View {
|
||||||
Form {
|
Form {
|
||||||
requiredFeaturesView
|
requiredFeaturesView
|
||||||
@ -91,12 +99,6 @@ private extension PaywallView {
|
|||||||
.disabled(purchasingIdentifier != nil)
|
.disabled(purchasingIdentifier != nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var otherFeatures: [AppFeature] {
|
|
||||||
AppFeature.allCases.filter {
|
|
||||||
!features.contains($0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
var productsView: some View {
|
var productsView: some View {
|
||||||
oneTimeProduct.map {
|
oneTimeProduct.map {
|
||||||
@ -127,21 +129,21 @@ private extension PaywallView {
|
|||||||
FeatureListView(
|
FeatureListView(
|
||||||
style: .list,
|
style: .list,
|
||||||
header: Strings.Views.Paywall.Sections.Features.Required.header,
|
header: Strings.Views.Paywall.Sections.Features.Required.header,
|
||||||
features: Array(features)
|
features: Array(features),
|
||||||
) {
|
content: {
|
||||||
Text($0.localizedDescription)
|
featureView(for: $0)
|
||||||
.fontWeight(.bold)
|
.fontWeight(.bold)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var otherFeaturesView: some View {
|
var otherFeaturesView: some View {
|
||||||
FeatureListView(
|
FeatureListView(
|
||||||
style: otherFeaturesStyle,
|
style: otherFeaturesStyle,
|
||||||
header: Strings.Views.Paywall.Sections.Features.Other.header,
|
header: Strings.Views.Paywall.Sections.Features.Other.header,
|
||||||
features: otherFeatures
|
features: otherFeatures,
|
||||||
) {
|
content: featureView(for:)
|
||||||
Text($0.localizedDescription)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var otherFeaturesStyle: FeatureListViewStyle {
|
var otherFeaturesStyle: FeatureListViewStyle {
|
||||||
@ -152,6 +154,16 @@ private extension PaywallView {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func featureView(for feature: AppFeature) -> some View {
|
||||||
|
#if os(tvOS)
|
||||||
|
Button(feature.localizedDescription) {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
Text(feature.localizedDescription)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
var restoreView: some View {
|
var restoreView: some View {
|
||||||
RestorePurchasesButton(errorHandler: errorHandler)
|
RestorePurchasesButton(errorHandler: errorHandler)
|
||||||
.themeSectionWithSingleRow(
|
.themeSectionWithSingleRow(
|
||||||
|
@ -64,16 +64,18 @@ private extension ProductView {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func withPaywallStyle(_ paywallStyle: PaywallProductViewStyle) -> some View {
|
func withPaywallStyle(_ paywallStyle: PaywallProductViewStyle) -> some View {
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
productViewStyle(.compact)
|
|
||||||
.padding()
|
|
||||||
#else
|
|
||||||
switch paywallStyle {
|
switch paywallStyle {
|
||||||
case .recurring:
|
case .oneTime, .recurring:
|
||||||
productViewStyle(.compact)
|
productViewStyle(.regular)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowInsets(.init())
|
||||||
|
|
||||||
case .oneTime, .donation:
|
case .donation:
|
||||||
productViewStyle(.compact)
|
productViewStyle(.compact)
|
||||||
|
.padding()
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
productViewStyle(.compact)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,85 @@
|
|||||||
|
//
|
||||||
|
// PurchaseAlertModifier.swift
|
||||||
|
// Passepartout
|
||||||
|
//
|
||||||
|
// Created by Davide De Rosa on 11/23/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 CommonIAP
|
||||||
|
import CommonUtils
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
public struct PurchaseAlertModifier: ViewModifier {
|
||||||
|
|
||||||
|
@Binding
|
||||||
|
private var isPresented: Bool
|
||||||
|
|
||||||
|
@Binding
|
||||||
|
private var paywallReason: PaywallReason?
|
||||||
|
|
||||||
|
private let requiredFeatures: Set<AppFeature>
|
||||||
|
|
||||||
|
private let okTitle: String?
|
||||||
|
|
||||||
|
private let okAction: (() -> Void)?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
isPresented: Binding<Bool>,
|
||||||
|
paywallReason: Binding<PaywallReason?>,
|
||||||
|
requiredFeatures: Set<AppFeature>,
|
||||||
|
okTitle: String? = nil,
|
||||||
|
okAction: (() -> Void)? = nil
|
||||||
|
) {
|
||||||
|
_isPresented = isPresented
|
||||||
|
_paywallReason = paywallReason
|
||||||
|
self.requiredFeatures = requiredFeatures
|
||||||
|
self.okTitle = okTitle
|
||||||
|
self.okAction = okAction
|
||||||
|
}
|
||||||
|
|
||||||
|
public func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.alert(Strings.Views.Ui.PurchaseAlert.title, isPresented: $isPresented) {
|
||||||
|
Button(Strings.Global.Actions.purchase) {
|
||||||
|
setLater(.purchase(requiredFeatures, nil)) {
|
||||||
|
paywallReason = $0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let okTitle {
|
||||||
|
Button(okTitle) {
|
||||||
|
okAction?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(Strings.Global.Actions.cancel, role: .cancel, action: {})
|
||||||
|
} message: {
|
||||||
|
Text(purchaseMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension PurchaseAlertModifier {
|
||||||
|
var purchaseMessage: String {
|
||||||
|
let msg = Strings.Views.Ui.PurchaseAlert.message
|
||||||
|
return msg + "\n\n" + requiredFeatures
|
||||||
|
.map(\.localizedDescription)
|
||||||
|
.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
}
|
@ -24,6 +24,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import CommonLibrary
|
import CommonLibrary
|
||||||
|
import CommonUtils
|
||||||
import PassepartoutKit
|
import PassepartoutKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
@ -120,7 +120,7 @@ private extension TunnelToggleButton {
|
|||||||
guard let provider = providerModule.provider else {
|
guard let provider = providerModule.provider else {
|
||||||
errorHandler.handle(
|
errorHandler.handle(
|
||||||
PassepartoutError(.providerRequired),
|
PassepartoutError(.providerRequired),
|
||||||
title: Strings.Global.Nouns.connection
|
title: profile.name
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -168,7 +168,7 @@ private extension TunnelToggleButton {
|
|||||||
} catch {
|
} catch {
|
||||||
errorHandler.handle(
|
errorHandler.handle(
|
||||||
error,
|
error,
|
||||||
title: Strings.Global.Nouns.connection,
|
title: profile.name,
|
||||||
message: Strings.Views.App.Errors.tunnel
|
message: Strings.Views.App.Errors.tunnel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -711,17 +711,21 @@ private extension ProfileManagerTests {
|
|||||||
) async throws {
|
) async throws {
|
||||||
let exp = expectation(description: description)
|
let exp = expectation(description: description)
|
||||||
var wasMet = false
|
var wasMet = false
|
||||||
sut.objectWillChange
|
|
||||||
.sink {
|
Publishers.Merge(
|
||||||
guard !wasMet else {
|
sut.objectWillChange,
|
||||||
return
|
sut.didChange.map { _ in }
|
||||||
}
|
)
|
||||||
if condition(sut) {
|
.sink {
|
||||||
wasMet = true
|
guard !wasMet else {
|
||||||
exp.fulfill()
|
return
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.store(in: &subscriptions)
|
if condition(sut) {
|
||||||
|
wasMet = true
|
||||||
|
exp.fulfill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &subscriptions)
|
||||||
|
|
||||||
try await action(sut)
|
try await action(sut)
|
||||||
await fulfillment(of: [exp], timeout: timeout)
|
await fulfillment(of: [exp], timeout: timeout)
|
||||||
|
Loading…
Reference in New Issue
Block a user