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 import SwiftUI
struct AboutView: View { struct AboutView: View {
// private let appName = Unlocalized.appName
private let versionString = Constants.Global.appVersionString private let versionString = Constants.Global.appVersionString
private let redditURL = Constants.URLs.subreddit private let redditURL = Constants.URLs.subreddit
@ -52,11 +50,15 @@ struct AboutView: View {
supportSection supportSection
webSection webSection
githubSection 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 { Section {
NavigationLink { NavigationLink {
VersionView() VersionView()
@ -70,7 +72,7 @@ struct AboutView: View {
} }
} }
private var supportSection: some View { var supportSection: some View {
Section { Section {
Button(L10n.About.Items.JoinCommunity.caption) { Button(L10n.About.Items.JoinCommunity.caption) {
URL.open(redditURL) URL.open(redditURL)
@ -82,7 +84,7 @@ struct AboutView: View {
} }
} }
private var webSection: some View { var webSection: some View {
Section { Section {
Button(L10n.About.Items.Website.caption) { Button(L10n.About.Items.Website.caption) {
URL.open(homeURL) URL.open(homeURL)
@ -101,7 +103,7 @@ struct AboutView: View {
} }
} }
private var githubSection: some View { var githubSection: some View {
Section { Section {
Button(Unlocalized.About.readme) { Button(Unlocalized.About.readme) {
URL.open(readmeURL) URL.open(readmeURL)
@ -115,13 +117,15 @@ struct AboutView: View {
} }
} }
extension AboutView { // MARK: -
private func shareOnTwitter() {
private extension AboutView {
func shareOnTwitter() {
let url = Unlocalized.Social.twitterIntent(withMessage: shareMessage) let url = Unlocalized.Social.twitterIntent(withMessage: shareMessage)
URL.open(url) URL.open(url)
} }
private func submitReview() { func submitReview() {
let reviewURL = Reviewer.urlForReview(withAppId: Constants.App.appStoreId) let reviewURL = Reviewer.urlForReview(withAppId: Constants.App.appStoreId)
URL.open(reviewURL) URL.open(reviewURL)
} }

View File

@ -58,7 +58,7 @@ struct AccountView: View {
var body: some View { var body: some View {
List { List {
// TODO: interactive, re-enable after fixing // TODO: interactive, re-enable after fixing
// Section { // Section {
// // TODO: interactive, l10n // // TODO: interactive, l10n
// themeTextPicker(L10n.Global.Strings.authentication, selection: $liveAccount.authenticationMethod ?? .persistent, values: [ // themeTextPicker(L10n.Global.Strings.authentication, selection: $liveAccount.authenticationMethod ?? .persistent, values: [
@ -83,7 +83,7 @@ struct AccountView: View {
.withLeadingText(L10n.Account.Items.Password.caption) .withLeadingText(L10n.Account.Items.Password.caption)
} }
// TODO: interactive, scan QR code // TODO: interactive, scan QR code
case .totp: case .totp:
themeSecureField(L10n.Account.Items.Password.placeholder, text: $liveAccount.password, contentType: .oneTimeCode) themeSecureField(L10n.Account.Items.Password.placeholder, text: $liveAccount.password, contentType: .oneTimeCode)
.withLeadingText(L10n.Account.Items.Seed.caption) .withLeadingText(L10n.Account.Items.Seed.caption)
@ -102,8 +102,7 @@ struct AccountView: View {
} }
} }
} }
}.navigationTitle(L10n.Account.title) }.toolbar {
.toolbar {
CopySavingButton( CopySavingButton(
original: $account, original: $account,
copy: $liveAccount, copy: $liveAccount,
@ -112,25 +111,21 @@ struct AccountView: View {
saveAnyway: saveAnyway, saveAnyway: saveAnyway,
onSave: onSave onSave: onSave
) )
} }.navigationTitle(L10n.Account.title)
}
private func openGuidanceURL(_ url: URL) {
URL.open(url)
} }
} }
// MARK: Provider // MARK: -
extension AccountView { private extension AccountView {
private var usernamePlaceholder: String? { var usernamePlaceholder: String? {
guard let name = providerName else { guard let name = providerName else {
return nil return nil
} }
return providerManager.defaultUsername(name, vpnProtocol: vpnProtocol) return providerManager.defaultUsername(name, vpnProtocol: vpnProtocol)
} }
private var metadata: ProviderMetadata? { var metadata: ProviderMetadata? {
guard let name = providerName else { guard let name = providerName else {
return nil 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 @State private var isEnteringCredentials = false
private var isComplete: Bool {
!viewModel.processedProfile.isPlaceholder
}
init( init(
url: URL, url: URL,
deletingURLOnSuccess: Bool, deletingURLOnSuccess: Bool,
@ -84,123 +80,136 @@ extension AddHostView {
.navigationTitle(L10n.AddProfile.Shared.title) .navigationTitle(L10n.AddProfile.Shared.title)
.themeSecondaryView() .themeSecondaryView()
} }
}
}
@ViewBuilder // MARK: -
private var mainView: some View {
AddProfileView.ProfileNameSection(
profileName: $viewModel.profileName,
errorMessage: viewModel.errorMessage
) {
processProfile(replacingExisting: false)
}.onAppear {
viewModel.presetName(withURL: url)
}.disabled(isComplete)
if !isComplete { private extension AddHostView.NameView {
if viewModel.requiresPassphrase {
encryptionSection @ViewBuilder
} var mainView: some View {
let headers = profileManager.headers.sorted() AddProfileView.ProfileNameSection(
if !headers.isEmpty { profileName: $viewModel.profileName,
AddProfileView.ExistingProfilesSection( errorMessage: viewModel.errorMessage
headers: headers, ) {
profileName: $viewModel.profileName processProfile(replacingExisting: false)
) }.onAppear {
} viewModel.presetName(withURL: url)
} else { }.disabled(isComplete)
completeSection
if !isComplete {
if viewModel.requiresPassphrase {
encryptionSection
} }
} let headers = profileManager.headers.sorted()
if !headers.isEmpty {
private var encryptionSection: some View { AddProfileView.ExistingProfilesSection(
Section { headers: headers,
SecureField(L10n.AddProfile.Host.Sections.Encryption.footer, text: $viewModel.encryptionPassphrase) { profileName: $viewModel.profileName
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
) )
} }
} else {
completeSection
} }
}
private var nextString: String { var encryptionSection: some View {
if !viewModel.processedProfile.isPlaceholder { Section {
return viewModel.processedProfile.requiresCredentials ? L10n.Global.Strings.next : L10n.Global.Strings.save SecureField(L10n.AddProfile.Host.Sections.Encryption.footer, text: $viewModel.encryptionPassphrase) {
} else { processProfile(replacingExisting: false)
return L10n.Global.Strings.next
} }
} header: {
Text(L10n.Global.Strings.encryption)
} }
}
private func requestResourcePermissions() { var completeSection: some View {
_ = url.startAccessingSecurityScopedResource() Section {
} Text(Unlocalized.Network.url)
.withTrailingText(url.lastPathComponent)
private func dropResourcePermissions() { viewModel.processedProfile.vpnProtocols.first.map {
url.stopAccessingSecurityScopedResource() Text(L10n.Global.Strings.protocol)
} .withTrailingText($0.description)
@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)
} }
} header: {
Text(L10n.AddProfile.Shared.title)
} footer: {
themeErrorMessage(viewModel.errorMessage)
} }
}
private func alertOverwriteMessage() -> some View { var hiddenAccountLink: some View {
Text(L10n.AddProfile.Shared.Alerts.Overwrite.message) NavigationLink("", isActive: $isEnteringCredentials) {
} AddProfileView.AccountWrapperView(
profile: $viewModel.processedProfile,
private func processProfile(replacingExisting: Bool) { bindings: bindings
viewModel.processURL(
url,
with: profileManager,
replacingExisting: replacingExisting,
deletingURLOnSuccess: deletingURLOnSuccess
) )
} }
}
private func saveProfile() { var nextString: String {
let result = viewModel.addProcessedProfile(to: profileManager) if !viewModel.processedProfile.isPlaceholder {
guard result else { return viewModel.processedProfile.requiresCredentials ? L10n.Global.Strings.next : L10n.Global.Strings.save
return } else {
} return L10n.Global.Strings.next
}
}
let profile = viewModel.processedProfile @ViewBuilder
if profile.requiresCredentials { func alertOverwriteActions() -> some View {
isEnteringCredentials = true Button(role: .destructive) {
} else { processProfile(replacingExisting: true)
bindings.isPresented = false } label: {
profileManager.didCreateProfile.send(profile) 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)
} }
} }
} }

View File

@ -61,11 +61,11 @@ struct AddProfileMenu: View {
Button(action: presentHostFileImporter) { Button(action: presentHostFileImporter) {
Label(L10n.Menu.Contextual.AddProfile.fromFiles, systemImage: themeHostFilesImage) Label(L10n.Menu.Contextual.AddProfile.fromFiles, systemImage: themeHostFilesImage)
} }
// Button { // Button {
// // TODO: add profile from text // // TODO: add profile from text
// } label: { // } label: {
// Label(L10n.Organizer.Menus.AddProfile.fromText, systemImage: themeHostTextImage) // Label(L10n.Organizer.Menus.AddProfile.fromText, systemImage: themeHostTextImage)
// } // }
if let urls = importedURLs, !urls.isEmpty { if let urls = importedURLs, !urls.isEmpty {
Divider() Divider()
ForEach(urls, id: \.absoluteString, content: importedURLRow) ForEach(urls, id: \.absoluteString, content: importedURLRow)
@ -74,9 +74,14 @@ struct AddProfileMenu: View {
themeAddMenuImage.asSystemImage themeAddMenuImage.asSystemImage
}.sheet(item: $modalType, content: presentedModal) }.sheet(item: $modalType, content: presentedModal)
} }
}
// MARK: -
private extension AddProfileMenu {
@ViewBuilder @ViewBuilder
private func presentedModal(_ modalType: ModalType) -> some View { func presentedModal(_ modalType: ModalType) -> some View {
switch modalType { switch modalType {
case .addProvider: case .addProvider:
NavigationView { NavigationView {
@ -100,7 +105,7 @@ struct AddProfileMenu: View {
} }
} }
private var isModalPresented: Binding<Bool> { var isModalPresented: Binding<Bool> {
.init { .init {
modalType != nil modalType != nil
} set: { } 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)) { Button(L10n.Menu.Contextual.AddProfile.imported(url.lastPathComponent)) {
presentAddHost(withURL: url, deletingURLOnSuccess: true) presentAddHost(withURL: url, deletingURLOnSuccess: true)
} }
} }
private var importedURLs: [URL]? { var importedURLs: [URL]? {
do { do {
let url = FileManager.default.userURL(for: .documentDirectory, appending: nil) let url = FileManager.default.userURL(for: .documentDirectory, appending: nil)
let list = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) let list = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil)
@ -129,16 +134,18 @@ struct AddProfileMenu: View {
} }
} }
extension AddProfileMenu { // MARK: -
private func presentAddProvider() {
private extension AddProfileMenu {
func presentAddProvider() {
modalType = .addProvider modalType = .addProvider
} }
private func presentAddHost(withURL url: URL, deletingURLOnSuccess: Bool) { func presentAddHost(withURL url: URL, deletingURLOnSuccess: Bool) {
modalType = .addHost(url, deletingURLOnSuccess) modalType = .addHost(url, deletingURLOnSuccess)
} }
private func presentHostFileImporter() { func presentHostFileImporter() {
// XXX: iOS bug, hack around crappy bug when dismissing by swiping down // XXX: iOS bug, hack around crappy bug when dismissing by swiping down
// //

View File

@ -84,50 +84,58 @@ extension AddProviderView {
message: alertOverwriteMessage message: alertOverwriteMessage
).navigationTitle(providerMetadata.fullName) ).navigationTitle(providerMetadata.fullName)
} }
}
}
private var hiddenAccountLink: some View { // MARK: -
NavigationLink("", isActive: $isEnteringCredentials) {
AddProfileView.AccountWrapperView(
profile: $profile,
bindings: bindings
)
}
}
@ViewBuilder private extension AddProviderView.NameView {
private func alertOverwriteActions() -> some View { var hiddenAccountLink: some View {
Button(role: .destructive) { NavigationLink("", isActive: $isEnteringCredentials) {
saveProfile(replacingExisting: true) AddProfileView.AccountWrapperView(
} label: { profile: $profile,
Text(L10n.Global.Strings.ok) bindings: bindings
}
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
) )
guard let addedProfile = addedProfile else { }
return }
}
profile = addedProfile
if profile.requiresCredentials { @ViewBuilder
isEnteringCredentials = true func alertOverwriteActions() -> some View {
} else { Button(role: .destructive) {
bindings.isPresented = false saveProfile(replacingExisting: true)
profileManager.didCreateProfile.send(profile) } 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)
} }
} }
} }

View File

@ -41,23 +41,6 @@ struct AddProviderView: View {
self.bindings = bindings 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 { var body: some View {
ZStack { ZStack {
ForEach(providers, id: \.navigationId, content: hiddenProviderLink) ForEach(providers, id: \.navigationId, content: hiddenProviderLink)
@ -80,10 +63,14 @@ struct AddProviderView: View {
PaywallView(isPresented: $viewModel.isPaywallPresented) PaywallView(isPresented: $viewModel.isPaywallPresented)
}.themeGlobal() }.themeGlobal()
}.navigationTitle(L10n.AddProfile.Shared.title) }.navigationTitle(L10n.AddProfile.Shared.title)
.themeSecondaryView() .themeSecondaryView()
} }
}
private var mainSection: some View { // MARK: -
private extension AddProviderView {
var mainSection: some View {
Section { Section {
let protos = availableVPNProtocols let protos = availableVPNProtocols
if !protos.isEmpty { if !protos.isEmpty {
@ -100,7 +87,7 @@ struct AddProviderView: View {
} }
} }
private var providersSection: some View { var providersSection: some View {
Section { Section {
ForEach(providers, content: providerRow) ForEach(providers, content: providerRow)
} footer: { } footer: {
@ -108,7 +95,7 @@ struct AddProviderView: View {
}.disabled(viewModel.isFetchingAnyProvider) }.disabled(viewModel.isFetchingAnyProvider)
} }
private func providerRow(_ metadata: ProviderMetadata) -> some View { func providerRow(_ metadata: ProviderMetadata) -> some View {
Button { Button {
presentOrPurchaseProvider(metadata) presentOrPurchaseProvider(metadata)
} label: { } label: {
@ -116,7 +103,7 @@ struct AddProviderView: View {
}.withTrailingProgress(when: viewModel.isFetchingProvider(metadata.name)) }.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) { NavigationLink("", tag: metadata, selection: $viewModel.selectedProvider) {
NameView( NameView(
profile: $viewModel.pendingProfile, 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) { Button(L10n.AddProfile.Provider.Items.updateList) {
viewModel.updateIndex(providerManager) viewModel.updateIndex(providerManager)
}.withTrailingProgress(when: viewModel.isUpdatingIndex) }.withTrailingProgress(when: viewModel.isUpdatingIndex)
.disabled(viewModel.isUpdatingIndex) .disabled(viewModel.isUpdatingIndex)
} }
// eligibility: select or purchase provider var providers: [ProviderMetadata] {
private func presentOrPurchaseProvider(_ metadata: ProviderMetadata) { providerManager.allProviders()
guard productManager.isEligible(forProvider: metadata.name) else { .filter {
viewModel.presentPaywall() $0.supportedVPNProtocols.contains(viewModel.selectedVPNProtocol)
return }.sorted()
}
var availableVPNProtocols: [VPNProtocolType] {
var protos: Set<VPNProtocolType> = []
providers.forEach {
$0.supportedVPNProtocols.forEach {
protos.insert($0)
}
} }
viewModel.selectProvider(metadata, providerManager) return protos.sorted()
}
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)
} }
} }
@ -161,3 +143,28 @@ private extension ProviderMetadata {
"navigation.\(name)" "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) .navigationTitle(title)
.themeDebugLogStyle() .themeDebugLogStyle()
} }
}
private var contentView: some View { // MARK: -
private extension DebugLogView {
var contentView: some View {
LazyVStack { LazyVStack {
ForEach(logLines.indices, id: \.self) { ForEach(logLines.indices, id: \.self) {
Text(logLines[$0]) Text(logLines[$0])
@ -100,32 +104,11 @@ struct DebugLogView: View {
// TODO: layout, a slight padding would be nice, but it glitches on first touch // TODO: layout, a slight padding would be nice, but it glitches on first touch
} }
private func refreshLog(scrollingToLatestWith scrollProxy: ScrollViewProxy?) { func sharingActivityView() -> some View {
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 {
ActivityView(activityItems: sharingItems) ActivityView(activityItems: sharingItems)
} }
private var sharingItems: [Any] { var sharingItems: [Any] {
let raw = logLines.joined(separator: "\n") let raw = logLines.joined(separator: "\n")
let data = DebugLog(content: raw) let data = DebugLog(content: raw)
.decoratedData(appName, appVersion) .decoratedData(appName, appVersion)
@ -143,8 +126,29 @@ extension DebugLogView {
} }
} }
extension DebugLogView { // MARK: -
private func copyDebugLog() {
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 { guard !logLines.isEmpty else {
assertionFailure("Log is empty, why could it copy?") assertionFailure("Log is empty, why could it copy?")
return return
@ -155,10 +159,8 @@ extension DebugLogView {
Utils.copyToPasteboard(content) Utils.copyToPasteboard(content)
} }
}
extension DebugLogView { func scrollToLatestUpdate(_ proxy: ScrollViewProxy) {
private func scrollToLatestUpdate(_ proxy: ScrollViewProxy) {
proxy.maybeScrollTo(logLines.count - 1, anchor: .bottomLeading) proxy.maybeScrollTo(logLines.count - 1, anchor: .bottomLeading)
} }
} }

View File

@ -47,10 +47,6 @@ extension DiagnosticsView {
private let providerName: ProviderName? private let providerName: ProviderName?
private var isEligibleForFeedback: Bool {
productManager.isEligibleForFeedback()
}
@State private var isReportingIssue = false @State private var isReportingIssue = false
@State private var isAlertPresented = false @State private var isAlertPresented = false
@ -85,75 +81,77 @@ extension DiagnosticsView {
message: alertMessage 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 { // MARK: -
private var currentServerConfiguration: OpenVPN.ConfigurationBuilder? {
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 { guard currentVPNState.vpnStatus == .connected else {
return nil return nil
} }
@ -164,17 +162,23 @@ extension DiagnosticsView.OpenVPNView {
return cfg.builder(withFallbacks: false) return cfg.builder(withFallbacks: false)
} }
private var appLogURL: URL? { var appLogURL: URL? {
Passepartout.shared.logger.logFile Passepartout.shared.logger.logFile
} }
private var tunnelLogURL: URL? { var tunnelLogURL: URL? {
vpnManager.debugLogURL(forProtocol: vpnProtocol) vpnManager.debugLogURL(forProtocol: vpnProtocol)
} }
var isEligibleForFeedback: Bool {
productManager.isEligibleForFeedback()
}
} }
extension DiagnosticsView.OpenVPNView { // MARK: -
private func presentReportIssue() {
private extension DiagnosticsView.OpenVPNView {
func presentReportIssue() {
guard MailComposerView.canSendMail() else { guard MailComposerView.canSendMail() else {
openReportIssueMailTo() openReportIssueMailTo()
return return
@ -182,7 +186,7 @@ extension DiagnosticsView.OpenVPNView {
isReportingIssue = true isReportingIssue = true
} }
private func openReportIssueMailTo() { func openReportIssueMailTo() {
let V = Unlocalized.Issues.self let V = Unlocalized.Issues.self
let body = V.body(V.template, DebugLog(content: "--").decoratedString()) let body = V.body(V.template, DebugLog(content: "--").decoratedString())

View File

@ -50,12 +50,14 @@ extension DiagnosticsView {
} }
} }
extension DiagnosticsView.WireGuardView { // MARK: -
private var appLogURL: URL? {
private extension DiagnosticsView.WireGuardView {
var appLogURL: URL? {
Passepartout.shared.logger.logFile Passepartout.shared.logger.logFile
} }
private var tunnelLogURL: URL? { var tunnelLogURL: URL? {
vpnManager.debugLogURL(forProtocol: .wireGuard) vpnManager.debugLogURL(forProtocol: .wireGuard)
} }
} }

View File

@ -60,33 +60,37 @@ extension DiagnosticsView {
appLink appLink
tunnelLink tunnelLink
} }
}
private var appLink: some View { }
navigationLink(
withTitle: L10n.Diagnostics.Items.AppLog.title, // MARK: -
url: appLogURL,
refreshInterval: nil private extension DiagnosticsView.DebugLogSection {
) var appLink: some View {
} navigationLink(
withTitle: L10n.Diagnostics.Items.AppLog.title,
private var tunnelLink: some View { url: appLogURL,
navigationLink( refreshInterval: nil
withTitle: Unlocalized.VPN.vpn, )
url: tunnelLogURL, }
refreshInterval: refreshInterval
) var tunnelLink: some View {
} navigationLink(
withTitle: Unlocalized.VPN.vpn,
private func navigationLink(withTitle title: String, url: URL?, refreshInterval: TimeInterval?) -> some View { url: tunnelLogURL,
NavigationLink(title) { refreshInterval: refreshInterval
url.map { )
DebugLogView( }
title: title,
url: $0, func navigationLink(withTitle title: String, url: URL?, refreshInterval: TimeInterval?) -> some View {
refreshInterval: refreshInterval NavigationLink(title) {
) url.map {
} DebugLogView(
}.disabled(url == nil) title: title,
} url: $0,
refreshInterval: refreshInterval
)
}
}.disabled(url == nil)
} }
} }

View File

@ -76,8 +76,12 @@ struct DonateView: View {
} }
}.themeAnimation(on: productManager.isRefreshingProducts) }.themeAnimation(on: productManager.isRefreshingProducts)
} }
}
private func alertActions(_ alertType: AlertType) -> some View { // MARK: -
private extension DonateView {
func alertActions(_ alertType: AlertType) -> some View {
switch alertType { switch alertType {
case .thankYou: case .thankYou:
return Button(role: .cancel) { 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 { switch alertType {
case .thankYou: case .thankYou:
return Text(L10n.Donate.Alerts.Purchase.Success.message) return Text(L10n.Donate.Alerts.Purchase.Success.message)
} }
} }
private var productsSection: some View { var productsSection: some View {
Section { Section {
if !productManager.isRefreshingProducts { if !productManager.isRefreshingProducts {
ForEach(productManager.donations, id: \.productIdentifier, content: productRow) ForEach(productManager.donations, id: \.productIdentifier, content: productRow)
@ -109,7 +113,7 @@ struct DonateView: View {
} }
@ViewBuilder @ViewBuilder
private func productRow(_ product: SKProduct) -> some View { func productRow(_ product: SKProduct) -> some View {
HStack { HStack {
Button(product.localizedTitle) { Button(product.localizedTitle) {
purchaseProduct(product) purchaseProduct(product)
@ -127,13 +131,27 @@ struct DonateView: View {
} }
} }
extension DonateView { private extension ProductManager {
private func purchaseProduct(_ product: SKProduct) { 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 pendingDonationIdentifier = product.productIdentifier
productManager.purchase(product, completionHandler: handlePurchaseResult) productManager.purchase(product, completionHandler: handlePurchaseResult)
} }
private func handlePurchaseResult(_ result: Result<InAppPurchaseResult, Error>) { func handlePurchaseResult(_ result: Result<InAppPurchaseResult, Error>) {
switch result { switch result {
case .success(let value): case .success(let value):
if case .done = value { if case .done = value {
@ -152,15 +170,3 @@ extension DonateView {
pendingDonationIdentifier = nil 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 { // MARK: -
private func pullSection(configuration: OpenVPN.Configuration) -> some View {
private extension EndpointAdvancedView.OpenVPNView {
func pullSection(configuration: OpenVPN.Configuration) -> some View {
configuration.pullMask.map { mask in configuration.pullMask.map { mask in
Section { Section {
ForEach(mask, id: \.self) { ForEach(mask, id: \.self) {
@ -80,7 +82,7 @@ extension EndpointAdvancedView.OpenVPNView {
} }
} }
private var ipv4Section: some View { var ipv4Section: some View {
Section { Section {
if let settings = builder.ipv4 { if let settings = builder.ipv4 {
themeLongContentLinkDefault( themeLongContentLinkDefault(
@ -105,7 +107,7 @@ extension EndpointAdvancedView.OpenVPNView {
} }
} }
private var ipv6Section: some View { var ipv6Section: some View {
Section { Section {
if let settings = builder.ipv6 { if let settings = builder.ipv6 {
themeLongContentLinkDefault( 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 configuration.communicationSettings.map { settings in
Section { Section {
settings.cipher.map { settings.cipher.map {
@ -157,7 +159,7 @@ extension EndpointAdvancedView.OpenVPNView {
} }
} }
private var communicationEditableSection: some View { var communicationEditableSection: some View {
Section { Section {
themeTextPicker( themeTextPicker(
L10n.Endpoint.Advanced.Openvpn.Items.Cipher.caption, 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 configuration.compressionSettings.map { settings in
Section { Section {
settings.framing.map { settings.framing.map {
@ -203,7 +205,7 @@ extension EndpointAdvancedView.OpenVPNView {
} }
} }
private var compressionEditableSection: some View { var compressionEditableSection: some View {
Section { Section {
themeTextPicker( themeTextPicker(
L10n.Endpoint.Advanced.Openvpn.Items.CompressionFraming.caption, 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 configuration.dnsSettings.map { settings in
Section { Section {
ForEach(settings.servers, id: \.self) { 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 configuration.proxySettings.map { settings in
Section { Section {
settings.proxy.map { settings.proxy.map {
@ -260,7 +262,7 @@ extension EndpointAdvancedView.OpenVPNView {
} }
} }
private var tlsSection: some View { var tlsSection: some View {
Section { Section {
builder.ca.map { ca in builder.ca.map { ca in
themeLongContentLink( 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 configuration.otherSettings.map { settings in
Section { Section {
settings.keepAlive.map { settings.keepAlive.map {

View File

@ -44,8 +44,10 @@ extension EndpointAdvancedView {
} }
} }
extension EndpointAdvancedView.WireGuardView { // MARK: -
private var keySection: some View {
private extension EndpointAdvancedView.WireGuardView {
var keySection: some View {
Section { Section {
themeLongContentLink(L10n.Global.Strings.privateKey, content: .constant(builder.privateKey)) themeLongContentLink(L10n.Global.Strings.privateKey, content: .constant(builder.privateKey))
themeLongContentLink(L10n.Global.Strings.publicKey, content: .constant(builder.publicKey)) 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 { Section {
ForEach(builder.addresses, id: \.self, content: Text.init) ForEach(builder.addresses, id: \.self, content: Text.init)
} header: { } 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 configuration.dnsSettings.map { settings in
Section { Section {
ForEach(settings.servers, id: \.self) { 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 builder.mtu.map { mtu in
Section { Section {
Text(Unlocalized.Network.mtu) Text(Unlocalized.Network.mtu)

View File

@ -39,10 +39,6 @@ extension EndpointView {
@Binding private var customEndpoint: Endpoint? @Binding private var customEndpoint: Endpoint?
private var isConfigurationReadonly: Bool {
currentProfile.value.isProvider
}
@State private var isFirstAppearance = true @State private var isFirstAppearance = true
@State private var isAutomatic = false @State private var isAutomatic = false
@ -129,14 +125,16 @@ extension EndpointView {
} }
} }
extension EndpointView.OpenVPNView { // MARK: -
private var mainSection: some View {
private extension EndpointView.OpenVPNView {
var mainSection: some View {
Section { Section {
Toggle(L10n.Global.Strings.automatic, isOn: $isAutomatic.themeAnimation()) Toggle(L10n.Global.Strings.automatic, isOn: $isAutomatic.themeAnimation())
} }
} }
private var filtersSection: some View { var filtersSection: some View {
Section { Section {
themeTextPicker( themeTextPicker(
L10n.Global.Strings.protocol, L10n.Global.Strings.protocol,
@ -153,7 +151,7 @@ extension EndpointView.OpenVPNView {
} }
} }
private var addressesSection: some View { var addressesSection: some View {
Section { Section {
filteredRemotes.map { filteredRemotes.map {
ForEach($0, content: button(forEndpoint:)) ForEach($0, content: button(forEndpoint:))
@ -163,7 +161,7 @@ extension EndpointView.OpenVPNView {
} }
} }
private var advancedSection: some View { var advancedSection: some View {
Section { Section {
let caption = L10n.Endpoint.Advanced.title let caption = L10n.Endpoint.Advanced.title
NavigationLink(caption) { 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 { Button {
customEndpoint = endpoint customEndpoint = endpoint
presentationMode.wrappedValue.dismiss() presentationMode.wrappedValue.dismiss()
@ -185,56 +183,12 @@ extension EndpointView.OpenVPNView {
}.withTrailingCheckmark(when: customEndpoint == endpoint) }.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) Text(endpoint?.address ?? L10n.Global.Strings.automatic)
.themeLongTextStyle() .themeLongTextStyle()
} }
}
extension EndpointView.OpenVPNView { var availableSocketTypes: [SocketType] {
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] {
guard let remotes = builder.remotes else { guard let remotes = builder.remotes else {
return [] return []
} }
@ -256,7 +210,7 @@ extension EndpointView.OpenVPNView {
return availableTypes return availableTypes
} }
private func allPorts(forSocketType socketType: SocketType) -> [UInt16] { func allPorts(forSocketType socketType: SocketType) -> [UInt16] {
guard let remotes = builder.remotes else { guard let remotes = builder.remotes else {
return [] return []
} }
@ -266,15 +220,63 @@ extension EndpointView.OpenVPNView {
return Array(allPorts).sorted() return Array(allPorts).sorted()
} }
private var filteredRemotes: [Endpoint]? { var filteredRemotes: [Endpoint]? {
builder.remotes?.filter { builder.remotes?.filter {
$0.proto.socketType == selectedSocketType && $0.proto.port == selectedPort $0.proto.socketType == selectedSocketType && $0.proto.port == selectedPort
} }
} }
var isConfigurationReadonly: Bool {
currentProfile.value.isProvider
}
} }
extension EndpointView.OpenVPNView { // MARK: -
private func scrollToCustomEndpoint(_ proxy: ScrollViewProxy) {
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) proxy.maybeScrollTo(customEndpoint?.id)
} }
} }

View File

@ -88,8 +88,10 @@ extension EndpointView {
} }
} }
extension EndpointView.WireGuardView { // MARK: -
private var peersSections: some View {
private extension EndpointView.WireGuardView {
var peersSections: some View {
// TODO: WireGuard, make peers editable // TODO: WireGuard, make peers editable
// if !isReadonly { // 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 { Section {
themeLongContentLink( themeLongContentLink(
L10n.Global.Strings.publicKey, L10n.Global.Strings.publicKey,
@ -132,7 +134,7 @@ extension EndpointView.WireGuardView {
} }
} }
private var advancedSection: some View { var advancedSection: some View {
Section { Section {
let caption = L10n.Endpoint.Advanced.title let caption = L10n.Endpoint.Advanced.title
NavigationLink(caption) { NavigationLink(caption) {

View File

@ -64,8 +64,12 @@ struct InteractiveConnectionView: View {
} }
}.navigationTitle(profile.header.name) }.navigationTitle(profile.header.name)
} }
}
private func saveAccount() { // MARK: -
private extension InteractiveConnectionView {
func saveAccount() {
Task { Task {
try? await vpnManager.connect(with: profile.id, newPassword: password) try? await vpnManager.connect(with: profile.id, newPassword: password)
} }

View File

@ -29,10 +29,6 @@ import SwiftUI
struct NetworkSettingsView: View { struct NetworkSettingsView: View {
@ObservedObject private var currentProfile: ObservableProfile @ObservedObject private var currentProfile: ObservableProfile
private var vpnProtocol: VPNProtocolType {
currentProfile.value.currentVPNProtocol
}
@State private var settings = Profile.NetworkSettings() @State private var settings = Profile.NetworkSettings()
init(currentProfile: ObservableProfile) { 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 // MARK: Gateway
extension NetworkSettingsView { private extension NetworkSettingsView {
private var gatewayView: some View { var gatewayView: some View {
Section { Section {
Toggle(L10n.Global.Strings.automatic, isOn: $settings.isAutomaticGateway.themeAnimation()) Toggle(L10n.Global.Strings.automatic, isOn: $settings.isAutomaticGateway.themeAnimation())
@ -109,10 +83,10 @@ extension NetworkSettingsView {
// MARK: DNS // MARK: DNS
extension NetworkSettingsView { private extension NetworkSettingsView {
@ViewBuilder @ViewBuilder
private var dnsView: some View { var dnsView: some View {
Section { Section {
Toggle(L10n.Global.Strings.automatic, isOn: $settings.isAutomaticDNS.themeAnimation()) 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()) TextField(Unlocalized.Placeholders.dohURL, text: $settings.dns.dnsHTTPSURL.toString())
.themeValidURL(settings.dns.dnsHTTPSURL?.absoluteString) .themeValidURL(settings.dns.dnsHTTPSURL?.absoluteString)
} }
private var dnsManualTLSRow: some View { var dnsManualTLSRow: some View {
TextField(Unlocalized.Placeholders.dotServerName, text: $settings.dns.dnsTLSServerName ?? "") TextField(Unlocalized.Placeholders.dotServerName, text: $settings.dns.dnsTLSServerName ?? "")
.themeValidDNSOverTLSServerName(settings.dns.dnsTLSServerName) .themeValidDNSOverTLSServerName(settings.dns.dnsTLSServerName)
} }
private var dnsManualServers: some View { var dnsManualServers: some View {
Section { Section {
EditableTextList( EditableTextList(
elements: $settings.dns.dnsServers ?? [], 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 ?? "") TextField(L10n.Global.Strings.domain, text: $settings.dns.dnsDomain ?? "")
.themeValidDomainName(settings.dns.dnsDomain) .themeValidDomainName(settings.dns.dnsDomain)
} }
private var dnsManualSearchDomains: some View { var dnsManualSearchDomains: some View {
Section { Section {
EditableTextList( EditableTextList(
elements: $settings.dns.dnsSearchDomains ?? [], elements: $settings.dns.dnsSearchDomains ?? [],
@ -208,10 +182,10 @@ extension NetworkSettingsView {
// MARK: Proxy // MARK: Proxy
extension NetworkSettingsView { private extension NetworkSettingsView {
@ViewBuilder @ViewBuilder
private var proxyView: some View { var proxyView: some View {
Section { Section {
Toggle(L10n.Global.Strings.automatic, isOn: $settings.isAutomaticProxy.themeAnimation()) 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 { Section {
EditableTextList( EditableTextList(
elements: $settings.proxy.proxyBypassDomains ?? [], elements: $settings.proxy.proxyBypassDomains ?? [],
@ -273,8 +247,8 @@ extension NetworkSettingsView {
// MARK: MTU // MARK: MTU
extension NetworkSettingsView { private extension NetworkSettingsView {
private var mtuView: some View { var mtuView: some View {
Section { Section {
Toggle(L10n.Global.Strings.automatic, isOn: $settings.isAutomaticMTU.themeAnimation()) 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,47 +44,40 @@ extension OnDemandView {
Text(L10n.Global.Strings.add) Text(L10n.Global.Strings.add)
} }
} }
}
}
private func mapElements(elements: [IdentifiableString]) -> [IdentifiableString] { // MARK: -
elements
.filter { !$0.string.isEmpty }
.sorted { $0.string.lowercased() < $1.string.lowercased() }
}
private func ssidRow(callback: EditableTextFieldCallback) -> some View { private extension OnDemandView.SSIDList {
Group { func mapElements(elements: [IdentifiableString]) -> [IdentifiableString] {
if callback.isNewElement { 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) 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<String>) {
Task { @MainActor in
let ssid = try await reader.currentSSID()
if !withSSIDs.keys.contains(ssid) {
text.wrappedValue = ssid
} }
} }
} }
} }
}
extension OnDemandView.SSIDList { func ssidField(callback: EditableTextFieldCallback) -> some View {
private var allSSIDs: Binding<[String]> { TextField(
Unlocalized.Network.ssid,
text: callback.text,
onEditingChanged: callback.onEditingChanged,
onCommit: callback.onCommit
).themeValidSSID(callback.text.wrappedValue)
}
var allSSIDs: Binding<[String]> {
.init { .init {
Array(withSSIDs.keys) Array(withSSIDs.keys)
} set: { newValue in } set: { newValue in
@ -104,7 +97,7 @@ extension OnDemandView.SSIDList {
} }
} }
private var onSSIDs: Binding<Set<String>> { var onSSIDs: Binding<Set<String>> {
.init { .init {
Set(withSSIDs.filter { Set(withSSIDs.filter {
$0.value $0.value
@ -130,7 +123,7 @@ extension OnDemandView.SSIDList {
} }
} }
private func isSSIDOn(_ ssid: String) -> Binding<Bool> { func isSSIDOn(_ ssid: String) -> Binding<Bool> {
.init { .init {
withSSIDs[ssid] ?? false withSSIDs[ssid] ?? false
} set: { } 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 @ObservedObject private var currentProfile: ObservableProfile
private var isEligibleForSiri: Bool {
productManager.isEligible(forFeature: .siriShortcuts)
}
@State private var onDemand = Profile.OnDemand() @State private var onDemand = Profile.OnDemand()
init(currentProfile: ObservableProfile) { init(currentProfile: ObservableProfile) {
@ -66,21 +62,23 @@ struct OnDemandView: View {
} }
} }
extension OnDemandView { // MARK: -
private var enabledView: some View {
private extension OnDemandView {
var enabledView: some View {
Section { Section {
Toggle(L10n.Global.Strings.enabled, isOn: $onDemand.isEnabled.themeAnimation()) Toggle(L10n.Global.Strings.enabled, isOn: $onDemand.isEnabled.themeAnimation())
} }
} }
@ViewBuilder @ViewBuilder
private var mainView: some View { var mainView: some View {
if Utils.hasCellularData() { if Utils.hasCellularData() {
Section { Section {
Toggle(L10n.OnDemand.Items.Mobile.caption, isOn: $onDemand.withMobileNetwork) Toggle(L10n.OnDemand.Items.Mobile.caption, isOn: $onDemand.withMobileNetwork)
} header: { } header: {
// TODO: on-demand, restore when "trusted networks" -> "on-demand" // TODO: on-demand, restore when "trusted networks" -> "on-demand"
// Text(L10n.Profile.Sections.Trusted.header) // Text(L10n.Profile.Sections.Trusted.header)
} }
Section { Section {
SSIDList(withSSIDs: $onDemand.withSSIDs) SSIDList(withSSIDs: $onDemand.withSSIDs)
@ -90,7 +88,7 @@ extension OnDemandView {
Toggle(L10n.OnDemand.Items.Ethernet.caption, isOn: $onDemand.withEthernetNetwork) Toggle(L10n.OnDemand.Items.Ethernet.caption, isOn: $onDemand.withEthernetNetwork)
} header: { } header: {
// TODO: on-demand, restore when "trusted networks" -> "on-demand" // TODO: on-demand, restore when "trusted networks" -> "on-demand"
// Text(L10n.Profile.Sections.Trusted.header) // Text(L10n.Profile.Sections.Trusted.header)
} }
Section { Section {
SSIDList(withSSIDs: $onDemand.withSSIDs) SSIDList(withSSIDs: $onDemand.withSSIDs)
@ -100,7 +98,7 @@ extension OnDemandView {
SSIDList(withSSIDs: $onDemand.withSSIDs) SSIDList(withSSIDs: $onDemand.withSSIDs)
} header: { } header: {
// TODO: on-demand, restore when "trusted networks" -> "on-demand" // TODO: on-demand, restore when "trusted networks" -> "on-demand"
// Text(L10n.Profile.Sections.Trusted.header) // Text(L10n.Profile.Sections.Trusted.header)
} }
} }
Section { 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 // eligibility: donate intents if eligible for Siri
private func donateMobileIntent(_ isEnabled: Bool) { func donateMobileIntent(_ isEnabled: Bool) {
guard isEligibleForSiri else { guard isEligibleForSiri else {
return return
} }
@ -120,7 +127,7 @@ extension OnDemandView {
} }
// eligibility: donate intents if eligible for Siri // eligibility: donate intents if eligible for Siri
private func donateNetworkIntents(_: [String: Bool]) { func donateNetworkIntents(_: [String: Bool]) {
guard isEligibleForSiri else { guard isEligibleForSiri else {
return return
} }

View File

@ -34,21 +34,6 @@ extension OrganizerView {
@Binding private var modalType: ModalType? @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?>) { init(profile: Profile, isActiveProfile: Bool, modalType: Binding<ModalType?>) {
self.profile = profile self.profile = profile
self.isActiveProfile = isActiveProfile 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 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 duplicateButton
deleteButton deleteButton
} }
}
}
private var reconnectButton: some View { // MARK: -
ProfileView.ReconnectButton()
}
private var duplicateButton: some View { private extension OrganizerView.ProfilesList {
ProfileView.DuplicateButton( var mainView: some View {
header: header, List {
setAsCurrent: false if profileManager.hasProfiles {
)
}
private var deleteButton: some View { // FIXME: iPad multitasking, navigation binding does not clear on pop
DestructiveButton { // - if listStyle is different than .sidebar
withAnimation { // - if listStyle is .sidebar but List has no Section
profileManager.removeProfiles(withIds: [header.id]) 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)
} }
} }
} }

View File

@ -51,31 +51,36 @@ extension OrganizerView {
.hidden() .hidden()
.onAppear(perform: onAppear) .onAppear(perform: onAppear)
} }
}
}
@MainActor // MARK: -
private func onAppear() {
guard didHandleSubreddit else {
alertType = .subscribeReddit
isAlertPresented = true
return
}
// private extension OrganizerView.SceneView {
// FIXME: iPad portrait/compact, loading current profile adds ProfileView() twice
// @MainActor
// - from MainView func onAppear() {
// - from NavigationLink destination in OrganizerView guard didHandleSubreddit else {
// alertType = .subscribeReddit
// can notice becase "Back" needs to be tapped twice to show sidebar isAlertPresented = true
// workaround: set active profile but do not load as current (prevents NavigationLink activation) return
// }
guard isFirstLaunch else {
return //
} // FIXME: iPad portrait/compact, loading current profile adds ProfileView() twice
isFirstLaunch = false //
if themeIdiom != .phone && !themeIsiPadPortrait, let activeProfileId = ProfileManager.shared.activeProfileId { // - from MainView
ProfileManager.shared.currentProfileId = activeProfileId // - 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
} }
} }
} }

View File

@ -97,42 +97,21 @@ struct OrganizerView: View {
).onOpenURL(perform: onOpenURL) ).onOpenURL(perform: onOpenURL)
.themePrimaryView() .themePrimaryView()
} }
}
private var hiddenSceneView: some View { // MARK: -
private extension OrganizerView {
var hiddenSceneView: some View {
SceneView( SceneView(
isAlertPresented: $isAlertPresented, isAlertPresented: $isAlertPresented,
alertType: $alertType, alertType: $alertType,
didHandleSubreddit: $didHandleSubreddit 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 @ViewBuilder
private func presentedModal(_ modalType: ModalType) -> some View { func presentedModal(_ modalType: ModalType) -> some View {
switch modalType { switch modalType {
case .interactiveAccount(let profile): case .interactiveAccount(let profile):
NavigationView { NavigationView {
@ -141,7 +120,7 @@ extension OrganizerView {
} }
} }
private func alertActions(_ alertType: AlertType) -> some View { func alertActions(_ alertType: AlertType) -> some View {
switch alertType { switch alertType {
case .subscribeReddit: case .subscribeReddit:
return Group { return Group {
@ -158,10 +137,37 @@ extension OrganizerView {
} }
} }
private func alertMessage(_ alertType: AlertType) -> some View { func alertMessage(_ alertType: AlertType) -> some View {
switch alertType { switch alertType {
case .subscribeReddit: case .subscribeReddit:
return Text(L10n.Organizer.Alerts.Reddit.message) 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 case restoring
} }
private typealias RowModel = (product: SKProduct, extra: String?)
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@ObservedObject private var productManager: ProductManager @ObservedObject private var productManager: ProductManager
@ -68,47 +66,186 @@ extension PaywallView {
} }
}.themeAnimation(on: productManager.isRefreshingProducts) }.themeAnimation(on: productManager.isRefreshingProducts)
} }
}
}
private var productsSection: some View { private struct PurchaseRow: View {
Section { var product: SKProduct?
if !productManager.isRefreshingProducts {
ForEach(productRowModels, id: \.product.productIdentifier, content: productRow) let title: String
} else {
ProgressView() 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 { var restoreButton: some View {
PurchaseRow( HStack {
product: model.product, Button(title, action: action)
title: model.product.localizedTitle, Spacer()
extra: model.extra, if case .restoring = purchaseState {
action: { ProgressView()
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
)
} }
} }
} }
extension PaywallView.PurchaseView { // MARK: -
private func purchaseProduct(_ product: SKProduct) {
private extension PaywallView.PurchaseView {
func purchaseProduct(_ product: SKProduct) {
purchaseState = .purchasing(product) purchaseState = .purchasing(product)
productManager.purchase(product) { productManager.purchase(product) {
@ -135,7 +272,7 @@ extension PaywallView.PurchaseView {
} }
} }
private func restorePurchases() { func restorePurchases() {
purchaseState = .restoring purchaseState = .restoring
productManager.restorePurchases { 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? @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?>) { init(currentProfile: ObservableProfile, modalType: Binding<ModalType?>) {
productManager = .shared productManager = .shared
self.currentProfile = currentProfile self.currentProfile = currentProfile
@ -111,13 +103,25 @@ extension ProfileView {
Text(L10n.Global.Strings.configuration) Text(L10n.Global.Strings.configuration)
} }
} }
}
private var networkSettingsRow: some View { }
Label(L10n.NetworkSettings.title, systemImage: themeNetworkSettingsImage)
} // MARK: -
private var onDemandRow: some View { private extension ProfileView.ConfigurationSection {
Label(L10n.OnDemand.title, systemImage: themeOnDemandImage) 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)
} }
} }

View File

@ -46,10 +46,6 @@ extension ProfileView {
@ObservedObject private var currentProfile: ObservableProfile @ObservedObject private var currentProfile: ObservableProfile
private var header: Profile.Header {
currentProfile.value.header
}
@Binding private var modalType: ModalType? @Binding private var modalType: ModalType?
@State private var isAlertPresented = false @State private var isAlertPresented = false
@ -78,94 +74,6 @@ extension ProfileView {
message: alertMessage 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 _modalType = modalType
} }
private var isEligibleForSiri: Bool {
productManager.isEligible(forFeature: .siriShortcuts)
}
var body: some View { var body: some View {
Button { Button {
presentShortcutsOrPaywall() presentShortcutsOrPaywall()
@ -209,16 +113,6 @@ extension ProfileView {
Label(Unlocalized.Other.siri, systemImage: themeShortcutsImage) 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 { struct RenameButton: View {
@ -257,9 +151,129 @@ extension ProfileView {
Label(L10n.Global.Strings.duplicate, systemImage: themeDuplicateImage) Label(L10n.Global.Strings.duplicate, systemImage: themeDuplicateImage)
} }
} }
}
}
private func duplicateProfile(withId id: UUID) { // MARK: -
profileManager.duplicateProfile(withId: id, setAsCurrent: setAsCurrent)
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 @ObservedObject private var currentProfile: ObservableProfile
var profile: Profile {
currentProfile.value
}
@State private var isProviderLocationPresented = false @State private var isProviderLocationPresented = false
@State private var isRefreshingInfrastructure = false @State private var isRefreshingInfrastructure = false
var profile: Profile {
currentProfile.value
}
init(currentProfile: ObservableProfile) { init(currentProfile: ObservableProfile) {
providerManager = .shared providerManager = .shared
self.currentProfile = currentProfile self.currentProfile = currentProfile
@ -55,102 +55,111 @@ extension ProfileView {
} }
} }
} }
}
}
@ViewBuilder // MARK: -
private var mainView: some View {
Section { private extension ProfileView.ProviderSection {
NavigationLink(isActive: $isProviderLocationPresented) {
ProviderLocationView( @ViewBuilder
currentProfile: currentProfile, var mainView: some View {
isEditable: true, Section {
isPresented: $isProviderLocationPresented NavigationLink(isActive: $isProviderLocationPresented) {
) ProviderLocationView(
} label: { currentProfile: currentProfile,
HStack { isEditable: true,
Label(L10n.Provider.Location.title, systemImage: themeProviderLocationImage) isPresented: $isProviderLocationPresented
Spacer()
currentProviderCountryImage
}
}
} header: {
currentProviderFullName.map(Text.init)
} footer: {
currentProviderServerDescription.map(Text.init)
}
Section {
Toggle(
L10n.Profile.Items.RandomizesServer.caption,
isOn: $currentProfile.value.providerRandomizesServer ?? false
) )
Toggle( } label: {
L10n.Profile.Items.VpnResolvesHostname.caption, HStack {
isOn: $currentProfile.value.networkSettings.resolvesHostname Label(L10n.Provider.Location.title, systemImage: themeProviderLocationImage)
) Spacer()
} footer: { currentProviderCountryImage
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))
} }
} }
} header: {
currentProviderFullName.map(Text.init)
} footer: {
currentProviderServerDescription.map(Text.init)
} }
Section {
private var currentProviderFullName: String? { Toggle(
guard let name = profile.header.providerName else { L10n.Profile.Items.RandomizesServer.caption,
assertionFailure("Provider name accessed but profile is not a provider (isPlaceholder? \(profile.isPlaceholder))") isOn: $currentProfile.value.providerRandomizesServer ?? false
return nil )
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 { Button(action: refreshInfrastructure) {
assertionFailure("Provider metadata not found") Text(L10n.Profile.Items.Provider.Refresh.caption)
return nil }.withTrailingProgress(when: isRefreshingInfrastructure)
} } footer: {
return metadata.fullName lastInfrastructureUpdate.map {
} Text(L10n.Profile.Sections.ProviderInfrastructure.footer($0))
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
} }
} }
} }
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
}
}
} }

View File

@ -66,51 +66,60 @@ extension ProfileView {
message: alertOverwriteMessage message: alertOverwriteMessage
) )
} }
}
@ViewBuilder }
private func alertOverwriteActions() -> some View {
Button(role: .destructive) { // MARK: -
commitRenaming(force: true)
} label: { private extension ProfileView.RenameView {
Text(L10n.Global.Strings.ok)
} @ViewBuilder
Button(role: .cancel) { func alertOverwriteActions() -> some View {
} label: { Button(role: .destructive) {
Text(L10n.Global.Strings.cancel) commitRenaming(force: true)
} } label: {
} Text(L10n.Global.Strings.ok)
}
private func alertOverwriteMessage() -> some View { Button(role: .cancel) {
Text(L10n.AddProfile.Shared.Alerts.Overwrite.message) } label: {
} Text(L10n.Global.Strings.cancel)
}
private func loadCurrentName() { }
newName = currentProfile.value.header.name
} func alertOverwriteMessage() -> some View {
Text(L10n.AddProfile.Shared.Alerts.Overwrite.message)
private func commitRenaming() { }
commitRenaming(force: false) }
}
// MARK: -
private func commitRenaming(force: Bool) {
let name = newName.stripped private extension ProfileView.RenameView {
func loadCurrentName() {
guard !name.isEmpty else { newName = currentProfile.value.header.name
return }
}
guard name != currentProfile.value.header.name else { func commitRenaming() {
presentationMode.wrappedValue.dismiss() commitRenaming(force: false)
return }
}
guard force || !profileManager.isExistingProfile(withName: name) else { func commitRenaming(force: Bool) {
isOverwritingExistingProfile = true let name = newName.stripped
return
} guard !name.isEmpty else {
return
let renamed = currentProfile.value.renamed(to: name) }
profileManager.saveProfile(renamed, isActive: nil) guard name != currentProfile.value.header.name else {
presentationMode.wrappedValue.dismiss()
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()
} }
} }

View File

@ -34,18 +34,6 @@ extension ProfileView {
@Binding private var modalType: ModalType? @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?>) { init(profile: Profile, modalType: Binding<ModalType?>) {
profileManager = .shared profileManager = .shared
self.profile = profile self.profile = profile
@ -63,22 +51,38 @@ extension ProfileView {
.xxxThemeTruncation() .xxxThemeTruncation()
} }
} }
}
}
private var toggleView: some View { // MARK: -
VPNToggle(
profile: profile, private extension ProfileView.VPNSection {
interactiveProfile: interactiveProfile, var interactiveProfile: Binding<Profile?> {
rateLimit: Constants.RateLimit.vpnToggle .init {
) modalType == .interactiveAccount ? profile : nil
} set: {
modalType = $0 != nil ? .interactiveAccount : nil
} }
}
private var statusView: some View { var isActiveProfile: Bool {
HStack { profileManager.isActiveProfile(profile.id)
Text(L10n.Profile.Items.ConnectionStatus.caption) }
Spacer()
VPNStatusText(isActiveProfile: isActiveProfile) var toggleView: some View {
.themeSecondaryTextStyle() 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()
} }
} }
} }

View File

@ -47,14 +47,6 @@ struct ProfileView: View {
@ObservedObject private var currentProfile: ObservableProfile @ObservedObject private var currentProfile: ObservableProfile
private var isLoading: Bool {
currentProfile.isLoading
}
private var isExisting: Bool {
!currentProfile.value.isPlaceholder
}
@State private var modalType: ModalType? @State private var modalType: ModalType?
init() { init() {
@ -83,12 +75,24 @@ struct ProfileView: View {
.navigationTitle(title) .navigationTitle(title)
.themeSecondaryView() .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 currentProfile.name
} }
private var mainView: some View { var mainView: some View {
List { List {
if !isLoading { if !isLoading {
VPNSection( VPNSection(
@ -109,7 +113,7 @@ struct ProfileView: View {
} }
@ViewBuilder @ViewBuilder
private func presentedModal(_ modalType: ModalType) -> some View { func presentedModal(_ modalType: ModalType) -> some View {
switch modalType { switch modalType {
case .interactiveAccount: case .interactiveAccount:
NavigationView { NavigationView {

View File

@ -31,35 +31,16 @@ struct ProviderLocationView: View, ProviderProfileAvailability {
@ObservedObject private var currentProfile: ObservableProfile @ObservedObject private var currentProfile: ObservableProfile
var profile: Profile {
currentProfile.value
}
private let isEditable: Bool 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 selectedServer: ProviderServer?
@Binding private var favoriteLocationIds: Set<String>? @Binding private var favoriteLocationIds: Set<String>?
@AppStorage(AppPreference.isShowingFavorites.key) private var isShowingFavorites = false @AppStorage(AppPreference.isShowingFavorites.key) private var isShowingFavorites = false
private var isShowingEmptyFavorites: Bool { var profile: Profile {
guard isShowingFavorites else { currentProfile.value
return false
}
return favoriteLocationIds?.isEmpty ?? true
} }
// XXX: do not escape mutating 'self', use constant providerManager // XXX: do not escape mutating 'self', use constant providerManager
@ -108,139 +89,6 @@ struct ProviderLocationView: View, ProviderProfileAvailability {
} }
}.navigationTitle(L10n.Provider.Location.title) }.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 { extension ProviderLocationView {
@ -293,21 +141,183 @@ extension ProviderLocationView {
} }
} }
} }
private var servers: [ProviderServer] {
providerManager.servers(forLocation: location).sorted()
}
} }
} }
extension ProviderLocationView { // MARK: -
private func scrollToSelectedLocation(_ proxy: ScrollViewProxy) {
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) proxy.maybeScrollTo(selectedServer?.locationId)
} }
} }
extension ProviderLocationView.ServerListView { private extension ProviderLocationView.ServerListView {
private func scrollToSelectedServer(_ proxy: ScrollViewProxy) { func scrollToSelectedServer(_ proxy: ScrollViewProxy) {
proxy.maybeScrollTo(selectedServer?.id) proxy.maybeScrollTo(selectedServer?.id)
} }
} }

View File

@ -67,8 +67,12 @@ struct ProviderPresetView: View {
ForEach(availablePresets, id: \.id, content: presetSection) ForEach(availablePresets, id: \.id, content: presetSection)
}.navigationTitle(L10n.Provider.Preset.title) }.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 { Section {
Button { Button {
selectedPreset = preset 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) Text(preset.comment)
.withTrailingCheckmark(when: preset.id == selectedPreset?.id) .withTrailingCheckmark(when: preset.id == selectedPreset?.id)
} }
// some providers (e.g. NordVPN) have specific presets based on selected server // some providers (e.g. NordVPN) have specific presets based on selected server
private var availablePresets: [ProviderServer.Preset] { var availablePresets: [ProviderServer.Preset] {
server?.presets?.sorted() ?? [] server?.presets?.sorted() ?? []
} }
} }

View File

@ -53,14 +53,18 @@ struct SettingsView: View {
}.themeSecondaryView() }.themeSecondaryView()
.navigationTitle(L10n.Settings.title) .navigationTitle(L10n.Settings.title)
} }
}
private var preferencesSection: some View { // MARK: -
private extension SettingsView {
var preferencesSection: some View {
Section { Section {
Toggle(L10n.Settings.Items.LocksInBackground.caption, isOn: $locksInBackground) Toggle(L10n.Settings.Items.LocksInBackground.caption, isOn: $locksInBackground)
} }
} }
private var aboutSection: some View { var aboutSection: some View {
Section { Section {
NavigationLink { NavigationLink {
AboutView() AboutView()

View File

@ -71,32 +71,34 @@ extension ShortcutsView {
} }
}.navigationTitle(L10n.Shortcuts.Add.title) }.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 { // MARK: -
private var isProviderLocationPresented: Binding<Bool> {
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<Bool> {
.init { .init {
isPresentingProviderLocation isPresentingProviderLocation
} set: { } 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( pendingShortcut = INShortcut(intent: IntentDispatcher.intentConnect(
header: header header: header
)) ))
} }
private func addMoveToPendingProfile() { func addMoveToPendingProfile() {
let header = pendingProfile.value.header let header = pendingProfile.value.header
guard let server = pendingProfile.value.providerServer(providerManager) else { guard let server = pendingProfile.value.providerServer(providerManager) else {
return return
@ -125,31 +131,31 @@ extension ShortcutsView.AddView {
)) ))
} }
private func addEnableVPN() { func addEnableVPN() {
addShortcut(with: IntentDispatcher.intentEnable()) addShortcut(with: IntentDispatcher.intentEnable())
} }
private func addDisableVPN() { func addDisableVPN() {
addShortcut(with: IntentDispatcher.intentDisable()) addShortcut(with: IntentDispatcher.intentDisable())
} }
private func addTrustWiFi() { func addTrustWiFi() {
addShortcut(with: IntentDispatcher.intentTrustWiFi()) addShortcut(with: IntentDispatcher.intentTrustWiFi())
} }
private func addUntrustWiFi() { func addUntrustWiFi() {
addShortcut(with: IntentDispatcher.intentUntrustWiFi()) addShortcut(with: IntentDispatcher.intentUntrustWiFi())
} }
private func addTrustCellular() { func addTrustCellular() {
addShortcut(with: IntentDispatcher.intentTrustCellular()) addShortcut(with: IntentDispatcher.intentTrustCellular())
} }
private func addUntrustCellular() { func addUntrustCellular() {
addShortcut(with: IntentDispatcher.intentUntrustCellular()) addShortcut(with: IntentDispatcher.intentUntrustCellular())
} }
private func addShortcut(with intent: INIntent) { func addShortcut(with intent: INIntent) {
guard let shortcut = INShortcut(intent: intent) else { guard let shortcut = INShortcut(intent: intent) else {
fatalError("Unable to create INShortcut, intent '\(intent.description)' not exposed by app?") 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) .navigationTitle(Unlocalized.Other.siri)
.themeSecondaryView() .themeSecondaryView()
} }
}
private var shortcutsSection: some View { // MARK: -
private extension ShortcutsView {
var shortcutsSection: some View {
Section { Section {
ForEach(relevantShortcuts, content: rowView) ForEach(relevantShortcuts, content: rowView)
} header: { } header: {
@ -87,13 +91,13 @@ struct ShortcutsView: View {
} }
} }
private var relevantShortcuts: [Shortcut] { var relevantShortcuts: [Shortcut] {
intentsManager.shortcuts.values.filter { intentsManager.shortcuts.values.filter {
$0.isRelevant(to: target) $0.isRelevant(to: target)
}.sorted() }.sorted()
} }
private var addSection: some View { var addSection: some View {
Section { Section {
NavigationLink(isActive: $isNavigationPresented) { NavigationLink(isActive: $isNavigationPresented) {
AddView( AddView(
@ -109,7 +113,7 @@ struct ShortcutsView: View {
} }
@ViewBuilder @ViewBuilder
private func presentedModal(_ modalType: ModalType) -> some View { func presentedModal(_ modalType: ModalType) -> some View {
switch modalType { switch modalType {
case .edit(let shortcut): case .edit(let shortcut):
IntentEditView( IntentEditView(
@ -125,7 +129,7 @@ struct ShortcutsView: View {
} }
} }
private func rowView(forShortcut vs: Shortcut) -> some View { func rowView(forShortcut vs: Shortcut) -> some View {
Button { Button {
presentEditShortcut(vs) presentEditShortcut(vs)
} label: { } label: {
@ -133,7 +137,7 @@ struct ShortcutsView: View {
} }
} }
private var delegatingPendingShortcut: Binding<INShortcut?> { var delegatingPendingShortcut: Binding<INShortcut?> {
.init { .init {
pendingShortcut pendingShortcut
} set: { } set: {
@ -143,15 +147,6 @@ struct ShortcutsView: View {
presentAddShortcut(pendingShortcut) 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 { private extension Shortcut {
@ -168,3 +163,16 @@ private extension Shortcut {
return true 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 { var body: some View {
Text(statusText) Text(statusText)
} }
}
private var statusText: String { // MARK: -
private extension VPNStatusText {
var statusText: String {
currentVPNState.localizedStatusDescription( currentVPNState.localizedStatusDescription(
isActiveProfile: isActiveProfile, isActiveProfile: isActiveProfile,
withErrors: true, withErrors: true,

View File

@ -41,34 +41,6 @@ struct VPNToggle: View {
private let rateLimit: Int 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 @State private var canToggle = true
init(profile: Profile, interactiveProfile: Binding<Profile?>, rateLimit: Int) { init(profile: Profile, interactiveProfile: Binding<Profile?>, rateLimit: Int) {
@ -86,8 +58,44 @@ struct VPNToggle: View {
.disabled(!canToggle) .disabled(!canToggle)
.themeAnimation(on: currentVPNState.isEnabled) .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 Task { @MainActor in
canToggle = false canToggle = false
await Task.maybeWait(forMilliseconds: rateLimit) await Task.maybeWait(forMilliseconds: rateLimit)
@ -104,7 +112,7 @@ struct VPNToggle: View {
} }
} }
private func disableVPN() { func disableVPN() {
Task { @MainActor in Task { @MainActor in
canToggle = false canToggle = false
await vpnManager.disable() 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 // eligibility: donate intents if eligible for Siri
guard isEligibleForSiri else { guard isEligibleForSiri else {