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:
Davide De Rosa 2022-05-03 12:32:03 +02:00
parent 93abaf538b
commit 3c0e511e84
4 changed files with 223 additions and 187 deletions

View File

@ -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 */,

View 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
}
}
}
}

View File

@ -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

View File

@ -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
}