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