Handle TV requirements on connection (#922)

Fixes #913
This commit is contained in:
Davide 2024-11-24 01:01:04 +01:00 committed by GitHub
parent bad9e8b58e
commit cb530d8a65
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 287 additions and 93 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -24,6 +24,7 @@
// //
import CommonLibrary import CommonLibrary
import CommonUtils
import PassepartoutKit import PassepartoutKit
import SwiftUI import SwiftUI

View File

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

View File

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