mirror of
https://github.com/passepartoutvpn/passepartout-apple.git
synced 2025-01-31 04:52:05 +00:00
Split again OrganizerView and ProfilesList
On iOS 14, Organizer scrolls abruptly on profile selection. It looks like this was introduced by merging ProfilesList into OrganizerView. Try to revert merge to split observation responsibilities. Drop unused AppManager in +Scene along the way.
This commit is contained in:
parent
93abaf538b
commit
3c0e511e84
@ -121,6 +121,7 @@
|
||||
0EF2212D27E66EB5001D0BD7 /* AddProviderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EF2212C27E66EB5001D0BD7 /* AddProviderView.swift */; };
|
||||
0EF2212F27E66F60001D0BD7 /* AddProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EF2212E27E66F60001D0BD7 /* AddProfileView.swift */; };
|
||||
0EF2213127E674BD001D0BD7 /* AddProviderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EF2213027E674BD001D0BD7 /* AddProviderViewModel.swift */; };
|
||||
0EF8C5A828213C510053CE89 /* OrganizerView+Profiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EF8C5A728213C510053CE89 /* OrganizerView+Profiles.swift */; };
|
||||
0EF98A6D281F3A9B005C0D3A /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0ED31C3920CF39510027975F /* NetworkExtension.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@ -337,6 +338,7 @@
|
||||
0EF2212C27E66EB5001D0BD7 /* AddProviderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddProviderView.swift; sourceTree = "<group>"; };
|
||||
0EF2212E27E66F60001D0BD7 /* AddProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddProfileView.swift; sourceTree = "<group>"; };
|
||||
0EF2213027E674BD001D0BD7 /* AddProviderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddProviderViewModel.swift; sourceTree = "<group>"; };
|
||||
0EF8C5A728213C510053CE89 /* OrganizerView+Profiles.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "OrganizerView+Profiles.swift"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@ -442,6 +444,7 @@
|
||||
0E34AC8127F892C40042F2AB /* OnDemandView+SSID.swift */,
|
||||
0E2A8D4E27B04BB900207D04 /* OrganizerView.swift */,
|
||||
0E3CD47E280DA14B007075C0 /* OrganizerView+AddMenu.swift */,
|
||||
0EF8C5A728213C510053CE89 /* OrganizerView+Profiles.swift */,
|
||||
0E34AC7727F840890042F2AB /* OrganizerView+Scene.swift */,
|
||||
0EE11CD1280D8317003BE431 /* OrganizerView+SettingsMenu.swift */,
|
||||
0EF0FAF527DD0211007EB181 /* PaywallView.swift */,
|
||||
@ -957,6 +960,7 @@
|
||||
0E71ACF927C12E4800F85C4B /* CreditsView.swift in Sources */,
|
||||
0ED89C1527DE0A0C008B36D6 /* Shortcut.swift in Sources */,
|
||||
0E34A2B927CAA96A00C73B67 /* OpenVPN+L10n.swift in Sources */,
|
||||
0EF8C5A828213C510053CE89 /* OrganizerView+Profiles.swift in Sources */,
|
||||
0E3CD483280DAE92007075C0 /* ProfileView+MainMenu.swift in Sources */,
|
||||
0EB17EAE27D226CF00D473B5 /* LocalProduct.swift in Sources */,
|
||||
0E71ACEB27C1060D00F85C4B /* EndpointView.swift in Sources */,
|
||||
|
215
Passepartout/App/Views/OrganizerView+Profiles.swift
Normal file
215
Passepartout/App/Views/OrganizerView+Profiles.swift
Normal file
@ -0,0 +1,215 @@
|
||||
//
|
||||
// OrganizerView+Profiles.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 5/3/22.
|
||||
// Copyright (c) 2022 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
|
||||
import PassepartoutCore
|
||||
|
||||
extension OrganizerView {
|
||||
struct ProfilesList: View {
|
||||
@ObservedObject private var profileManager: ProfileManager
|
||||
|
||||
@ObservedObject private var providerManager: ProviderManager
|
||||
|
||||
// just to observe changes in profiles eligibility
|
||||
@ObservedObject private var productManager: ProductManager
|
||||
|
||||
@State private var isFirstLaunch = true
|
||||
|
||||
@State private var presentedProfileId: UUID?
|
||||
|
||||
private var presentedAndLoadedProfileId: Binding<UUID?> {
|
||||
.init {
|
||||
presentedProfileId
|
||||
} set: {
|
||||
guard $0 != presentedProfileId else {
|
||||
return
|
||||
}
|
||||
guard let id = $0 else {
|
||||
presentedProfileId = nil
|
||||
return
|
||||
}
|
||||
presentedProfileId = id
|
||||
|
||||
// load profile contextually with navigation
|
||||
do {
|
||||
try profileManager.loadCurrentProfile(withId: id)
|
||||
} catch {
|
||||
pp_log.error("Unable to load profile: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
profileManager = .shared
|
||||
providerManager = .shared
|
||||
productManager = .shared
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
debugChanges()
|
||||
return Group {
|
||||
mainView
|
||||
if !profileManager.hasProfiles {
|
||||
emptyView
|
||||
}
|
||||
}
|
||||
|
||||
// events
|
||||
.onAppear {
|
||||
performMigrationsIfNeeded()
|
||||
}.onChange(of: profileManager.headers) {
|
||||
dismissSelectionIfDeleted(headers: $0)
|
||||
}.onReceive(profileManager.didCreateProfile) {
|
||||
presentedAndLoadedProfileId.wrappedValue = $0.id
|
||||
}
|
||||
}
|
||||
|
||||
private var mainView: some View {
|
||||
List {
|
||||
if profileManager.hasProfiles {
|
||||
switch themeIdiom {
|
||||
case .mac:
|
||||
profilesView
|
||||
|
||||
default:
|
||||
// FIXME: iPad multitasking, navigation binding does not clear on pop without Section
|
||||
Section {
|
||||
profilesView
|
||||
} header: {
|
||||
Text(L10n.Global.Strings.profiles)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.themeAnimation(on: profileManager.headers)
|
||||
}
|
||||
|
||||
private var profilesView: some View {
|
||||
ForEach(sortedHeaders, content: profileRow(forHeader:))
|
||||
.onDelete(perform: removeProfiles)
|
||||
}
|
||||
|
||||
private var emptyView: some View {
|
||||
VStack {
|
||||
Text(L10n.Organizer.Empty.noProfiles)
|
||||
.themeInformativeTextStyle()
|
||||
}
|
||||
}
|
||||
|
||||
private func profileRow(forHeader header: Profile.Header) -> some View {
|
||||
NavigationLink(tag: header.id, selection: presentedAndLoadedProfileId) {
|
||||
ProfileView()
|
||||
} label: {
|
||||
profileLabel(forHeader: header)
|
||||
}.contextMenu {
|
||||
profileMenu(forHeader: header)
|
||||
}.onAppear {
|
||||
presentIfActiveProfile(header.id)
|
||||
}
|
||||
}
|
||||
|
||||
private func profileLabel(forHeader header: Profile.Header) -> some View {
|
||||
ProfileRow(
|
||||
header: header,
|
||||
isActive: profileManager.isActiveProfile(header.id)
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func profileMenu(forHeader header: Profile.Header) -> some View {
|
||||
ProfileView.DuplicateButton(
|
||||
header: header,
|
||||
switchCurrentProfile: false
|
||||
)
|
||||
}
|
||||
|
||||
private var sortedHeaders: [Profile.Header] {
|
||||
profileManager.headers
|
||||
.sorted {
|
||||
if profileManager.isActiveProfile($0.id) {
|
||||
return true
|
||||
} else if profileManager.isActiveProfile($1.id) {
|
||||
return false
|
||||
} else {
|
||||
return $0 < $1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func presentIfActiveProfile(_ id: UUID) {
|
||||
guard id == profileManager.activeProfileId else {
|
||||
return
|
||||
}
|
||||
presentActiveProfile()
|
||||
}
|
||||
|
||||
private func presentActiveProfile() {
|
||||
guard isFirstLaunch else {
|
||||
return
|
||||
}
|
||||
isFirstLaunch = false
|
||||
|
||||
guard let activeProfileId = profileManager.activeProfileId else {
|
||||
return
|
||||
}
|
||||
|
||||
// FIXME: iPad portrait/compact, preselecting profile on launch adds ProfileView() twice
|
||||
// can notice becase "Back" needs to be tapped twice to show sidebar
|
||||
if themeIdiom != .pad {
|
||||
presentedProfileId = activeProfileId
|
||||
}
|
||||
}
|
||||
|
||||
private func removeProfiles(at offsets: IndexSet) {
|
||||
let currentHeaders = sortedHeaders
|
||||
var toDelete: [UUID] = []
|
||||
offsets.forEach {
|
||||
toDelete.append(currentHeaders[$0].id)
|
||||
}
|
||||
removeProfiles(withIds: toDelete)
|
||||
}
|
||||
|
||||
private func removeProfiles(withIds toDelete: [UUID]) {
|
||||
|
||||
// clear selection before removal to avoid triggering a bogus navigation push
|
||||
if toDelete.contains(profileManager.currentProfile.value.id) {
|
||||
presentedProfileId = nil
|
||||
}
|
||||
|
||||
profileManager.removeProfiles(withIds: toDelete)
|
||||
}
|
||||
|
||||
private func performMigrationsIfNeeded() {
|
||||
Task {
|
||||
await AppManager.shared.doMigrations(profileManager)
|
||||
}
|
||||
}
|
||||
|
||||
private func dismissSelectionIfDeleted(headers: [Profile.Header]) {
|
||||
if let _ = presentedProfileId, !profileManager.isCurrentProfileExisting() {
|
||||
presentedProfileId = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -30,8 +30,6 @@ extension OrganizerView {
|
||||
struct SceneView: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
@ObservedObject private var appManager: AppManager
|
||||
|
||||
@ObservedObject private var profileManager: ProfileManager
|
||||
|
||||
@ObservedObject private var vpnManager: VPNManager
|
||||
@ -43,7 +41,6 @@ extension OrganizerView {
|
||||
@Binding private var didHandleSubreddit: Bool
|
||||
|
||||
init(alertType: Binding<AlertType?>, didHandleSubreddit: Binding<Bool>) {
|
||||
appManager = .shared
|
||||
profileManager = .shared
|
||||
vpnManager = .shared
|
||||
productManager = .shared
|
||||
|
@ -69,65 +69,23 @@ struct OrganizerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ObservedObject private var profileManager: ProfileManager
|
||||
|
||||
@ObservedObject private var providerManager: ProviderManager
|
||||
|
||||
// just to observe changes in profiles eligibility
|
||||
@ObservedObject private var productManager: ProductManager
|
||||
|
||||
@State private var isFirstLaunch = true
|
||||
|
||||
@State private var modalType: ModalType?
|
||||
|
||||
@State private var alertType: AlertType?
|
||||
|
||||
@State private var isHostFileImporterPresented = false
|
||||
|
||||
@State private var presentedProfileId: UUID?
|
||||
|
||||
private var presentedAndLoadedProfileId: Binding<UUID?> {
|
||||
.init {
|
||||
presentedProfileId
|
||||
} set: {
|
||||
guard $0 != presentedProfileId else {
|
||||
return
|
||||
}
|
||||
guard let id = $0 else {
|
||||
presentedProfileId = nil
|
||||
return
|
||||
}
|
||||
presentedProfileId = id
|
||||
|
||||
// load profile contextually with navigation
|
||||
do {
|
||||
try profileManager.loadCurrentProfile(withId: id)
|
||||
} catch {
|
||||
pp_log.error("Unable to load profile: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AppStorage(AppManager.DefaultKey.didHandleSubreddit.rawValue) var didHandleSubreddit = false
|
||||
|
||||
private let hostFileTypes = Constants.URLs.filetypes
|
||||
|
||||
private let redditURL = Constants.URLs.subreddit
|
||||
|
||||
init() {
|
||||
profileManager = .shared
|
||||
providerManager = .shared
|
||||
productManager = .shared
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
debugChanges()
|
||||
return ZStack {
|
||||
hiddenSceneView
|
||||
mainView
|
||||
if !profileManager.hasProfiles {
|
||||
emptyView
|
||||
}
|
||||
ProfilesList()
|
||||
}.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
AddMenu(
|
||||
@ -144,22 +102,14 @@ struct OrganizerView: View {
|
||||
}
|
||||
}.sheet(item: $modalType, content: presentedModal)
|
||||
.alert(item: $alertType, content: presentedAlert)
|
||||
.navigationTitle(Unlocalized.appName)
|
||||
.themePrimaryView()
|
||||
|
||||
// events
|
||||
.onAppear {
|
||||
performMigrationsIfNeeded()
|
||||
}.onChange(of: profileManager.headers) {
|
||||
dismissSelectionIfDeleted(headers: $0)
|
||||
}.onReceive(profileManager.didCreateProfile) {
|
||||
presentedAndLoadedProfileId.wrappedValue = $0.id
|
||||
}.fileImporter(
|
||||
.fileImporter(
|
||||
isPresented: $isHostFileImporterPresented,
|
||||
allowedContentTypes: hostFileTypes,
|
||||
allowsMultipleSelection: false,
|
||||
onCompletion: onHostFileImporterResult
|
||||
).onOpenURL(perform: onOpenURL)
|
||||
.navigationTitle(Unlocalized.appName)
|
||||
.themePrimaryView()
|
||||
}
|
||||
|
||||
private var hiddenSceneView: some View {
|
||||
@ -168,77 +118,6 @@ struct OrganizerView: View {
|
||||
didHandleSubreddit: $didHandleSubreddit
|
||||
)
|
||||
}
|
||||
|
||||
private var mainView: some View {
|
||||
List {
|
||||
if profileManager.hasProfiles {
|
||||
switch themeIdiom {
|
||||
case .mac:
|
||||
profilesView
|
||||
|
||||
default:
|
||||
// FIXME: iPad multitasking, navigation binding does not clear on pop without Section
|
||||
Section {
|
||||
profilesView
|
||||
} header: {
|
||||
Text(L10n.Global.Strings.profiles)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.themeAnimation(on: profileManager.headers)
|
||||
}
|
||||
|
||||
private var profilesView: some View {
|
||||
ForEach(sortedHeaders, content: profileRow(forHeader:))
|
||||
.onDelete(perform: removeProfiles)
|
||||
}
|
||||
|
||||
private var emptyView: some View {
|
||||
VStack {
|
||||
Text(L10n.Organizer.Empty.noProfiles)
|
||||
.themeInformativeTextStyle()
|
||||
}
|
||||
}
|
||||
|
||||
private func profileRow(forHeader header: Profile.Header) -> some View {
|
||||
NavigationLink(tag: header.id, selection: presentedAndLoadedProfileId) {
|
||||
ProfileView()
|
||||
} label: {
|
||||
profileLabel(forHeader: header)
|
||||
}.contextMenu {
|
||||
profileMenu(forHeader: header)
|
||||
}.onAppear {
|
||||
presentIfActiveProfile(header.id)
|
||||
}
|
||||
}
|
||||
|
||||
private func profileLabel(forHeader header: Profile.Header) -> some View {
|
||||
ProfileRow(
|
||||
header: header,
|
||||
isActive: profileManager.isActiveProfile(header.id)
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func profileMenu(forHeader header: Profile.Header) -> some View {
|
||||
ProfileView.DuplicateButton(
|
||||
header: header,
|
||||
switchCurrentProfile: false
|
||||
)
|
||||
}
|
||||
|
||||
private var sortedHeaders: [Profile.Header] {
|
||||
profileManager.headers
|
||||
.sorted {
|
||||
if profileManager.isActiveProfile($0.id) {
|
||||
return true
|
||||
} else if profileManager.isActiveProfile($1.id) {
|
||||
return false
|
||||
} else {
|
||||
return $0 < $1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension OrganizerView {
|
||||
@ -338,65 +217,6 @@ extension OrganizerView {
|
||||
}
|
||||
|
||||
extension OrganizerView {
|
||||
private func presentIfActiveProfile(_ id: UUID) {
|
||||
guard id == profileManager.activeProfileId else {
|
||||
return
|
||||
}
|
||||
presentActiveProfile()
|
||||
}
|
||||
|
||||
private func presentActiveProfile() {
|
||||
guard isFirstLaunch else {
|
||||
return
|
||||
}
|
||||
isFirstLaunch = false
|
||||
|
||||
// presenting profile when an alert is active seems to break navigation
|
||||
guard alertType == nil else {
|
||||
return
|
||||
}
|
||||
guard let activeProfileId = profileManager.activeProfileId else {
|
||||
return
|
||||
}
|
||||
|
||||
// FIXME: iPad portrait/compact, preselecting profile on launch adds ProfileView() twice
|
||||
// can notice becase "Back" needs to be tapped twice to show sidebar
|
||||
if themeIdiom != .pad {
|
||||
presentedProfileId = activeProfileId
|
||||
}
|
||||
}
|
||||
|
||||
private func removeProfiles(at offsets: IndexSet) {
|
||||
let currentHeaders = sortedHeaders
|
||||
var toDelete: [UUID] = []
|
||||
offsets.forEach {
|
||||
toDelete.append(currentHeaders[$0].id)
|
||||
}
|
||||
removeProfiles(withIds: toDelete)
|
||||
}
|
||||
|
||||
private func removeProfiles(withIds toDelete: [UUID]) {
|
||||
|
||||
// clear selection before removal to avoid triggering a bogus navigation push
|
||||
if toDelete.contains(profileManager.currentProfile.value.id) {
|
||||
presentedProfileId = nil
|
||||
}
|
||||
|
||||
profileManager.removeProfiles(withIds: toDelete)
|
||||
}
|
||||
|
||||
private func performMigrationsIfNeeded() {
|
||||
Task {
|
||||
await AppManager.shared.doMigrations(profileManager)
|
||||
}
|
||||
}
|
||||
|
||||
private func dismissSelectionIfDeleted(headers: [Profile.Header]) {
|
||||
if let _ = presentedProfileId, !profileManager.isCurrentProfileExisting() {
|
||||
presentedProfileId = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func presentSubscribeReddit() {
|
||||
alertType = .subscribeReddit
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user