diff --git a/Passepartout/App/Views/AboutView.swift b/Passepartout/App/Views/AboutView.swift index f9798da3..3770694d 100644 --- a/Passepartout/App/Views/AboutView.swift +++ b/Passepartout/App/Views/AboutView.swift @@ -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) } diff --git a/Passepartout/App/Views/AccountView.swift b/Passepartout/App/Views/AccountView.swift index ef822fdf..483575f5 100644 --- a/Passepartout/App/Views/AccountView.swift +++ b/Passepartout/App/Views/AccountView.swift @@ -58,7 +58,7 @@ struct AccountView: View { var body: some View { List { - // TODO: interactive, re-enable after fixing +// TODO: interactive, re-enable after fixing // Section { // // TODO: interactive, l10n // themeTextPicker(L10n.Global.Strings.authentication, selection: $liveAccount.authenticationMethod ?? .persistent, values: [ @@ -83,7 +83,7 @@ struct AccountView: View { .withLeadingText(L10n.Account.Items.Password.caption) } - // TODO: interactive, scan QR code + // TODO: interactive, scan QR code case .totp: themeSecureField(L10n.Account.Items.Password.placeholder, text: $liveAccount.password, contentType: .oneTimeCode) .withLeadingText(L10n.Account.Items.Seed.caption) @@ -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 ) - } - } - - private func openGuidanceURL(_ url: URL) { - URL.open(url) + }.navigationTitle(L10n.Account.title) } } -// MARK: Provider +// MARK: - -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) + } +} diff --git a/Passepartout/App/Views/AddHostView+Name.swift b/Passepartout/App/Views/AddHostView+Name.swift index 50b4e350..61c51463 100644 --- a/Passepartout/App/Views/AddHostView+Name.swift +++ b/Passepartout/App/Views/AddHostView+Name.swift @@ -42,10 +42,6 @@ extension AddHostView { @State private var isEnteringCredentials = false - private var isComplete: Bool { - !viewModel.processedProfile.isPlaceholder - } - init( url: URL, deletingURLOnSuccess: Bool, @@ -84,123 +80,136 @@ extension AddHostView { .navigationTitle(L10n.AddProfile.Shared.title) .themeSecondaryView() } + } +} - @ViewBuilder - private var mainView: some View { - AddProfileView.ProfileNameSection( - profileName: $viewModel.profileName, - errorMessage: viewModel.errorMessage - ) { - processProfile(replacingExisting: false) - }.onAppear { - viewModel.presetName(withURL: url) - }.disabled(isComplete) +// MARK: - - if !isComplete { - if viewModel.requiresPassphrase { - encryptionSection - } - let headers = profileManager.headers.sorted() - if !headers.isEmpty { - AddProfileView.ExistingProfilesSection( - headers: headers, - profileName: $viewModel.profileName - ) - } - } else { - completeSection +private extension AddHostView.NameView { + + @ViewBuilder + var mainView: some View { + AddProfileView.ProfileNameSection( + profileName: $viewModel.profileName, + errorMessage: viewModel.errorMessage + ) { + processProfile(replacingExisting: false) + }.onAppear { + viewModel.presetName(withURL: url) + }.disabled(isComplete) + + if !isComplete { + if viewModel.requiresPassphrase { + encryptionSection } - } - - private var encryptionSection: some View { - Section { - SecureField(L10n.AddProfile.Host.Sections.Encryption.footer, text: $viewModel.encryptionPassphrase) { - processProfile(replacingExisting: false) - } - } header: { - Text(L10n.Global.Strings.encryption) - } - } - - private var completeSection: some View { - Section { - Text(Unlocalized.Network.url) - .withTrailingText(url.lastPathComponent) - viewModel.processedProfile.vpnProtocols.first.map { - Text(L10n.Global.Strings.protocol) - .withTrailingText($0.description) - } - } header: { - Text(L10n.AddProfile.Shared.title) - } footer: { - themeErrorMessage(viewModel.errorMessage) - } - } - - private var hiddenAccountLink: some View { - NavigationLink("", isActive: $isEnteringCredentials) { - AddProfileView.AccountWrapperView( - profile: $viewModel.processedProfile, - bindings: bindings + let headers = profileManager.headers.sorted() + if !headers.isEmpty { + AddProfileView.ExistingProfilesSection( + headers: headers, + profileName: $viewModel.profileName ) } + } else { + completeSection } + } - private var nextString: String { - if !viewModel.processedProfile.isPlaceholder { - return viewModel.processedProfile.requiresCredentials ? L10n.Global.Strings.next : L10n.Global.Strings.save - } else { - return L10n.Global.Strings.next + var encryptionSection: some View { + Section { + SecureField(L10n.AddProfile.Host.Sections.Encryption.footer, text: $viewModel.encryptionPassphrase) { + processProfile(replacingExisting: false) } + } header: { + Text(L10n.Global.Strings.encryption) } + } - private func requestResourcePermissions() { - _ = url.startAccessingSecurityScopedResource() - } - - private func dropResourcePermissions() { - url.stopAccessingSecurityScopedResource() - } - - @ViewBuilder - private func alertOverwriteActions() -> some View { - Button(role: .destructive) { - processProfile(replacingExisting: true) - } label: { - Text(L10n.Global.Strings.ok) - } - Button(role: .cancel) { - } label: { - Text(L10n.Global.Strings.cancel) + var completeSection: some View { + Section { + Text(Unlocalized.Network.url) + .withTrailingText(url.lastPathComponent) + viewModel.processedProfile.vpnProtocols.first.map { + Text(L10n.Global.Strings.protocol) + .withTrailingText($0.description) } + } header: { + Text(L10n.AddProfile.Shared.title) + } footer: { + themeErrorMessage(viewModel.errorMessage) } + } - private func alertOverwriteMessage() -> some View { - Text(L10n.AddProfile.Shared.Alerts.Overwrite.message) - } - - private func processProfile(replacingExisting: Bool) { - viewModel.processURL( - url, - with: profileManager, - replacingExisting: replacingExisting, - deletingURLOnSuccess: deletingURLOnSuccess + var hiddenAccountLink: some View { + NavigationLink("", isActive: $isEnteringCredentials) { + AddProfileView.AccountWrapperView( + profile: $viewModel.processedProfile, + bindings: bindings ) } + } - private func saveProfile() { - let result = viewModel.addProcessedProfile(to: profileManager) - guard result else { - return - } + var nextString: String { + if !viewModel.processedProfile.isPlaceholder { + return viewModel.processedProfile.requiresCredentials ? L10n.Global.Strings.next : L10n.Global.Strings.save + } else { + return L10n.Global.Strings.next + } + } - let profile = viewModel.processedProfile - if profile.requiresCredentials { - isEnteringCredentials = true - } else { - bindings.isPresented = false - profileManager.didCreateProfile.send(profile) - } + @ViewBuilder + func alertOverwriteActions() -> some View { + Button(role: .destructive) { + processProfile(replacingExisting: true) + } label: { + Text(L10n.Global.Strings.ok) + } + Button(role: .cancel) { + } label: { + Text(L10n.Global.Strings.cancel) + } + } + + func alertOverwriteMessage() -> some View { + Text(L10n.AddProfile.Shared.Alerts.Overwrite.message) + } + + 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, + replacingExisting: replacingExisting, + deletingURLOnSuccess: deletingURLOnSuccess + ) + } + + func saveProfile() { + let result = viewModel.addProcessedProfile(to: profileManager) + guard result else { + return + } + + let profile = viewModel.processedProfile + if profile.requiresCredentials { + isEnteringCredentials = true + } else { + bindings.isPresented = false + profileManager.didCreateProfile.send(profile) } } } diff --git a/Passepartout/App/Views/AddProfileMenu.swift b/Passepartout/App/Views/AddProfileMenu.swift index 459a243d..43525e4e 100644 --- a/Passepartout/App/Views/AddProfileMenu.swift +++ b/Passepartout/App/Views/AddProfileMenu.swift @@ -61,11 +61,11 @@ struct AddProfileMenu: View { Button(action: presentHostFileImporter) { Label(L10n.Menu.Contextual.AddProfile.fromFiles, systemImage: themeHostFilesImage) } -// Button { -// // TODO: add profile from text -// } label: { -// Label(L10n.Organizer.Menus.AddProfile.fromText, systemImage: themeHostTextImage) -// } +// Button { +// // TODO: add profile from text +// } label: { +// Label(L10n.Organizer.Menus.AddProfile.fromText, systemImage: themeHostTextImage) +// } if let urls = importedURLs, !urls.isEmpty { Divider() ForEach(urls, id: \.absoluteString, content: importedURLRow) @@ -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 { + var isModalPresented: Binding { .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 // diff --git a/Passepartout/App/Views/AddProviderView+Name.swift b/Passepartout/App/Views/AddProviderView+Name.swift index eb8a880e..af2a701f 100644 --- a/Passepartout/App/Views/AddProviderView+Name.swift +++ b/Passepartout/App/Views/AddProviderView+Name.swift @@ -84,50 +84,58 @@ extension AddProviderView { message: alertOverwriteMessage ).navigationTitle(providerMetadata.fullName) } + } +} - private var hiddenAccountLink: some View { - NavigationLink("", isActive: $isEnteringCredentials) { - AddProfileView.AccountWrapperView( - profile: $profile, - bindings: bindings - ) - } - } +// MARK: - - @ViewBuilder - private func alertOverwriteActions() -> some View { - Button(role: .destructive) { - saveProfile(replacingExisting: true) - } label: { - Text(L10n.Global.Strings.ok) - } - Button(role: .cancel) { - } label: { - Text(L10n.Global.Strings.cancel) - } - } - - private func alertOverwriteMessage() -> some View { - Text(L10n.AddProfile.Shared.Alerts.Overwrite.message) - } - - private func saveProfile(replacingExisting: Bool) { - let addedProfile = viewModel.addProfile( - profile, - to: profileManager, - replacingExisting: replacingExisting +private extension AddProviderView.NameView { + var hiddenAccountLink: some View { + NavigationLink("", isActive: $isEnteringCredentials) { + AddProfileView.AccountWrapperView( + profile: $profile, + bindings: bindings ) - guard let addedProfile = addedProfile else { - return - } - profile = addedProfile + } + } - if profile.requiresCredentials { - isEnteringCredentials = true - } else { - bindings.isPresented = false - profileManager.didCreateProfile.send(profile) - } + @ViewBuilder + func alertOverwriteActions() -> some View { + Button(role: .destructive) { + saveProfile(replacingExisting: true) + } label: { + Text(L10n.Global.Strings.ok) + } + Button(role: .cancel) { + } label: { + Text(L10n.Global.Strings.cancel) + } + } + + func alertOverwriteMessage() -> some View { + Text(L10n.AddProfile.Shared.Alerts.Overwrite.message) + } +} + +// MARK: - + +private extension AddProviderView.NameView { + func saveProfile(replacingExisting: Bool) { + let addedProfile = viewModel.addProfile( + profile, + to: profileManager, + replacingExisting: replacingExisting + ) + guard let addedProfile = addedProfile else { + return + } + profile = addedProfile + + if profile.requiresCredentials { + isEnteringCredentials = true + } else { + bindings.isPresented = false + profileManager.didCreateProfile.send(profile) } } } diff --git a/Passepartout/App/Views/AddProviderView.swift b/Passepartout/App/Views/AddProviderView.swift index 7f48a1ac..f6cccfa3 100644 --- a/Passepartout/App/Views/AddProviderView.swift +++ b/Passepartout/App/Views/AddProviderView.swift @@ -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 = [] - providers.forEach { - $0.supportedVPNProtocols.forEach { - protos.insert($0) - } - } - return protos.sorted() - } - var body: some View { ZStack { ForEach(providers, id: \.navigationId, content: hiddenProviderLink) @@ -80,10 +63,14 @@ struct AddProviderView: View { PaywallView(isPresented: $viewModel.isPaywallPresented) }.themeGlobal() }.navigationTitle(L10n.AddProfile.Shared.title) - .themeSecondaryView() + .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) + .disabled(viewModel.isUpdatingIndex) } - // eligibility: select or purchase provider - private func presentOrPurchaseProvider(_ metadata: ProviderMetadata) { - guard productManager.isEligible(forProvider: metadata.name) else { - viewModel.presentPaywall() - return + var providers: [ProviderMetadata] { + providerManager.allProviders() + .filter { + $0.supportedVPNProtocols.contains(viewModel.selectedVPNProtocol) + }.sorted() + } + + var availableVPNProtocols: [VPNProtocolType] { + var protos: Set = [] + providers.forEach { + $0.supportedVPNProtocols.forEach { + protos.insert($0) + } } - viewModel.selectProvider(metadata, providerManager) - } - - private func onErrorMessage(_ message: String?, _ scrollProxy: ScrollViewProxy) { - guard message != nil else { - return - } - scrollToErrorMessage(scrollProxy) - } -} - -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) + } +} diff --git a/Passepartout/App/Views/DebugLogView.swift b/Passepartout/App/Views/DebugLogView.swift index 46faa0ff..8aa4f69a 100644 --- a/Passepartout/App/Views/DebugLogView.swift +++ b/Passepartout/App/Views/DebugLogView.swift @@ -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) } } diff --git a/Passepartout/App/Views/DiagnosticsView+OpenVPN.swift b/Passepartout/App/Views/DiagnosticsView+OpenVPN.swift index ed2a2aa9..67f6cf2b 100644 --- a/Passepartout/App/Views/DiagnosticsView+OpenVPN.swift +++ b/Passepartout/App/Views/DiagnosticsView+OpenVPN.swift @@ -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,75 +81,77 @@ extension DiagnosticsView { message: alertMessage ) } - - private func alertActions(_ alertType: AlertType) -> some View { - Button(role: .cancel) { - } label: { - Text(L10n.Global.Strings.ok) - } - } - - private func alertMessage(_ alertType: AlertType) -> some View { - switch alertType { - case .emailNotConfigured: - return Text(L10n.Global.Messages.emailNotConfigured) - } - } - - private var serverConfigurationSection: some View { - Section { - let cfg = currentServerConfiguration - NavigationLink(L10n.Diagnostics.Items.ServerConfiguration.caption) { - cfg.map { - EndpointAdvancedView.OpenVPNView( - builder: .constant($0), - isReadonly: true, - isServerPushed: true - ).navigationTitle(L10n.Diagnostics.Items.ServerConfiguration.caption) - } - }.disabled(cfg == nil) - } - } - - private var debugLogSection: some View { - Section { - DebugLogSection(appLogURL: appLogURL, tunnelLogURL: tunnelLogURL) - Toggle(L10n.Diagnostics.Items.MasksPrivateData.caption, isOn: $vpnManager.masksPrivateData) - } header: { - Text(L10n.DebugLog.title) - } footer: { - Text(L10n.Diagnostics.Sections.DebugLog.footer) - } - } - - private var issueReporterSection: some View { - Section { - Button(L10n.Diagnostics.Items.ReportIssue.caption, action: presentReportIssue) - } - } - - private func reportIssueView() -> some View { - let logURL = vpnManager.debugLogURL(forProtocol: vpnProtocol) - var metadata: ProviderMetadata? - var lastUpdate: Date? - if let name = providerName { - metadata = providerManager.provider(withName: name) - lastUpdate = providerManager.lastUpdate(name, vpnProtocol: vpnProtocol) - } - - return ReportIssueView( - isPresented: $isReportingIssue, - vpnProtocol: vpnProtocol, - logURL: logURL, - providerMetadata: metadata, - lastUpdate: lastUpdate - ) - } } } -extension DiagnosticsView.OpenVPNView { - private var currentServerConfiguration: OpenVPN.ConfigurationBuilder? { +// MARK: - + +private extension DiagnosticsView.OpenVPNView { + func alertActions(_ alertType: AlertType) -> some View { + Button(role: .cancel) { + } label: { + Text(L10n.Global.Strings.ok) + } + } + + func alertMessage(_ alertType: AlertType) -> some View { + switch alertType { + case .emailNotConfigured: + return Text(L10n.Global.Messages.emailNotConfigured) + } + } + + var serverConfigurationSection: some View { + Section { + let cfg = currentServerConfiguration + NavigationLink(L10n.Diagnostics.Items.ServerConfiguration.caption) { + cfg.map { + EndpointAdvancedView.OpenVPNView( + builder: .constant($0), + isReadonly: true, + isServerPushed: true + ).navigationTitle(L10n.Diagnostics.Items.ServerConfiguration.caption) + } + }.disabled(cfg == nil) + } + } + + var debugLogSection: some View { + Section { + DiagnosticsView.DebugLogSection(appLogURL: appLogURL, tunnelLogURL: tunnelLogURL) + Toggle(L10n.Diagnostics.Items.MasksPrivateData.caption, isOn: $vpnManager.masksPrivateData) + } header: { + Text(L10n.DebugLog.title) + } footer: { + Text(L10n.Diagnostics.Sections.DebugLog.footer) + } + } + + var issueReporterSection: some View { + Section { + Button(L10n.Diagnostics.Items.ReportIssue.caption, action: presentReportIssue) + } + } + + func reportIssueView() -> some View { + let logURL = vpnManager.debugLogURL(forProtocol: vpnProtocol) + var metadata: ProviderMetadata? + var lastUpdate: Date? + if let name = providerName { + metadata = providerManager.provider(withName: name) + lastUpdate = providerManager.lastUpdate(name, vpnProtocol: vpnProtocol) + } + + return ReportIssueView( + isPresented: $isReportingIssue, + vpnProtocol: vpnProtocol, + logURL: logURL, + providerMetadata: metadata, + lastUpdate: lastUpdate + ) + } + + 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()) diff --git a/Passepartout/App/Views/DiagnosticsView+WireGuard.swift b/Passepartout/App/Views/DiagnosticsView+WireGuard.swift index f3bca660..74cec185 100644 --- a/Passepartout/App/Views/DiagnosticsView+WireGuard.swift +++ b/Passepartout/App/Views/DiagnosticsView+WireGuard.swift @@ -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) } } diff --git a/Passepartout/App/Views/DiagnosticsView.swift b/Passepartout/App/Views/DiagnosticsView.swift index 83c436e8..8e9960f1 100644 --- a/Passepartout/App/Views/DiagnosticsView.swift +++ b/Passepartout/App/Views/DiagnosticsView.swift @@ -60,33 +60,37 @@ extension DiagnosticsView { appLink tunnelLink } - - private var appLink: some View { - navigationLink( - withTitle: L10n.Diagnostics.Items.AppLog.title, - url: appLogURL, - refreshInterval: nil - ) - } - - private var tunnelLink: some View { - navigationLink( - withTitle: Unlocalized.VPN.vpn, - url: tunnelLogURL, - refreshInterval: refreshInterval - ) - } - - private func navigationLink(withTitle title: String, url: URL?, refreshInterval: TimeInterval?) -> some View { - NavigationLink(title) { - url.map { - DebugLogView( - title: title, - url: $0, - refreshInterval: refreshInterval - ) - } - }.disabled(url == nil) - } + } +} + +// MARK: - + +private extension DiagnosticsView.DebugLogSection { + var appLink: some View { + navigationLink( + withTitle: L10n.Diagnostics.Items.AppLog.title, + url: appLogURL, + refreshInterval: nil + ) + } + + var tunnelLink: some View { + navigationLink( + withTitle: Unlocalized.VPN.vpn, + url: tunnelLogURL, + refreshInterval: refreshInterval + ) + } + + func navigationLink(withTitle title: String, url: URL?, refreshInterval: TimeInterval?) -> some View { + NavigationLink(title) { + url.map { + DebugLogView( + title: title, + url: $0, + refreshInterval: refreshInterval + ) + } + }.disabled(url == nil) } } diff --git a/Passepartout/App/Views/DonateView.swift b/Passepartout/App/Views/DonateView.swift index f04a266f..5b6640d5 100644 --- a/Passepartout/App/Views/DonateView.swift +++ b/Passepartout/App/Views/DonateView.swift @@ -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) { + func handlePurchaseResult(_ result: Result) { 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 - } - } -} diff --git a/Passepartout/App/Views/EndpointAdvancedView+OpenVPN.swift b/Passepartout/App/Views/EndpointAdvancedView+OpenVPN.swift index e4c5ebcd..bbb77c30 100644 --- a/Passepartout/App/Views/EndpointAdvancedView+OpenVPN.swift +++ b/Passepartout/App/Views/EndpointAdvancedView+OpenVPN.swift @@ -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 { diff --git a/Passepartout/App/Views/EndpointAdvancedView+WireGuard.swift b/Passepartout/App/Views/EndpointAdvancedView+WireGuard.swift index 347944a1..300ba735 100644 --- a/Passepartout/App/Views/EndpointAdvancedView+WireGuard.swift +++ b/Passepartout/App/Views/EndpointAdvancedView+WireGuard.swift @@ -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) diff --git a/Passepartout/App/Views/EndpointView+OpenVPN.swift b/Passepartout/App/Views/EndpointView+OpenVPN.swift index 983ab461..54c17fd2 100644 --- a/Passepartout/App/Views/EndpointView+OpenVPN.swift +++ b/Passepartout/App/Views/EndpointView+OpenVPN.swift @@ -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) } } diff --git a/Passepartout/App/Views/EndpointView+WireGuard.swift b/Passepartout/App/Views/EndpointView+WireGuard.swift index 1cf1196d..7693addc 100644 --- a/Passepartout/App/Views/EndpointView+WireGuard.swift +++ b/Passepartout/App/Views/EndpointView+WireGuard.swift @@ -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) { diff --git a/Passepartout/App/Views/InteractiveConnectionView.swift b/Passepartout/App/Views/InteractiveConnectionView.swift index daf16427..d06e5229 100644 --- a/Passepartout/App/Views/InteractiveConnectionView.swift +++ b/Passepartout/App/Views/InteractiveConnectionView.swift @@ -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) } diff --git a/Passepartout/App/Views/NetworkSettingsView.swift b/Passepartout/App/Views/NetworkSettingsView.swift index d70a0ade..2541abf2 100644 --- a/Passepartout/App/Views/NetworkSettingsView.swift +++ b/Passepartout/App/Views/NetworkSettingsView.swift @@ -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 } + } +} diff --git a/Passepartout/App/Views/OnDemandView+SSID.swift b/Passepartout/App/Views/OnDemandView+SSID.swift index 68d3b1dc..d3f17001 100644 --- a/Passepartout/App/Views/OnDemandView+SSID.swift +++ b/Passepartout/App/Views/OnDemandView+SSID.swift @@ -44,47 +44,40 @@ extension OnDemandView { Text(L10n.Global.Strings.add) } } + } +} - private func mapElements(elements: [IdentifiableString]) -> [IdentifiableString] { - elements - .filter { !$0.string.isEmpty } - .sorted { $0.string.lowercased() < $1.string.lowercased() } - } +// MARK: - - private func ssidRow(callback: EditableTextFieldCallback) -> some View { - Group { - if callback.isNewElement { +private extension OnDemandView.SSIDList { + func mapElements(elements: [IdentifiableString]) -> [IdentifiableString] { + elements + .filter { !$0.string.isEmpty } + .sorted { $0.string.lowercased() < $1.string.lowercased() } + } + + func ssidRow(callback: EditableTextFieldCallback) -> some View { + Group { + if callback.isNewElement { + ssidField(callback: callback) + } else { + Toggle(isOn: isSSIDOn(callback.text.wrappedValue)) { ssidField(callback: callback) - } else { - Toggle(isOn: isSSIDOn(callback.text.wrappedValue)) { - ssidField(callback: callback) - } - } - } - } - - private func ssidField(callback: EditableTextFieldCallback) -> some View { - TextField( - Unlocalized.Network.ssid, - text: callback.text, - onEditingChanged: callback.onEditingChanged, - onCommit: callback.onCommit - ).themeValidSSID(callback.text.wrappedValue) - } - - private func requestSSID(_ text: Binding) { - 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]> { + func ssidField(callback: EditableTextFieldCallback) -> some View { + TextField( + Unlocalized.Network.ssid, + text: callback.text, + onEditingChanged: callback.onEditingChanged, + onCommit: callback.onCommit + ).themeValidSSID(callback.text.wrappedValue) + } + + var allSSIDs: Binding<[String]> { .init { Array(withSSIDs.keys) } set: { newValue in @@ -104,7 +97,7 @@ extension OnDemandView.SSIDList { } } - private var onSSIDs: Binding> { + var onSSIDs: Binding> { .init { Set(withSSIDs.filter { $0.value @@ -130,7 +123,7 @@ extension OnDemandView.SSIDList { } } - private func isSSIDOn(_ ssid: String) -> Binding { + func isSSIDOn(_ ssid: String) -> Binding { .init { withSSIDs[ssid] ?? false } set: { @@ -138,3 +131,16 @@ extension OnDemandView.SSIDList { } } } + +// MARK: - + +private extension OnDemandView.SSIDList { + func requestSSID(_ text: Binding) { + Task { @MainActor in + let ssid = try await reader.currentSSID() + if !withSSIDs.keys.contains(ssid) { + text.wrappedValue = ssid + } + } + } +} diff --git a/Passepartout/App/Views/OnDemandView.swift b/Passepartout/App/Views/OnDemandView.swift index b23b995d..2d598350 100644 --- a/Passepartout/App/Views/OnDemandView.swift +++ b/Passepartout/App/Views/OnDemandView.swift @@ -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,21 +62,23 @@ 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) } header: { // TODO: on-demand, restore when "trusted networks" -> "on-demand" -// Text(L10n.Profile.Sections.Trusted.header) + // Text(L10n.Profile.Sections.Trusted.header) } Section { SSIDList(withSSIDs: $onDemand.withSSIDs) @@ -90,7 +88,7 @@ extension OnDemandView { Toggle(L10n.OnDemand.Items.Ethernet.caption, isOn: $onDemand.withEthernetNetwork) } header: { // TODO: on-demand, restore when "trusted networks" -> "on-demand" -// Text(L10n.Profile.Sections.Trusted.header) + // Text(L10n.Profile.Sections.Trusted.header) } Section { SSIDList(withSSIDs: $onDemand.withSSIDs) @@ -100,7 +98,7 @@ extension OnDemandView { SSIDList(withSSIDs: $onDemand.withSSIDs) } header: { // TODO: on-demand, restore when "trusted networks" -> "on-demand" -// Text(L10n.Profile.Sections.Trusted.header) + // Text(L10n.Profile.Sections.Trusted.header) } } Section { @@ -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 } diff --git a/Passepartout/App/Views/OrganizerView+ProfileRow.swift b/Passepartout/App/Views/OrganizerView+ProfileRow.swift index 6d8579d9..765623b1 100644 --- a/Passepartout/App/Views/OrganizerView+ProfileRow.swift +++ b/Passepartout/App/Views/OrganizerView+ProfileRow.swift @@ -34,21 +34,6 @@ extension OrganizerView { @Binding private var modalType: ModalType? - private var interactiveProfile: Binding { - .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) { self.profile = profile self.isActiveProfile = isActiveProfile @@ -77,3 +62,22 @@ extension OrganizerView { } } } + +// MARK: - + +private extension OrganizerView.ProfileRow { + var interactiveProfile: Binding { + .init { + if case .interactiveAccount(let profile) = modalType { + return profile + } + return nil + } set: { + if let profile = $0 { + modalType = .interactiveAccount(profile: profile) + } else { + modalType = nil + } + } + } +} diff --git a/Passepartout/App/Views/OrganizerView+Profiles.swift b/Passepartout/App/Views/OrganizerView+Profiles.swift index 4816d98f..ffaa5728 100644 --- a/Passepartout/App/Views/OrganizerView+Profiles.swift +++ b/Passepartout/App/Views/OrganizerView+Profiles.swift @@ -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,26 +73,117 @@ extension OrganizerView { duplicateButton deleteButton } + } +} - private var reconnectButton: some View { - ProfileView.ReconnectButton() - } +// MARK: - - private var duplicateButton: some View { - ProfileView.DuplicateButton( - header: header, - setAsCurrent: false - ) - } +private extension OrganizerView.ProfilesList { + var mainView: some View { + List { + if profileManager.hasProfiles { - private var deleteButton: some View { - DestructiveButton { - withAnimation { - profileManager.removeProfiles(withIds: [header.id]) + // 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 } - } label: { - Label(L10n.Global.Strings.delete, systemImage: themeDeleteImage) } + }.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() + } + + var duplicateButton: some View { + ProfileView.DuplicateButton( + header: header, + setAsCurrent: false + ) + } + + var deleteButton: some View { + DestructiveButton { + withAnimation { + profileManager.removeProfiles(withIds: [header.id]) + } + } label: { + Label(L10n.Global.Strings.delete, systemImage: themeDeleteImage) + } + } +} + +// 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) } } } diff --git a/Passepartout/App/Views/OrganizerView+Scene.swift b/Passepartout/App/Views/OrganizerView+Scene.swift index 8593c069..79f360e5 100644 --- a/Passepartout/App/Views/OrganizerView+Scene.swift +++ b/Passepartout/App/Views/OrganizerView+Scene.swift @@ -51,31 +51,36 @@ extension OrganizerView { .hidden() .onAppear(perform: onAppear) } + } +} - @MainActor - private func onAppear() { - guard didHandleSubreddit else { - alertType = .subscribeReddit - isAlertPresented = true - return - } +// MARK: - - // - // FIXME: iPad portrait/compact, loading current profile adds ProfileView() twice - // - // - from MainView - // - from NavigationLink destination in OrganizerView - // - // can notice becase "Back" needs to be tapped twice to show sidebar - // workaround: set active profile but do not load as current (prevents NavigationLink activation) - // - guard isFirstLaunch else { - return - } - isFirstLaunch = false - if themeIdiom != .phone && !themeIsiPadPortrait, let activeProfileId = ProfileManager.shared.activeProfileId { - ProfileManager.shared.currentProfileId = activeProfileId - } +private extension OrganizerView.SceneView { + + @MainActor + func onAppear() { + guard didHandleSubreddit else { + alertType = .subscribeReddit + isAlertPresented = true + return + } + + // + // FIXME: iPad portrait/compact, loading current profile adds ProfileView() twice + // + // - from MainView + // - from NavigationLink destination in OrganizerView + // + // can notice becase "Back" needs to be tapped twice to show sidebar + // workaround: set active profile but do not load as current (prevents NavigationLink activation) + // + guard isFirstLaunch else { + return + } + isFirstLaunch = false + if themeIdiom != .phone && !themeIsiPadPortrait, let activeProfileId = ProfileManager.shared.activeProfileId { + ProfileManager.shared.currentProfileId = activeProfileId } } } diff --git a/Passepartout/App/Views/OrganizerView.swift b/Passepartout/App/Views/OrganizerView.swift index 9e6dc14f..ad728686 100644 --- a/Passepartout/App/Views/OrganizerView.swift +++ b/Passepartout/App/Views/OrganizerView.swift @@ -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) + } +} diff --git a/Passepartout/App/Views/PaywallView+Purchase.swift b/Passepartout/App/Views/PaywallView+Purchase.swift index b2ebdc61..675dc6f0 100644 --- a/Passepartout/App/Views/PaywallView+Purchase.swift +++ b/Passepartout/App/Views/PaywallView+Purchase.swift @@ -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,47 +66,186 @@ extension PaywallView { } }.themeAnimation(on: productManager.isRefreshingProducts) } + } +} - private var productsSection: some View { - Section { - if !productManager.isRefreshingProducts { - ForEach(productRowModels, id: \.product.productIdentifier, content: productRow) - } else { - ProgressView() +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) + } else { + ProgressView() + } + restoreRow + } header: { + Text(L10n.Paywall.title) + } footer: { + Text(L10n.Paywall.Sections.Products.footer) + } + } + + func productRow(_ model: RowModel) -> some View { + PurchaseRow( + product: model.product, + title: model.product.localizedTitle, + extra: model.extra, + action: { + purchaseProduct(model.product) + }, + purchaseState: purchaseState + ) + } + + var restoreRow: some View { + PurchaseRow( + title: L10n.Paywall.Items.Restore.title, + extra: L10n.Paywall.Items.Restore.description, + action: restorePurchases, + purchaseState: purchaseState + ) + } +} + +private extension PaywallView.PurchaseView { + var skFeature: SKProduct? { + guard let feature = feature else { + return nil + } + return productManager.product(withIdentifier: feature) + } + + 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() } - restoreRow - } header: { - Text(L10n.Paywall.title) - } footer: { - Text(L10n.Paywall.Sections.Products.footer) } } + } - private func productRow(_ model: RowModel) -> some View { - PurchaseRow( - product: model.product, - title: model.product.localizedTitle, - extra: model.extra, - action: { - purchaseProduct(model.product) - }, - purchaseState: purchaseState - ) - } - - private var restoreRow: some View { - PurchaseRow( - title: L10n.Paywall.Items.Restore.title, - extra: L10n.Paywall.Items.Restore.description, - action: restorePurchases, - purchaseState: purchaseState - ) + var restoreButton: some View { + HStack { + Button(title, action: action) + Spacer() + if case .restoring = purchaseState { + ProgressView() + } } } } -extension PaywallView.PurchaseView { - private func purchaseProduct(_ product: SKProduct) { +// 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() - } - } - } -} diff --git a/Passepartout/App/Views/ProfileView+Configuration.swift b/Passepartout/App/Views/ProfileView+Configuration.swift index fa63981e..e562e068 100644 --- a/Passepartout/App/Views/ProfileView+Configuration.swift +++ b/Passepartout/App/Views/ProfileView+Configuration.swift @@ -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) { productManager = .shared self.currentProfile = currentProfile @@ -111,13 +103,25 @@ extension ProfileView { Text(L10n.Global.Strings.configuration) } } - - private var networkSettingsRow: some View { - Label(L10n.NetworkSettings.title, systemImage: themeNetworkSettingsImage) - } - - private var onDemandRow: some View { - Label(L10n.OnDemand.title, systemImage: themeOnDemandImage) - } + } +} + +// MARK: - + +private extension ProfileView.ConfigurationSection { + var networkSettingsRow: some View { + Label(L10n.NetworkSettings.title, systemImage: themeNetworkSettingsImage) + } + + var onDemandRow: some View { + Label(L10n.OnDemand.title, systemImage: themeOnDemandImage) + } + + var isEligibleForNetworkSettings: Bool { + productManager.isEligible(forFeature: .networkSettings) + } + + var isEligibleForTrustedNetworks: Bool { + productManager.isEligible(forFeature: .trustedNetworks) } } diff --git a/Passepartout/App/Views/ProfileView+MainMenu.swift b/Passepartout/App/Views/ProfileView+MainMenu.swift index ed6b4ece..50e6c066 100644 --- a/Passepartout/App/Views/ProfileView+MainMenu.swift +++ b/Passepartout/App/Views/ProfileView+MainMenu.swift @@ -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) { - profileManager.duplicateProfile(withId: id, setAsCurrent: setAsCurrent) +// 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) + } +} diff --git a/Passepartout/App/Views/ProfileView+Provider.swift b/Passepartout/App/Views/ProfileView+Provider.swift index 9b1616f7..fa79b91e 100644 --- a/Passepartout/App/Views/ProfileView+Provider.swift +++ b/Passepartout/App/Views/ProfileView+Provider.swift @@ -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,102 +55,111 @@ extension ProfileView { } } } + } +} - @ViewBuilder - private var mainView: some View { - Section { - NavigationLink(isActive: $isProviderLocationPresented) { - ProviderLocationView( - currentProfile: currentProfile, - isEditable: true, - isPresented: $isProviderLocationPresented - ) - } label: { - HStack { - Label(L10n.Provider.Location.title, systemImage: themeProviderLocationImage) - Spacer() - currentProviderCountryImage - } - } - } header: { - currentProviderFullName.map(Text.init) - } footer: { - currentProviderServerDescription.map(Text.init) - } - Section { - Toggle( - L10n.Profile.Items.RandomizesServer.caption, - isOn: $currentProfile.value.providerRandomizesServer ?? false +// MARK: - + +private extension ProfileView.ProviderSection { + + @ViewBuilder + var mainView: some View { + Section { + NavigationLink(isActive: $isProviderLocationPresented) { + ProviderLocationView( + currentProfile: currentProfile, + isEditable: true, + isPresented: $isProviderLocationPresented ) - Toggle( - L10n.Profile.Items.VpnResolvesHostname.caption, - isOn: $currentProfile.value.networkSettings.resolvesHostname - ) - } footer: { - Text(L10n.Profile.Sections.VpnResolvesHostname.footer) - .xxxThemeTruncation() - } - Section { - NavigationLink { - ProviderPresetView(currentProfile: currentProfile) - } label: { - Label(L10n.Provider.Preset.title, systemImage: themeProviderPresetImage) - .withTrailingText(currentProviderPreset) - } - Button(action: refreshInfrastructure) { - Text(L10n.Profile.Items.Provider.Refresh.caption) - }.withTrailingProgress(when: isRefreshingInfrastructure) - } footer: { - lastInfrastructureUpdate.map { - Text(L10n.Profile.Sections.ProviderInfrastructure.footer($0)) + } label: { + HStack { + Label(L10n.Provider.Location.title, systemImage: themeProviderLocationImage) + Spacer() + currentProviderCountryImage } } + } header: { + currentProviderFullName.map(Text.init) + } footer: { + currentProviderServerDescription.map(Text.init) } - - private 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 + Section { + Toggle( + L10n.Profile.Items.RandomizesServer.caption, + isOn: $currentProfile.value.providerRandomizesServer ?? false + ) + Toggle( + L10n.Profile.Items.VpnResolvesHostname.caption, + isOn: $currentProfile.value.networkSettings.resolvesHostname + ) + } footer: { + Text(L10n.Profile.Sections.VpnResolvesHostname.footer) + .xxxThemeTruncation() + } + Section { + NavigationLink { + ProviderPresetView(currentProfile: currentProfile) + } label: { + Label(L10n.Provider.Preset.title, systemImage: themeProviderPresetImage) + .withTrailingText(currentProviderPreset) } - guard let metadata = providerManager.provider(withName: name) else { - assertionFailure("Provider metadata not found") - return nil - } - return metadata.fullName - } - - private var currentProviderServerDescription: String? { - guard let server = profile.providerServer(providerManager) else { - return nil - } - if currentProfile.value.providerRandomizesServer ?? false { - return server.localizedCountry(withCategory: true) - } else { - return server.localizedLongDescription(withCategory: true) - } - } - - private var currentProviderCountryImage: Image? { - guard let code = profile.providerServer(providerManager)?.countryCode else { - return nil - } - return themeAssetsCountryImage(code).asAssetImage - } - - private var currentProviderPreset: String? { - providerManager.localizedPreset(forProfile: profile) - } - - private var lastInfrastructureUpdate: String? { - providerManager.localizedInfrastructureUpdate(forProfile: profile) - } - - private func refreshInfrastructure() { - isRefreshingInfrastructure = true - Task { @MainActor in - try? await providerManager.fetchRemoteProviderPublisher(forProfile: profile).async() - isRefreshingInfrastructure = false + Button(action: refreshInfrastructure) { + Text(L10n.Profile.Items.Provider.Refresh.caption) + }.withTrailingProgress(when: isRefreshingInfrastructure) + } footer: { + lastInfrastructureUpdate.map { + Text(L10n.Profile.Sections.ProviderInfrastructure.footer($0)) } } } + + 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 + } + guard let metadata = providerManager.provider(withName: name) else { + assertionFailure("Provider metadata not found") + return nil + } + return metadata.fullName + } + + var currentProviderServerDescription: String? { + guard let server = profile.providerServer(providerManager) else { + return nil + } + if currentProfile.value.providerRandomizesServer ?? false { + return server.localizedCountry(withCategory: true) + } else { + return server.localizedLongDescription(withCategory: true) + } + } + + var currentProviderCountryImage: Image? { + guard let code = profile.providerServer(providerManager)?.countryCode else { + return nil + } + return themeAssetsCountryImage(code).asAssetImage + } + + var currentProviderPreset: String? { + providerManager.localizedPreset(forProfile: profile) + } + + var lastInfrastructureUpdate: String? { + providerManager.localizedInfrastructureUpdate(forProfile: profile) + } +} + +// MARK: - + +private extension ProfileView.ProviderSection { + func refreshInfrastructure() { + isRefreshingInfrastructure = true + Task { @MainActor in + try? await providerManager.fetchRemoteProviderPublisher(forProfile: profile).async() + isRefreshingInfrastructure = false + } + } } diff --git a/Passepartout/App/Views/ProfileView+Rename.swift b/Passepartout/App/Views/ProfileView+Rename.swift index 4671e0dc..cec82799 100644 --- a/Passepartout/App/Views/ProfileView+Rename.swift +++ b/Passepartout/App/Views/ProfileView+Rename.swift @@ -66,51 +66,60 @@ extension ProfileView { message: alertOverwriteMessage ) } - - @ViewBuilder - private func alertOverwriteActions() -> some View { - Button(role: .destructive) { - commitRenaming(force: true) - } label: { - Text(L10n.Global.Strings.ok) - } - Button(role: .cancel) { - } label: { - Text(L10n.Global.Strings.cancel) - } - } - - private func alertOverwriteMessage() -> some View { - Text(L10n.AddProfile.Shared.Alerts.Overwrite.message) - } - - private func loadCurrentName() { - newName = currentProfile.value.header.name - } - - private func commitRenaming() { - commitRenaming(force: false) - } - - private func commitRenaming(force: Bool) { - let name = newName.stripped - - guard !name.isEmpty else { - return - } - guard name != currentProfile.value.header.name else { - presentationMode.wrappedValue.dismiss() - return - } - guard force || !profileManager.isExistingProfile(withName: name) else { - isOverwritingExistingProfile = true - return - } - - let renamed = currentProfile.value.renamed(to: name) - profileManager.saveProfile(renamed, isActive: nil) - - presentationMode.wrappedValue.dismiss() - } + } +} + +// MARK: - + +private extension ProfileView.RenameView { + + @ViewBuilder + func alertOverwriteActions() -> some View { + Button(role: .destructive) { + commitRenaming(force: true) + } label: { + Text(L10n.Global.Strings.ok) + } + Button(role: .cancel) { + } label: { + Text(L10n.Global.Strings.cancel) + } + } + + func alertOverwriteMessage() -> some View { + Text(L10n.AddProfile.Shared.Alerts.Overwrite.message) + } +} + +// MARK: - + +private extension ProfileView.RenameView { + func loadCurrentName() { + newName = currentProfile.value.header.name + } + + func commitRenaming() { + commitRenaming(force: false) + } + + func commitRenaming(force: Bool) { + let name = newName.stripped + + guard !name.isEmpty else { + return + } + guard name != currentProfile.value.header.name else { + presentationMode.wrappedValue.dismiss() + return + } + guard force || !profileManager.isExistingProfile(withName: name) else { + isOverwritingExistingProfile = true + return + } + + let renamed = currentProfile.value.renamed(to: name) + profileManager.saveProfile(renamed, isActive: nil) + + presentationMode.wrappedValue.dismiss() } } diff --git a/Passepartout/App/Views/ProfileView+VPN.swift b/Passepartout/App/Views/ProfileView+VPN.swift index f3471a9d..63ce5714 100644 --- a/Passepartout/App/Views/ProfileView+VPN.swift +++ b/Passepartout/App/Views/ProfileView+VPN.swift @@ -34,18 +34,6 @@ extension ProfileView { @Binding private var modalType: ModalType? - private var interactiveProfile: Binding { - .init { - modalType == .interactiveAccount ? profile : nil - } set: { - modalType = $0 != nil ? .interactiveAccount : nil - } - } - - private var isActiveProfile: Bool { - profileManager.isActiveProfile(profile.id) - } - init(profile: Profile, modalType: Binding) { profileManager = .shared self.profile = profile @@ -63,22 +51,38 @@ extension ProfileView { .xxxThemeTruncation() } } + } +} - private var toggleView: some View { - VPNToggle( - profile: profile, - interactiveProfile: interactiveProfile, - rateLimit: Constants.RateLimit.vpnToggle - ) +// MARK: - + +private extension ProfileView.VPNSection { + var interactiveProfile: Binding { + .init { + modalType == .interactiveAccount ? profile : nil + } set: { + modalType = $0 != nil ? .interactiveAccount : nil } + } - private var statusView: some View { - HStack { - Text(L10n.Profile.Items.ConnectionStatus.caption) - Spacer() - VPNStatusText(isActiveProfile: isActiveProfile) - .themeSecondaryTextStyle() - } + var isActiveProfile: Bool { + profileManager.isActiveProfile(profile.id) + } + + var toggleView: some View { + VPNToggle( + profile: profile, + interactiveProfile: interactiveProfile, + rateLimit: Constants.RateLimit.vpnToggle + ) + } + + var statusView: some View { + HStack { + Text(L10n.Profile.Items.ConnectionStatus.caption) + Spacer() + VPNStatusText(isActiveProfile: isActiveProfile) + .themeSecondaryTextStyle() } } } diff --git a/Passepartout/App/Views/ProfileView.swift b/Passepartout/App/Views/ProfileView.swift index 56e265f6..56628c87 100644 --- a/Passepartout/App/Views/ProfileView.swift +++ b/Passepartout/App/Views/ProfileView.swift @@ -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 { diff --git a/Passepartout/App/Views/ProviderLocationView.swift b/Passepartout/App/Views/ProviderLocationView.swift index 70a2835c..7c6efc15 100644 --- a/Passepartout/App/Views/ProviderLocationView.swift +++ b/Passepartout/App/Views/ProviderLocationView.swift @@ -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? @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] { - providerManager.servers(forLocation: location).sorted() - } } } -extension ProviderLocationView { - private func scrollToSelectedLocation(_ proxy: ScrollViewProxy) { +// 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 ?? [])") + } +} + +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) } } diff --git a/Passepartout/App/Views/ProviderPresetView.swift b/Passepartout/App/Views/ProviderPresetView.swift index 8c71bbe6..f11092a2 100644 --- a/Passepartout/App/Views/ProviderPresetView.swift +++ b/Passepartout/App/Views/ProviderPresetView.swift @@ -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() ?? [] } } diff --git a/Passepartout/App/Views/SettingsView.swift b/Passepartout/App/Views/SettingsView.swift index 577f458d..73c9d390 100644 --- a/Passepartout/App/Views/SettingsView.swift +++ b/Passepartout/App/Views/SettingsView.swift @@ -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() diff --git a/Passepartout/App/Views/ShortcutsView+Add.swift b/Passepartout/App/Views/ShortcutsView+Add.swift index d39bd05f..4d3a8624 100644 --- a/Passepartout/App/Views/ShortcutsView+Add.swift +++ b/Passepartout/App/Views/ShortcutsView+Add.swift @@ -71,32 +71,34 @@ extension ShortcutsView { } }.navigationTitle(L10n.Shortcuts.Add.title) } - - private var addConnectView: some View { - Button(L10n.Shortcuts.Add.Items.Connect.caption) { - if target.isProvider { - pendingProfile.value = target - isPresentingProviderLocation = true - } else { - addConnect(target.header) - } - } - } - - private var hiddenProviderLocationLink: some View { - NavigationLink("", isActive: $isPresentingProviderLocation) { - ProviderLocationView( - currentProfile: pendingProfile, - isEditable: false, - isPresented: isProviderLocationPresented - ) - } - } } } -extension ShortcutsView.AddView { - private var isProviderLocationPresented: Binding { +// MARK: - + +private extension ShortcutsView.AddView { + var addConnectView: some View { + Button(L10n.Shortcuts.Add.Items.Connect.caption) { + if target.isProvider { + pendingProfile.value = target + isPresentingProviderLocation = true + } else { + addConnect(target.header) + } + } + } + + var hiddenProviderLocationLink: some View { + NavigationLink("", isActive: $isPresentingProviderLocation) { + ProviderLocationView( + currentProfile: pendingProfile, + isEditable: false, + isPresented: isProviderLocationPresented + ) + } + } + + var isProviderLocationPresented: Binding { .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?") } diff --git a/Passepartout/App/Views/ShortcutsView.swift b/Passepartout/App/Views/ShortcutsView.swift index f127f353..c2d60937 100644 --- a/Passepartout/App/Views/ShortcutsView.swift +++ b/Passepartout/App/Views/ShortcutsView.swift @@ -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 { + var delegatingPendingShortcut: Binding { .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) + } +} diff --git a/Passepartout/App/Views/VPNStatusText.swift b/Passepartout/App/Views/VPNStatusText.swift index 90a453e5..70b023b1 100644 --- a/Passepartout/App/Views/VPNStatusText.swift +++ b/Passepartout/App/Views/VPNStatusText.swift @@ -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, diff --git a/Passepartout/App/Views/VPNToggle.swift b/Passepartout/App/Views/VPNToggle.swift index 879496a9..51756e90 100644 --- a/Passepartout/App/Views/VPNToggle.swift +++ b/Passepartout/App/Views/VPNToggle.swift @@ -41,34 +41,6 @@ struct VPNToggle: View { private let rateLimit: Int - private var isEnabled: Binding { - .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, 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 { + .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 {