Avoid nested module navigation (#749)

A NavigationLink in VPNProviderContentModifier raised a few questions
about the navigation approach in module views. It turned out that having
a Binding to a local ObservedObject (ProfileEditor) is a recipe for
disaster.

Therefore:

- We don't need a binding to the editor module (the draft), because by
doing so we end up _observing_ the same changes from two properties, the
binding and the editor. This seems to drive SwiftUI crazy and freezes
the app once we navigate from the module to another view (e.g. in
OpenVPN the credentials or the provider server). Use the module binding
as a shortcut, but do not assign the binding to the view to avoid
unnecessary observation.
- Keep .navigationDestination() in the module view, and pass a known
destination to VPNProviderContentModifier. This will save the modifier
from creating a nested NavigationLink destination. The
VPNProviderServerView is now openly instantiated by the module view when
such destination is triggered by the NavigationLink in the modifier.
- Do not implicitly dismiss VPNProviderServerView on selection, let the
presenter take care. In order to do so, we add a .navigationPath
environment key through which the module view can modify the current
navigation stack.
This commit is contained in:
Davide 2024-10-23 15:42:54 +02:00 committed by GitHub
parent a94db35d01
commit ee8ef34f06
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 228 additions and 186 deletions

View File

@ -0,0 +1,41 @@
//
// EnvironmentValues+Extensions.swift
// Passepartout
//
// Created by Davide De Rosa on 10/23/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import SwiftUI
extension EnvironmentValues {
var navigationPath: Binding<NavigationPath> {
get {
self[NavigationPathKey.self]
}
set {
self[NavigationPathKey.self] = newValue
}
}
}
private struct NavigationPathKey: EnvironmentKey {
static let defaultValue: Binding<NavigationPath> = .constant(NavigationPath())
}

View File

@ -0,0 +1,43 @@
//
// ModuleDraftEditing.swift
// Passepartout
//
// Created by Davide De Rosa on 10/23/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import PassepartoutKit
import SwiftUI
protocol ModuleDraftEditing {
associatedtype Draft: ModuleBuilder
var editor: ProfileEditor { get }
var module: Draft { get }
}
extension ModuleDraftEditing {
@MainActor
var draft: Binding<Draft> {
editor[module]
}
}

View File

@ -28,21 +28,15 @@ import PassepartoutKit
import SwiftUI
import UtilsLibrary
struct DNSView: View {
struct DNSView: View, ModuleDraftEditing {
@EnvironmentObject
private var theme: Theme
@ObservedObject
private var editor: ProfileEditor
var editor: ProfileEditor
@Binding
private var draft: DNSModule.Builder
init(editor: ProfileEditor, module: DNSModule.Builder) {
self.editor = editor
_draft = editor.binding(forModule: module)
}
let module: DNSModule.Builder
var body: some View {
debugChanges()
@ -56,7 +50,7 @@ struct DNSView: View {
.labelsHidden()
}
.themeManualInput()
.moduleView(editor: editor, draft: draft)
.moduleView(editor: editor, draft: draft.wrappedValue)
}
}
@ -69,21 +63,21 @@ private extension DNSView {
var protocolSection: some View {
Section {
Picker(Strings.Global.protocol, selection: $draft.protocolType) {
Picker(Strings.Global.protocol, selection: draft.protocolType) {
ForEach(Self.allProtocols, id: \.self) {
Text($0.localizedDescription)
}
}
switch draft.protocolType {
switch draft.wrappedValue.protocolType {
case .cleartext:
EmptyView()
case .https:
ThemeTextField(Strings.Unlocalized.url, text: $draft.dohURL, placeholder: Strings.Unlocalized.Placeholders.dohURL)
ThemeTextField(Strings.Unlocalized.url, text: draft.dohURL, placeholder: Strings.Unlocalized.Placeholders.dohURL)
.labelsHidden()
case .tls:
ThemeTextField(Strings.Global.hostname, text: $draft.dotHostname, placeholder: Strings.Unlocalized.Placeholders.dotHostname)
ThemeTextField(Strings.Global.hostname, text: draft.dotHostname, placeholder: Strings.Unlocalized.Placeholders.dotHostname)
.labelsHidden()
}
}
@ -91,7 +85,7 @@ private extension DNSView {
var domainSection: some View {
Group {
ThemeTextField(Strings.Global.domain, text: $draft.domainName ?? "", placeholder: Strings.Unlocalized.Placeholders.hostname)
ThemeTextField(Strings.Global.domain, text: draft.domainName ?? "", placeholder: Strings.Unlocalized.Placeholders.hostname)
}
.themeSection(header: Strings.Global.domain)
}
@ -100,7 +94,7 @@ private extension DNSView {
theme.listSection(
Strings.Entities.Dns.servers,
addTitle: Strings.Modules.Dns.Servers.add,
originalItems: $draft.servers,
originalItems: draft.servers,
itemLabel: {
if $0 {
Text($1.wrappedValue)
@ -115,7 +109,7 @@ private extension DNSView {
theme.listSection(
Strings.Entities.Dns.searchDomains,
addTitle: Strings.Modules.Dns.SearchDomains.add,
originalItems: $draft.searchDomains ?? [],
originalItems: draft.searchDomains ?? [],
itemLabel: {
if $0 {
Text($1.wrappedValue)

View File

@ -34,7 +34,7 @@ extension OpenVPNModule.Builder: ModuleViewProviding {
extension OpenVPNModule.Builder: InteractiveViewProviding {
func interactiveView(with editor: ProfileEditor) -> some View {
let draft = editor.binding(forModule: self)
let draft = editor[self]
return OpenVPNView.CredentialsView(
isInteractive: draft.isInteractive,

View File

@ -27,21 +27,15 @@ import PassepartoutKit
import SwiftUI
import UtilsLibrary
struct HTTPProxyView: View {
struct HTTPProxyView: View, ModuleDraftEditing {
@EnvironmentObject
private var theme: Theme
@ObservedObject
private var editor: ProfileEditor
var editor: ProfileEditor
@Binding
private var draft: HTTPProxyModule.Builder
init(editor: ProfileEditor, module: HTTPProxyModule.Builder) {
self.editor = editor
_draft = editor.binding(forModule: module)
}
let module: HTTPProxyModule.Builder
var body: some View {
Group {
@ -52,30 +46,30 @@ struct HTTPProxyView: View {
}
.labelsHidden()
.themeManualInput()
.moduleView(editor: editor, draft: draft)
.moduleView(editor: editor, draft: draft.wrappedValue)
}
}
private extension HTTPProxyView {
var httpSection: some View {
Group {
ThemeTextField(Strings.Global.address, text: $draft.address, placeholder: Strings.Unlocalized.Placeholders.proxyIPv4Address)
ThemeTextField(Strings.Global.port, text: $draft.port.toString(omittingZero: true), placeholder: Strings.Unlocalized.Placeholders.proxyPort)
ThemeTextField(Strings.Global.address, text: draft.address, placeholder: Strings.Unlocalized.Placeholders.proxyIPv4Address)
ThemeTextField(Strings.Global.port, text: draft.port.toString(omittingZero: true), placeholder: Strings.Unlocalized.Placeholders.proxyPort)
}
.themeSection(header: Strings.Unlocalized.http)
}
var httpsSection: some View {
Group {
ThemeTextField(Strings.Global.address, text: $draft.secureAddress, placeholder: Strings.Unlocalized.Placeholders.proxyIPv4Address)
ThemeTextField(Strings.Global.port, text: $draft.securePort.toString(omittingZero: true), placeholder: Strings.Unlocalized.Placeholders.proxyPort)
ThemeTextField(Strings.Global.address, text: draft.secureAddress, placeholder: Strings.Unlocalized.Placeholders.proxyIPv4Address)
ThemeTextField(Strings.Global.port, text: draft.securePort.toString(omittingZero: true), placeholder: Strings.Unlocalized.Placeholders.proxyPort)
}
.themeSection(header: Strings.Unlocalized.https)
}
var pacSection: some View {
Group {
ThemeTextField(Strings.Unlocalized.url, text: $draft.pacURLString, placeholder: Strings.Unlocalized.Placeholders.pacURL)
ThemeTextField(Strings.Unlocalized.url, text: draft.pacURLString, placeholder: Strings.Unlocalized.Placeholders.pacURL)
}
.themeSection(header: Strings.Unlocalized.pac)
}
@ -85,7 +79,7 @@ private extension HTTPProxyView {
theme.listSection(
Strings.Entities.HttpProxy.bypassDomains,
addTitle: Strings.Modules.HttpProxy.BypassDomains.add,
originalItems: $draft.bypassDomains,
originalItems: draft.bypassDomains,
itemLabel: {
if $0 {
Text($1.wrappedValue)

View File

@ -27,29 +27,23 @@ import PassepartoutKit
import SwiftUI
import UtilsLibrary
struct IPView: View {
struct IPView: View, ModuleDraftEditing {
@ObservedObject
private var editor: ProfileEditor
var editor: ProfileEditor
@Binding
private var draft: IPModule.Builder
let module: IPModule.Builder
@State
private var routePresentation: RoutePresentation?
init(editor: ProfileEditor, module: IPModule.Builder) {
self.editor = editor
_draft = editor.binding(forModule: module)
}
var body: some View {
Group {
ipSections(for: .v4)
ipSections(for: .v6)
interfaceSection
}
.moduleView(editor: editor, draft: draft)
.moduleView(editor: editor, draft: draft.wrappedValue)
.themeModal(item: $routePresentation, content: routeModal)
}
}
@ -137,9 +131,9 @@ private extension IPView {
ThemeTextField(
Strings.Unlocalized.mtu,
text: Binding {
draft.mtu?.description ?? ""
draft.wrappedValue.mtu?.description ?? ""
} set: {
draft.mtu = Int($0)
draft.wrappedValue.mtu = Int($0)
},
placeholder: Strings.Unlocalized.Placeholders.mtu
)
@ -153,16 +147,16 @@ private extension IPView {
switch family {
case .v4:
return Binding {
draft.ipv4 ?? IPSettings(subnet: nil)
draft.wrappedValue.ipv4 ?? IPSettings(subnet: nil)
} set: {
draft.ipv4 = $0
draft.wrappedValue.ipv4 = $0
}
case .v6:
return Binding {
draft.ipv6 ?? IPSettings(subnet: nil)
draft.wrappedValue.ipv6 ?? IPSettings(subnet: nil)
} set: {
draft.ipv6 = $0
draft.wrappedValue.ipv6 = $0
}
}
}
@ -180,31 +174,31 @@ private extension IPView {
case .included(let family):
switch family {
case .v4:
if draft.ipv4 == nil {
draft.ipv4 = IPSettings(subnet: nil)
if draft.wrappedValue.ipv4 == nil {
draft.wrappedValue.ipv4 = IPSettings(subnet: nil)
}
draft.ipv4?.include(route)
draft.wrappedValue.ipv4?.include(route)
case .v6:
if draft.ipv6 == nil {
draft.ipv6 = IPSettings(subnet: nil)
if draft.wrappedValue.ipv6 == nil {
draft.wrappedValue.ipv6 = IPSettings(subnet: nil)
}
draft.ipv6?.include(route)
draft.wrappedValue.ipv6?.include(route)
}
case .excluded(let family):
switch family {
case .v4:
if draft.ipv4 == nil {
draft.ipv4 = IPSettings(subnet: nil)
if draft.wrappedValue.ipv4 == nil {
draft.wrappedValue.ipv4 = IPSettings(subnet: nil)
}
draft.ipv4?.exclude(route)
draft.wrappedValue.ipv4?.exclude(route)
case .v6:
if draft.ipv6 == nil {
draft.ipv6 = IPSettings(subnet: nil)
if draft.wrappedValue.ipv6 == nil {
draft.wrappedValue.ipv6 = IPSettings(subnet: nil)
}
draft.ipv6?.exclude(route)
draft.wrappedValue.ipv6?.exclude(route)
}
}
}

View File

@ -27,7 +27,7 @@ import PassepartoutKit
import SwiftUI
import UtilsLibrary
struct OnDemandView: View {
struct OnDemandView: View, ModuleDraftEditing {
@EnvironmentObject
private var theme: Theme
@ -36,13 +36,12 @@ struct OnDemandView: View {
private var iapManager: IAPManager
@ObservedObject
private var editor: ProfileEditor
var editor: ProfileEditor
let module: OnDemandModule.Builder
private let wifi: Wifi
@Binding
private var draft: OnDemandModule.Builder
@State
private var paywallReason: PaywallReason?
@ -52,8 +51,8 @@ struct OnDemandView: View {
observer: WifiObserver? = nil
) {
self.editor = editor
self.module = module
wifi = Wifi(observer: observer ?? CoreLocationWifiObserver())
_draft = editor.binding(forModule: module)
}
var body: some View {
@ -61,7 +60,7 @@ struct OnDemandView: View {
enabledSection
restrictedArea
}
.moduleView(editor: editor, draft: draft)
.moduleView(editor: editor, draft: draft.wrappedValue)
.modifier(PaywallModifier(reason: $paywallReason))
}
}
@ -75,7 +74,7 @@ private extension OnDemandView {
var enabledSection: some View {
Section {
Toggle(Strings.Global.enabled, isOn: $draft.isEnabled)
Toggle(Strings.Global.enabled, isOn: draft.isEnabled)
}
}
@ -91,9 +90,9 @@ private extension OnDemandView {
EmptyView()
default:
if draft.isEnabled {
if draft.wrappedValue.isEnabled {
policySection
if draft.policy != .any {
if draft.wrappedValue.policy != .any {
networkSection
wifiSection
}
@ -102,7 +101,7 @@ private extension OnDemandView {
}
var policySection: some View {
Picker(Strings.Modules.OnDemand.policy, selection: $draft.policy) {
Picker(Strings.Modules.OnDemand.policy, selection: draft.policy) {
ForEach(Self.allPolicies, id: \.self) {
Text($0.localizedDescription)
}
@ -111,16 +110,16 @@ private extension OnDemandView {
}
var policyFooterDescription: String {
guard draft.isEnabled else {
guard draft.wrappedValue.isEnabled else {
return "" // better animation than removing footer completely
}
let suffix: String
switch draft.policy {
switch draft.wrappedValue.policy {
case .any:
suffix = Strings.Modules.OnDemand.Policy.Footer.any
case .including, .excluding:
if draft.policy == .including {
if draft.wrappedValue.policy == .including {
suffix = Strings.Modules.OnDemand.Policy.Footer.including
} else {
suffix = Strings.Modules.OnDemand.Policy.Footer.excluding
@ -132,9 +131,9 @@ private extension OnDemandView {
var networkSection: some View {
Group {
if Utils.hasCellularData() {
Toggle(Strings.Modules.OnDemand.mobile, isOn: $draft.withMobileNetwork)
Toggle(Strings.Modules.OnDemand.mobile, isOn: draft.withMobileNetwork)
} else if Utils.hasEthernet() {
Toggle(Strings.Modules.OnDemand.ethernet, isOn: $draft.withEthernetNetwork)
Toggle(Strings.Modules.OnDemand.ethernet, isOn: draft.withEthernetNetwork)
}
}
.themeSection(header: Strings.Global.networks)
@ -173,53 +172,53 @@ private extension OnDemandView {
private extension OnDemandView {
var allSSIDs: Binding<[String]> {
.init {
Array(draft.withSSIDs.keys)
Array(draft.wrappedValue.withSSIDs.keys)
} set: { newValue in
draft.withSSIDs.forEach {
draft.wrappedValue.withSSIDs.forEach {
guard newValue.contains($0.key) else {
draft.withSSIDs.removeValue(forKey: $0.key)
draft.wrappedValue.withSSIDs.removeValue(forKey: $0.key)
return
}
}
newValue.forEach {
guard draft.withSSIDs[$0] == nil else {
guard draft.wrappedValue.withSSIDs[$0] == nil else {
return
}
draft.withSSIDs[$0] = false
draft.wrappedValue.withSSIDs[$0] = false
}
}
}
var onSSIDs: Binding<Set<String>> {
.init {
Set(draft.withSSIDs.filter {
Set(draft.wrappedValue.withSSIDs.filter {
$0.value
}.map(\.key))
} set: { newValue in
draft.withSSIDs.forEach {
draft.wrappedValue.withSSIDs.forEach {
guard newValue.contains($0.key) else {
if draft.withSSIDs[$0.key] != nil {
draft.withSSIDs[$0.key] = false
if draft.wrappedValue.withSSIDs[$0.key] != nil {
draft.wrappedValue.withSSIDs[$0.key] = false
} else {
draft.withSSIDs.removeValue(forKey: $0.key)
draft.wrappedValue.withSSIDs.removeValue(forKey: $0.key)
}
return
}
}
newValue.forEach {
guard !(draft.withSSIDs[$0] ?? false) else {
guard !(draft.wrappedValue.withSSIDs[$0] ?? false) else {
return
}
draft.withSSIDs[$0] = true
draft.wrappedValue.withSSIDs[$0] = true
}
}
}
func isSSIDOn(_ ssid: String) -> Binding<Bool> {
.init {
draft.withSSIDs[ssid] ?? false
draft.wrappedValue.withSSIDs[ssid] ?? false
} set: {
draft.withSSIDs[ssid] = $0
draft.wrappedValue.withSSIDs[ssid] = $0
}
}
}
@ -228,7 +227,7 @@ private extension OnDemandView {
func requestSSID(_ text: Binding<String>) {
Task { @MainActor in
let ssid = try await wifi.currentSSID()
if !draft.withSSIDs.keys.contains(ssid) {
if !draft.wrappedValue.withSSIDs.keys.contains(ssid) {
text.wrappedValue = ssid
}
}

View File

@ -26,35 +26,36 @@
import PassepartoutKit
import SwiftUI
struct OpenVPNView: View {
struct OpenVPNView: View, ModuleDraftEditing {
@Environment(\.navigationPath)
private var path
@ObservedObject
private var editor: ProfileEditor
var editor: ProfileEditor
let module: OpenVPNModule.Builder
private let isServerPushed: Bool
@Binding
private var draft: OpenVPNModule.Builder
init(serverConfiguration: OpenVPN.Configuration) {
let module = OpenVPNModule.Builder(configurationBuilder: serverConfiguration.builder())
let editor = ProfileEditor(modules: [module])
self.editor = editor
_draft = .constant(module)
self.module = module
isServerPushed = true
}
init(editor: ProfileEditor, module: OpenVPNModule.Builder) {
self.editor = editor
_draft = editor.binding(forModule: module)
self.module = module
isServerPushed = false
}
var body: some View {
contentView
.themeAnimation(on: draft, category: .modules)
.moduleView(editor: editor, draft: draft, withName: !isServerPushed)
.moduleView(editor: editor, draft: draft.wrappedValue, withName: !isServerPushed)
.navigationDestination(for: Subroute.self, destination: destination)
}
}
@ -63,7 +64,7 @@ struct OpenVPNView: View {
private extension OpenVPNView {
var configuration: OpenVPN.Configuration.Builder {
draft.configurationBuilder ?? .init(withFallbacks: true)
draft.wrappedValue.configurationBuilder ?? .init(withFallbacks: true)
}
@ViewBuilder
@ -80,7 +81,8 @@ private extension OpenVPNView {
VPNProviderContentModifier(
providerId: providerId,
selectedEntity: providerEntity,
isRequired: draft.configurationBuilder == nil,
isRequired: draft.wrappedValue.configurationBuilder == nil,
entityDestination: Subroute.providerServer,
providerRows: {
moduleGroup(for: providerAccountRows)
}
@ -88,11 +90,11 @@ private extension OpenVPNView {
}
var providerId: Binding<ProviderID?> {
editor.binding(forProviderOf: draft.id)
editor.binding(forProviderOf: module.id)
}
var providerEntity: Binding<VPNEntity<OpenVPN.Configuration>?> {
editor.binding(forProviderEntityOf: draft.id)
editor.binding(forProviderEntityOf: module.id)
}
var providerAccountRows: [ModuleRow]? {
@ -101,6 +103,11 @@ private extension OpenVPNView {
}
private extension OpenVPNView {
func onSelectServer(server: VPNServer, preset: VPNPreset<OpenVPN.Configuration>) {
providerEntity.wrappedValue = VPNEntity(server: server, preset: preset)
path.wrappedValue.removeLast()
}
func importConfiguration(from url: URL) {
// TODO: #657, import draft from external URL
}
@ -110,16 +117,27 @@ private extension OpenVPNView {
private extension OpenVPNView {
enum Subroute: Hashable {
case providerServer
case credentials
}
@ViewBuilder
func destination(for route: Subroute) -> some View {
switch route {
case .providerServer:
providerId.wrappedValue.map {
VPNProviderServerView(
providerId: $0,
configurationType: OpenVPN.Configuration.self,
onSelect: onSelectServer
)
}
case .credentials:
CredentialsView(
isInteractive: $draft.isInteractive,
credentials: $draft.credentials
isInteractive: draft.isInteractive,
credentials: draft.credentials
)
}
}

View File

@ -28,41 +28,16 @@ import PassepartoutKit
import PassepartoutWireGuardGo
import SwiftUI
struct WireGuardView: View {
private enum Subroute: Hashable {
case providerServer(id: ProviderID)
}
struct WireGuardView: View, ModuleDraftEditing {
@ObservedObject
private var editor: ProfileEditor
var editor: ProfileEditor
@Binding
private var draft: WireGuardModule.Builder
// @Binding
// private var providerId: ProviderID?
//
// @State
// private var providerServer: VPNServer?
init(editor: ProfileEditor, module: WireGuardModule.Builder) {
self.editor = editor
_draft = editor.binding(forModule: module)
// _providerId = editor.binding(forProviderOf: module.id)
}
let module: WireGuardModule.Builder
var body: some View {
contentView
// .modifier(providerModifier)
.moduleView(editor: editor, draft: draft)
// .navigationDestination(for: Subroute.self) {
// switch $0 {
// case .providerServer(let id):
// VPNProviderServerView<WireGuard.Configuration>(providerId: id) {
// providerServer = $1
// }
// }
// }
.moduleView(editor: editor, draft: draft.wrappedValue)
}
}
@ -70,7 +45,7 @@ struct WireGuardView: View {
private extension WireGuardView {
var configuration: WireGuard.Configuration.Builder {
draft.configurationBuilder ?? .default
draft.wrappedValue.configurationBuilder ?? .default
}
@ViewBuilder
@ -81,17 +56,6 @@ private extension WireGuardView {
moduleSection(for: peersRows(for: peer), header: Strings.Modules.Wireguard.peer(index + 1))
}
}
// var providerModifier: some ViewModifier {
// ProviderPanelModifier(
// providerId: $providerId,
// selectedServer: $providerServer,
// configurationType: WireGuard.Configuration.self,
// serverRoute: {
// Subroute.providerServer(id: $0)
// }
// )
// }
}
// MARK: - Subviews

View File

@ -71,6 +71,7 @@ struct ProfileEditView: View, Routable {
.navigationTitle(Strings.Global.profile)
.navigationBarBackButtonHidden(true)
.navigationDestination(for: NavigationRoute.self, destination: pushDestination)
.environment(\.navigationPath, $path)
}
}

View File

@ -36,6 +36,9 @@ struct ProfileSplitView: View, Routable {
var flow: ProfileCoordinator.Flow?
@State
private var detailPath = NavigationPath()
@State
private var selectedModuleId: UUID? = ModuleListView.generalModuleId
@ -52,7 +55,7 @@ struct ProfileSplitView: View, Routable {
flow: flow
)
} detail: {
NavigationStack {
NavigationStack(path: $detailPath) {
switch selectedModuleId {
case ModuleListView.generalModuleId:
detailView(for: .general)
@ -62,6 +65,7 @@ struct ProfileSplitView: View, Routable {
}
}
.toolbar(content: toolbarContent)
.environment(\.navigationPath, $detailPath)
}
}
}

View File

@ -51,7 +51,7 @@ extension ProfileEditor {
}
}
func binding<T>(forModule module: T) -> Binding<T> where T: ModuleBuilder {
subscript<T>(module: T) -> Binding<T> where T: ModuleBuilder {
Binding { [weak self] in
guard let foundModule = self?.module(withId: module.id) else {
fatalError("Module not found in editor: \(module.id)")

View File

@ -28,8 +28,7 @@ import PassepartoutKit
import SwiftUI
import UtilsLibrary
@MainActor
struct VPNProviderContentModifier<Configuration, ProviderRows>: ViewModifier where Configuration: ProviderConfigurationIdentifiable & Codable, ProviderRows: View {
struct VPNProviderContentModifier<Configuration, Destination, ProviderRows>: ViewModifier where Configuration: ProviderConfigurationIdentifiable & Codable, Destination: Hashable, ProviderRows: View {
var apis: [APIMapper] = API.shared
@ -41,6 +40,8 @@ struct VPNProviderContentModifier<Configuration, ProviderRows>: ViewModifier whe
let isRequired: Bool
let entityDestination: Destination
@ViewBuilder
let providerRows: ProviderRows
@ -53,7 +54,7 @@ struct VPNProviderContentModifier<Configuration, ProviderRows>: ViewModifier whe
entityType: VPNEntity<Configuration>.self,
isRequired: isRequired,
providerRows: {
providerServerRow
providerEntityRow
providerRows
},
onSelectProvider: onSelectProvider
@ -62,18 +63,8 @@ struct VPNProviderContentModifier<Configuration, ProviderRows>: ViewModifier whe
}
private extension VPNProviderContentModifier {
var providerServerRow: some View {
NavigationLink {
providerId.map {
VPNProviderServerView<Configuration>(
apis: apis,
providerId: $0,
configurationType: Configuration.self,
selectedEntity: selectedEntity,
onSelect: onSelectServer
)
}
} label: {
var providerEntityRow: some View {
NavigationLink(value: entityDestination) {
HStack {
Text(Strings.Global.server)
if let selectedEntity {
@ -92,25 +83,29 @@ private extension VPNProviderContentModifier {
selectedEntity = nil
}
}
func onSelectServer(server: VPNServer, preset: VPNPreset<Configuration>) {
selectedEntity = VPNEntity(server: server, preset: preset)
}
}
// MARK: - Preview
#Preview {
List {
EmptyView()
.modifier(VPNProviderContentModifier(
providerId: .constant(.hideme),
selectedEntity: .constant(nil as VPNEntity<OpenVPN.Configuration>?),
isRequired: false,
providerRows: {
Text("Other")
}
))
NavigationStack {
List {
EmptyView()
.modifier(VPNProviderContentModifier(
apis: [API.bundled],
providerId: .constant(.hideme),
selectedEntity: .constant(nil as VPNEntity<OpenVPN.Configuration>?),
isRequired: false,
entityDestination: "Destination",
providerRows: {
Text("Other")
}
))
}
.navigationTitle("Preview")
.navigationDestination(for: String.self) {
Text($0)
}
}
.withMockEnvironment()
}

View File

@ -33,10 +33,7 @@ struct VPNProviderServerView<Configuration>: View where Configuration: ProviderC
@EnvironmentObject
private var providerManager: ProviderManager
@Environment(\.dismiss)
private var dismiss
let apis: [APIMapper]
var apis: [APIMapper] = API.shared
let providerId: ProviderID
@ -112,14 +109,12 @@ extension VPNProviderServerView {
return
}
onSelect(server, preset)
dismiss()
}
}
// MARK: - Preview
#Preview {
NavigationStack {
VPNProviderServerView<OpenVPN.Configuration>(
apis: [API.bundled],