Split views into extensions (#321)

Nothing but moving code around to reorganize views into the following
sections (MARK):

- Properties/Body
- Subviews
- Actions
This commit is contained in:
Davide De Rosa 2023-07-03 16:54:43 +02:00 committed by GitHub
parent 7198150f00
commit d7ebcb23ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 1629 additions and 1420 deletions

View File

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

View File

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

View File

@ -42,10 +42,6 @@ extension AddHostView {
@State private var isEnteringCredentials = false
private var isComplete: Bool {
!viewModel.processedProfile.isPlaceholder
}
init(
url: URL,
deletingURLOnSuccess: Bool,
@ -84,9 +80,15 @@ extension AddHostView {
.navigationTitle(L10n.AddProfile.Shared.title)
.themeSecondaryView()
}
}
}
// MARK: -
private extension AddHostView.NameView {
@ViewBuilder
private var mainView: some View {
var mainView: some View {
AddProfileView.ProfileNameSection(
profileName: $viewModel.profileName,
errorMessage: viewModel.errorMessage
@ -112,7 +114,7 @@ extension AddHostView {
}
}
private var encryptionSection: some View {
var encryptionSection: some View {
Section {
SecureField(L10n.AddProfile.Host.Sections.Encryption.footer, text: $viewModel.encryptionPassphrase) {
processProfile(replacingExisting: false)
@ -122,7 +124,7 @@ extension AddHostView {
}
}
private var completeSection: some View {
var completeSection: some View {
Section {
Text(Unlocalized.Network.url)
.withTrailingText(url.lastPathComponent)
@ -137,7 +139,7 @@ extension AddHostView {
}
}
private var hiddenAccountLink: some View {
var hiddenAccountLink: some View {
NavigationLink("", isActive: $isEnteringCredentials) {
AddProfileView.AccountWrapperView(
profile: $viewModel.processedProfile,
@ -146,7 +148,7 @@ extension AddHostView {
}
}
private var nextString: String {
var nextString: String {
if !viewModel.processedProfile.isPlaceholder {
return viewModel.processedProfile.requiresCredentials ? L10n.Global.Strings.next : L10n.Global.Strings.save
} else {
@ -154,16 +156,8 @@ extension AddHostView {
}
}
private func requestResourcePermissions() {
_ = url.startAccessingSecurityScopedResource()
}
private func dropResourcePermissions() {
url.stopAccessingSecurityScopedResource()
}
@ViewBuilder
private func alertOverwriteActions() -> some View {
func alertOverwriteActions() -> some View {
Button(role: .destructive) {
processProfile(replacingExisting: true)
} label: {
@ -175,11 +169,27 @@ extension AddHostView {
}
}
private func alertOverwriteMessage() -> some View {
func alertOverwriteMessage() -> some View {
Text(L10n.AddProfile.Shared.Alerts.Overwrite.message)
}
private func processProfile(replacingExisting: Bool) {
var isComplete: Bool {
!viewModel.processedProfile.isPlaceholder
}
}
// MARK: -
private extension AddHostView.NameView {
func requestResourcePermissions() {
_ = url.startAccessingSecurityScopedResource()
}
func dropResourcePermissions() {
url.stopAccessingSecurityScopedResource()
}
func processProfile(replacingExisting: Bool) {
viewModel.processURL(
url,
with: profileManager,
@ -188,7 +198,7 @@ extension AddHostView {
)
}
private func saveProfile() {
func saveProfile() {
let result = viewModel.addProcessedProfile(to: profileManager)
guard result else {
return
@ -202,5 +212,4 @@ extension AddHostView {
profileManager.didCreateProfile.send(profile)
}
}
}
}

View File

@ -74,9 +74,14 @@ struct AddProfileMenu: View {
themeAddMenuImage.asSystemImage
}.sheet(item: $modalType, content: presentedModal)
}
}
// MARK: -
private extension AddProfileMenu {
@ViewBuilder
private func presentedModal(_ modalType: ModalType) -> some View {
func presentedModal(_ modalType: ModalType) -> some View {
switch modalType {
case .addProvider:
NavigationView {
@ -100,7 +105,7 @@ struct AddProfileMenu: View {
}
}
private var isModalPresented: Binding<Bool> {
var isModalPresented: Binding<Bool> {
.init {
modalType != nil
} set: {
@ -110,13 +115,13 @@ struct AddProfileMenu: View {
}
}
private func importedURLRow(_ url: URL) -> some View {
func importedURLRow(_ url: URL) -> some View {
Button(L10n.Menu.Contextual.AddProfile.imported(url.lastPathComponent)) {
presentAddHost(withURL: url, deletingURLOnSuccess: true)
}
}
private var importedURLs: [URL]? {
var importedURLs: [URL]? {
do {
let url = FileManager.default.userURL(for: .documentDirectory, appending: nil)
let list = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil)
@ -129,16 +134,18 @@ struct AddProfileMenu: View {
}
}
extension AddProfileMenu {
private func presentAddProvider() {
// MARK: -
private extension AddProfileMenu {
func presentAddProvider() {
modalType = .addProvider
}
private func presentAddHost(withURL url: URL, deletingURLOnSuccess: Bool) {
func presentAddHost(withURL url: URL, deletingURLOnSuccess: Bool) {
modalType = .addHost(url, deletingURLOnSuccess)
}
private func presentHostFileImporter() {
func presentHostFileImporter() {
// XXX: iOS bug, hack around crappy bug when dismissing by swiping down
//

View File

@ -84,8 +84,13 @@ extension AddProviderView {
message: alertOverwriteMessage
).navigationTitle(providerMetadata.fullName)
}
}
}
private var hiddenAccountLink: some View {
// MARK: -
private extension AddProviderView.NameView {
var hiddenAccountLink: some View {
NavigationLink("", isActive: $isEnteringCredentials) {
AddProfileView.AccountWrapperView(
profile: $profile,
@ -95,7 +100,7 @@ extension AddProviderView {
}
@ViewBuilder
private func alertOverwriteActions() -> some View {
func alertOverwriteActions() -> some View {
Button(role: .destructive) {
saveProfile(replacingExisting: true)
} label: {
@ -107,11 +112,15 @@ extension AddProviderView {
}
}
private func alertOverwriteMessage() -> some View {
func alertOverwriteMessage() -> some View {
Text(L10n.AddProfile.Shared.Alerts.Overwrite.message)
}
}
private func saveProfile(replacingExisting: Bool) {
// MARK: -
private extension AddProviderView.NameView {
func saveProfile(replacingExisting: Bool) {
let addedProfile = viewModel.addProfile(
profile,
to: profileManager,
@ -129,5 +138,4 @@ extension AddProviderView {
profileManager.didCreateProfile.send(profile)
}
}
}
}

View File

@ -41,23 +41,6 @@ struct AddProviderView: View {
self.bindings = bindings
}
private var providers: [ProviderMetadata] {
providerManager.allProviders()
.filter {
$0.supportedVPNProtocols.contains(viewModel.selectedVPNProtocol)
}.sorted()
}
private var availableVPNProtocols: [VPNProtocolType] {
var protos: Set<VPNProtocolType> = []
providers.forEach {
$0.supportedVPNProtocols.forEach {
protos.insert($0)
}
}
return protos.sorted()
}
var body: some View {
ZStack {
ForEach(providers, id: \.navigationId, content: hiddenProviderLink)
@ -82,8 +65,12 @@ struct AddProviderView: View {
}.navigationTitle(L10n.AddProfile.Shared.title)
.themeSecondaryView()
}
}
private var mainSection: some View {
// MARK: -
private extension AddProviderView {
var mainSection: some View {
Section {
let protos = availableVPNProtocols
if !protos.isEmpty {
@ -100,7 +87,7 @@ struct AddProviderView: View {
}
}
private var providersSection: some View {
var providersSection: some View {
Section {
ForEach(providers, content: providerRow)
} footer: {
@ -108,7 +95,7 @@ struct AddProviderView: View {
}.disabled(viewModel.isFetchingAnyProvider)
}
private func providerRow(_ metadata: ProviderMetadata) -> some View {
func providerRow(_ metadata: ProviderMetadata) -> some View {
Button {
presentOrPurchaseProvider(metadata)
} label: {
@ -116,7 +103,7 @@ struct AddProviderView: View {
}.withTrailingProgress(when: viewModel.isFetchingProvider(metadata.name))
}
private func hiddenProviderLink(_ metadata: ProviderMetadata) -> some View {
func hiddenProviderLink(_ metadata: ProviderMetadata) -> some View {
NavigationLink("", tag: metadata, selection: $viewModel.selectedProvider) {
NameView(
profile: $viewModel.pendingProfile,
@ -126,33 +113,28 @@ struct AddProviderView: View {
}
}
private var updateListButton: some View {
var updateListButton: some View {
Button(L10n.AddProfile.Provider.Items.updateList) {
viewModel.updateIndex(providerManager)
}.withTrailingProgress(when: viewModel.isUpdatingIndex)
.disabled(viewModel.isUpdatingIndex)
}
// eligibility: select or purchase provider
private func presentOrPurchaseProvider(_ metadata: ProviderMetadata) {
guard productManager.isEligible(forProvider: metadata.name) else {
viewModel.presentPaywall()
return
}
viewModel.selectProvider(metadata, providerManager)
var providers: [ProviderMetadata] {
providerManager.allProviders()
.filter {
$0.supportedVPNProtocols.contains(viewModel.selectedVPNProtocol)
}.sorted()
}
private func onErrorMessage(_ message: String?, _ scrollProxy: ScrollViewProxy) {
guard message != nil else {
return
var availableVPNProtocols: [VPNProtocolType] {
var protos: Set<VPNProtocolType> = []
providers.forEach {
$0.supportedVPNProtocols.forEach {
protos.insert($0)
}
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)
}
}

View File

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

View File

@ -47,10 +47,6 @@ extension DiagnosticsView {
private let providerName: ProviderName?
private var isEligibleForFeedback: Bool {
productManager.isEligibleForFeedback()
}
@State private var isReportingIssue = false
@State private var isAlertPresented = false
@ -85,22 +81,27 @@ extension DiagnosticsView {
message: alertMessage
)
}
}
}
private func alertActions(_ alertType: AlertType) -> some View {
// MARK: -
private extension DiagnosticsView.OpenVPNView {
func alertActions(_ alertType: AlertType) -> some View {
Button(role: .cancel) {
} label: {
Text(L10n.Global.Strings.ok)
}
}
private func alertMessage(_ alertType: AlertType) -> some View {
func alertMessage(_ alertType: AlertType) -> some View {
switch alertType {
case .emailNotConfigured:
return Text(L10n.Global.Messages.emailNotConfigured)
}
}
private var serverConfigurationSection: some View {
var serverConfigurationSection: some View {
Section {
let cfg = currentServerConfiguration
NavigationLink(L10n.Diagnostics.Items.ServerConfiguration.caption) {
@ -115,9 +116,9 @@ extension DiagnosticsView {
}
}
private var debugLogSection: some View {
var debugLogSection: some View {
Section {
DebugLogSection(appLogURL: appLogURL, tunnelLogURL: tunnelLogURL)
DiagnosticsView.DebugLogSection(appLogURL: appLogURL, tunnelLogURL: tunnelLogURL)
Toggle(L10n.Diagnostics.Items.MasksPrivateData.caption, isOn: $vpnManager.masksPrivateData)
} header: {
Text(L10n.DebugLog.title)
@ -126,13 +127,13 @@ extension DiagnosticsView {
}
}
private var issueReporterSection: some View {
var issueReporterSection: some View {
Section {
Button(L10n.Diagnostics.Items.ReportIssue.caption, action: presentReportIssue)
}
}
private func reportIssueView() -> some View {
func reportIssueView() -> some View {
let logURL = vpnManager.debugLogURL(forProtocol: vpnProtocol)
var metadata: ProviderMetadata?
var lastUpdate: Date?
@ -149,11 +150,8 @@ extension DiagnosticsView {
lastUpdate: lastUpdate
)
}
}
}
extension DiagnosticsView.OpenVPNView {
private var currentServerConfiguration: OpenVPN.ConfigurationBuilder? {
var currentServerConfiguration: OpenVPN.ConfigurationBuilder? {
guard currentVPNState.vpnStatus == .connected else {
return nil
}
@ -164,17 +162,23 @@ extension DiagnosticsView.OpenVPNView {
return cfg.builder(withFallbacks: false)
}
private var appLogURL: URL? {
var appLogURL: URL? {
Passepartout.shared.logger.logFile
}
private var tunnelLogURL: URL? {
var tunnelLogURL: URL? {
vpnManager.debugLogURL(forProtocol: vpnProtocol)
}
var isEligibleForFeedback: Bool {
productManager.isEligibleForFeedback()
}
}
extension DiagnosticsView.OpenVPNView {
private func presentReportIssue() {
// MARK: -
private extension DiagnosticsView.OpenVPNView {
func presentReportIssue() {
guard MailComposerView.canSendMail() else {
openReportIssueMailTo()
return
@ -182,7 +186,7 @@ extension DiagnosticsView.OpenVPNView {
isReportingIssue = true
}
private func openReportIssueMailTo() {
func openReportIssueMailTo() {
let V = Unlocalized.Issues.self
let body = V.body(V.template, DebugLog(content: "--").decoratedString())

View File

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

View File

@ -60,8 +60,13 @@ extension DiagnosticsView {
appLink
tunnelLink
}
}
}
private var appLink: some View {
// MARK: -
private extension DiagnosticsView.DebugLogSection {
var appLink: some View {
navigationLink(
withTitle: L10n.Diagnostics.Items.AppLog.title,
url: appLogURL,
@ -69,7 +74,7 @@ extension DiagnosticsView {
)
}
private var tunnelLink: some View {
var tunnelLink: some View {
navigationLink(
withTitle: Unlocalized.VPN.vpn,
url: tunnelLogURL,
@ -77,7 +82,7 @@ extension DiagnosticsView {
)
}
private func navigationLink(withTitle title: String, url: URL?, refreshInterval: TimeInterval?) -> some View {
func navigationLink(withTitle title: String, url: URL?, refreshInterval: TimeInterval?) -> some View {
NavigationLink(title) {
url.map {
DebugLogView(
@ -88,5 +93,4 @@ extension DiagnosticsView {
}
}.disabled(url == nil)
}
}
}

View File

@ -76,8 +76,12 @@ struct DonateView: View {
}
}.themeAnimation(on: productManager.isRefreshingProducts)
}
}
private func alertActions(_ alertType: AlertType) -> some View {
// MARK: -
private extension DonateView {
func alertActions(_ alertType: AlertType) -> some View {
switch alertType {
case .thankYou:
return Button(role: .cancel) {
@ -87,14 +91,14 @@ struct DonateView: View {
}
}
private func alertMessage(_ alertType: AlertType) -> some View {
func alertMessage(_ alertType: AlertType) -> some View {
switch alertType {
case .thankYou:
return Text(L10n.Donate.Alerts.Purchase.Success.message)
}
}
private var productsSection: some View {
var productsSection: some View {
Section {
if !productManager.isRefreshingProducts {
ForEach(productManager.donations, id: \.productIdentifier, content: productRow)
@ -109,7 +113,7 @@ struct DonateView: View {
}
@ViewBuilder
private func productRow(_ product: SKProduct) -> some View {
func productRow(_ product: SKProduct) -> some View {
HStack {
Button(product.localizedTitle) {
purchaseProduct(product)
@ -127,13 +131,27 @@ struct DonateView: View {
}
}
extension DonateView {
private func purchaseProduct(_ product: SKProduct) {
private extension ProductManager {
var donations: [SKProduct] {
products.filter { product in
LocalProduct.allDonations.contains {
$0.matchesStoreKitProduct(product)
}
}.sorted {
$0.price.decimalValue < $1.price.decimalValue
}
}
}
// MARK: -
private extension DonateView {
func purchaseProduct(_ product: SKProduct) {
pendingDonationIdentifier = product.productIdentifier
productManager.purchase(product, completionHandler: handlePurchaseResult)
}
private func handlePurchaseResult(_ result: Result<InAppPurchaseResult, Error>) {
func handlePurchaseResult(_ result: Result<InAppPurchaseResult, Error>) {
switch result {
case .success(let value):
if case .done = value {
@ -152,15 +170,3 @@ extension DonateView {
pendingDonationIdentifier = nil
}
}
private extension ProductManager {
var donations: [SKProduct] {
products.filter { product in
LocalProduct.allDonations.contains {
$0.matchesStoreKitProduct(product)
}
}.sorted {
$0.price.decimalValue < $1.price.decimalValue
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -44,14 +44,19 @@ extension OnDemandView {
Text(L10n.Global.Strings.add)
}
}
}
}
private func mapElements(elements: [IdentifiableString]) -> [IdentifiableString] {
// MARK: -
private extension OnDemandView.SSIDList {
func mapElements(elements: [IdentifiableString]) -> [IdentifiableString] {
elements
.filter { !$0.string.isEmpty }
.sorted { $0.string.lowercased() < $1.string.lowercased() }
}
private func ssidRow(callback: EditableTextFieldCallback) -> some View {
func ssidRow(callback: EditableTextFieldCallback) -> some View {
Group {
if callback.isNewElement {
ssidField(callback: callback)
@ -63,7 +68,7 @@ extension OnDemandView {
}
}
private func ssidField(callback: EditableTextFieldCallback) -> some View {
func ssidField(callback: EditableTextFieldCallback) -> some View {
TextField(
Unlocalized.Network.ssid,
text: callback.text,
@ -72,19 +77,7 @@ extension OnDemandView {
).themeValidSSID(callback.text.wrappedValue)
}
private func requestSSID(_ text: Binding<String>) {
Task { @MainActor in
let ssid = try await reader.currentSSID()
if !withSSIDs.keys.contains(ssid) {
text.wrappedValue = ssid
}
}
}
}
}
extension OnDemandView.SSIDList {
private var allSSIDs: Binding<[String]> {
var allSSIDs: Binding<[String]> {
.init {
Array(withSSIDs.keys)
} set: { newValue in
@ -104,7 +97,7 @@ extension OnDemandView.SSIDList {
}
}
private var onSSIDs: Binding<Set<String>> {
var onSSIDs: Binding<Set<String>> {
.init {
Set(withSSIDs.filter {
$0.value
@ -130,7 +123,7 @@ extension OnDemandView.SSIDList {
}
}
private func isSSIDOn(_ ssid: String) -> Binding<Bool> {
func isSSIDOn(_ ssid: String) -> Binding<Bool> {
.init {
withSSIDs[ssid] ?? false
} set: {
@ -138,3 +131,16 @@ extension OnDemandView.SSIDList {
}
}
}
// MARK: -
private extension OnDemandView.SSIDList {
func requestSSID(_ text: Binding<String>) {
Task { @MainActor in
let ssid = try await reader.currentSSID()
if !withSSIDs.keys.contains(ssid) {
text.wrappedValue = ssid
}
}
}
}

View File

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

View File

@ -34,21 +34,6 @@ extension OrganizerView {
@Binding private var modalType: ModalType?
private var interactiveProfile: Binding<Profile?> {
.init {
if case .interactiveAccount(let profile) = modalType {
return profile
}
return nil
} set: {
if let profile = $0 {
modalType = .interactiveAccount(profile: profile)
} else {
modalType = nil
}
}
}
init(profile: Profile, isActiveProfile: Bool, modalType: Binding<ModalType?>) {
self.profile = profile
self.isActiveProfile = isActiveProfile
@ -77,3 +62,22 @@ extension OrganizerView {
}
}
}
// MARK: -
private extension OrganizerView.ProfileRow {
var interactiveProfile: Binding<Profile?> {
.init {
if case .interactiveAccount(let profile) = modalType {
return profile
}
return nil
} set: {
if let profile = $0 {
modalType = .interactiveAccount(profile: profile)
} else {
modalType = nil
}
}
}
}

View File

@ -49,87 +49,6 @@ extension OrganizerView {
profileManager.currentProfileId = $0.id
}
}
private var mainView: some View {
List {
if profileManager.hasProfiles {
// FIXME: iPad multitasking, navigation binding does not clear on pop
// - if listStyle is different than .sidebar
// - if listStyle is .sidebar but List has no Section
if themeIsiPadMultitasking {
Section {
profilesView
} header: {
Text(L10n.Global.Strings.profiles)
}
} else {
profilesView
}
}
}.themeAnimation(on: profileManager.headers)
}
private var profilesView: some View {
ForEach(sortedProfiles, content: profileRow(forProfile:))
.onDelete(perform: removeProfiles)
}
private var emptyView: some View {
VStack {
Text(L10n.Organizer.Empty.noProfiles)
.themeInformativeTextStyle()
}
}
private func profileRow(forProfile profile: Profile) -> some View {
NavigationLink(tag: profile.id, selection: $profileManager.currentProfileId) {
ProfileView()
} label: {
profileLabel(forProfile: profile)
}.contextMenu {
ProfileContextMenu(header: profile.header)
}
}
private func profileLabel(forProfile profile: Profile) -> some View {
ProfileRow(
profile: profile,
isActiveProfile: profileManager.isActiveProfile(profile.id),
modalType: $modalType
)
}
private var sortedProfiles: [Profile] {
profileManager.profiles
.sorted()
// .sorted {
// if profileManager.isActiveProfile($0.id) {
// return true
// } else if profileManager.isActiveProfile($1.id) {
// return false
// } else {
// return $0 < $1
// }
// }
}
private func removeProfiles(at offsets: IndexSet) {
let currentHeaders = sortedProfiles
var toDelete: [UUID] = []
offsets.forEach {
toDelete.append(currentHeaders[$0].id)
}
withAnimation {
profileManager.removeProfiles(withIds: toDelete)
}
}
private func performMigrationsIfNeeded() {
Task { @MainActor in
UpgradeManager.shared.doMigrations(profileManager)
}
}
}
}
@ -154,19 +73,90 @@ extension OrganizerView {
duplicateButton
deleteButton
}
}
}
private var reconnectButton: some View {
// MARK: -
private extension OrganizerView.ProfilesList {
var mainView: some View {
List {
if profileManager.hasProfiles {
// FIXME: iPad multitasking, navigation binding does not clear on pop
// - if listStyle is different than .sidebar
// - if listStyle is .sidebar but List has no Section
if themeIsiPadMultitasking {
Section {
profilesView
} header: {
Text(L10n.Global.Strings.profiles)
}
} else {
profilesView
}
}
}.themeAnimation(on: profileManager.headers)
}
var profilesView: some View {
ForEach(sortedProfiles, content: profileRow(forProfile:))
.onDelete(perform: removeProfiles)
}
var emptyView: some View {
VStack {
Text(L10n.Organizer.Empty.noProfiles)
.themeInformativeTextStyle()
}
}
func profileRow(forProfile profile: Profile) -> some View {
NavigationLink(tag: profile.id, selection: $profileManager.currentProfileId) {
ProfileView()
} label: {
profileLabel(forProfile: profile)
}.contextMenu {
OrganizerView.ProfileContextMenu(header: profile.header)
}
}
func profileLabel(forProfile profile: Profile) -> some View {
OrganizerView.ProfileRow(
profile: profile,
isActiveProfile: profileManager.isActiveProfile(profile.id),
modalType: $modalType
)
}
var sortedProfiles: [Profile] {
profileManager.profiles
.sorted()
// .sorted {
// if profileManager.isActiveProfile($0.id) {
// return true
// } else if profileManager.isActiveProfile($1.id) {
// return false
// } else {
// return $0 < $1
// }
// }
}
}
private extension OrganizerView.ProfileContextMenu {
var reconnectButton: some View {
ProfileView.ReconnectButton()
}
private var duplicateButton: some View {
var duplicateButton: some View {
ProfileView.DuplicateButton(
header: header,
setAsCurrent: false
)
}
private var deleteButton: some View {
var deleteButton: some View {
DestructiveButton {
withAnimation {
profileManager.removeProfiles(withIds: [header.id])
@ -175,5 +165,25 @@ extension OrganizerView {
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)
}
}
}

View File

@ -51,9 +51,15 @@ extension OrganizerView {
.hidden()
.onAppear(perform: onAppear)
}
}
}
// MARK: -
private extension OrganizerView.SceneView {
@MainActor
private func onAppear() {
func onAppear() {
guard didHandleSubreddit else {
alertType = .subscribeReddit
isAlertPresented = true
@ -77,5 +83,4 @@ extension OrganizerView {
ProfileManager.shared.currentProfileId = activeProfileId
}
}
}
}

View File

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

View File

@ -35,8 +35,6 @@ extension PaywallView {
case restoring
}
private typealias RowModel = (product: SKProduct, extra: String?)
@Environment(\.scenePhase) private var scenePhase
@ObservedObject private var productManager: ProductManager
@ -68,8 +66,39 @@ extension PaywallView {
}
}.themeAnimation(on: productManager.isRefreshingProducts)
}
}
}
private var productsSection: some View {
private struct PurchaseRow: View {
var product: SKProduct?
let title: String
let extra: String?
let action: () -> Void
let purchaseState: PaywallView.PurchaseView.PurchaseState?
var body: some View {
VStack(alignment: .leading) {
actionButton
.padding(.bottom, 5)
extra.map {
Text($0)
.frame(maxHeight: .infinity)
}
}.padding([.top, .bottom])
}
}
private typealias RowModel = (product: SKProduct, extra: String?)
// MARK: -
private extension PaywallView.PurchaseView {
var productsSection: some View {
Section {
if !productManager.isRefreshingProducts {
ForEach(productRowModels, id: \.product.productIdentifier, content: productRow)
@ -84,7 +113,7 @@ extension PaywallView {
}
}
private func productRow(_ model: RowModel) -> some View {
func productRow(_ model: RowModel) -> some View {
PurchaseRow(
product: model.product,
title: model.product.localizedTitle,
@ -96,7 +125,7 @@ extension PaywallView {
)
}
private var restoreRow: some View {
var restoreRow: some View {
PurchaseRow(
title: L10n.Paywall.Items.Restore.title,
extra: L10n.Paywall.Items.Restore.description,
@ -104,11 +133,119 @@ extension PaywallView {
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
}
}
extension PaywallView.PurchaseView {
private func purchaseProduct(_ product: SKProduct) {
private extension PurchaseRow {
@ViewBuilder
var actionButton: some View {
if let product = product {
purchaseButton(product)
} else {
restoreButton
}
}
func purchaseButton(_ product: SKProduct) -> some View {
HStack {
Button(title, action: action)
Spacer()
if case .purchasing(let pending) = purchaseState, pending.productIdentifier == product.productIdentifier {
ProgressView()
} else {
product.localizedPrice.map {
Text($0)
.themeSecondaryTextStyle()
}
}
}
}
var restoreButton: some View {
HStack {
Button(title, action: action)
Spacer()
if case .restoring = purchaseState {
ProgressView()
}
}
}
}
// MARK: -
private extension PaywallView.PurchaseView {
func purchaseProduct(_ product: SKProduct) {
purchaseState = .purchasing(product)
productManager.purchase(product) {
@ -135,7 +272,7 @@ extension PaywallView.PurchaseView {
}
}
private func restorePurchases() {
func restorePurchases() {
purchaseState = .restoring
productManager.restorePurchases {
@ -154,131 +291,3 @@ extension PaywallView.PurchaseView {
}
}
}
extension PaywallView.PurchaseView {
private var skFeature: SKProduct? {
guard let feature = feature else {
return nil
}
return productManager.product(withIdentifier: feature)
}
private var skPlatformVersion: SKProduct? {
#if targetEnvironment(macCatalyst)
productManager.product(withIdentifier: .fullVersion_macOS)
#else
productManager.product(withIdentifier: .fullVersion_iOS)
#endif
}
// hide full version if already bought the other platform version
private var skFullVersion: SKProduct? {
#if targetEnvironment(macCatalyst)
guard !productManager.hasPurchased(.fullVersion_iOS) else {
return nil
}
#else
guard !productManager.hasPurchased(.fullVersion_macOS) else {
return nil
}
#endif
return productManager.product(withIdentifier: .fullVersion)
}
private var platformVersionExtra: [String] {
productManager.featureProducts(excluding: [
.fullVersion,
.fullVersion_iOS,
.fullVersion_macOS
]).map {
$0.localizedTitle
}.sorted {
$0.lowercased() < $1.lowercased()
}
}
private var fullVersionExtra: [String] {
productManager.featureProducts(including: [
.fullVersion_iOS,
.fullVersion_macOS
]).map {
$0.localizedTitle
}.sorted {
$0.lowercased() < $1.lowercased()
}
}
private var productRowModels: [RowModel] {
var models: [RowModel] = []
skPlatformVersion.map {
let extra = platformVersionExtra.joined(separator: "\n")
models.append(($0, extra))
}
skFullVersion.map {
let extra = fullVersionExtra.joined(separator: "\n")
models.append(($0, extra))
}
skFeature.map {
models.append(($0, nil))
}
return models
}
}
private struct PurchaseRow: View {
var product: SKProduct?
let title: String
let extra: String?
let action: () -> Void
let purchaseState: PaywallView.PurchaseView.PurchaseState?
var body: some View {
VStack(alignment: .leading) {
actionButton
.padding(.bottom, 5)
extra.map {
Text($0)
.frame(maxHeight: .infinity)
}
}.padding([.top, .bottom])
}
@ViewBuilder
private var actionButton: some View {
if let product = product {
purchaseButton(product)
} else {
restoreButton
}
}
private func purchaseButton(_ product: SKProduct) -> some View {
HStack {
Button(title, action: action)
Spacer()
if case .purchasing(let pending) = purchaseState, pending.productIdentifier == product.productIdentifier {
ProgressView()
} else {
product.localizedPrice.map {
Text($0)
.themeSecondaryTextStyle()
}
}
}
}
private var restoreButton: some View {
HStack {
Button(title, action: action)
Spacer()
if case .restoring = purchaseState {
ProgressView()
}
}
}
}

View File

@ -34,14 +34,6 @@ extension ProfileView {
@Binding private var modalType: ModalType?
private var isEligibleForNetworkSettings: Bool {
productManager.isEligible(forFeature: .networkSettings)
}
private var isEligibleForTrustedNetworks: Bool {
productManager.isEligible(forFeature: .trustedNetworks)
}
init(currentProfile: ObservableProfile, modalType: Binding<ModalType?>) {
productManager = .shared
self.currentProfile = currentProfile
@ -111,13 +103,25 @@ extension ProfileView {
Text(L10n.Global.Strings.configuration)
}
}
}
}
private var networkSettingsRow: some View {
// MARK: -
private extension ProfileView.ConfigurationSection {
var networkSettingsRow: some View {
Label(L10n.NetworkSettings.title, systemImage: themeNetworkSettingsImage)
}
private var onDemandRow: some View {
var onDemandRow: some View {
Label(L10n.OnDemand.title, systemImage: themeOnDemandImage)
}
var isEligibleForNetworkSettings: Bool {
productManager.isEligible(forFeature: .networkSettings)
}
var isEligibleForTrustedNetworks: Bool {
productManager.isEligible(forFeature: .trustedNetworks)
}
}

View File

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

View File

@ -32,14 +32,14 @@ extension ProfileView {
@ObservedObject private var currentProfile: ObservableProfile
var profile: Profile {
currentProfile.value
}
@State private var isProviderLocationPresented = false
@State private var isRefreshingInfrastructure = false
var profile: Profile {
currentProfile.value
}
init(currentProfile: ObservableProfile) {
providerManager = .shared
self.currentProfile = currentProfile
@ -55,9 +55,15 @@ extension ProfileView {
}
}
}
}
}
// MARK: -
private extension ProfileView.ProviderSection {
@ViewBuilder
private var mainView: some View {
var mainView: some View {
Section {
NavigationLink(isActive: $isProviderLocationPresented) {
ProviderLocationView(
@ -107,7 +113,7 @@ extension ProfileView {
}
}
private var currentProviderFullName: String? {
var currentProviderFullName: String? {
guard let name = profile.header.providerName else {
assertionFailure("Provider name accessed but profile is not a provider (isPlaceholder? \(profile.isPlaceholder))")
return nil
@ -119,7 +125,7 @@ extension ProfileView {
return metadata.fullName
}
private var currentProviderServerDescription: String? {
var currentProviderServerDescription: String? {
guard let server = profile.providerServer(providerManager) else {
return nil
}
@ -130,27 +136,30 @@ extension ProfileView {
}
}
private var currentProviderCountryImage: Image? {
var currentProviderCountryImage: Image? {
guard let code = profile.providerServer(providerManager)?.countryCode else {
return nil
}
return themeAssetsCountryImage(code).asAssetImage
}
private var currentProviderPreset: String? {
var currentProviderPreset: String? {
providerManager.localizedPreset(forProfile: profile)
}
private var lastInfrastructureUpdate: String? {
var lastInfrastructureUpdate: String? {
providerManager.localizedInfrastructureUpdate(forProfile: profile)
}
}
private func refreshInfrastructure() {
// MARK: -
private extension ProfileView.ProviderSection {
func refreshInfrastructure() {
isRefreshingInfrastructure = true
Task { @MainActor in
try? await providerManager.fetchRemoteProviderPublisher(forProfile: profile).async()
isRefreshingInfrastructure = false
}
}
}
}

View File

@ -66,9 +66,15 @@ extension ProfileView {
message: alertOverwriteMessage
)
}
}
}
// MARK: -
private extension ProfileView.RenameView {
@ViewBuilder
private func alertOverwriteActions() -> some View {
func alertOverwriteActions() -> some View {
Button(role: .destructive) {
commitRenaming(force: true)
} label: {
@ -80,19 +86,23 @@ extension ProfileView {
}
}
private func alertOverwriteMessage() -> some View {
func alertOverwriteMessage() -> some View {
Text(L10n.AddProfile.Shared.Alerts.Overwrite.message)
}
}
private func loadCurrentName() {
// MARK: -
private extension ProfileView.RenameView {
func loadCurrentName() {
newName = currentProfile.value.header.name
}
private func commitRenaming() {
func commitRenaming() {
commitRenaming(force: false)
}
private func commitRenaming(force: Bool) {
func commitRenaming(force: Bool) {
let name = newName.stripped
guard !name.isEmpty else {
@ -112,5 +122,4 @@ extension ProfileView {
presentationMode.wrappedValue.dismiss()
}
}
}

View File

@ -34,18 +34,6 @@ extension ProfileView {
@Binding private var modalType: ModalType?
private var interactiveProfile: Binding<Profile?> {
.init {
modalType == .interactiveAccount ? profile : nil
} set: {
modalType = $0 != nil ? .interactiveAccount : nil
}
}
private var isActiveProfile: Bool {
profileManager.isActiveProfile(profile.id)
}
init(profile: Profile, modalType: Binding<ModalType?>) {
profileManager = .shared
self.profile = profile
@ -63,8 +51,25 @@ extension ProfileView {
.xxxThemeTruncation()
}
}
}
}
private var toggleView: some View {
// MARK: -
private extension ProfileView.VPNSection {
var interactiveProfile: Binding<Profile?> {
.init {
modalType == .interactiveAccount ? profile : nil
} set: {
modalType = $0 != nil ? .interactiveAccount : nil
}
}
var isActiveProfile: Bool {
profileManager.isActiveProfile(profile.id)
}
var toggleView: some View {
VPNToggle(
profile: profile,
interactiveProfile: interactiveProfile,
@ -72,7 +77,7 @@ extension ProfileView {
)
}
private var statusView: some View {
var statusView: some View {
HStack {
Text(L10n.Profile.Items.ConnectionStatus.caption)
Spacer()
@ -80,5 +85,4 @@ extension ProfileView {
.themeSecondaryTextStyle()
}
}
}
}

View File

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

View File

@ -31,35 +31,16 @@ struct ProviderLocationView: View, ProviderProfileAvailability {
@ObservedObject private var currentProfile: ObservableProfile
var profile: Profile {
currentProfile.value
}
private let isEditable: Bool
private var providerName: ProviderName {
guard let name = currentProfile.value.header.providerName else {
assertionFailure("Not a provider")
return ""
}
return name
}
private var vpnProtocol: VPNProtocolType {
currentProfile.value.currentVPNProtocol
}
@Binding private var selectedServer: ProviderServer?
@Binding private var favoriteLocationIds: Set<String>?
@AppStorage(AppPreference.isShowingFavorites.key) private var isShowingFavorites = false
private var isShowingEmptyFavorites: Bool {
guard isShowingFavorites else {
return false
}
return favoriteLocationIds?.isEmpty ?? true
var profile: Profile {
currentProfile.value
}
// XXX: do not escape mutating 'self', use constant providerManager
@ -108,139 +89,6 @@ struct ProviderLocationView: View, ProviderProfileAvailability {
}
}.navigationTitle(L10n.Provider.Location.title)
}
private var mainView: some View {
// FIXME: layout, restore ScrollViewReader, but content inside it is not re-rendered on isShowingFavorites
// ScrollViewReader { scrollProxy in
List {
if !isShowingEmptyFavorites {
categoriesView
} else {
emptyFavoritesSection
}
// }.onAppear {
// scrollToSelectedLocation(scrollProxy)
}
// }
}
private var categoriesView: some View {
ForEach(categories, content: categorySection)
}
private func categorySection(_ category: ProviderCategory) -> some View {
Section {
ForEach(filteredLocations(for: category)) { location in
if isEditable {
locationRow(location)
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
favoriteActions(location)
}
} else {
locationRow(location)
}
}
} header: {
!category.name.isEmpty ? Text(category.name) : nil
}
}
@ViewBuilder
private func locationRow(_ location: ProviderLocation) -> some View {
if let onlyServer = location.onlyServer {
singleServerRow(location, onlyServer)
} else if profile.providerRandomizesServer ?? false {
singleServerRow(location, nil)
} else {
multipleServersRow(location)
}
}
private func multipleServersRow(_ location: ProviderLocation) -> some View {
NavigationLink(destination: {
ServerListView(
location: location,
selectedServer: $selectedServer
).navigationTitle(location.localizedCountry)
}, label: {
LocationRow(
location: location,
selectedLocationId: selectedServer?.locationId
)
})
}
private func singleServerRow(_ location: ProviderLocation, _ server: ProviderServer?) -> some View {
Button {
selectedServer = server ?? location.servers?.randomElement()
} label: {
LocationRow(
location: location,
selectedLocationId: selectedServer?.locationId
)
}
}
private var emptyFavoritesSection: some View {
Section {
} footer: {
Text(L10n.Provider.Location.Sections.EmptyFavorites.footer)
}
}
@available(iOS 15, *)
private func favoriteActions(_ location: ProviderLocation) -> some View {
Button {
withAnimation {
toggleFavoriteLocation(location)
}
} label: {
themeFavoriteActionImage(!isFavoriteLocation(location)).asSystemImage
}.themePrimaryTintStyle()
}
}
extension ProviderLocationView {
private func server(withId serverId: String) -> ProviderServer? {
providerManager.server(withId: serverId)
}
private var categories: [ProviderCategory] {
providerManager.categories(providerName, vpnProtocol: vpnProtocol)
.filter {
!filteredLocations(for: $0).isEmpty
}.sorted()
}
private func filteredLocations(for category: ProviderCategory) -> [ProviderLocation] {
let locations: [ProviderLocation]
if isShowingFavorites {
locations = category.locations.filter {
favoriteLocationIds?.contains($0.id) ?? false
}
} else {
locations = category.locations
}
return locations.sorted()
}
private func isFavoriteLocation(_ location: ProviderLocation) -> Bool {
favoriteLocationIds?.contains(location.id) ?? false
}
private func toggleFavoriteLocation(_ location: ProviderLocation) {
if !isFavoriteLocation(location) {
if favoriteLocationIds == nil {
favoriteLocationIds = [location.id]
} else {
favoriteLocationIds?.insert(location.id)
}
} else {
favoriteLocationIds?.remove(location.id)
}
// may trigger view updates?
// pp_log.debug("New favorite locations: \(favoriteLocationIds ?? [])")
}
}
extension ProviderLocationView {
@ -293,21 +141,183 @@ extension ProviderLocationView {
}
}
}
private var servers: [ProviderServer] {
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)
}
}

View File

@ -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() ?? []
}
}

View File

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

View File

@ -71,8 +71,13 @@ extension ShortcutsView {
}
}.navigationTitle(L10n.Shortcuts.Add.title)
}
}
}
private var addConnectView: some View {
// MARK: -
private extension ShortcutsView.AddView {
var addConnectView: some View {
Button(L10n.Shortcuts.Add.Items.Connect.caption) {
if target.isProvider {
pendingProfile.value = target
@ -83,7 +88,7 @@ extension ShortcutsView {
}
}
private var hiddenProviderLocationLink: some View {
var hiddenProviderLocationLink: some View {
NavigationLink("", isActive: $isPresentingProviderLocation) {
ProviderLocationView(
currentProfile: pendingProfile,
@ -92,11 +97,8 @@ extension ShortcutsView {
)
}
}
}
}
extension ShortcutsView.AddView {
private var isProviderLocationPresented: Binding<Bool> {
var isProviderLocationPresented: Binding<Bool> {
.init {
isPresentingProviderLocation
} set: {
@ -106,14 +108,18 @@ extension ShortcutsView.AddView {
}
}
}
}
private func addConnect(_ header: Profile.Header) {
// MARK: -
private extension ShortcutsView.AddView {
func addConnect(_ header: Profile.Header) {
pendingShortcut = INShortcut(intent: IntentDispatcher.intentConnect(
header: header
))
}
private func addMoveToPendingProfile() {
func addMoveToPendingProfile() {
let header = pendingProfile.value.header
guard let server = pendingProfile.value.providerServer(providerManager) else {
return
@ -125,31 +131,31 @@ extension ShortcutsView.AddView {
))
}
private func addEnableVPN() {
func addEnableVPN() {
addShortcut(with: IntentDispatcher.intentEnable())
}
private func addDisableVPN() {
func addDisableVPN() {
addShortcut(with: IntentDispatcher.intentDisable())
}
private func addTrustWiFi() {
func addTrustWiFi() {
addShortcut(with: IntentDispatcher.intentTrustWiFi())
}
private func addUntrustWiFi() {
func addUntrustWiFi() {
addShortcut(with: IntentDispatcher.intentUntrustWiFi())
}
private func addTrustCellular() {
func addTrustCellular() {
addShortcut(with: IntentDispatcher.intentTrustCellular())
}
private func addUntrustCellular() {
func addUntrustCellular() {
addShortcut(with: IntentDispatcher.intentUntrustCellular())
}
private func addShortcut(with intent: INIntent) {
func addShortcut(with intent: INIntent) {
guard let shortcut = INShortcut(intent: intent) else {
fatalError("Unable to create INShortcut, intent '\(intent.description)' not exposed by app?")
}

View File

@ -78,8 +78,12 @@ struct ShortcutsView: View {
.navigationTitle(Unlocalized.Other.siri)
.themeSecondaryView()
}
}
private var shortcutsSection: some View {
// MARK: -
private extension ShortcutsView {
var shortcutsSection: some View {
Section {
ForEach(relevantShortcuts, content: rowView)
} header: {
@ -87,13 +91,13 @@ struct ShortcutsView: View {
}
}
private var relevantShortcuts: [Shortcut] {
var relevantShortcuts: [Shortcut] {
intentsManager.shortcuts.values.filter {
$0.isRelevant(to: target)
}.sorted()
}
private var addSection: some View {
var addSection: some View {
Section {
NavigationLink(isActive: $isNavigationPresented) {
AddView(
@ -109,7 +113,7 @@ struct ShortcutsView: View {
}
@ViewBuilder
private func presentedModal(_ modalType: ModalType) -> some View {
func presentedModal(_ modalType: ModalType) -> some View {
switch modalType {
case .edit(let shortcut):
IntentEditView(
@ -125,7 +129,7 @@ struct ShortcutsView: View {
}
}
private func rowView(forShortcut vs: Shortcut) -> some View {
func rowView(forShortcut vs: Shortcut) -> some View {
Button {
presentEditShortcut(vs)
} label: {
@ -133,7 +137,7 @@ struct ShortcutsView: View {
}
}
private var delegatingPendingShortcut: Binding<INShortcut?> {
var delegatingPendingShortcut: Binding<INShortcut?> {
.init {
pendingShortcut
} set: {
@ -143,15 +147,6 @@ struct ShortcutsView: View {
presentAddShortcut(pendingShortcut)
}
}
private func presentEditShortcut(_ shortcut: Shortcut) {
modalType = .edit(shortcut: shortcut)
}
private func presentAddShortcut(_ shortcut: INShortcut) {
isNavigationPresented = false
modalType = .add(shortcut: shortcut)
}
}
private extension Shortcut {
@ -168,3 +163,16 @@ private extension Shortcut {
return true
}
}
// MARK: -
private extension ShortcutsView {
func presentEditShortcut(_ shortcut: Shortcut) {
modalType = .edit(shortcut: shortcut)
}
func presentAddShortcut(_ shortcut: INShortcut) {
isNavigationPresented = false
modalType = .add(shortcut: shortcut)
}
}

View File

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

View File

@ -41,34 +41,6 @@ struct VPNToggle: View {
private let rateLimit: Int
private var isEnabled: Binding<Bool> {
.init {
isActiveProfile && currentVPNState.isEnabled && !shouldPromptForAccount
} set: { newValue in
guard !shouldPromptForAccount else {
interactiveProfile = profile
return
}
guard newValue else {
disableVPN()
return
}
enableVPN()
}
}
private var isActiveProfile: Bool {
profileManager.isActiveProfile(profile.id)
}
private var shouldPromptForAccount: Bool {
profile.account.authenticationMethod == .interactive && (currentVPNState.vpnStatus == .disconnecting || currentVPNState.vpnStatus == .disconnected)
}
private var isEligibleForSiri: Bool {
productManager.isEligible(forFeature: .siriShortcuts)
}
@State private var canToggle = true
init(profile: Profile, interactiveProfile: Binding<Profile?>, rateLimit: Int) {
@ -86,8 +58,44 @@ struct VPNToggle: View {
.disabled(!canToggle)
.themeAnimation(on: currentVPNState.isEnabled)
}
}
private func enableVPN() {
// MARK: -
private extension VPNToggle {
var isEnabled: Binding<Bool> {
.init {
isActiveProfile && currentVPNState.isEnabled && !shouldPromptForAccount
} set: { newValue in
guard !shouldPromptForAccount else {
interactiveProfile = profile
return
}
guard newValue else {
disableVPN()
return
}
enableVPN()
}
}
var isActiveProfile: Bool {
profileManager.isActiveProfile(profile.id)
}
var shouldPromptForAccount: Bool {
profile.account.authenticationMethod == .interactive && (currentVPNState.vpnStatus == .disconnecting || currentVPNState.vpnStatus == .disconnected)
}
var isEligibleForSiri: Bool {
productManager.isEligible(forFeature: .siriShortcuts)
}
}
// MARK: -
private extension VPNToggle {
func enableVPN() {
Task { @MainActor in
canToggle = false
await Task.maybeWait(forMilliseconds: rateLimit)
@ -104,7 +112,7 @@ struct VPNToggle: View {
}
}
private func disableVPN() {
func disableVPN() {
Task { @MainActor in
canToggle = false
await vpnManager.disable()
@ -112,7 +120,7 @@ struct VPNToggle: View {
}
}
private func donateIntents(withProfile profile: Profile) {
func donateIntents(withProfile profile: Profile) {
// eligibility: donate intents if eligible for Siri
guard isEligibleForSiri else {