Split views into extensions (#321)
Nothing but moving code around to reorganize views into the following sections (MARK): - Properties/Body - Subviews - Actions
This commit is contained in:
parent
7198150f00
commit
d7ebcb23ba
|
@ -26,8 +26,6 @@
|
|||
import SwiftUI
|
||||
|
||||
struct AboutView: View {
|
||||
// private let appName = Unlocalized.appName
|
||||
|
||||
private let versionString = Constants.Global.appVersionString
|
||||
|
||||
private let redditURL = Constants.URLs.subreddit
|
||||
|
@ -52,11 +50,15 @@ struct AboutView: View {
|
|||
supportSection
|
||||
webSection
|
||||
githubSection
|
||||
}.themeSecondaryView()
|
||||
.navigationTitle(L10n.About.title)
|
||||
}.navigationTitle(L10n.About.title)
|
||||
.themeSecondaryView()
|
||||
}
|
||||
}
|
||||
|
||||
private var infoSection: some View {
|
||||
// MARK: -
|
||||
|
||||
private extension AboutView {
|
||||
var infoSection: some View {
|
||||
Section {
|
||||
NavigationLink {
|
||||
VersionView()
|
||||
|
@ -70,7 +72,7 @@ struct AboutView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private var supportSection: some View {
|
||||
var supportSection: some View {
|
||||
Section {
|
||||
Button(L10n.About.Items.JoinCommunity.caption) {
|
||||
URL.open(redditURL)
|
||||
|
@ -82,7 +84,7 @@ struct AboutView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private var webSection: some View {
|
||||
var webSection: some View {
|
||||
Section {
|
||||
Button(L10n.About.Items.Website.caption) {
|
||||
URL.open(homeURL)
|
||||
|
@ -101,7 +103,7 @@ struct AboutView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private var githubSection: some View {
|
||||
var githubSection: some View {
|
||||
Section {
|
||||
Button(Unlocalized.About.readme) {
|
||||
URL.open(readmeURL)
|
||||
|
@ -115,13 +117,15 @@ struct AboutView: View {
|
|||
}
|
||||
}
|
||||
|
||||
extension AboutView {
|
||||
private func shareOnTwitter() {
|
||||
// MARK: -
|
||||
|
||||
private extension AboutView {
|
||||
func shareOnTwitter() {
|
||||
let url = Unlocalized.Social.twitterIntent(withMessage: shareMessage)
|
||||
URL.open(url)
|
||||
}
|
||||
|
||||
private func submitReview() {
|
||||
func submitReview() {
|
||||
let reviewURL = Reviewer.urlForReview(withAppId: Constants.App.appStoreId)
|
||||
URL.open(reviewURL)
|
||||
}
|
||||
|
|
|
@ -102,8 +102,7 @@ struct AccountView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
}.navigationTitle(L10n.Account.title)
|
||||
.toolbar {
|
||||
}.toolbar {
|
||||
CopySavingButton(
|
||||
original: $account,
|
||||
copy: $liveAccount,
|
||||
|
@ -112,25 +111,21 @@ struct AccountView: View {
|
|||
saveAnyway: saveAnyway,
|
||||
onSave: onSave
|
||||
)
|
||||
}.navigationTitle(L10n.Account.title)
|
||||
}
|
||||
}
|
||||
|
||||
private func openGuidanceURL(_ url: URL) {
|
||||
URL.open(url)
|
||||
}
|
||||
}
|
||||
// MARK: -
|
||||
|
||||
// MARK: Provider
|
||||
|
||||
extension AccountView {
|
||||
private var usernamePlaceholder: String? {
|
||||
private extension AccountView {
|
||||
var usernamePlaceholder: String? {
|
||||
guard let name = providerName else {
|
||||
return nil
|
||||
}
|
||||
return providerManager.defaultUsername(name, vpnProtocol: vpnProtocol)
|
||||
}
|
||||
|
||||
private var metadata: ProviderMetadata? {
|
||||
var metadata: ProviderMetadata? {
|
||||
guard let name = providerName else {
|
||||
return nil
|
||||
}
|
||||
|
@ -152,3 +147,11 @@ private extension Profile.Account.AuthenticationMethod {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private extension AccountView {
|
||||
func openGuidanceURL(_ url: URL) {
|
||||
URL.open(url)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -42,10 +42,6 @@ extension AddHostView {
|
|||
|
||||
@State private var isEnteringCredentials = false
|
||||
|
||||
private var isComplete: Bool {
|
||||
!viewModel.processedProfile.isPlaceholder
|
||||
}
|
||||
|
||||
init(
|
||||
url: URL,
|
||||
deletingURLOnSuccess: Bool,
|
||||
|
@ -84,9 +80,15 @@ extension AddHostView {
|
|||
.navigationTitle(L10n.AddProfile.Shared.title)
|
||||
.themeSecondaryView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private extension AddHostView.NameView {
|
||||
|
||||
@ViewBuilder
|
||||
private var mainView: some View {
|
||||
var mainView: some View {
|
||||
AddProfileView.ProfileNameSection(
|
||||
profileName: $viewModel.profileName,
|
||||
errorMessage: viewModel.errorMessage
|
||||
|
@ -112,7 +114,7 @@ extension AddHostView {
|
|||
}
|
||||
}
|
||||
|
||||
private var encryptionSection: some View {
|
||||
var encryptionSection: some View {
|
||||
Section {
|
||||
SecureField(L10n.AddProfile.Host.Sections.Encryption.footer, text: $viewModel.encryptionPassphrase) {
|
||||
processProfile(replacingExisting: false)
|
||||
|
@ -122,7 +124,7 @@ extension AddHostView {
|
|||
}
|
||||
}
|
||||
|
||||
private var completeSection: some View {
|
||||
var completeSection: some View {
|
||||
Section {
|
||||
Text(Unlocalized.Network.url)
|
||||
.withTrailingText(url.lastPathComponent)
|
||||
|
@ -137,7 +139,7 @@ extension AddHostView {
|
|||
}
|
||||
}
|
||||
|
||||
private var hiddenAccountLink: some View {
|
||||
var hiddenAccountLink: some View {
|
||||
NavigationLink("", isActive: $isEnteringCredentials) {
|
||||
AddProfileView.AccountWrapperView(
|
||||
profile: $viewModel.processedProfile,
|
||||
|
@ -146,7 +148,7 @@ extension AddHostView {
|
|||
}
|
||||
}
|
||||
|
||||
private var nextString: String {
|
||||
var nextString: String {
|
||||
if !viewModel.processedProfile.isPlaceholder {
|
||||
return viewModel.processedProfile.requiresCredentials ? L10n.Global.Strings.next : L10n.Global.Strings.save
|
||||
} else {
|
||||
|
@ -154,16 +156,8 @@ extension AddHostView {
|
|||
}
|
||||
}
|
||||
|
||||
private func requestResourcePermissions() {
|
||||
_ = url.startAccessingSecurityScopedResource()
|
||||
}
|
||||
|
||||
private func dropResourcePermissions() {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func alertOverwriteActions() -> some View {
|
||||
func alertOverwriteActions() -> some View {
|
||||
Button(role: .destructive) {
|
||||
processProfile(replacingExisting: true)
|
||||
} label: {
|
||||
|
@ -175,11 +169,27 @@ extension AddHostView {
|
|||
}
|
||||
}
|
||||
|
||||
private func alertOverwriteMessage() -> some View {
|
||||
func alertOverwriteMessage() -> some View {
|
||||
Text(L10n.AddProfile.Shared.Alerts.Overwrite.message)
|
||||
}
|
||||
|
||||
private func processProfile(replacingExisting: Bool) {
|
||||
var isComplete: Bool {
|
||||
!viewModel.processedProfile.isPlaceholder
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private extension AddHostView.NameView {
|
||||
func requestResourcePermissions() {
|
||||
_ = url.startAccessingSecurityScopedResource()
|
||||
}
|
||||
|
||||
func dropResourcePermissions() {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
|
||||
func processProfile(replacingExisting: Bool) {
|
||||
viewModel.processURL(
|
||||
url,
|
||||
with: profileManager,
|
||||
|
@ -188,7 +198,7 @@ extension AddHostView {
|
|||
)
|
||||
}
|
||||
|
||||
private func saveProfile() {
|
||||
func saveProfile() {
|
||||
let result = viewModel.addProcessedProfile(to: profileManager)
|
||||
guard result else {
|
||||
return
|
||||
|
@ -203,4 +213,3 @@ extension AddHostView {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -74,9 +74,14 @@ struct AddProfileMenu: View {
|
|||
themeAddMenuImage.asSystemImage
|
||||
}.sheet(item: $modalType, content: presentedModal)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private extension AddProfileMenu {
|
||||
|
||||
@ViewBuilder
|
||||
private func presentedModal(_ modalType: ModalType) -> some View {
|
||||
func presentedModal(_ modalType: ModalType) -> some View {
|
||||
switch modalType {
|
||||
case .addProvider:
|
||||
NavigationView {
|
||||
|
@ -100,7 +105,7 @@ struct AddProfileMenu: View {
|
|||
}
|
||||
}
|
||||
|
||||
private var isModalPresented: Binding<Bool> {
|
||||
var isModalPresented: Binding<Bool> {
|
||||
.init {
|
||||
modalType != nil
|
||||
} set: {
|
||||
|
@ -110,13 +115,13 @@ struct AddProfileMenu: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func importedURLRow(_ url: URL) -> some View {
|
||||
func importedURLRow(_ url: URL) -> some View {
|
||||
Button(L10n.Menu.Contextual.AddProfile.imported(url.lastPathComponent)) {
|
||||
presentAddHost(withURL: url, deletingURLOnSuccess: true)
|
||||
}
|
||||
}
|
||||
|
||||
private var importedURLs: [URL]? {
|
||||
var importedURLs: [URL]? {
|
||||
do {
|
||||
let url = FileManager.default.userURL(for: .documentDirectory, appending: nil)
|
||||
let list = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil)
|
||||
|
@ -129,16 +134,18 @@ struct AddProfileMenu: View {
|
|||
}
|
||||
}
|
||||
|
||||
extension AddProfileMenu {
|
||||
private func presentAddProvider() {
|
||||
// MARK: -
|
||||
|
||||
private extension AddProfileMenu {
|
||||
func presentAddProvider() {
|
||||
modalType = .addProvider
|
||||
}
|
||||
|
||||
private func presentAddHost(withURL url: URL, deletingURLOnSuccess: Bool) {
|
||||
func presentAddHost(withURL url: URL, deletingURLOnSuccess: Bool) {
|
||||
modalType = .addHost(url, deletingURLOnSuccess)
|
||||
}
|
||||
|
||||
private func presentHostFileImporter() {
|
||||
func presentHostFileImporter() {
|
||||
|
||||
// XXX: iOS bug, hack around crappy bug when dismissing by swiping down
|
||||
//
|
||||
|
|
|
@ -84,8 +84,13 @@ extension AddProviderView {
|
|||
message: alertOverwriteMessage
|
||||
).navigationTitle(providerMetadata.fullName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var hiddenAccountLink: some View {
|
||||
// MARK: -
|
||||
|
||||
private extension AddProviderView.NameView {
|
||||
var hiddenAccountLink: some View {
|
||||
NavigationLink("", isActive: $isEnteringCredentials) {
|
||||
AddProfileView.AccountWrapperView(
|
||||
profile: $profile,
|
||||
|
@ -95,7 +100,7 @@ extension AddProviderView {
|
|||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func alertOverwriteActions() -> some View {
|
||||
func alertOverwriteActions() -> some View {
|
||||
Button(role: .destructive) {
|
||||
saveProfile(replacingExisting: true)
|
||||
} label: {
|
||||
|
@ -107,11 +112,15 @@ extension AddProviderView {
|
|||
}
|
||||
}
|
||||
|
||||
private func alertOverwriteMessage() -> some View {
|
||||
func alertOverwriteMessage() -> some View {
|
||||
Text(L10n.AddProfile.Shared.Alerts.Overwrite.message)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveProfile(replacingExisting: Bool) {
|
||||
// MARK: -
|
||||
|
||||
private extension AddProviderView.NameView {
|
||||
func saveProfile(replacingExisting: Bool) {
|
||||
let addedProfile = viewModel.addProfile(
|
||||
profile,
|
||||
to: profileManager,
|
||||
|
@ -130,4 +139,3 @@ extension AddProviderView {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -41,23 +41,6 @@ struct AddProviderView: View {
|
|||
self.bindings = bindings
|
||||
}
|
||||
|
||||
private var providers: [ProviderMetadata] {
|
||||
providerManager.allProviders()
|
||||
.filter {
|
||||
$0.supportedVPNProtocols.contains(viewModel.selectedVPNProtocol)
|
||||
}.sorted()
|
||||
}
|
||||
|
||||
private var availableVPNProtocols: [VPNProtocolType] {
|
||||
var protos: Set<VPNProtocolType> = []
|
||||
providers.forEach {
|
||||
$0.supportedVPNProtocols.forEach {
|
||||
protos.insert($0)
|
||||
}
|
||||
}
|
||||
return protos.sorted()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ForEach(providers, id: \.navigationId, content: hiddenProviderLink)
|
||||
|
@ -82,8 +65,12 @@ struct AddProviderView: View {
|
|||
}.navigationTitle(L10n.AddProfile.Shared.title)
|
||||
.themeSecondaryView()
|
||||
}
|
||||
}
|
||||
|
||||
private var mainSection: some View {
|
||||
// MARK: -
|
||||
|
||||
private extension AddProviderView {
|
||||
var mainSection: some View {
|
||||
Section {
|
||||
let protos = availableVPNProtocols
|
||||
if !protos.isEmpty {
|
||||
|
@ -100,7 +87,7 @@ struct AddProviderView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private var providersSection: some View {
|
||||
var providersSection: some View {
|
||||
Section {
|
||||
ForEach(providers, content: providerRow)
|
||||
} footer: {
|
||||
|
@ -108,7 +95,7 @@ struct AddProviderView: View {
|
|||
}.disabled(viewModel.isFetchingAnyProvider)
|
||||
}
|
||||
|
||||
private func providerRow(_ metadata: ProviderMetadata) -> some View {
|
||||
func providerRow(_ metadata: ProviderMetadata) -> some View {
|
||||
Button {
|
||||
presentOrPurchaseProvider(metadata)
|
||||
} label: {
|
||||
|
@ -116,7 +103,7 @@ struct AddProviderView: View {
|
|||
}.withTrailingProgress(when: viewModel.isFetchingProvider(metadata.name))
|
||||
}
|
||||
|
||||
private func hiddenProviderLink(_ metadata: ProviderMetadata) -> some View {
|
||||
func hiddenProviderLink(_ metadata: ProviderMetadata) -> some View {
|
||||
NavigationLink("", tag: metadata, selection: $viewModel.selectedProvider) {
|
||||
NameView(
|
||||
profile: $viewModel.pendingProfile,
|
||||
|
@ -126,33 +113,28 @@ struct AddProviderView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private var updateListButton: some View {
|
||||
var updateListButton: some View {
|
||||
Button(L10n.AddProfile.Provider.Items.updateList) {
|
||||
viewModel.updateIndex(providerManager)
|
||||
}.withTrailingProgress(when: viewModel.isUpdatingIndex)
|
||||
.disabled(viewModel.isUpdatingIndex)
|
||||
}
|
||||
|
||||
// eligibility: select or purchase provider
|
||||
private func presentOrPurchaseProvider(_ metadata: ProviderMetadata) {
|
||||
guard productManager.isEligible(forProvider: metadata.name) else {
|
||||
viewModel.presentPaywall()
|
||||
return
|
||||
}
|
||||
viewModel.selectProvider(metadata, providerManager)
|
||||
var providers: [ProviderMetadata] {
|
||||
providerManager.allProviders()
|
||||
.filter {
|
||||
$0.supportedVPNProtocols.contains(viewModel.selectedVPNProtocol)
|
||||
}.sorted()
|
||||
}
|
||||
|
||||
private func onErrorMessage(_ message: String?, _ scrollProxy: ScrollViewProxy) {
|
||||
guard message != nil else {
|
||||
return
|
||||
}
|
||||
scrollToErrorMessage(scrollProxy)
|
||||
var availableVPNProtocols: [VPNProtocolType] {
|
||||
var protos: Set<VPNProtocolType> = []
|
||||
providers.forEach {
|
||||
$0.supportedVPNProtocols.forEach {
|
||||
protos.insert($0)
|
||||
}
|
||||
}
|
||||
|
||||
extension AddProviderView {
|
||||
private func scrollToErrorMessage(_ proxy: ScrollViewProxy) {
|
||||
proxy.maybeScrollTo(providers.last?.id, animated: true)
|
||||
return protos.sorted()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -161,3 +143,28 @@ private extension ProviderMetadata {
|
|||
"navigation.\(name)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private extension AddProviderView {
|
||||
|
||||
// eligibility: select or purchase provider
|
||||
func presentOrPurchaseProvider(_ metadata: ProviderMetadata) {
|
||||
guard productManager.isEligible(forProvider: metadata.name) else {
|
||||
viewModel.presentPaywall()
|
||||
return
|
||||
}
|
||||
viewModel.selectProvider(metadata, providerManager)
|
||||
}
|
||||
|
||||
func onErrorMessage(_ message: String?, _ scrollProxy: ScrollViewProxy) {
|
||||
guard message != nil else {
|
||||
return
|
||||
}
|
||||
scrollToErrorMessage(scrollProxy)
|
||||
}
|
||||
|
||||
func scrollToErrorMessage(_ proxy: ScrollViewProxy) {
|
||||
proxy.maybeScrollTo(providers.last?.id, animated: true)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -89,8 +89,12 @@ struct DebugLogView: View {
|
|||
.navigationTitle(title)
|
||||
.themeDebugLogStyle()
|
||||
}
|
||||
}
|
||||
|
||||
private var contentView: some View {
|
||||
// MARK: -
|
||||
|
||||
private extension DebugLogView {
|
||||
var contentView: some View {
|
||||
LazyVStack {
|
||||
ForEach(logLines.indices, id: \.self) {
|
||||
Text(logLines[$0])
|
||||
|
@ -100,32 +104,11 @@ struct DebugLogView: View {
|
|||
// TODO: layout, a slight padding would be nice, but it glitches on first touch
|
||||
}
|
||||
|
||||
private func refreshLog(scrollingToLatestWith scrollProxy: ScrollViewProxy?) {
|
||||
logLines = url.trailingLines(bytes: maxBytes)
|
||||
if let scrollProxy = scrollProxy {
|
||||
scrollToLatestUpdate(scrollProxy)
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshLog(_: Date) {
|
||||
refreshLog(scrollingToLatestWith: nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension DebugLogView {
|
||||
private func shareDebugLog() {
|
||||
guard !logLines.isEmpty else {
|
||||
assertionFailure("Log is empty, why could it share?")
|
||||
return
|
||||
}
|
||||
isSharing = true
|
||||
}
|
||||
|
||||
private func sharingActivityView() -> some View {
|
||||
func sharingActivityView() -> some View {
|
||||
ActivityView(activityItems: sharingItems)
|
||||
}
|
||||
|
||||
private var sharingItems: [Any] {
|
||||
var sharingItems: [Any] {
|
||||
let raw = logLines.joined(separator: "\n")
|
||||
let data = DebugLog(content: raw)
|
||||
.decoratedData(appName, appVersion)
|
||||
|
@ -143,8 +126,29 @@ extension DebugLogView {
|
|||
}
|
||||
}
|
||||
|
||||
extension DebugLogView {
|
||||
private func copyDebugLog() {
|
||||
// MARK: -
|
||||
|
||||
private extension DebugLogView {
|
||||
func refreshLog(_: Date) {
|
||||
refreshLog(scrollingToLatestWith: nil)
|
||||
}
|
||||
|
||||
func refreshLog(scrollingToLatestWith scrollProxy: ScrollViewProxy?) {
|
||||
logLines = url.trailingLines(bytes: maxBytes)
|
||||
if let scrollProxy = scrollProxy {
|
||||
scrollToLatestUpdate(scrollProxy)
|
||||
}
|
||||
}
|
||||
|
||||
func shareDebugLog() {
|
||||
guard !logLines.isEmpty else {
|
||||
assertionFailure("Log is empty, why could it share?")
|
||||
return
|
||||
}
|
||||
isSharing = true
|
||||
}
|
||||
|
||||
func copyDebugLog() {
|
||||
guard !logLines.isEmpty else {
|
||||
assertionFailure("Log is empty, why could it copy?")
|
||||
return
|
||||
|
@ -155,10 +159,8 @@ extension DebugLogView {
|
|||
|
||||
Utils.copyToPasteboard(content)
|
||||
}
|
||||
}
|
||||
|
||||
extension DebugLogView {
|
||||
private func scrollToLatestUpdate(_ proxy: ScrollViewProxy) {
|
||||
func scrollToLatestUpdate(_ proxy: ScrollViewProxy) {
|
||||
proxy.maybeScrollTo(logLines.count - 1, anchor: .bottomLeading)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -47,10 +47,6 @@ extension DiagnosticsView {
|
|||
|
||||
private let providerName: ProviderName?
|
||||
|
||||
private var isEligibleForFeedback: Bool {
|
||||
productManager.isEligibleForFeedback()
|
||||
}
|
||||
|
||||
@State private var isReportingIssue = false
|
||||
|
||||
@State private var isAlertPresented = false
|
||||
|
@ -85,22 +81,27 @@ extension DiagnosticsView {
|
|||
message: alertMessage
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func alertActions(_ alertType: AlertType) -> some View {
|
||||
// MARK: -
|
||||
|
||||
private extension DiagnosticsView.OpenVPNView {
|
||||
func alertActions(_ alertType: AlertType) -> some View {
|
||||
Button(role: .cancel) {
|
||||
} label: {
|
||||
Text(L10n.Global.Strings.ok)
|
||||
}
|
||||
}
|
||||
|
||||
private func alertMessage(_ alertType: AlertType) -> some View {
|
||||
func alertMessage(_ alertType: AlertType) -> some View {
|
||||
switch alertType {
|
||||
case .emailNotConfigured:
|
||||
return Text(L10n.Global.Messages.emailNotConfigured)
|
||||
}
|
||||
}
|
||||
|
||||
private var serverConfigurationSection: some View {
|
||||
var serverConfigurationSection: some View {
|
||||
Section {
|
||||
let cfg = currentServerConfiguration
|
||||
NavigationLink(L10n.Diagnostics.Items.ServerConfiguration.caption) {
|
||||
|
@ -115,9 +116,9 @@ extension DiagnosticsView {
|
|||
}
|
||||
}
|
||||
|
||||
private var debugLogSection: some View {
|
||||
var debugLogSection: some View {
|
||||
Section {
|
||||
DebugLogSection(appLogURL: appLogURL, tunnelLogURL: tunnelLogURL)
|
||||
DiagnosticsView.DebugLogSection(appLogURL: appLogURL, tunnelLogURL: tunnelLogURL)
|
||||
Toggle(L10n.Diagnostics.Items.MasksPrivateData.caption, isOn: $vpnManager.masksPrivateData)
|
||||
} header: {
|
||||
Text(L10n.DebugLog.title)
|
||||
|
@ -126,13 +127,13 @@ extension DiagnosticsView {
|
|||
}
|
||||
}
|
||||
|
||||
private var issueReporterSection: some View {
|
||||
var issueReporterSection: some View {
|
||||
Section {
|
||||
Button(L10n.Diagnostics.Items.ReportIssue.caption, action: presentReportIssue)
|
||||
}
|
||||
}
|
||||
|
||||
private func reportIssueView() -> some View {
|
||||
func reportIssueView() -> some View {
|
||||
let logURL = vpnManager.debugLogURL(forProtocol: vpnProtocol)
|
||||
var metadata: ProviderMetadata?
|
||||
var lastUpdate: Date?
|
||||
|
@ -149,11 +150,8 @@ extension DiagnosticsView {
|
|||
lastUpdate: lastUpdate
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension DiagnosticsView.OpenVPNView {
|
||||
private var currentServerConfiguration: OpenVPN.ConfigurationBuilder? {
|
||||
var currentServerConfiguration: OpenVPN.ConfigurationBuilder? {
|
||||
guard currentVPNState.vpnStatus == .connected else {
|
||||
return nil
|
||||
}
|
||||
|
@ -164,17 +162,23 @@ extension DiagnosticsView.OpenVPNView {
|
|||
return cfg.builder(withFallbacks: false)
|
||||
}
|
||||
|
||||
private var appLogURL: URL? {
|
||||
var appLogURL: URL? {
|
||||
Passepartout.shared.logger.logFile
|
||||
}
|
||||
|
||||
private var tunnelLogURL: URL? {
|
||||
var tunnelLogURL: URL? {
|
||||
vpnManager.debugLogURL(forProtocol: vpnProtocol)
|
||||
}
|
||||
|
||||
var isEligibleForFeedback: Bool {
|
||||
productManager.isEligibleForFeedback()
|
||||
}
|
||||
}
|
||||
|
||||
extension DiagnosticsView.OpenVPNView {
|
||||
private func presentReportIssue() {
|
||||
// MARK: -
|
||||
|
||||
private extension DiagnosticsView.OpenVPNView {
|
||||
func presentReportIssue() {
|
||||
guard MailComposerView.canSendMail() else {
|
||||
openReportIssueMailTo()
|
||||
return
|
||||
|
@ -182,7 +186,7 @@ extension DiagnosticsView.OpenVPNView {
|
|||
isReportingIssue = true
|
||||
}
|
||||
|
||||
private func openReportIssueMailTo() {
|
||||
func openReportIssueMailTo() {
|
||||
let V = Unlocalized.Issues.self
|
||||
let body = V.body(V.template, DebugLog(content: "--").decoratedString())
|
||||
|
||||
|
|
|
@ -50,12 +50,14 @@ extension DiagnosticsView {
|
|||
}
|
||||
}
|
||||
|
||||
extension DiagnosticsView.WireGuardView {
|
||||
private var appLogURL: URL? {
|
||||
// MARK: -
|
||||
|
||||
private extension DiagnosticsView.WireGuardView {
|
||||
var appLogURL: URL? {
|
||||
Passepartout.shared.logger.logFile
|
||||
}
|
||||
|
||||
private var tunnelLogURL: URL? {
|
||||
var tunnelLogURL: URL? {
|
||||
vpnManager.debugLogURL(forProtocol: .wireGuard)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -60,8 +60,13 @@ extension DiagnosticsView {
|
|||
appLink
|
||||
tunnelLink
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var appLink: some View {
|
||||
// MARK: -
|
||||
|
||||
private extension DiagnosticsView.DebugLogSection {
|
||||
var appLink: some View {
|
||||
navigationLink(
|
||||
withTitle: L10n.Diagnostics.Items.AppLog.title,
|
||||
url: appLogURL,
|
||||
|
@ -69,7 +74,7 @@ extension DiagnosticsView {
|
|||
)
|
||||
}
|
||||
|
||||
private var tunnelLink: some View {
|
||||
var tunnelLink: some View {
|
||||
navigationLink(
|
||||
withTitle: Unlocalized.VPN.vpn,
|
||||
url: tunnelLogURL,
|
||||
|
@ -77,7 +82,7 @@ extension DiagnosticsView {
|
|||
)
|
||||
}
|
||||
|
||||
private func navigationLink(withTitle title: String, url: URL?, refreshInterval: TimeInterval?) -> some View {
|
||||
func navigationLink(withTitle title: String, url: URL?, refreshInterval: TimeInterval?) -> some View {
|
||||
NavigationLink(title) {
|
||||
url.map {
|
||||
DebugLogView(
|
||||
|
@ -89,4 +94,3 @@ extension DiagnosticsView {
|
|||
}.disabled(url == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -76,8 +76,12 @@ struct DonateView: View {
|
|||
}
|
||||
}.themeAnimation(on: productManager.isRefreshingProducts)
|
||||
}
|
||||
}
|
||||
|
||||
private func alertActions(_ alertType: AlertType) -> some View {
|
||||
// MARK: -
|
||||
|
||||
private extension DonateView {
|
||||
func alertActions(_ alertType: AlertType) -> some View {
|
||||
switch alertType {
|
||||
case .thankYou:
|
||||
return Button(role: .cancel) {
|
||||
|
@ -87,14 +91,14 @@ struct DonateView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func alertMessage(_ alertType: AlertType) -> some View {
|
||||
func alertMessage(_ alertType: AlertType) -> some View {
|
||||
switch alertType {
|
||||
case .thankYou:
|
||||
return Text(L10n.Donate.Alerts.Purchase.Success.message)
|
||||
}
|
||||
}
|
||||
|
||||
private var productsSection: some View {
|
||||
var productsSection: some View {
|
||||
Section {
|
||||
if !productManager.isRefreshingProducts {
|
||||
ForEach(productManager.donations, id: \.productIdentifier, content: productRow)
|
||||
|
@ -109,7 +113,7 @@ struct DonateView: View {
|
|||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func productRow(_ product: SKProduct) -> some View {
|
||||
func productRow(_ product: SKProduct) -> some View {
|
||||
HStack {
|
||||
Button(product.localizedTitle) {
|
||||
purchaseProduct(product)
|
||||
|
@ -127,13 +131,27 @@ struct DonateView: View {
|
|||
}
|
||||
}
|
||||
|
||||
extension DonateView {
|
||||
private func purchaseProduct(_ product: SKProduct) {
|
||||
private extension ProductManager {
|
||||
var donations: [SKProduct] {
|
||||
products.filter { product in
|
||||
LocalProduct.allDonations.contains {
|
||||
$0.matchesStoreKitProduct(product)
|
||||
}
|
||||
}.sorted {
|
||||
$0.price.decimalValue < $1.price.decimalValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private extension DonateView {
|
||||
func purchaseProduct(_ product: SKProduct) {
|
||||
pendingDonationIdentifier = product.productIdentifier
|
||||
productManager.purchase(product, completionHandler: handlePurchaseResult)
|
||||
}
|
||||
|
||||
private func handlePurchaseResult(_ result: Result<InAppPurchaseResult, Error>) {
|
||||
func handlePurchaseResult(_ result: Result<InAppPurchaseResult, Error>) {
|
||||
switch result {
|
||||
case .success(let value):
|
||||
if case .done = value {
|
||||
|
@ -152,15 +170,3 @@ extension DonateView {
|
|||
pendingDonationIdentifier = nil
|
||||
}
|
||||
}
|
||||
|
||||
private extension ProductManager {
|
||||
var donations: [SKProduct] {
|
||||
products.filter { product in
|
||||
LocalProduct.allDonations.contains {
|
||||
$0.matchesStoreKitProduct(product)
|
||||
}
|
||||
}.sorted {
|
||||
$0.price.decimalValue < $1.price.decimalValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -67,8 +67,10 @@ extension EndpointAdvancedView {
|
|||
}
|
||||
}
|
||||
|
||||
extension EndpointAdvancedView.OpenVPNView {
|
||||
private func pullSection(configuration: OpenVPN.Configuration) -> some View {
|
||||
// MARK: -
|
||||
|
||||
private extension EndpointAdvancedView.OpenVPNView {
|
||||
func pullSection(configuration: OpenVPN.Configuration) -> some View {
|
||||
configuration.pullMask.map { mask in
|
||||
Section {
|
||||
ForEach(mask, id: \.self) {
|
||||
|
@ -80,7 +82,7 @@ extension EndpointAdvancedView.OpenVPNView {
|
|||
}
|
||||
}
|
||||
|
||||
private var ipv4Section: some View {
|
||||
var ipv4Section: some View {
|
||||
Section {
|
||||
if let settings = builder.ipv4 {
|
||||
themeLongContentLinkDefault(
|
||||
|
@ -105,7 +107,7 @@ extension EndpointAdvancedView.OpenVPNView {
|
|||
}
|
||||
}
|
||||
|
||||
private var ipv6Section: some View {
|
||||
var ipv6Section: some View {
|
||||
Section {
|
||||
if let settings = builder.ipv6 {
|
||||
themeLongContentLinkDefault(
|
||||
|
@ -130,7 +132,7 @@ extension EndpointAdvancedView.OpenVPNView {
|
|||
}
|
||||
}
|
||||
|
||||
private func communicationSection(configuration: OpenVPN.Configuration) -> some View {
|
||||
func communicationSection(configuration: OpenVPN.Configuration) -> some View {
|
||||
configuration.communicationSettings.map { settings in
|
||||
Section {
|
||||
settings.cipher.map {
|
||||
|
@ -157,7 +159,7 @@ extension EndpointAdvancedView.OpenVPNView {
|
|||
}
|
||||
}
|
||||
|
||||
private var communicationEditableSection: some View {
|
||||
var communicationEditableSection: some View {
|
||||
Section {
|
||||
themeTextPicker(
|
||||
L10n.Endpoint.Advanced.Openvpn.Items.Cipher.caption,
|
||||
|
@ -186,7 +188,7 @@ extension EndpointAdvancedView.OpenVPNView {
|
|||
}
|
||||
}
|
||||
|
||||
private func compressionSection(configuration: OpenVPN.Configuration) -> some View {
|
||||
func compressionSection(configuration: OpenVPN.Configuration) -> some View {
|
||||
configuration.compressionSettings.map { settings in
|
||||
Section {
|
||||
settings.framing.map {
|
||||
|
@ -203,7 +205,7 @@ extension EndpointAdvancedView.OpenVPNView {
|
|||
}
|
||||
}
|
||||
|
||||
private var compressionEditableSection: some View {
|
||||
var compressionEditableSection: some View {
|
||||
Section {
|
||||
themeTextPicker(
|
||||
L10n.Endpoint.Advanced.Openvpn.Items.CompressionFraming.caption,
|
||||
|
@ -222,7 +224,7 @@ extension EndpointAdvancedView.OpenVPNView {
|
|||
}
|
||||
}
|
||||
|
||||
private func dnsSection(configuration: OpenVPN.Configuration) -> some View {
|
||||
func dnsSection(configuration: OpenVPN.Configuration) -> some View {
|
||||
configuration.dnsSettings.map { settings in
|
||||
Section {
|
||||
ForEach(settings.servers, id: \.self) {
|
||||
|
@ -239,7 +241,7 @@ extension EndpointAdvancedView.OpenVPNView {
|
|||
}
|
||||
}
|
||||
|
||||
private func proxySection(configuration: OpenVPN.Configuration) -> some View {
|
||||
func proxySection(configuration: OpenVPN.Configuration) -> some View {
|
||||
configuration.proxySettings.map { settings in
|
||||
Section {
|
||||
settings.proxy.map {
|
||||
|
@ -260,7 +262,7 @@ extension EndpointAdvancedView.OpenVPNView {
|
|||
}
|
||||
}
|
||||
|
||||
private var tlsSection: some View {
|
||||
var tlsSection: some View {
|
||||
Section {
|
||||
builder.ca.map { ca in
|
||||
themeLongContentLink(
|
||||
|
@ -294,7 +296,7 @@ extension EndpointAdvancedView.OpenVPNView {
|
|||
}
|
||||
}
|
||||
|
||||
private func otherSection(configuration: OpenVPN.Configuration) -> some View {
|
||||
func otherSection(configuration: OpenVPN.Configuration) -> some View {
|
||||
configuration.otherSettings.map { settings in
|
||||
Section {
|
||||
settings.keepAlive.map {
|
||||
|
|
|
@ -44,8 +44,10 @@ extension EndpointAdvancedView {
|
|||
}
|
||||
}
|
||||
|
||||
extension EndpointAdvancedView.WireGuardView {
|
||||
private var keySection: some View {
|
||||
// MARK: -
|
||||
|
||||
private extension EndpointAdvancedView.WireGuardView {
|
||||
var keySection: some View {
|
||||
Section {
|
||||
themeLongContentLink(L10n.Global.Strings.privateKey, content: .constant(builder.privateKey))
|
||||
themeLongContentLink(L10n.Global.Strings.publicKey, content: .constant(builder.publicKey))
|
||||
|
@ -54,7 +56,7 @@ extension EndpointAdvancedView.WireGuardView {
|
|||
}
|
||||
}
|
||||
|
||||
private var addressesSection: some View {
|
||||
var addressesSection: some View {
|
||||
Section {
|
||||
ForEach(builder.addresses, id: \.self, content: Text.init)
|
||||
} header: {
|
||||
|
@ -62,7 +64,7 @@ extension EndpointAdvancedView.WireGuardView {
|
|||
}
|
||||
}
|
||||
|
||||
private func dnsSection(configuration: WireGuard.Configuration) -> some View {
|
||||
func dnsSection(configuration: WireGuard.Configuration) -> some View {
|
||||
configuration.dnsSettings.map { settings in
|
||||
Section {
|
||||
ForEach(settings.servers, id: \.self) {
|
||||
|
@ -79,7 +81,7 @@ extension EndpointAdvancedView.WireGuardView {
|
|||
}
|
||||
}
|
||||
|
||||
private var mtuSection: some View {
|
||||
var mtuSection: some View {
|
||||
builder.mtu.map { mtu in
|
||||
Section {
|
||||
Text(Unlocalized.Network.mtu)
|
||||
|
|
|
@ -39,10 +39,6 @@ extension EndpointView {
|
|||
|
||||
@Binding private var customEndpoint: Endpoint?
|
||||
|
||||
private var isConfigurationReadonly: Bool {
|
||||
currentProfile.value.isProvider
|
||||
}
|
||||
|
||||
@State private var isFirstAppearance = true
|
||||
|
||||
@State private var isAutomatic = false
|
||||
|
@ -129,14 +125,16 @@ extension EndpointView {
|
|||
}
|
||||
}
|
||||
|
||||
extension EndpointView.OpenVPNView {
|
||||
private var mainSection: some View {
|
||||
// MARK: -
|
||||
|
||||
private extension EndpointView.OpenVPNView {
|
||||
var mainSection: some View {
|
||||
Section {
|
||||
Toggle(L10n.Global.Strings.automatic, isOn: $isAutomatic.themeAnimation())
|
||||
}
|
||||
}
|
||||
|
||||
private var filtersSection: some View {
|
||||
var filtersSection: some View {
|
||||
Section {
|
||||
themeTextPicker(
|
||||
L10n.Global.Strings.protocol,
|
||||
|
@ -153,7 +151,7 @@ extension EndpointView.OpenVPNView {
|
|||
}
|
||||
}
|
||||
|
||||
private var addressesSection: some View {
|
||||
var addressesSection: some View {
|
||||
Section {
|
||||
filteredRemotes.map {
|
||||
ForEach($0, content: button(forEndpoint:))
|
||||
|
@ -163,7 +161,7 @@ extension EndpointView.OpenVPNView {
|
|||
}
|
||||
}
|
||||
|
||||
private var advancedSection: some View {
|
||||
var advancedSection: some View {
|
||||
Section {
|
||||
let caption = L10n.Endpoint.Advanced.title
|
||||
NavigationLink(caption) {
|
||||
|
@ -176,7 +174,7 @@ extension EndpointView.OpenVPNView {
|
|||
}
|
||||
}
|
||||
|
||||
private func button(forEndpoint endpoint: Endpoint?) -> some View {
|
||||
func button(forEndpoint endpoint: Endpoint?) -> some View {
|
||||
Button {
|
||||
customEndpoint = endpoint
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
|
@ -185,56 +183,12 @@ extension EndpointView.OpenVPNView {
|
|||
}.withTrailingCheckmark(when: customEndpoint == endpoint)
|
||||
}
|
||||
|
||||
private func text(forEndpoint endpoint: Endpoint?) -> some View {
|
||||
func text(forEndpoint endpoint: Endpoint?) -> some View {
|
||||
Text(endpoint?.address ?? L10n.Global.Strings.automatic)
|
||||
.themeLongTextStyle()
|
||||
}
|
||||
}
|
||||
|
||||
extension EndpointView.OpenVPNView {
|
||||
private func onToggleAutomatic(_ value: Bool) {
|
||||
if value {
|
||||
guard customEndpoint != nil else {
|
||||
return
|
||||
}
|
||||
customEndpoint = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func preselectFilters(once: Bool) {
|
||||
guard !once || isFirstAppearance else {
|
||||
return
|
||||
}
|
||||
isFirstAppearance = false
|
||||
|
||||
if let customEndpoint = customEndpoint {
|
||||
isAutomatic = false
|
||||
selectedSocketType = customEndpoint.proto.socketType
|
||||
selectedPort = customEndpoint.proto.port
|
||||
} else {
|
||||
isAutomatic = true
|
||||
guard let socketType = availableSocketTypes.first else {
|
||||
assertionFailure("No socket types, empty remotes?")
|
||||
return
|
||||
}
|
||||
selectedSocketType = socketType
|
||||
preselectPort(forSocketType: socketType)
|
||||
}
|
||||
}
|
||||
|
||||
private func preselectPort(forSocketType socketType: SocketType) {
|
||||
let supported = allPorts(forSocketType: socketType)
|
||||
guard !supported.contains(selectedPort) else {
|
||||
return
|
||||
}
|
||||
guard let port = supported.first else {
|
||||
assertionFailure("No ports, empty remotes?")
|
||||
return
|
||||
}
|
||||
selectedPort = port
|
||||
}
|
||||
|
||||
private var availableSocketTypes: [SocketType] {
|
||||
var availableSocketTypes: [SocketType] {
|
||||
guard let remotes = builder.remotes else {
|
||||
return []
|
||||
}
|
||||
|
@ -256,7 +210,7 @@ extension EndpointView.OpenVPNView {
|
|||
return availableTypes
|
||||
}
|
||||
|
||||
private func allPorts(forSocketType socketType: SocketType) -> [UInt16] {
|
||||
func allPorts(forSocketType socketType: SocketType) -> [UInt16] {
|
||||
guard let remotes = builder.remotes else {
|
||||
return []
|
||||
}
|
||||
|
@ -266,15 +220,63 @@ extension EndpointView.OpenVPNView {
|
|||
return Array(allPorts).sorted()
|
||||
}
|
||||
|
||||
private var filteredRemotes: [Endpoint]? {
|
||||
var filteredRemotes: [Endpoint]? {
|
||||
builder.remotes?.filter {
|
||||
$0.proto.socketType == selectedSocketType && $0.proto.port == selectedPort
|
||||
}
|
||||
}
|
||||
|
||||
var isConfigurationReadonly: Bool {
|
||||
currentProfile.value.isProvider
|
||||
}
|
||||
}
|
||||
|
||||
extension EndpointView.OpenVPNView {
|
||||
private func scrollToCustomEndpoint(_ proxy: ScrollViewProxy) {
|
||||
// MARK: -
|
||||
|
||||
private extension EndpointView.OpenVPNView {
|
||||
func onToggleAutomatic(_ value: Bool) {
|
||||
if value {
|
||||
guard customEndpoint != nil else {
|
||||
return
|
||||
}
|
||||
customEndpoint = nil
|
||||
}
|
||||
}
|
||||
|
||||
func preselectFilters(once: Bool) {
|
||||
guard !once || isFirstAppearance else {
|
||||
return
|
||||
}
|
||||
isFirstAppearance = false
|
||||
|
||||
if let customEndpoint = customEndpoint {
|
||||
isAutomatic = false
|
||||
selectedSocketType = customEndpoint.proto.socketType
|
||||
selectedPort = customEndpoint.proto.port
|
||||
} else {
|
||||
isAutomatic = true
|
||||
guard let socketType = availableSocketTypes.first else {
|
||||
assertionFailure("No socket types, empty remotes?")
|
||||
return
|
||||
}
|
||||
selectedSocketType = socketType
|
||||
preselectPort(forSocketType: socketType)
|
||||
}
|
||||
}
|
||||
|
||||
func preselectPort(forSocketType socketType: SocketType) {
|
||||
let supported = allPorts(forSocketType: socketType)
|
||||
guard !supported.contains(selectedPort) else {
|
||||
return
|
||||
}
|
||||
guard let port = supported.first else {
|
||||
assertionFailure("No ports, empty remotes?")
|
||||
return
|
||||
}
|
||||
selectedPort = port
|
||||
}
|
||||
|
||||
func scrollToCustomEndpoint(_ proxy: ScrollViewProxy) {
|
||||
proxy.maybeScrollTo(customEndpoint?.id)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -88,8 +88,10 @@ extension EndpointView {
|
|||
}
|
||||
}
|
||||
|
||||
extension EndpointView.WireGuardView {
|
||||
private var peersSections: some View {
|
||||
// MARK: -
|
||||
|
||||
private extension EndpointView.WireGuardView {
|
||||
var peersSections: some View {
|
||||
|
||||
// TODO: WireGuard, make peers editable
|
||||
// if !isReadonly {
|
||||
|
@ -103,7 +105,7 @@ extension EndpointView.WireGuardView {
|
|||
// }
|
||||
}
|
||||
|
||||
private func section(forPeerAt peerIndex: Int) -> some View {
|
||||
func section(forPeerAt peerIndex: Int) -> some View {
|
||||
Section {
|
||||
themeLongContentLink(
|
||||
L10n.Global.Strings.publicKey,
|
||||
|
@ -132,7 +134,7 @@ extension EndpointView.WireGuardView {
|
|||
}
|
||||
}
|
||||
|
||||
private var advancedSection: some View {
|
||||
var advancedSection: some View {
|
||||
Section {
|
||||
let caption = L10n.Endpoint.Advanced.title
|
||||
NavigationLink(caption) {
|
||||
|
|
|
@ -64,8 +64,12 @@ struct InteractiveConnectionView: View {
|
|||
}
|
||||
}.navigationTitle(profile.header.name)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveAccount() {
|
||||
// MARK: -
|
||||
|
||||
private extension InteractiveConnectionView {
|
||||
func saveAccount() {
|
||||
Task {
|
||||
try? await vpnManager.connect(with: profile.id, newPassword: password)
|
||||
}
|
||||
|
|
|
@ -29,10 +29,6 @@ import SwiftUI
|
|||
struct NetworkSettingsView: View {
|
||||
@ObservedObject private var currentProfile: ObservableProfile
|
||||
|
||||
private var vpnProtocol: VPNProtocolType {
|
||||
currentProfile.value.currentVPNProtocol
|
||||
}
|
||||
|
||||
@State private var settings = Profile.NetworkSettings()
|
||||
|
||||
init(currentProfile: ObservableProfile) {
|
||||
|
@ -64,36 +60,14 @@ struct NetworkSettingsView: View {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
// EditButton()
|
||||
// .disabled(!isAnythingManual)
|
||||
|
||||
private var isAnythingManual: Bool {
|
||||
// if settings.gateway.choice == .manual {
|
||||
// return true
|
||||
// }
|
||||
if settings.dns.choice == .manual {
|
||||
return true
|
||||
}
|
||||
if settings.proxy.choice == .manual {
|
||||
return true
|
||||
}
|
||||
// if settings.mtu.choice == .manual {
|
||||
// return true
|
||||
// }
|
||||
return false
|
||||
}
|
||||
|
||||
private func mapNotEmpty(elements: [IdentifiableString]) -> [IdentifiableString] {
|
||||
elements
|
||||
.filter { !$0.string.isEmpty }
|
||||
}
|
||||
}
|
||||
// MARK: -
|
||||
|
||||
// MARK: Gateway
|
||||
|
||||
extension NetworkSettingsView {
|
||||
private var gatewayView: some View {
|
||||
private extension NetworkSettingsView {
|
||||
var gatewayView: some View {
|
||||
Section {
|
||||
Toggle(L10n.Global.Strings.automatic, isOn: $settings.isAutomaticGateway.themeAnimation())
|
||||
|
||||
|
@ -109,10 +83,10 @@ extension NetworkSettingsView {
|
|||
|
||||
// MARK: DNS
|
||||
|
||||
extension NetworkSettingsView {
|
||||
private extension NetworkSettingsView {
|
||||
|
||||
@ViewBuilder
|
||||
private var dnsView: some View {
|
||||
var dnsView: some View {
|
||||
Section {
|
||||
Toggle(L10n.Global.Strings.automatic, isOn: $settings.isAutomaticDNS.themeAnimation())
|
||||
|
||||
|
@ -148,17 +122,17 @@ extension NetworkSettingsView {
|
|||
}
|
||||
}
|
||||
|
||||
private var dnsManualHTTPSRow: some View {
|
||||
var dnsManualHTTPSRow: some View {
|
||||
TextField(Unlocalized.Placeholders.dohURL, text: $settings.dns.dnsHTTPSURL.toString())
|
||||
.themeValidURL(settings.dns.dnsHTTPSURL?.absoluteString)
|
||||
}
|
||||
|
||||
private var dnsManualTLSRow: some View {
|
||||
var dnsManualTLSRow: some View {
|
||||
TextField(Unlocalized.Placeholders.dotServerName, text: $settings.dns.dnsTLSServerName ?? "")
|
||||
.themeValidDNSOverTLSServerName(settings.dns.dnsTLSServerName)
|
||||
}
|
||||
|
||||
private var dnsManualServers: some View {
|
||||
var dnsManualServers: some View {
|
||||
Section {
|
||||
EditableTextList(
|
||||
elements: $settings.dns.dnsServers ?? [],
|
||||
|
@ -179,12 +153,12 @@ extension NetworkSettingsView {
|
|||
}
|
||||
}
|
||||
|
||||
private var dnsManualDomainRow: some View {
|
||||
var dnsManualDomainRow: some View {
|
||||
TextField(L10n.Global.Strings.domain, text: $settings.dns.dnsDomain ?? "")
|
||||
.themeValidDomainName(settings.dns.dnsDomain)
|
||||
}
|
||||
|
||||
private var dnsManualSearchDomains: some View {
|
||||
var dnsManualSearchDomains: some View {
|
||||
Section {
|
||||
EditableTextList(
|
||||
elements: $settings.dns.dnsSearchDomains ?? [],
|
||||
|
@ -208,10 +182,10 @@ extension NetworkSettingsView {
|
|||
|
||||
// MARK: Proxy
|
||||
|
||||
extension NetworkSettingsView {
|
||||
private extension NetworkSettingsView {
|
||||
|
||||
@ViewBuilder
|
||||
private var proxyView: some View {
|
||||
var proxyView: some View {
|
||||
Section {
|
||||
Toggle(L10n.Global.Strings.automatic, isOn: $settings.isAutomaticProxy.themeAnimation())
|
||||
|
||||
|
@ -249,7 +223,7 @@ extension NetworkSettingsView {
|
|||
}
|
||||
}
|
||||
|
||||
private var proxyManualBypassDomains: some View {
|
||||
var proxyManualBypassDomains: some View {
|
||||
Section {
|
||||
EditableTextList(
|
||||
elements: $settings.proxy.proxyBypassDomains ?? [],
|
||||
|
@ -273,8 +247,8 @@ extension NetworkSettingsView {
|
|||
|
||||
// MARK: MTU
|
||||
|
||||
extension NetworkSettingsView {
|
||||
private var mtuView: some View {
|
||||
private extension NetworkSettingsView {
|
||||
var mtuView: some View {
|
||||
Section {
|
||||
Toggle(L10n.Global.Strings.automatic, isOn: $settings.isAutomaticMTU.themeAnimation())
|
||||
|
||||
|
@ -291,3 +265,35 @@ extension NetworkSettingsView {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Global
|
||||
|
||||
private extension NetworkSettingsView {
|
||||
var vpnProtocol: VPNProtocolType {
|
||||
currentProfile.value.currentVPNProtocol
|
||||
}
|
||||
|
||||
// EditButton()
|
||||
// .disabled(!isAnythingManual)
|
||||
|
||||
var isAnythingManual: Bool {
|
||||
// if settings.gateway.choice == .manual {
|
||||
// return true
|
||||
// }
|
||||
if settings.dns.choice == .manual {
|
||||
return true
|
||||
}
|
||||
if settings.proxy.choice == .manual {
|
||||
return true
|
||||
}
|
||||
// if settings.mtu.choice == .manual {
|
||||
// return true
|
||||
// }
|
||||
return false
|
||||
}
|
||||
|
||||
func mapNotEmpty(elements: [IdentifiableString]) -> [IdentifiableString] {
|
||||
elements
|
||||
.filter { !$0.string.isEmpty }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -44,14 +44,19 @@ extension OnDemandView {
|
|||
Text(L10n.Global.Strings.add)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func mapElements(elements: [IdentifiableString]) -> [IdentifiableString] {
|
||||
// MARK: -
|
||||
|
||||
private extension OnDemandView.SSIDList {
|
||||
func mapElements(elements: [IdentifiableString]) -> [IdentifiableString] {
|
||||
elements
|
||||
.filter { !$0.string.isEmpty }
|
||||
.sorted { $0.string.lowercased() < $1.string.lowercased() }
|
||||
}
|
||||
|
||||
private func ssidRow(callback: EditableTextFieldCallback) -> some View {
|
||||
func ssidRow(callback: EditableTextFieldCallback) -> some View {
|
||||
Group {
|
||||
if callback.isNewElement {
|
||||
ssidField(callback: callback)
|
||||
|
@ -63,7 +68,7 @@ extension OnDemandView {
|
|||
}
|
||||
}
|
||||
|
||||
private func ssidField(callback: EditableTextFieldCallback) -> some View {
|
||||
func ssidField(callback: EditableTextFieldCallback) -> some View {
|
||||
TextField(
|
||||
Unlocalized.Network.ssid,
|
||||
text: callback.text,
|
||||
|
@ -72,19 +77,7 @@ extension OnDemandView {
|
|||
).themeValidSSID(callback.text.wrappedValue)
|
||||
}
|
||||
|
||||
private func requestSSID(_ text: Binding<String>) {
|
||||
Task { @MainActor in
|
||||
let ssid = try await reader.currentSSID()
|
||||
if !withSSIDs.keys.contains(ssid) {
|
||||
text.wrappedValue = ssid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension OnDemandView.SSIDList {
|
||||
private var allSSIDs: Binding<[String]> {
|
||||
var allSSIDs: Binding<[String]> {
|
||||
.init {
|
||||
Array(withSSIDs.keys)
|
||||
} set: { newValue in
|
||||
|
@ -104,7 +97,7 @@ extension OnDemandView.SSIDList {
|
|||
}
|
||||
}
|
||||
|
||||
private var onSSIDs: Binding<Set<String>> {
|
||||
var onSSIDs: Binding<Set<String>> {
|
||||
.init {
|
||||
Set(withSSIDs.filter {
|
||||
$0.value
|
||||
|
@ -130,7 +123,7 @@ extension OnDemandView.SSIDList {
|
|||
}
|
||||
}
|
||||
|
||||
private func isSSIDOn(_ ssid: String) -> Binding<Bool> {
|
||||
func isSSIDOn(_ ssid: String) -> Binding<Bool> {
|
||||
.init {
|
||||
withSSIDs[ssid] ?? false
|
||||
} set: {
|
||||
|
@ -138,3 +131,16 @@ extension OnDemandView.SSIDList {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private extension OnDemandView.SSIDList {
|
||||
func requestSSID(_ text: Binding<String>) {
|
||||
Task { @MainActor in
|
||||
let ssid = try await reader.currentSSID()
|
||||
if !withSSIDs.keys.contains(ssid) {
|
||||
text.wrappedValue = ssid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,10 +31,6 @@ struct OnDemandView: View {
|
|||
|
||||
@ObservedObject private var currentProfile: ObservableProfile
|
||||
|
||||
private var isEligibleForSiri: Bool {
|
||||
productManager.isEligible(forFeature: .siriShortcuts)
|
||||
}
|
||||
|
||||
@State private var onDemand = Profile.OnDemand()
|
||||
|
||||
init(currentProfile: ObservableProfile) {
|
||||
|
@ -66,15 +62,17 @@ struct OnDemandView: View {
|
|||
}
|
||||
}
|
||||
|
||||
extension OnDemandView {
|
||||
private var enabledView: some View {
|
||||
// MARK: -
|
||||
|
||||
private extension OnDemandView {
|
||||
var enabledView: some View {
|
||||
Section {
|
||||
Toggle(L10n.Global.Strings.enabled, isOn: $onDemand.isEnabled.themeAnimation())
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var mainView: some View {
|
||||
var mainView: some View {
|
||||
if Utils.hasCellularData() {
|
||||
Section {
|
||||
Toggle(L10n.OnDemand.Items.Mobile.caption, isOn: $onDemand.withMobileNetwork)
|
||||
|
@ -110,8 +108,17 @@ extension OnDemandView {
|
|||
}
|
||||
}
|
||||
|
||||
var isEligibleForSiri: Bool {
|
||||
productManager.isEligible(forFeature: .siriShortcuts)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private extension OnDemandView {
|
||||
|
||||
// eligibility: donate intents if eligible for Siri
|
||||
private func donateMobileIntent(_ isEnabled: Bool) {
|
||||
func donateMobileIntent(_ isEnabled: Bool) {
|
||||
guard isEligibleForSiri else {
|
||||
return
|
||||
}
|
||||
|
@ -120,7 +127,7 @@ extension OnDemandView {
|
|||
}
|
||||
|
||||
// eligibility: donate intents if eligible for Siri
|
||||
private func donateNetworkIntents(_: [String: Bool]) {
|
||||
func donateNetworkIntents(_: [String: Bool]) {
|
||||
guard isEligibleForSiri else {
|
||||
return
|
||||
}
|
||||
|
|
|
@ -34,21 +34,6 @@ extension OrganizerView {
|
|||
|
||||
@Binding private var modalType: ModalType?
|
||||
|
||||
private var interactiveProfile: Binding<Profile?> {
|
||||
.init {
|
||||
if case .interactiveAccount(let profile) = modalType {
|
||||
return profile
|
||||
}
|
||||
return nil
|
||||
} set: {
|
||||
if let profile = $0 {
|
||||
modalType = .interactiveAccount(profile: profile)
|
||||
} else {
|
||||
modalType = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init(profile: Profile, isActiveProfile: Bool, modalType: Binding<ModalType?>) {
|
||||
self.profile = profile
|
||||
self.isActiveProfile = isActiveProfile
|
||||
|
@ -77,3 +62,22 @@ extension OrganizerView {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private extension OrganizerView.ProfileRow {
|
||||
var interactiveProfile: Binding<Profile?> {
|
||||
.init {
|
||||
if case .interactiveAccount(let profile) = modalType {
|
||||
return profile
|
||||
}
|
||||
return nil
|
||||
} set: {
|
||||
if let profile = $0 {
|
||||
modalType = .interactiveAccount(profile: profile)
|
||||
} else {
|
||||
modalType = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -49,87 +49,6 @@ extension OrganizerView {
|
|||
profileManager.currentProfileId = $0.id
|
||||
}
|
||||
}
|
||||
|
||||
private var mainView: some View {
|
||||
List {
|
||||
if profileManager.hasProfiles {
|
||||
|
||||
// FIXME: iPad multitasking, navigation binding does not clear on pop
|
||||
// - if listStyle is different than .sidebar
|
||||
// - if listStyle is .sidebar but List has no Section
|
||||
if themeIsiPadMultitasking {
|
||||
Section {
|
||||
profilesView
|
||||
} header: {
|
||||
Text(L10n.Global.Strings.profiles)
|
||||
}
|
||||
} else {
|
||||
profilesView
|
||||
}
|
||||
}
|
||||
}.themeAnimation(on: profileManager.headers)
|
||||
}
|
||||
|
||||
private var profilesView: some View {
|
||||
ForEach(sortedProfiles, content: profileRow(forProfile:))
|
||||
.onDelete(perform: removeProfiles)
|
||||
}
|
||||
|
||||
private var emptyView: some View {
|
||||
VStack {
|
||||
Text(L10n.Organizer.Empty.noProfiles)
|
||||
.themeInformativeTextStyle()
|
||||
}
|
||||
}
|
||||
|
||||
private func profileRow(forProfile profile: Profile) -> some View {
|
||||
NavigationLink(tag: profile.id, selection: $profileManager.currentProfileId) {
|
||||
ProfileView()
|
||||
} label: {
|
||||
profileLabel(forProfile: profile)
|
||||
}.contextMenu {
|
||||
ProfileContextMenu(header: profile.header)
|
||||
}
|
||||
}
|
||||
|
||||
private func profileLabel(forProfile profile: Profile) -> some View {
|
||||
ProfileRow(
|
||||
profile: profile,
|
||||
isActiveProfile: profileManager.isActiveProfile(profile.id),
|
||||
modalType: $modalType
|
||||
)
|
||||
}
|
||||
|
||||
private var sortedProfiles: [Profile] {
|
||||
profileManager.profiles
|
||||
.sorted()
|
||||
// .sorted {
|
||||
// if profileManager.isActiveProfile($0.id) {
|
||||
// return true
|
||||
// } else if profileManager.isActiveProfile($1.id) {
|
||||
// return false
|
||||
// } else {
|
||||
// return $0 < $1
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
private func removeProfiles(at offsets: IndexSet) {
|
||||
let currentHeaders = sortedProfiles
|
||||
var toDelete: [UUID] = []
|
||||
offsets.forEach {
|
||||
toDelete.append(currentHeaders[$0].id)
|
||||
}
|
||||
withAnimation {
|
||||
profileManager.removeProfiles(withIds: toDelete)
|
||||
}
|
||||
}
|
||||
|
||||
private func performMigrationsIfNeeded() {
|
||||
Task { @MainActor in
|
||||
UpgradeManager.shared.doMigrations(profileManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -154,19 +73,90 @@ extension OrganizerView {
|
|||
duplicateButton
|
||||
deleteButton
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var reconnectButton: some View {
|
||||
// MARK: -
|
||||
|
||||
private extension OrganizerView.ProfilesList {
|
||||
var mainView: some View {
|
||||
List {
|
||||
if profileManager.hasProfiles {
|
||||
|
||||
// FIXME: iPad multitasking, navigation binding does not clear on pop
|
||||
// - if listStyle is different than .sidebar
|
||||
// - if listStyle is .sidebar but List has no Section
|
||||
if themeIsiPadMultitasking {
|
||||
Section {
|
||||
profilesView
|
||||
} header: {
|
||||
Text(L10n.Global.Strings.profiles)
|
||||
}
|
||||
} else {
|
||||
profilesView
|
||||
}
|
||||
}
|
||||
}.themeAnimation(on: profileManager.headers)
|
||||
}
|
||||
|
||||
var profilesView: some View {
|
||||
ForEach(sortedProfiles, content: profileRow(forProfile:))
|
||||
.onDelete(perform: removeProfiles)
|
||||
}
|
||||
|
||||
var emptyView: some View {
|
||||
VStack {
|
||||
Text(L10n.Organizer.Empty.noProfiles)
|
||||
.themeInformativeTextStyle()
|
||||
}
|
||||
}
|
||||
|
||||
func profileRow(forProfile profile: Profile) -> some View {
|
||||
NavigationLink(tag: profile.id, selection: $profileManager.currentProfileId) {
|
||||
ProfileView()
|
||||
} label: {
|
||||
profileLabel(forProfile: profile)
|
||||
}.contextMenu {
|
||||
OrganizerView.ProfileContextMenu(header: profile.header)
|
||||
}
|
||||
}
|
||||
|
||||
func profileLabel(forProfile profile: Profile) -> some View {
|
||||
OrganizerView.ProfileRow(
|
||||
profile: profile,
|
||||
isActiveProfile: profileManager.isActiveProfile(profile.id),
|
||||
modalType: $modalType
|
||||
)
|
||||
}
|
||||
|
||||
var sortedProfiles: [Profile] {
|
||||
profileManager.profiles
|
||||
.sorted()
|
||||
// .sorted {
|
||||
// if profileManager.isActiveProfile($0.id) {
|
||||
// return true
|
||||
// } else if profileManager.isActiveProfile($1.id) {
|
||||
// return false
|
||||
// } else {
|
||||
// return $0 < $1
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
private extension OrganizerView.ProfileContextMenu {
|
||||
var reconnectButton: some View {
|
||||
ProfileView.ReconnectButton()
|
||||
}
|
||||
|
||||
private var duplicateButton: some View {
|
||||
var duplicateButton: some View {
|
||||
ProfileView.DuplicateButton(
|
||||
header: header,
|
||||
setAsCurrent: false
|
||||
)
|
||||
}
|
||||
|
||||
private var deleteButton: some View {
|
||||
var deleteButton: some View {
|
||||
DestructiveButton {
|
||||
withAnimation {
|
||||
profileManager.removeProfiles(withIds: [header.id])
|
||||
|
@ -176,4 +166,24 @@ extension OrganizerView {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private extension OrganizerView.ProfilesList {
|
||||
func removeProfiles(at offsets: IndexSet) {
|
||||
let currentHeaders = sortedProfiles
|
||||
var toDelete: [UUID] = []
|
||||
offsets.forEach {
|
||||
toDelete.append(currentHeaders[$0].id)
|
||||
}
|
||||
withAnimation {
|
||||
profileManager.removeProfiles(withIds: toDelete)
|
||||
}
|
||||
}
|
||||
|
||||
func performMigrationsIfNeeded() {
|
||||
Task { @MainActor in
|
||||
UpgradeManager.shared.doMigrations(profileManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -51,9 +51,15 @@ extension OrganizerView {
|
|||
.hidden()
|
||||
.onAppear(perform: onAppear)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private extension OrganizerView.SceneView {
|
||||
|
||||
@MainActor
|
||||
private func onAppear() {
|
||||
func onAppear() {
|
||||
guard didHandleSubreddit else {
|
||||
alertType = .subscribeReddit
|
||||
isAlertPresented = true
|
||||
|
@ -78,4 +84,3 @@ extension OrganizerView {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -97,42 +97,21 @@ struct OrganizerView: View {
|
|||
).onOpenURL(perform: onOpenURL)
|
||||
.themePrimaryView()
|
||||
}
|
||||
}
|
||||
|
||||
private var hiddenSceneView: some View {
|
||||
// MARK: -
|
||||
|
||||
private extension OrganizerView {
|
||||
var hiddenSceneView: some View {
|
||||
SceneView(
|
||||
isAlertPresented: $isAlertPresented,
|
||||
alertType: $alertType,
|
||||
didHandleSubreddit: $didHandleSubreddit
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension OrganizerView {
|
||||
|
||||
@MainActor
|
||||
private func onHostFileImporterResult(_ result: Result<[URL], Error>) {
|
||||
switch result {
|
||||
case .success(let urls):
|
||||
guard let url = urls.first else {
|
||||
assertionFailure("Empty URLs from file importer?")
|
||||
return
|
||||
}
|
||||
Task {
|
||||
await Task.maybeWait(forMilliseconds: Constants.Delays.xxxPresentFileImporter)
|
||||
addProfileModalType = .addHost(url, false)
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
ErrorHandler.shared.handle(error, title: L10n.Menu.Contextual.AddProfile.fromFiles)
|
||||
}
|
||||
}
|
||||
|
||||
private func onOpenURL(_ url: URL) {
|
||||
addProfileModalType = .addHost(url, false)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func presentedModal(_ modalType: ModalType) -> some View {
|
||||
func presentedModal(_ modalType: ModalType) -> some View {
|
||||
switch modalType {
|
||||
case .interactiveAccount(let profile):
|
||||
NavigationView {
|
||||
|
@ -141,7 +120,7 @@ extension OrganizerView {
|
|||
}
|
||||
}
|
||||
|
||||
private func alertActions(_ alertType: AlertType) -> some View {
|
||||
func alertActions(_ alertType: AlertType) -> some View {
|
||||
switch alertType {
|
||||
case .subscribeReddit:
|
||||
return Group {
|
||||
|
@ -158,10 +137,37 @@ extension OrganizerView {
|
|||
}
|
||||
}
|
||||
|
||||
private func alertMessage(_ alertType: AlertType) -> some View {
|
||||
func alertMessage(_ alertType: AlertType) -> some View {
|
||||
switch alertType {
|
||||
case .subscribeReddit:
|
||||
return Text(L10n.Organizer.Alerts.Reddit.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private extension OrganizerView {
|
||||
|
||||
@MainActor
|
||||
func onHostFileImporterResult(_ result: Result<[URL], Error>) {
|
||||
switch result {
|
||||
case .success(let urls):
|
||||
guard let url = urls.first else {
|
||||
assertionFailure("Empty URLs from file importer?")
|
||||
return
|
||||
}
|
||||
Task {
|
||||
await Task.maybeWait(forMilliseconds: Constants.Delays.xxxPresentFileImporter)
|
||||
addProfileModalType = .addHost(url, false)
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
ErrorHandler.shared.handle(error, title: L10n.Menu.Contextual.AddProfile.fromFiles)
|
||||
}
|
||||
}
|
||||
|
||||
func onOpenURL(_ url: URL) {
|
||||
addProfileModalType = .addHost(url, false)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,8 +35,6 @@ extension PaywallView {
|
|||
case restoring
|
||||
}
|
||||
|
||||
private typealias RowModel = (product: SKProduct, extra: String?)
|
||||
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
@ObservedObject private var productManager: ProductManager
|
||||
|
@ -68,8 +66,39 @@ extension PaywallView {
|
|||
}
|
||||
}.themeAnimation(on: productManager.isRefreshingProducts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var productsSection: some View {
|
||||
private struct PurchaseRow: View {
|
||||
var product: SKProduct?
|
||||
|
||||
let title: String
|
||||
|
||||
let extra: String?
|
||||
|
||||
let action: () -> Void
|
||||
|
||||
let purchaseState: PaywallView.PurchaseView.PurchaseState?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
actionButton
|
||||
.padding(.bottom, 5)
|
||||
|
||||
extra.map {
|
||||
Text($0)
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
}.padding([.top, .bottom])
|
||||
}
|
||||
}
|
||||
|
||||
private typealias RowModel = (product: SKProduct, extra: String?)
|
||||
|
||||
// MARK: -
|
||||
|
||||
private extension PaywallView.PurchaseView {
|
||||
var productsSection: some View {
|
||||
Section {
|
||||
if !productManager.isRefreshingProducts {
|
||||
ForEach(productRowModels, id: \.product.productIdentifier, content: productRow)
|
||||
|
@ -84,7 +113,7 @@ extension PaywallView {
|
|||
}
|
||||
}
|
||||
|
||||
private func productRow(_ model: RowModel) -> some View {
|
||||
func productRow(_ model: RowModel) -> some View {
|
||||
PurchaseRow(
|
||||
product: model.product,
|
||||
title: model.product.localizedTitle,
|
||||
|
@ -96,7 +125,7 @@ extension PaywallView {
|
|||
)
|
||||
}
|
||||
|
||||
private var restoreRow: some View {
|
||||
var restoreRow: some View {
|
||||
PurchaseRow(
|
||||
title: L10n.Paywall.Items.Restore.title,
|
||||
extra: L10n.Paywall.Items.Restore.description,
|
||||
|
@ -105,10 +134,118 @@ extension PaywallView {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
private extension PaywallView.PurchaseView {
|
||||
var skFeature: SKProduct? {
|
||||
guard let feature = feature else {
|
||||
return nil
|
||||
}
|
||||
return productManager.product(withIdentifier: feature)
|
||||
}
|
||||
|
||||
extension PaywallView.PurchaseView {
|
||||
private func purchaseProduct(_ product: SKProduct) {
|
||||
var skPlatformVersion: SKProduct? {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
productManager.product(withIdentifier: .fullVersion_macOS)
|
||||
#else
|
||||
productManager.product(withIdentifier: .fullVersion_iOS)
|
||||
#endif
|
||||
}
|
||||
|
||||
// hide full version if already bought the other platform version
|
||||
var skFullVersion: SKProduct? {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
guard !productManager.hasPurchased(.fullVersion_iOS) else {
|
||||
return nil
|
||||
}
|
||||
#else
|
||||
guard !productManager.hasPurchased(.fullVersion_macOS) else {
|
||||
return nil
|
||||
}
|
||||
#endif
|
||||
return productManager.product(withIdentifier: .fullVersion)
|
||||
}
|
||||
|
||||
var platformVersionExtra: [String] {
|
||||
productManager.featureProducts(excluding: [
|
||||
.fullVersion,
|
||||
.fullVersion_iOS,
|
||||
.fullVersion_macOS
|
||||
]).map {
|
||||
$0.localizedTitle
|
||||
}.sorted {
|
||||
$0.lowercased() < $1.lowercased()
|
||||
}
|
||||
}
|
||||
|
||||
var fullVersionExtra: [String] {
|
||||
productManager.featureProducts(including: [
|
||||
.fullVersion_iOS,
|
||||
.fullVersion_macOS
|
||||
]).map {
|
||||
$0.localizedTitle
|
||||
}.sorted {
|
||||
$0.lowercased() < $1.lowercased()
|
||||
}
|
||||
}
|
||||
|
||||
var productRowModels: [RowModel] {
|
||||
var models: [RowModel] = []
|
||||
skPlatformVersion.map {
|
||||
let extra = platformVersionExtra.joined(separator: "\n")
|
||||
models.append(($0, extra))
|
||||
}
|
||||
skFullVersion.map {
|
||||
let extra = fullVersionExtra.joined(separator: "\n")
|
||||
models.append(($0, extra))
|
||||
}
|
||||
skFeature.map {
|
||||
models.append(($0, nil))
|
||||
}
|
||||
return models
|
||||
}
|
||||
}
|
||||
|
||||
private extension PurchaseRow {
|
||||
|
||||
@ViewBuilder
|
||||
var actionButton: some View {
|
||||
if let product = product {
|
||||
purchaseButton(product)
|
||||
} else {
|
||||
restoreButton
|
||||
}
|
||||
}
|
||||
|
||||
func purchaseButton(_ product: SKProduct) -> some View {
|
||||
HStack {
|
||||
Button(title, action: action)
|
||||
Spacer()
|
||||
if case .purchasing(let pending) = purchaseState, pending.productIdentifier == product.productIdentifier {
|
||||
ProgressView()
|
||||
} else {
|
||||
product.localizedPrice.map {
|
||||
Text($0)
|
||||
.themeSecondaryTextStyle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var restoreButton: some View {
|
||||
HStack {
|
||||
Button(title, action: action)
|
||||
Spacer()
|
||||
if case .restoring = purchaseState {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private extension PaywallView.PurchaseView {
|
||||
func purchaseProduct(_ product: SKProduct) {
|
||||
purchaseState = .purchasing(product)
|
||||
|
||||
productManager.purchase(product) {
|
||||
|
@ -135,7 +272,7 @@ extension PaywallView.PurchaseView {
|
|||
}
|
||||
}
|
||||
|
||||
private func restorePurchases() {
|
||||
func restorePurchases() {
|
||||
purchaseState = .restoring
|
||||
|
||||
productManager.restorePurchases {
|
||||
|
@ -154,131 +291,3 @@ extension PaywallView.PurchaseView {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PaywallView.PurchaseView {
|
||||
private var skFeature: SKProduct? {
|
||||
guard let feature = feature else {
|
||||
return nil
|
||||
}
|
||||
return productManager.product(withIdentifier: feature)
|
||||
}
|
||||
|
||||
private var skPlatformVersion: SKProduct? {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
productManager.product(withIdentifier: .fullVersion_macOS)
|
||||
#else
|
||||
productManager.product(withIdentifier: .fullVersion_iOS)
|
||||
#endif
|
||||
}
|
||||
|
||||
// hide full version if already bought the other platform version
|
||||
private var skFullVersion: SKProduct? {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
guard !productManager.hasPurchased(.fullVersion_iOS) else {
|
||||
return nil
|
||||
}
|
||||
#else
|
||||
guard !productManager.hasPurchased(.fullVersion_macOS) else {
|
||||
return nil
|
||||
}
|
||||
#endif
|
||||
return productManager.product(withIdentifier: .fullVersion)
|
||||
}
|
||||
|
||||
private var platformVersionExtra: [String] {
|
||||
productManager.featureProducts(excluding: [
|
||||
.fullVersion,
|
||||
.fullVersion_iOS,
|
||||
.fullVersion_macOS
|
||||
]).map {
|
||||
$0.localizedTitle
|
||||
}.sorted {
|
||||
$0.lowercased() < $1.lowercased()
|
||||
}
|
||||
}
|
||||
|
||||
private var fullVersionExtra: [String] {
|
||||
productManager.featureProducts(including: [
|
||||
.fullVersion_iOS,
|
||||
.fullVersion_macOS
|
||||
]).map {
|
||||
$0.localizedTitle
|
||||
}.sorted {
|
||||
$0.lowercased() < $1.lowercased()
|
||||
}
|
||||
}
|
||||
|
||||
private var productRowModels: [RowModel] {
|
||||
var models: [RowModel] = []
|
||||
skPlatformVersion.map {
|
||||
let extra = platformVersionExtra.joined(separator: "\n")
|
||||
models.append(($0, extra))
|
||||
}
|
||||
skFullVersion.map {
|
||||
let extra = fullVersionExtra.joined(separator: "\n")
|
||||
models.append(($0, extra))
|
||||
}
|
||||
skFeature.map {
|
||||
models.append(($0, nil))
|
||||
}
|
||||
return models
|
||||
}
|
||||
}
|
||||
|
||||
private struct PurchaseRow: View {
|
||||
var product: SKProduct?
|
||||
|
||||
let title: String
|
||||
|
||||
let extra: String?
|
||||
|
||||
let action: () -> Void
|
||||
|
||||
let purchaseState: PaywallView.PurchaseView.PurchaseState?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
actionButton
|
||||
.padding(.bottom, 5)
|
||||
|
||||
extra.map {
|
||||
Text($0)
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
}.padding([.top, .bottom])
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var actionButton: some View {
|
||||
if let product = product {
|
||||
purchaseButton(product)
|
||||
} else {
|
||||
restoreButton
|
||||
}
|
||||
}
|
||||
|
||||
private func purchaseButton(_ product: SKProduct) -> some View {
|
||||
HStack {
|
||||
Button(title, action: action)
|
||||
Spacer()
|
||||
if case .purchasing(let pending) = purchaseState, pending.productIdentifier == product.productIdentifier {
|
||||
ProgressView()
|
||||
} else {
|
||||
product.localizedPrice.map {
|
||||
Text($0)
|
||||
.themeSecondaryTextStyle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var restoreButton: some View {
|
||||
HStack {
|
||||
Button(title, action: action)
|
||||
Spacer()
|
||||
if case .restoring = purchaseState {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,14 +34,6 @@ extension ProfileView {
|
|||
|
||||
@Binding private var modalType: ModalType?
|
||||
|
||||
private var isEligibleForNetworkSettings: Bool {
|
||||
productManager.isEligible(forFeature: .networkSettings)
|
||||
}
|
||||
|
||||
private var isEligibleForTrustedNetworks: Bool {
|
||||
productManager.isEligible(forFeature: .trustedNetworks)
|
||||
}
|
||||
|
||||
init(currentProfile: ObservableProfile, modalType: Binding<ModalType?>) {
|
||||
productManager = .shared
|
||||
self.currentProfile = currentProfile
|
||||
|
@ -111,13 +103,25 @@ extension ProfileView {
|
|||
Text(L10n.Global.Strings.configuration)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var networkSettingsRow: some View {
|
||||
// MARK: -
|
||||
|
||||
private extension ProfileView.ConfigurationSection {
|
||||
var networkSettingsRow: some View {
|
||||
Label(L10n.NetworkSettings.title, systemImage: themeNetworkSettingsImage)
|
||||
}
|
||||
|
||||
private var onDemandRow: some View {
|
||||
var onDemandRow: some View {
|
||||
Label(L10n.OnDemand.title, systemImage: themeOnDemandImage)
|
||||
}
|
||||
|
||||
var isEligibleForNetworkSettings: Bool {
|
||||
productManager.isEligible(forFeature: .networkSettings)
|
||||
}
|
||||
|
||||
var isEligibleForTrustedNetworks: Bool {
|
||||
productManager.isEligible(forFeature: .trustedNetworks)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -46,10 +46,6 @@ extension ProfileView {
|
|||
|
||||
@ObservedObject private var currentProfile: ObservableProfile
|
||||
|
||||
private var header: Profile.Header {
|
||||
currentProfile.value.header
|
||||
}
|
||||
|
||||
@Binding private var modalType: ModalType?
|
||||
|
||||
@State private var isAlertPresented = false
|
||||
|
@ -78,94 +74,6 @@ extension ProfileView {
|
|||
message: alertMessage
|
||||
)
|
||||
}
|
||||
|
||||
private var mainView: some View {
|
||||
Menu {
|
||||
ReconnectButton()
|
||||
ShortcutsButton(
|
||||
modalType: $modalType
|
||||
)
|
||||
Divider()
|
||||
RenameButton(
|
||||
modalType: $modalType
|
||||
)
|
||||
DuplicateButton(
|
||||
header: header,
|
||||
setAsCurrent: true
|
||||
)
|
||||
uninstallVPNButton
|
||||
Divider()
|
||||
deleteProfileButton
|
||||
} label: {
|
||||
themeSettingsMenuImage.asSystemImage
|
||||
}
|
||||
}
|
||||
|
||||
private func alertActions(_ alertType: AlertType) -> some View {
|
||||
switch alertType {
|
||||
case .uninstallVPN:
|
||||
return Group {
|
||||
Button(role: .destructive, action: uninstallVPN) {
|
||||
Text(uninstallVPNTitle)
|
||||
}
|
||||
Button(role: .cancel) {
|
||||
} label: {
|
||||
Text(L10n.Global.Strings.cancel)
|
||||
}
|
||||
}
|
||||
|
||||
case .deleteProfile:
|
||||
return Group {
|
||||
Button(role: .destructive, action: removeProfile) {
|
||||
Text(deleteProfileTitle)
|
||||
}
|
||||
Button(role: .cancel) {
|
||||
} label: {
|
||||
Text(L10n.Global.Strings.cancel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func alertMessage(_ alertType: AlertType) -> some View {
|
||||
switch alertType {
|
||||
case .uninstallVPN:
|
||||
return Text(L10n.Profile.Alerts.UninstallVpn.message)
|
||||
|
||||
case .deleteProfile:
|
||||
return Text(L10n.Organizer.Alerts.RemoveProfile.message(header.name))
|
||||
}
|
||||
}
|
||||
|
||||
private var uninstallVPNButton: some View {
|
||||
Button {
|
||||
alertType = .uninstallVPN
|
||||
isAlertPresented = true
|
||||
} label: {
|
||||
Label(uninstallVPNTitle, systemImage: themeUninstallImage)
|
||||
}
|
||||
}
|
||||
|
||||
private var deleteProfileButton: some View {
|
||||
DestructiveButton {
|
||||
alertType = .deleteProfile
|
||||
isAlertPresented = true
|
||||
} label: {
|
||||
Label(deleteProfileTitle, systemImage: themeDeleteImage)
|
||||
}
|
||||
}
|
||||
|
||||
private func uninstallVPN() {
|
||||
Task { @MainActor in
|
||||
await vpnManager.uninstall()
|
||||
}
|
||||
}
|
||||
|
||||
private func removeProfile() {
|
||||
withAnimation {
|
||||
profileManager.removeProfiles(withIds: [header.id])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -198,10 +106,6 @@ extension ProfileView {
|
|||
_modalType = modalType
|
||||
}
|
||||
|
||||
private var isEligibleForSiri: Bool {
|
||||
productManager.isEligible(forFeature: .siriShortcuts)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
presentShortcutsOrPaywall()
|
||||
|
@ -209,16 +113,6 @@ extension ProfileView {
|
|||
Label(Unlocalized.Other.siri, systemImage: themeShortcutsImage)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentShortcutsOrPaywall() {
|
||||
|
||||
// eligibility: enter Siri shortcuts or present paywall
|
||||
if isEligibleForSiri {
|
||||
modalType = .shortcuts
|
||||
} else {
|
||||
modalType = .paywallShortcuts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RenameButton: View {
|
||||
|
@ -257,9 +151,129 @@ extension ProfileView {
|
|||
Label(L10n.Global.Strings.duplicate, systemImage: themeDuplicateImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func duplicateProfile(withId id: UUID) {
|
||||
// MARK: -
|
||||
|
||||
private extension ProfileView.MainMenu {
|
||||
var header: Profile.Header {
|
||||
currentProfile.value.header
|
||||
}
|
||||
|
||||
var mainView: some View {
|
||||
Menu {
|
||||
ProfileView.ReconnectButton()
|
||||
ProfileView.ShortcutsButton(
|
||||
modalType: $modalType
|
||||
)
|
||||
Divider()
|
||||
ProfileView.RenameButton(
|
||||
modalType: $modalType
|
||||
)
|
||||
ProfileView.DuplicateButton(
|
||||
header: header,
|
||||
setAsCurrent: true
|
||||
)
|
||||
uninstallVPNButton
|
||||
Divider()
|
||||
deleteProfileButton
|
||||
} label: {
|
||||
themeSettingsMenuImage.asSystemImage
|
||||
}
|
||||
}
|
||||
|
||||
func alertActions(_ alertType: AlertType) -> some View {
|
||||
switch alertType {
|
||||
case .uninstallVPN:
|
||||
return Group {
|
||||
Button(role: .destructive, action: uninstallVPN) {
|
||||
Text(uninstallVPNTitle)
|
||||
}
|
||||
Button(role: .cancel) {
|
||||
} label: {
|
||||
Text(L10n.Global.Strings.cancel)
|
||||
}
|
||||
}
|
||||
|
||||
case .deleteProfile:
|
||||
return Group {
|
||||
Button(role: .destructive, action: removeProfile) {
|
||||
Text(deleteProfileTitle)
|
||||
}
|
||||
Button(role: .cancel) {
|
||||
} label: {
|
||||
Text(L10n.Global.Strings.cancel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func alertMessage(_ alertType: AlertType) -> some View {
|
||||
switch alertType {
|
||||
case .uninstallVPN:
|
||||
return Text(L10n.Profile.Alerts.UninstallVpn.message)
|
||||
|
||||
case .deleteProfile:
|
||||
return Text(L10n.Organizer.Alerts.RemoveProfile.message(header.name))
|
||||
}
|
||||
}
|
||||
|
||||
var uninstallVPNButton: some View {
|
||||
Button {
|
||||
alertType = .uninstallVPN
|
||||
isAlertPresented = true
|
||||
} label: {
|
||||
Label(uninstallVPNTitle, systemImage: themeUninstallImage)
|
||||
}
|
||||
}
|
||||
|
||||
var deleteProfileButton: some View {
|
||||
DestructiveButton {
|
||||
alertType = .deleteProfile
|
||||
isAlertPresented = true
|
||||
} label: {
|
||||
Label(deleteProfileTitle, systemImage: themeDeleteImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ProfileView.ShortcutsButton {
|
||||
var isEligibleForSiri: Bool {
|
||||
productManager.isEligible(forFeature: .siriShortcuts)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private extension ProfileView.MainMenu {
|
||||
func uninstallVPN() {
|
||||
Task { @MainActor in
|
||||
await vpnManager.uninstall()
|
||||
}
|
||||
}
|
||||
|
||||
func removeProfile() {
|
||||
withAnimation {
|
||||
profileManager.removeProfiles(withIds: [header.id])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ProfileView.ShortcutsButton {
|
||||
func presentShortcutsOrPaywall() {
|
||||
|
||||
// eligibility: enter Siri shortcuts or present paywall
|
||||
if isEligibleForSiri {
|
||||
modalType = .shortcuts
|
||||
} else {
|
||||
modalType = .paywallShortcuts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ProfileView.DuplicateButton {
|
||||
func duplicateProfile(withId id: UUID) {
|
||||
profileManager.duplicateProfile(withId: id, setAsCurrent: setAsCurrent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,14 +32,14 @@ extension ProfileView {
|
|||
|
||||
@ObservedObject private var currentProfile: ObservableProfile
|
||||
|
||||
var profile: Profile {
|
||||
currentProfile.value
|
||||
}
|
||||
|
||||
@State private var isProviderLocationPresented = false
|
||||
|
||||
@State private var isRefreshingInfrastructure = false
|
||||
|
||||
var profile: Profile {
|
||||
currentProfile.value
|
||||
}
|
||||
|
||||
init(currentProfile: ObservableProfile) {
|
||||
providerManager = .shared
|
||||
self.currentProfile = currentProfile
|
||||
|
@ -55,9 +55,15 @@ extension ProfileView {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private extension ProfileView.ProviderSection {
|
||||
|
||||
@ViewBuilder
|
||||
private var mainView: some View {
|
||||
var mainView: some View {
|
||||
Section {
|
||||
NavigationLink(isActive: $isProviderLocationPresented) {
|
||||
ProviderLocationView(
|
||||
|
@ -107,7 +113,7 @@ extension ProfileView {
|
|||
}
|
||||
}
|
||||
|
||||
private var currentProviderFullName: String? {
|
||||
var currentProviderFullName: String? {
|
||||
guard let name = profile.header.providerName else {
|
||||
assertionFailure("Provider name accessed but profile is not a provider (isPlaceholder? \(profile.isPlaceholder))")
|
||||
return nil
|
||||
|
@ -119,7 +125,7 @@ extension ProfileView {
|
|||
return metadata.fullName
|
||||
}
|
||||
|
||||
private var currentProviderServerDescription: String? {
|
||||
var currentProviderServerDescription: String? {
|
||||
guard let server = profile.providerServer(providerManager) else {
|
||||
return nil
|
||||
}
|
||||
|
@ -130,22 +136,26 @@ extension ProfileView {
|
|||
}
|
||||
}
|
||||
|
||||
private var currentProviderCountryImage: Image? {
|
||||
var currentProviderCountryImage: Image? {
|
||||
guard let code = profile.providerServer(providerManager)?.countryCode else {
|
||||
return nil
|
||||
}
|
||||
return themeAssetsCountryImage(code).asAssetImage
|
||||
}
|
||||
|
||||
private var currentProviderPreset: String? {
|
||||
var currentProviderPreset: String? {
|
||||
providerManager.localizedPreset(forProfile: profile)
|
||||
}
|
||||
|
||||
private var lastInfrastructureUpdate: String? {
|
||||
var lastInfrastructureUpdate: String? {
|
||||
providerManager.localizedInfrastructureUpdate(forProfile: profile)
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshInfrastructure() {
|
||||
// MARK: -
|
||||
|
||||
private extension ProfileView.ProviderSection {
|
||||
func refreshInfrastructure() {
|
||||
isRefreshingInfrastructure = true
|
||||
Task { @MainActor in
|
||||
try? await providerManager.fetchRemoteProviderPublisher(forProfile: profile).async()
|
||||
|
@ -153,4 +163,3 @@ extension ProfileView {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -66,9 +66,15 @@ extension ProfileView {
|
|||
message: alertOverwriteMessage
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private extension ProfileView.RenameView {
|
||||
|
||||
@ViewBuilder
|
||||
private func alertOverwriteActions() -> some View {
|
||||
func alertOverwriteActions() -> some View {
|
||||
Button(role: .destructive) {
|
||||
commitRenaming(force: true)
|
||||
} label: {
|
||||
|
@ -80,19 +86,23 @@ extension ProfileView {
|
|||
}
|
||||
}
|
||||
|
||||
private func alertOverwriteMessage() -> some View {
|
||||
func alertOverwriteMessage() -> some View {
|
||||
Text(L10n.AddProfile.Shared.Alerts.Overwrite.message)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadCurrentName() {
|
||||
// MARK: -
|
||||
|
||||
private extension ProfileView.RenameView {
|
||||
func loadCurrentName() {
|
||||
newName = currentProfile.value.header.name
|
||||
}
|
||||
|
||||
private func commitRenaming() {
|
||||
func commitRenaming() {
|
||||
commitRenaming(force: false)
|
||||
}
|
||||
|
||||
private func commitRenaming(force: Bool) {
|
||||
func commitRenaming(force: Bool) {
|
||||
let name = newName.stripped
|
||||
|
||||
guard !name.isEmpty else {
|
||||
|
@ -113,4 +123,3 @@ extension ProfileView {
|
|||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,18 +34,6 @@ extension ProfileView {
|
|||
|
||||
@Binding private var modalType: ModalType?
|
||||
|
||||
private var interactiveProfile: Binding<Profile?> {
|
||||
.init {
|
||||
modalType == .interactiveAccount ? profile : nil
|
||||
} set: {
|
||||
modalType = $0 != nil ? .interactiveAccount : nil
|
||||
}
|
||||
}
|
||||
|
||||
private var isActiveProfile: Bool {
|
||||
profileManager.isActiveProfile(profile.id)
|
||||
}
|
||||
|
||||
init(profile: Profile, modalType: Binding<ModalType?>) {
|
||||
profileManager = .shared
|
||||
self.profile = profile
|
||||
|
@ -63,8 +51,25 @@ extension ProfileView {
|
|||
.xxxThemeTruncation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var toggleView: some View {
|
||||
// MARK: -
|
||||
|
||||
private extension ProfileView.VPNSection {
|
||||
var interactiveProfile: Binding<Profile?> {
|
||||
.init {
|
||||
modalType == .interactiveAccount ? profile : nil
|
||||
} set: {
|
||||
modalType = $0 != nil ? .interactiveAccount : nil
|
||||
}
|
||||
}
|
||||
|
||||
var isActiveProfile: Bool {
|
||||
profileManager.isActiveProfile(profile.id)
|
||||
}
|
||||
|
||||
var toggleView: some View {
|
||||
VPNToggle(
|
||||
profile: profile,
|
||||
interactiveProfile: interactiveProfile,
|
||||
|
@ -72,7 +77,7 @@ extension ProfileView {
|
|||
)
|
||||
}
|
||||
|
||||
private var statusView: some View {
|
||||
var statusView: some View {
|
||||
HStack {
|
||||
Text(L10n.Profile.Items.ConnectionStatus.caption)
|
||||
Spacer()
|
||||
|
@ -81,4 +86,3 @@ extension ProfileView {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -47,14 +47,6 @@ struct ProfileView: View {
|
|||
|
||||
@ObservedObject private var currentProfile: ObservableProfile
|
||||
|
||||
private var isLoading: Bool {
|
||||
currentProfile.isLoading
|
||||
}
|
||||
|
||||
private var isExisting: Bool {
|
||||
!currentProfile.value.isPlaceholder
|
||||
}
|
||||
|
||||
@State private var modalType: ModalType?
|
||||
|
||||
init() {
|
||||
|
@ -83,12 +75,24 @@ struct ProfileView: View {
|
|||
.navigationTitle(title)
|
||||
.themeSecondaryView()
|
||||
}
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
// MARK: -
|
||||
|
||||
private extension ProfileView {
|
||||
var isLoading: Bool {
|
||||
currentProfile.isLoading
|
||||
}
|
||||
|
||||
var isExisting: Bool {
|
||||
!currentProfile.value.isPlaceholder
|
||||
}
|
||||
|
||||
var title: String {
|
||||
currentProfile.name
|
||||
}
|
||||
|
||||
private var mainView: some View {
|
||||
var mainView: some View {
|
||||
List {
|
||||
if !isLoading {
|
||||
VPNSection(
|
||||
|
@ -109,7 +113,7 @@ struct ProfileView: View {
|
|||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func presentedModal(_ modalType: ModalType) -> some View {
|
||||
func presentedModal(_ modalType: ModalType) -> some View {
|
||||
switch modalType {
|
||||
case .interactiveAccount:
|
||||
NavigationView {
|
||||
|
|
|
@ -31,35 +31,16 @@ struct ProviderLocationView: View, ProviderProfileAvailability {
|
|||
|
||||
@ObservedObject private var currentProfile: ObservableProfile
|
||||
|
||||
var profile: Profile {
|
||||
currentProfile.value
|
||||
}
|
||||
|
||||
private let isEditable: Bool
|
||||
|
||||
private var providerName: ProviderName {
|
||||
guard let name = currentProfile.value.header.providerName else {
|
||||
assertionFailure("Not a provider")
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
private var vpnProtocol: VPNProtocolType {
|
||||
currentProfile.value.currentVPNProtocol
|
||||
}
|
||||
|
||||
@Binding private var selectedServer: ProviderServer?
|
||||
|
||||
@Binding private var favoriteLocationIds: Set<String>?
|
||||
|
||||
@AppStorage(AppPreference.isShowingFavorites.key) private var isShowingFavorites = false
|
||||
|
||||
private var isShowingEmptyFavorites: Bool {
|
||||
guard isShowingFavorites else {
|
||||
return false
|
||||
}
|
||||
return favoriteLocationIds?.isEmpty ?? true
|
||||
var profile: Profile {
|
||||
currentProfile.value
|
||||
}
|
||||
|
||||
// XXX: do not escape mutating 'self', use constant providerManager
|
||||
|
@ -108,139 +89,6 @@ struct ProviderLocationView: View, ProviderProfileAvailability {
|
|||
}
|
||||
}.navigationTitle(L10n.Provider.Location.title)
|
||||
}
|
||||
|
||||
private var mainView: some View {
|
||||
// FIXME: layout, restore ScrollViewReader, but content inside it is not re-rendered on isShowingFavorites
|
||||
// ScrollViewReader { scrollProxy in
|
||||
List {
|
||||
if !isShowingEmptyFavorites {
|
||||
categoriesView
|
||||
} else {
|
||||
emptyFavoritesSection
|
||||
}
|
||||
// }.onAppear {
|
||||
// scrollToSelectedLocation(scrollProxy)
|
||||
}
|
||||
// }
|
||||
}
|
||||
|
||||
private var categoriesView: some View {
|
||||
ForEach(categories, content: categorySection)
|
||||
}
|
||||
|
||||
private func categorySection(_ category: ProviderCategory) -> some View {
|
||||
Section {
|
||||
ForEach(filteredLocations(for: category)) { location in
|
||||
if isEditable {
|
||||
locationRow(location)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
favoriteActions(location)
|
||||
}
|
||||
} else {
|
||||
locationRow(location)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
!category.name.isEmpty ? Text(category.name) : nil
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func locationRow(_ location: ProviderLocation) -> some View {
|
||||
if let onlyServer = location.onlyServer {
|
||||
singleServerRow(location, onlyServer)
|
||||
} else if profile.providerRandomizesServer ?? false {
|
||||
singleServerRow(location, nil)
|
||||
} else {
|
||||
multipleServersRow(location)
|
||||
}
|
||||
}
|
||||
|
||||
private func multipleServersRow(_ location: ProviderLocation) -> some View {
|
||||
NavigationLink(destination: {
|
||||
ServerListView(
|
||||
location: location,
|
||||
selectedServer: $selectedServer
|
||||
).navigationTitle(location.localizedCountry)
|
||||
}, label: {
|
||||
LocationRow(
|
||||
location: location,
|
||||
selectedLocationId: selectedServer?.locationId
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private func singleServerRow(_ location: ProviderLocation, _ server: ProviderServer?) -> some View {
|
||||
Button {
|
||||
selectedServer = server ?? location.servers?.randomElement()
|
||||
} label: {
|
||||
LocationRow(
|
||||
location: location,
|
||||
selectedLocationId: selectedServer?.locationId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyFavoritesSection: some View {
|
||||
Section {
|
||||
} footer: {
|
||||
Text(L10n.Provider.Location.Sections.EmptyFavorites.footer)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 15, *)
|
||||
private func favoriteActions(_ location: ProviderLocation) -> some View {
|
||||
Button {
|
||||
withAnimation {
|
||||
toggleFavoriteLocation(location)
|
||||
}
|
||||
} label: {
|
||||
themeFavoriteActionImage(!isFavoriteLocation(location)).asSystemImage
|
||||
}.themePrimaryTintStyle()
|
||||
}
|
||||
}
|
||||
|
||||
extension ProviderLocationView {
|
||||
private func server(withId serverId: String) -> ProviderServer? {
|
||||
providerManager.server(withId: serverId)
|
||||
}
|
||||
|
||||
private var categories: [ProviderCategory] {
|
||||
providerManager.categories(providerName, vpnProtocol: vpnProtocol)
|
||||
.filter {
|
||||
!filteredLocations(for: $0).isEmpty
|
||||
}.sorted()
|
||||
}
|
||||
|
||||
private func filteredLocations(for category: ProviderCategory) -> [ProviderLocation] {
|
||||
let locations: [ProviderLocation]
|
||||
if isShowingFavorites {
|
||||
locations = category.locations.filter {
|
||||
favoriteLocationIds?.contains($0.id) ?? false
|
||||
}
|
||||
} else {
|
||||
locations = category.locations
|
||||
}
|
||||
return locations.sorted()
|
||||
}
|
||||
|
||||
private func isFavoriteLocation(_ location: ProviderLocation) -> Bool {
|
||||
favoriteLocationIds?.contains(location.id) ?? false
|
||||
}
|
||||
|
||||
private func toggleFavoriteLocation(_ location: ProviderLocation) {
|
||||
if !isFavoriteLocation(location) {
|
||||
if favoriteLocationIds == nil {
|
||||
favoriteLocationIds = [location.id]
|
||||
} else {
|
||||
favoriteLocationIds?.insert(location.id)
|
||||
}
|
||||
} else {
|
||||
favoriteLocationIds?.remove(location.id)
|
||||
}
|
||||
// may trigger view updates?
|
||||
// pp_log.debug("New favorite locations: \(favoriteLocationIds ?? [])")
|
||||
}
|
||||
}
|
||||
|
||||
extension ProviderLocationView {
|
||||
|
@ -293,21 +141,183 @@ extension ProviderLocationView {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var servers: [ProviderServer] {
|
||||
// MARK: -
|
||||
|
||||
private extension ProviderLocationView {
|
||||
var providerName: ProviderName {
|
||||
guard let name = currentProfile.value.header.providerName else {
|
||||
assertionFailure("Not a provider")
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
var vpnProtocol: VPNProtocolType {
|
||||
currentProfile.value.currentVPNProtocol
|
||||
}
|
||||
|
||||
var mainView: some View {
|
||||
// FIXME: layout, restore ScrollViewReader, but content inside it is not re-rendered on isShowingFavorites
|
||||
// ScrollViewReader { scrollProxy in
|
||||
List {
|
||||
if !isShowingEmptyFavorites {
|
||||
categoriesView
|
||||
} else {
|
||||
emptyFavoritesSection
|
||||
}
|
||||
// }.onAppear {
|
||||
// scrollToSelectedLocation(scrollProxy)
|
||||
}
|
||||
// }
|
||||
}
|
||||
|
||||
var categoriesView: some View {
|
||||
ForEach(categories, content: categorySection)
|
||||
}
|
||||
|
||||
func categorySection(_ category: ProviderCategory) -> some View {
|
||||
Section {
|
||||
ForEach(filteredLocations(for: category)) { location in
|
||||
if isEditable {
|
||||
locationRow(location)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
favoriteActions(location)
|
||||
}
|
||||
} else {
|
||||
locationRow(location)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
!category.name.isEmpty ? Text(category.name) : nil
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func locationRow(_ location: ProviderLocation) -> some View {
|
||||
if let onlyServer = location.onlyServer {
|
||||
singleServerRow(location, onlyServer)
|
||||
} else if profile.providerRandomizesServer ?? false {
|
||||
singleServerRow(location, nil)
|
||||
} else {
|
||||
multipleServersRow(location)
|
||||
}
|
||||
}
|
||||
|
||||
func multipleServersRow(_ location: ProviderLocation) -> some View {
|
||||
NavigationLink(destination: {
|
||||
ServerListView(
|
||||
location: location,
|
||||
selectedServer: $selectedServer
|
||||
).navigationTitle(location.localizedCountry)
|
||||
}, label: {
|
||||
LocationRow(
|
||||
location: location,
|
||||
selectedLocationId: selectedServer?.locationId
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func singleServerRow(_ location: ProviderLocation, _ server: ProviderServer?) -> some View {
|
||||
Button {
|
||||
selectedServer = server ?? location.servers?.randomElement()
|
||||
} label: {
|
||||
LocationRow(
|
||||
location: location,
|
||||
selectedLocationId: selectedServer?.locationId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 15, *)
|
||||
func favoriteActions(_ location: ProviderLocation) -> some View {
|
||||
Button {
|
||||
withAnimation {
|
||||
toggleFavoriteLocation(location)
|
||||
}
|
||||
} label: {
|
||||
themeFavoriteActionImage(!isFavoriteLocation(location)).asSystemImage
|
||||
}.themePrimaryTintStyle()
|
||||
}
|
||||
|
||||
var emptyFavoritesSection: some View {
|
||||
Section {
|
||||
} footer: {
|
||||
Text(L10n.Provider.Location.Sections.EmptyFavorites.footer)
|
||||
}
|
||||
}
|
||||
|
||||
var isShowingEmptyFavorites: Bool {
|
||||
guard isShowingFavorites else {
|
||||
return false
|
||||
}
|
||||
return favoriteLocationIds?.isEmpty ?? true
|
||||
}
|
||||
}
|
||||
|
||||
private extension ProviderLocationView {
|
||||
func server(withId serverId: String) -> ProviderServer? {
|
||||
providerManager.server(withId: serverId)
|
||||
}
|
||||
|
||||
var categories: [ProviderCategory] {
|
||||
providerManager.categories(providerName, vpnProtocol: vpnProtocol)
|
||||
.filter {
|
||||
!filteredLocations(for: $0).isEmpty
|
||||
}.sorted()
|
||||
}
|
||||
|
||||
func filteredLocations(for category: ProviderCategory) -> [ProviderLocation] {
|
||||
let locations: [ProviderLocation]
|
||||
if isShowingFavorites {
|
||||
locations = category.locations.filter {
|
||||
favoriteLocationIds?.contains($0.id) ?? false
|
||||
}
|
||||
} else {
|
||||
locations = category.locations
|
||||
}
|
||||
return locations.sorted()
|
||||
}
|
||||
|
||||
func isFavoriteLocation(_ location: ProviderLocation) -> Bool {
|
||||
favoriteLocationIds?.contains(location.id) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
private extension ProviderLocationView.ServerListView {
|
||||
var servers: [ProviderServer] {
|
||||
providerManager.servers(forLocation: location).sorted()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private extension ProviderLocationView {
|
||||
func toggleFavoriteLocation(_ location: ProviderLocation) {
|
||||
if !isFavoriteLocation(location) {
|
||||
if favoriteLocationIds == nil {
|
||||
favoriteLocationIds = [location.id]
|
||||
} else {
|
||||
favoriteLocationIds?.insert(location.id)
|
||||
}
|
||||
} else {
|
||||
favoriteLocationIds?.remove(location.id)
|
||||
}
|
||||
// may trigger view updates?
|
||||
// pp_log.debug("New favorite locations: \(favoriteLocationIds ?? [])")
|
||||
}
|
||||
}
|
||||
|
||||
extension ProviderLocationView {
|
||||
private func scrollToSelectedLocation(_ proxy: ScrollViewProxy) {
|
||||
private extension ProviderLocationView {
|
||||
func scrollToSelectedLocation(_ proxy: ScrollViewProxy) {
|
||||
proxy.maybeScrollTo(selectedServer?.locationId)
|
||||
}
|
||||
}
|
||||
|
||||
extension ProviderLocationView.ServerListView {
|
||||
private func scrollToSelectedServer(_ proxy: ScrollViewProxy) {
|
||||
private extension ProviderLocationView.ServerListView {
|
||||
func scrollToSelectedServer(_ proxy: ScrollViewProxy) {
|
||||
proxy.maybeScrollTo(selectedServer?.id)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -67,8 +67,12 @@ struct ProviderPresetView: View {
|
|||
ForEach(availablePresets, id: \.id, content: presetSection)
|
||||
}.navigationTitle(L10n.Provider.Preset.title)
|
||||
}
|
||||
}
|
||||
|
||||
private func presetSection(_ preset: ProviderServer.Preset) -> some View {
|
||||
// MARK: -
|
||||
|
||||
private extension ProviderPresetView {
|
||||
func presetSection(_ preset: ProviderServer.Preset) -> some View {
|
||||
Section {
|
||||
Button {
|
||||
selectedPreset = preset
|
||||
|
@ -92,13 +96,13 @@ struct ProviderPresetView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func presetSelectionRow(_ preset: ProviderServer.Preset) -> some View {
|
||||
func presetSelectionRow(_ preset: ProviderServer.Preset) -> some View {
|
||||
Text(preset.comment)
|
||||
.withTrailingCheckmark(when: preset.id == selectedPreset?.id)
|
||||
}
|
||||
|
||||
// some providers (e.g. NordVPN) have specific presets based on selected server
|
||||
private var availablePresets: [ProviderServer.Preset] {
|
||||
var availablePresets: [ProviderServer.Preset] {
|
||||
server?.presets?.sorted() ?? []
|
||||
}
|
||||
}
|
||||
|
|
|
@ -53,14 +53,18 @@ struct SettingsView: View {
|
|||
}.themeSecondaryView()
|
||||
.navigationTitle(L10n.Settings.title)
|
||||
}
|
||||
}
|
||||
|
||||
private var preferencesSection: some View {
|
||||
// MARK: -
|
||||
|
||||
private extension SettingsView {
|
||||
var preferencesSection: some View {
|
||||
Section {
|
||||
Toggle(L10n.Settings.Items.LocksInBackground.caption, isOn: $locksInBackground)
|
||||
}
|
||||
}
|
||||
|
||||
private var aboutSection: some View {
|
||||
var aboutSection: some View {
|
||||
Section {
|
||||
NavigationLink {
|
||||
AboutView()
|
||||
|
|
|
@ -71,8 +71,13 @@ extension ShortcutsView {
|
|||
}
|
||||
}.navigationTitle(L10n.Shortcuts.Add.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var addConnectView: some View {
|
||||
// MARK: -
|
||||
|
||||
private extension ShortcutsView.AddView {
|
||||
var addConnectView: some View {
|
||||
Button(L10n.Shortcuts.Add.Items.Connect.caption) {
|
||||
if target.isProvider {
|
||||
pendingProfile.value = target
|
||||
|
@ -83,7 +88,7 @@ extension ShortcutsView {
|
|||
}
|
||||
}
|
||||
|
||||
private var hiddenProviderLocationLink: some View {
|
||||
var hiddenProviderLocationLink: some View {
|
||||
NavigationLink("", isActive: $isPresentingProviderLocation) {
|
||||
ProviderLocationView(
|
||||
currentProfile: pendingProfile,
|
||||
|
@ -92,11 +97,8 @@ extension ShortcutsView {
|
|||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ShortcutsView.AddView {
|
||||
private var isProviderLocationPresented: Binding<Bool> {
|
||||
var isProviderLocationPresented: Binding<Bool> {
|
||||
.init {
|
||||
isPresentingProviderLocation
|
||||
} set: {
|
||||
|
@ -106,14 +108,18 @@ extension ShortcutsView.AddView {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addConnect(_ header: Profile.Header) {
|
||||
// MARK: -
|
||||
|
||||
private extension ShortcutsView.AddView {
|
||||
func addConnect(_ header: Profile.Header) {
|
||||
pendingShortcut = INShortcut(intent: IntentDispatcher.intentConnect(
|
||||
header: header
|
||||
))
|
||||
}
|
||||
|
||||
private func addMoveToPendingProfile() {
|
||||
func addMoveToPendingProfile() {
|
||||
let header = pendingProfile.value.header
|
||||
guard let server = pendingProfile.value.providerServer(providerManager) else {
|
||||
return
|
||||
|
@ -125,31 +131,31 @@ extension ShortcutsView.AddView {
|
|||
))
|
||||
}
|
||||
|
||||
private func addEnableVPN() {
|
||||
func addEnableVPN() {
|
||||
addShortcut(with: IntentDispatcher.intentEnable())
|
||||
}
|
||||
|
||||
private func addDisableVPN() {
|
||||
func addDisableVPN() {
|
||||
addShortcut(with: IntentDispatcher.intentDisable())
|
||||
}
|
||||
|
||||
private func addTrustWiFi() {
|
||||
func addTrustWiFi() {
|
||||
addShortcut(with: IntentDispatcher.intentTrustWiFi())
|
||||
}
|
||||
|
||||
private func addUntrustWiFi() {
|
||||
func addUntrustWiFi() {
|
||||
addShortcut(with: IntentDispatcher.intentUntrustWiFi())
|
||||
}
|
||||
|
||||
private func addTrustCellular() {
|
||||
func addTrustCellular() {
|
||||
addShortcut(with: IntentDispatcher.intentTrustCellular())
|
||||
}
|
||||
|
||||
private func addUntrustCellular() {
|
||||
func addUntrustCellular() {
|
||||
addShortcut(with: IntentDispatcher.intentUntrustCellular())
|
||||
}
|
||||
|
||||
private func addShortcut(with intent: INIntent) {
|
||||
func addShortcut(with intent: INIntent) {
|
||||
guard let shortcut = INShortcut(intent: intent) else {
|
||||
fatalError("Unable to create INShortcut, intent '\(intent.description)' not exposed by app?")
|
||||
}
|
||||
|
|
|
@ -78,8 +78,12 @@ struct ShortcutsView: View {
|
|||
.navigationTitle(Unlocalized.Other.siri)
|
||||
.themeSecondaryView()
|
||||
}
|
||||
}
|
||||
|
||||
private var shortcutsSection: some View {
|
||||
// MARK: -
|
||||
|
||||
private extension ShortcutsView {
|
||||
var shortcutsSection: some View {
|
||||
Section {
|
||||
ForEach(relevantShortcuts, content: rowView)
|
||||
} header: {
|
||||
|
@ -87,13 +91,13 @@ struct ShortcutsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private var relevantShortcuts: [Shortcut] {
|
||||
var relevantShortcuts: [Shortcut] {
|
||||
intentsManager.shortcuts.values.filter {
|
||||
$0.isRelevant(to: target)
|
||||
}.sorted()
|
||||
}
|
||||
|
||||
private var addSection: some View {
|
||||
var addSection: some View {
|
||||
Section {
|
||||
NavigationLink(isActive: $isNavigationPresented) {
|
||||
AddView(
|
||||
|
@ -109,7 +113,7 @@ struct ShortcutsView: View {
|
|||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func presentedModal(_ modalType: ModalType) -> some View {
|
||||
func presentedModal(_ modalType: ModalType) -> some View {
|
||||
switch modalType {
|
||||
case .edit(let shortcut):
|
||||
IntentEditView(
|
||||
|
@ -125,7 +129,7 @@ struct ShortcutsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func rowView(forShortcut vs: Shortcut) -> some View {
|
||||
func rowView(forShortcut vs: Shortcut) -> some View {
|
||||
Button {
|
||||
presentEditShortcut(vs)
|
||||
} label: {
|
||||
|
@ -133,7 +137,7 @@ struct ShortcutsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private var delegatingPendingShortcut: Binding<INShortcut?> {
|
||||
var delegatingPendingShortcut: Binding<INShortcut?> {
|
||||
.init {
|
||||
pendingShortcut
|
||||
} set: {
|
||||
|
@ -143,15 +147,6 @@ struct ShortcutsView: View {
|
|||
presentAddShortcut(pendingShortcut)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentEditShortcut(_ shortcut: Shortcut) {
|
||||
modalType = .edit(shortcut: shortcut)
|
||||
}
|
||||
|
||||
private func presentAddShortcut(_ shortcut: INShortcut) {
|
||||
isNavigationPresented = false
|
||||
modalType = .add(shortcut: shortcut)
|
||||
}
|
||||
}
|
||||
|
||||
private extension Shortcut {
|
||||
|
@ -168,3 +163,16 @@ private extension Shortcut {
|
|||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private extension ShortcutsView {
|
||||
func presentEditShortcut(_ shortcut: Shortcut) {
|
||||
modalType = .edit(shortcut: shortcut)
|
||||
}
|
||||
|
||||
func presentAddShortcut(_ shortcut: INShortcut) {
|
||||
isNavigationPresented = false
|
||||
modalType = .add(shortcut: shortcut)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,8 +39,12 @@ struct VPNStatusText: View {
|
|||
var body: some View {
|
||||
Text(statusText)
|
||||
}
|
||||
}
|
||||
|
||||
private var statusText: String {
|
||||
// MARK: -
|
||||
|
||||
private extension VPNStatusText {
|
||||
var statusText: String {
|
||||
currentVPNState.localizedStatusDescription(
|
||||
isActiveProfile: isActiveProfile,
|
||||
withErrors: true,
|
||||
|
|
|
@ -41,34 +41,6 @@ struct VPNToggle: View {
|
|||
|
||||
private let rateLimit: Int
|
||||
|
||||
private var isEnabled: Binding<Bool> {
|
||||
.init {
|
||||
isActiveProfile && currentVPNState.isEnabled && !shouldPromptForAccount
|
||||
} set: { newValue in
|
||||
guard !shouldPromptForAccount else {
|
||||
interactiveProfile = profile
|
||||
return
|
||||
}
|
||||
guard newValue else {
|
||||
disableVPN()
|
||||
return
|
||||
}
|
||||
enableVPN()
|
||||
}
|
||||
}
|
||||
|
||||
private var isActiveProfile: Bool {
|
||||
profileManager.isActiveProfile(profile.id)
|
||||
}
|
||||
|
||||
private var shouldPromptForAccount: Bool {
|
||||
profile.account.authenticationMethod == .interactive && (currentVPNState.vpnStatus == .disconnecting || currentVPNState.vpnStatus == .disconnected)
|
||||
}
|
||||
|
||||
private var isEligibleForSiri: Bool {
|
||||
productManager.isEligible(forFeature: .siriShortcuts)
|
||||
}
|
||||
|
||||
@State private var canToggle = true
|
||||
|
||||
init(profile: Profile, interactiveProfile: Binding<Profile?>, rateLimit: Int) {
|
||||
|
@ -86,8 +58,44 @@ struct VPNToggle: View {
|
|||
.disabled(!canToggle)
|
||||
.themeAnimation(on: currentVPNState.isEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
private func enableVPN() {
|
||||
// MARK: -
|
||||
|
||||
private extension VPNToggle {
|
||||
var isEnabled: Binding<Bool> {
|
||||
.init {
|
||||
isActiveProfile && currentVPNState.isEnabled && !shouldPromptForAccount
|
||||
} set: { newValue in
|
||||
guard !shouldPromptForAccount else {
|
||||
interactiveProfile = profile
|
||||
return
|
||||
}
|
||||
guard newValue else {
|
||||
disableVPN()
|
||||
return
|
||||
}
|
||||
enableVPN()
|
||||
}
|
||||
}
|
||||
|
||||
var isActiveProfile: Bool {
|
||||
profileManager.isActiveProfile(profile.id)
|
||||
}
|
||||
|
||||
var shouldPromptForAccount: Bool {
|
||||
profile.account.authenticationMethod == .interactive && (currentVPNState.vpnStatus == .disconnecting || currentVPNState.vpnStatus == .disconnected)
|
||||
}
|
||||
|
||||
var isEligibleForSiri: Bool {
|
||||
productManager.isEligible(forFeature: .siriShortcuts)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private extension VPNToggle {
|
||||
func enableVPN() {
|
||||
Task { @MainActor in
|
||||
canToggle = false
|
||||
await Task.maybeWait(forMilliseconds: rateLimit)
|
||||
|
@ -104,7 +112,7 @@ struct VPNToggle: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func disableVPN() {
|
||||
func disableVPN() {
|
||||
Task { @MainActor in
|
||||
canToggle = false
|
||||
await vpnManager.disable()
|
||||
|
@ -112,7 +120,7 @@ struct VPNToggle: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func donateIntents(withProfile profile: Profile) {
|
||||
func donateIntents(withProfile profile: Profile) {
|
||||
|
||||
// eligibility: donate intents if eligible for Siri
|
||||
guard isEligibleForSiri else {
|
||||
|
|
Loading…
Reference in New Issue