diff --git a/Passepartout/Library/Sources/AppUI/Protocols/AppCoordinatorConforming.swift b/Passepartout/Library/Sources/AppUI/Protocols/AppCoordinatorConforming.swift
new file mode 100644
index 00000000..b4629ba0
--- /dev/null
+++ b/Passepartout/Library/Sources/AppUI/Protocols/AppCoordinatorConforming.swift
@@ -0,0 +1,36 @@
+//
+// AppCoordinatorConforming.swift
+// Passepartout
+//
+// Created by Davide De Rosa on 10/31/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 .
+//
+
+import AppLibrary
+import Foundation
+import PassepartoutKit
+
+public protocol AppCoordinatorConforming {
+ init(
+ profileManager: ProfileManager,
+ tunnel: Tunnel,
+ registry: Registry
+ )
+}
diff --git a/Passepartout/Library/Sources/AppUIMain/Views/App/AppCoordinator.swift b/Passepartout/Library/Sources/AppUIMain/Views/App/AppCoordinator.swift
index e71cd303..deba327a 100644
--- a/Passepartout/Library/Sources/AppUIMain/Views/App/AppCoordinator.swift
+++ b/Passepartout/Library/Sources/AppUIMain/Views/App/AppCoordinator.swift
@@ -27,15 +27,8 @@ import AppLibrary
import CommonLibrary
import PassepartoutKit
import SwiftUI
-import UtilsLibrary
-public struct AppCoordinator: View, SizeClassProviding {
-
- @Environment(\.horizontalSizeClass)
- public var hsClass
-
- @Environment(\.verticalSizeClass)
- public var vsClass
+public struct AppCoordinator: View, AppCoordinatorConforming {
@AppStorage(AppPreference.profilesLayout.key)
private var layout: ProfilesLayout = .list
@@ -49,6 +42,15 @@ public struct AppCoordinator: View, SizeClassProviding {
@StateObject
private var profileEditor = ProfileEditor()
+ @State
+ private var modalRoute: ModalRoute?
+
+ @State
+ private var isImporting = false
+
+ @State
+ private var profilePath = NavigationPath()
+
public init(
profileManager: ProfileManager,
tunnel: Tunnel,
@@ -60,22 +62,151 @@ public struct AppCoordinator: View, SizeClassProviding {
}
public var body: some View {
- if isBigDevice {
- AppModalCoordinator(
- layout: $layout,
+ NavigationStack {
+ contentView
+ .toolbar(content: toolbarContent)
+ }
+ .themeModal(
+ item: $modalRoute,
+ isRoot: true,
+ isInteractive: modalRoute?.isInteractive ?? true,
+ content: modalDestination
+ )
+ }
+}
+
+// MARK: - Destinations
+
+extension AppCoordinator {
+ enum ModalRoute: Identifiable {
+ case editProfile
+
+ case editProviderEntity(Profile, Module, ModuleMetadata.Provider)
+
+ case settings
+
+ case about
+
+ var id: Int {
+ switch self {
+ case .editProfile: return 1
+ case .editProviderEntity: return 2
+ case .settings: return 3
+ case .about: return 4
+ }
+ }
+
+ var isInteractive: Bool {
+ switch self {
+ case .editProfile:
+ return false
+
+ default:
+ return true
+ }
+ }
+ }
+
+ var contentView: some View {
+ ProfileContainerView(
+ layout: layout,
+ profileManager: profileManager,
+ tunnel: tunnel,
+ registry: registry,
+ isImporting: $isImporting,
+ flow: .init(
+ onEditProfile: {
+ guard let profile = profileManager.profile(withId: $0.id) else {
+ return
+ }
+ enterDetail(of: profile)
+ },
+ onEditProviderEntity: {
+ guard let pair = $0.firstProviderModuleWithMetadata else {
+ return
+ }
+ present(.editProviderEntity($0, pair.0, pair.1))
+ }
+ )
+ )
+ }
+
+ func toolbarContent() -> some ToolbarContent {
+ AppToolbar(
+ profileManager: profileManager,
+ layout: $layout,
+ isImporting: $isImporting,
+ onSettings: {
+ present(.settings)
+ },
+ onAbout: {
+ present(.about)
+ },
+ onNewProfile: enterDetail
+ )
+ }
+
+ @ViewBuilder
+ func modalDestination(for item: ModalRoute?) -> some View {
+ switch item {
+ case .editProfile:
+ ProfileCoordinator(
profileManager: profileManager,
profileEditor: profileEditor,
- tunnel: tunnel,
- registry: registry
+ moduleViewFactory: DefaultModuleViewFactory(),
+ modally: true,
+ path: $profilePath,
+ onDismiss: {
+ present(nil)
+ }
)
- } else {
- AppInlineCoordinator(
- layout: $layout,
+
+ case .editProviderEntity(let profile, let module, let provider):
+ ProviderEntitySelector(
profileManager: profileManager,
- profileEditor: profileEditor,
tunnel: tunnel,
- registry: registry
+ profile: profile,
+ module: module,
+ provider: provider
)
+
+ case .settings:
+ SettingsView(profileManager: profileManager)
+
+ case .about:
+ AboutRouterView(
+ profileManager: profileManager,
+ tunnel: tunnel
+ )
+
+ default:
+ EmptyView()
+ }
+ }
+
+ func enterDetail(of profile: Profile) {
+ profilePath = NavigationPath()
+ profileEditor.editProfile(
+ profile,
+ isShared: profileManager.isRemotelyShared(profileWithId: profile.id)
+ )
+ present(.editProfile)
+ }
+
+ func present(_ route: ModalRoute?) {
+ // XXX: this is a workaround for #791 on iOS 16
+ Task {
+ try await Task.sleep(for: .milliseconds(50))
+ modalRoute = route
}
}
}
+
+#Preview {
+ AppCoordinator(
+ profileManager: .mock,
+ tunnel: .mock,
+ registry: Registry()
+ )
+ .withMockEnvironment()
+}
diff --git a/Passepartout/Library/Sources/AppUIMain/Views/App/AppInlineCoordinator.swift b/Passepartout/Library/Sources/AppUIMain/Views/App/AppInlineCoordinator.swift
deleted file mode 100644
index f13e168b..00000000
--- a/Passepartout/Library/Sources/AppUIMain/Views/App/AppInlineCoordinator.swift
+++ /dev/null
@@ -1,194 +0,0 @@
-//
-// AppInlineCoordinator.swift
-// Passepartout
-//
-// Created by Davide De Rosa on 8/13/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 .
-//
-
-import AppLibrary
-import PassepartoutKit
-import SwiftUI
-import UtilsLibrary
-
-@MainActor
-struct AppInlineCoordinator: View {
-
- @Binding
- var layout: ProfilesLayout
-
- let profileManager: ProfileManager
-
- let profileEditor: ProfileEditor
-
- let tunnel: Tunnel
-
- let registry: Registry
-
- @State
- private var path = NavigationPath()
-
- @State
- private var modalRoute: ModalRoute?
-
- @State
- private var isImporting = false
-
- var body: some View {
- NavigationStack(path: $path) {
- contentView
- .toolbar(content: toolbarContent)
- .navigationDestination(for: NavigationRoute.self, destination: pushDestination)
- }
- .themeModal(item: $modalRoute, isRoot: true, content: modalDestination)
- }
-}
-
-// MARK: - Destinations
-
-private extension AppInlineCoordinator {
- enum NavigationRoute: Hashable {
- case editProfile
- }
-
- enum ModalRoute: Identifiable {
- case editProviderEntity(Profile, Module, ModuleMetadata.Provider)
-
- case settings
-
- case about
-
- var id: Int {
- switch self {
- case .editProviderEntity: return 1
- case .settings: return 2
- case .about: return 3
- }
- }
- }
-
- var contentView: some View {
- ProfileContainerView(
- layout: layout,
- profileManager: profileManager,
- tunnel: tunnel,
- registry: registry,
- isImporting: $isImporting,
- flow: .init(
- onEditProfile: {
- guard let profile = profileManager.profile(withId: $0.id) else {
- return
- }
- enterDetail(of: profile)
- },
- onEditProviderEntity: {
- guard let pair = $0.firstProviderModuleWithMetadata else {
- return
- }
- modalRoute = .editProviderEntity($0, pair.0, pair.1)
- }
- )
- )
- }
-
- func toolbarContent() -> some ToolbarContent {
- AppToolbar(
- profileManager: profileManager,
- layout: $layout,
- isImporting: $isImporting,
- onSettings: {
- modalRoute = .settings
- },
- onAbout: {
- modalRoute = .about
- },
- onNewProfile: enterDetail
- )
- }
-
- @ViewBuilder
- func pushDestination(for item: NavigationRoute) -> some View {
- switch item {
- case .editProfile:
- ProfileCoordinator(
- profileManager: profileManager,
- profileEditor: profileEditor,
- moduleViewFactory: DefaultModuleViewFactory(),
- modally: false,
- path: $path
- ) {
- path.removeLast()
- }
- }
- }
-
- @ViewBuilder
- func modalDestination(for item: ModalRoute?) -> some View {
- switch item {
- case .editProviderEntity(let profile, let module, let provider):
- ProviderEntitySelector(
- profileManager: profileManager,
- tunnel: tunnel,
- profile: profile,
- module: module,
- provider: provider
- )
-
- case .settings:
- SettingsView(profileManager: profileManager)
-
- case .about:
- AboutRouterView(
- profileManager: profileManager,
- tunnel: tunnel
- )
-
- default:
- EmptyView()
- }
- }
-
- func enterDetail(of profile: Profile) {
- profileEditor.editProfile(
- profile,
- isShared: profileManager.isRemotelyShared(profileWithId: profile.id)
- )
- push(.editProfile)
- }
-
- func push(_ item: NavigationRoute) {
- path.append(item)
- }
-}
-
-#Preview {
-
- @State
- var layout: ProfilesLayout = .list
-
- return AppInlineCoordinator(
- layout: $layout,
- profileManager: .mock,
- profileEditor: ProfileEditor(profile: .mock),
- tunnel: .mock,
- registry: Registry()
- )
- .withMockEnvironment()
-}
diff --git a/Passepartout/Library/Sources/AppUIMain/Views/App/AppModalCoordinator.swift b/Passepartout/Library/Sources/AppUIMain/Views/App/AppModalCoordinator.swift
deleted file mode 100644
index 5dfe2438..00000000
--- a/Passepartout/Library/Sources/AppUIMain/Views/App/AppModalCoordinator.swift
+++ /dev/null
@@ -1,183 +0,0 @@
-//
-// AppModalCoordinator.swift
-// Passepartout
-//
-// Created by Davide De Rosa on 6/19/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 .
-//
-
-import AppLibrary
-import PassepartoutKit
-import SwiftUI
-
-@MainActor
-struct AppModalCoordinator: View {
-
- @Binding
- var layout: ProfilesLayout
-
- let profileManager: ProfileManager
-
- let profileEditor: ProfileEditor
-
- let tunnel: Tunnel
-
- let registry: Registry
-
- @State
- private var modalRoute: ModalRoute?
-
- @State
- private var isImporting = false
-
- @State
- private var profilePath = NavigationPath()
-
- var body: some View {
- NavigationStack {
- contentView
- .toolbar(content: toolbarContent)
- }
- .themeModal(item: $modalRoute, isRoot: true, isInteractive: false, content: modalDestination)
- }
-}
-
-// MARK: - Destinations
-
-extension AppModalCoordinator {
- enum ModalRoute: Identifiable {
- case editProfile
-
- case editProviderEntity(Profile, Module, ModuleMetadata.Provider)
-
- case settings
-
- case about
-
- var id: Int {
- switch self {
- case .editProfile: return 1
- case .editProviderEntity: return 2
- case .settings: return 3
- case .about: return 4
- }
- }
- }
-
- var contentView: some View {
- ProfileContainerView(
- layout: layout,
- profileManager: profileManager,
- tunnel: tunnel,
- registry: registry,
- isImporting: $isImporting,
- flow: .init(
- onEditProfile: {
- guard let profile = profileManager.profile(withId: $0.id) else {
- return
- }
- enterDetail(of: profile)
- },
- onEditProviderEntity: {
- guard let pair = $0.firstProviderModuleWithMetadata else {
- return
- }
- modalRoute = .editProviderEntity($0, pair.0, pair.1)
- }
- )
- )
- }
-
- func toolbarContent() -> some ToolbarContent {
- AppToolbar(
- profileManager: profileManager,
- layout: $layout,
- isImporting: $isImporting,
- onSettings: {
- modalRoute = .settings
- },
- onAbout: {
- modalRoute = .about
- },
- onNewProfile: enterDetail
- )
- }
-
- @ViewBuilder
- func modalDestination(for item: ModalRoute?) -> some View {
- switch item {
- case .editProfile:
- ProfileCoordinator(
- profileManager: profileManager,
- profileEditor: profileEditor,
- moduleViewFactory: DefaultModuleViewFactory(),
- modally: true,
- path: $profilePath
- ) {
- modalRoute = nil
- }
-
- case .editProviderEntity(let profile, let module, let provider):
- ProviderEntitySelector(
- profileManager: profileManager,
- tunnel: tunnel,
- profile: profile,
- module: module,
- provider: provider
- )
-
- case .settings:
- SettingsView(profileManager: profileManager)
-
- case .about:
- AboutRouterView(
- profileManager: profileManager,
- tunnel: tunnel
- )
-
- default:
- EmptyView()
- }
- }
-
- func enterDetail(of profile: Profile) {
- profilePath = NavigationPath()
- profileEditor.editProfile(
- profile,
- isShared: profileManager.isRemotelyShared(profileWithId: profile.id)
- )
- modalRoute = .editProfile
- }
-}
-
-#Preview {
-
- @State
- var layout: ProfilesLayout = .grid
-
- return AppModalCoordinator(
- layout: $layout,
- profileManager: .mock,
- profileEditor: ProfileEditor(profile: .mock),
- tunnel: .mock,
- registry: Registry()
- )
- .withMockEnvironment()
-}
diff --git a/Passepartout/Library/Sources/AppUITV/Views/AppCoordinator.swift b/Passepartout/Library/Sources/AppUITV/Views/AppCoordinator.swift
index 87a463b6..8621e65f 100644
--- a/Passepartout/Library/Sources/AppUITV/Views/AppCoordinator.swift
+++ b/Passepartout/Library/Sources/AppUITV/Views/AppCoordinator.swift
@@ -29,7 +29,7 @@ import SwiftUI
// FIXME: #788, UI for Apple TV
-public struct AppCoordinator: View {
+public struct AppCoordinator: View, AppCoordinatorConforming {
private let profileManager: ProfileManager
private let tunnel: Tunnel