Merge List into OrganizerView
- Restore wrapping Section as it seems to fix multitasking - Hide profiles section if empty
This commit is contained in:
parent
8003b4a92d
commit
edc7cdf045
|
@ -27,7 +27,6 @@
|
|||
0E34A2B927CAA96A00C73B67 /* OpenVPN+L10n.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E34A2AF27CAA84500C73B67 /* OpenVPN+L10n.swift */; };
|
||||
0E34A2CF27CADA6300C73B67 /* GenericVersionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E34A2CE27CADA6300C73B67 /* GenericVersionView.swift */; };
|
||||
0E34AC7827F840890042F2AB /* OrganizerView+Scene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E34AC7727F840890042F2AB /* OrganizerView+Scene.swift */; };
|
||||
0E34AC7C27F845510042F2AB /* OrganizerView+Profiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E34AC7B27F845510042F2AB /* OrganizerView+Profiles.swift */; };
|
||||
0E34AC8227F892C40042F2AB /* OnDemandView+SSID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E34AC8127F892C40042F2AB /* OnDemandView+SSID.swift */; };
|
||||
0E35C09A280E95BB0071FA35 /* ProviderProfileAvailability.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E35C099280E95BB0071FA35 /* ProviderProfileAvailability.swift */; };
|
||||
0E3B7FCD27E47B3700C66F13 /* AddHostView+Name.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E3B7FCC27E47B3700C66F13 /* AddHostView+Name.swift */; };
|
||||
|
@ -211,7 +210,6 @@
|
|||
0E34A2B527CAA8CC00C73B67 /* Core+L10n.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Core+L10n.swift"; sourceTree = "<group>"; };
|
||||
0E34A2CE27CADA6300C73B67 /* GenericVersionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericVersionView.swift; sourceTree = "<group>"; };
|
||||
0E34AC7727F840890042F2AB /* OrganizerView+Scene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OrganizerView+Scene.swift"; sourceTree = "<group>"; };
|
||||
0E34AC7B27F845510042F2AB /* OrganizerView+Profiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OrganizerView+Profiles.swift"; sourceTree = "<group>"; };
|
||||
0E34AC8127F892C40042F2AB /* OnDemandView+SSID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OnDemandView+SSID.swift"; sourceTree = "<group>"; };
|
||||
0E35C099280E95BB0071FA35 /* ProviderProfileAvailability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderProfileAvailability.swift; sourceTree = "<group>"; };
|
||||
0E3B7FCC27E47B3700C66F13 /* AddHostView+Name.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AddHostView+Name.swift"; sourceTree = "<group>"; };
|
||||
|
@ -442,7 +440,6 @@
|
|||
0E34AC8127F892C40042F2AB /* OnDemandView+SSID.swift */,
|
||||
0E2A8D4E27B04BB900207D04 /* OrganizerView.swift */,
|
||||
0E3CD47E280DA14B007075C0 /* OrganizerView+AddMenu.swift */,
|
||||
0E34AC7B27F845510042F2AB /* OrganizerView+Profiles.swift */,
|
||||
0E34AC7727F840890042F2AB /* OrganizerView+Scene.swift */,
|
||||
0EE11CD1280D8317003BE431 /* OrganizerView+SettingsMenu.swift */,
|
||||
0EF0FAF527DD0211007EB181 /* PaywallView.swift */,
|
||||
|
@ -932,7 +929,6 @@
|
|||
0E71ACE927C1055300F85C4B /* NetworkSettingsView.swift in Sources */,
|
||||
0EB34BCA27C6A70200B126DA /* OnDemandView.swift in Sources */,
|
||||
0E0BD27327B2EA2C00583AC5 /* MainView.swift in Sources */,
|
||||
0E34AC7C27F845510042F2AB /* OrganizerView+Profiles.swift in Sources */,
|
||||
0EB17EBA27D2560300D473B5 /* PassepartoutProviders+Extensions.swift in Sources */,
|
||||
0E3B7FDA27E51A0200C66F13 /* ProfileView+Provider.swift in Sources */,
|
||||
0E71ACE327C0F2E400F85C4B /* Providers+L10n.swift in Sources */,
|
||||
|
|
|
@ -52,7 +52,6 @@ extension View {
|
|||
let color = themeAccentColor
|
||||
return accentColor(color)
|
||||
.toggleStyle(SwitchToggleStyle(tint: color))
|
||||
.themeNavigationViewStyle()
|
||||
#endif
|
||||
}
|
||||
|
||||
|
@ -66,7 +65,7 @@ extension View {
|
|||
navigationViewStyle(.automatic)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func themePrimaryView() -> some View {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
navigationBarHidden(true)
|
||||
|
|
|
@ -1,207 +0,0 @@
|
|||
//
|
||||
// OrganizerView+Profiles.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 4/2/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
|
||||
|
||||
@Binding private var alertType: AlertType?
|
||||
|
||||
@State private var isFirstLaunch = true
|
||||
|
||||
@State private var presentedProfileId: UUID?
|
||||
|
||||
private var presentedAndLoadedProfileId: Binding<UUID?> {
|
||||
.init {
|
||||
presentedProfileId
|
||||
} set: {
|
||||
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(alertType: Binding<AlertType?>) {
|
||||
profileManager = .shared
|
||||
providerManager = .shared
|
||||
productManager = .shared
|
||||
_alertType = alertType
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
debugChanges()
|
||||
return Group {
|
||||
mainView
|
||||
if profileManager.headers.isEmpty {
|
||||
emptyView
|
||||
}
|
||||
}.onAppear {
|
||||
performMigrationsIfNeeded()
|
||||
}.onChange(of: profileManager.headers) {
|
||||
dismissSelectionIfDeleted(headers: $0)
|
||||
}
|
||||
|
||||
// from AddProfileView
|
||||
.onReceive(profileManager.didCreateProfile) {
|
||||
presentedAndLoadedProfileId.wrappedValue = $0.id
|
||||
}
|
||||
}
|
||||
|
||||
private var mainView: some View {
|
||||
List {
|
||||
ForEach(sortedHeaders, content: profileRow(forHeader:))
|
||||
.onDelete(perform: removeProfiles)
|
||||
}.themeAnimation(on: profileManager.headers)
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
// FIXME: layout, moving active profile on top breaks row animation (content flashes on Mac)
|
||||
// .sorted {
|
||||
// if profileManager.isActiveProfile($0.id) {
|
||||
// return true
|
||||
// } else if profileManager.isActiveProfile($1.id) {
|
||||
// return false
|
||||
// } else {
|
||||
// return $0 < $1
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension OrganizerView.ProfilesList {
|
||||
private func presentIfActiveProfile(_ id: UUID) {
|
||||
guard id == profileManager.activeHeader?.id 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.activeHeader?.id else {
|
||||
return
|
||||
}
|
||||
|
||||
// FIXME: layout, preselecting profile on iPad portrait/compact 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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -69,26 +69,65 @@ 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 {
|
||||
SceneView(
|
||||
alertType: $alertType,
|
||||
didHandleSubreddit: $didHandleSubreddit
|
||||
)
|
||||
ProfilesList(alertType: $alertType)
|
||||
hiddenSceneView
|
||||
mainView
|
||||
if profileManager.headers.isEmpty {
|
||||
emptyView
|
||||
}
|
||||
}.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
AddMenu(
|
||||
|
@ -101,18 +140,98 @@ struct OrganizerView: View {
|
|||
modalType: $modalType,
|
||||
alertType: $alertType
|
||||
)
|
||||
// EditButton()
|
||||
// EditButton()
|
||||
}
|
||||
}.sheet(item: $modalType, content: presentedModal)
|
||||
.alert(item: $alertType, content: presentedAlert)
|
||||
.fileImporter(
|
||||
.navigationTitle(Unlocalized.appName)
|
||||
.themePrimaryView()
|
||||
|
||||
// events
|
||||
.onAppear {
|
||||
performMigrationsIfNeeded()
|
||||
}.onChange(of: profileManager.headers) {
|
||||
dismissSelectionIfDeleted(headers: $0)
|
||||
}.onReceive(profileManager.didCreateProfile) {
|
||||
presentedAndLoadedProfileId.wrappedValue = $0.id
|
||||
}.fileImporter(
|
||||
isPresented: $isHostFileImporterPresented,
|
||||
allowedContentTypes: hostFileTypes,
|
||||
allowsMultipleSelection: false,
|
||||
onCompletion: onHostFileImporterResult
|
||||
).onOpenURL(perform: onOpenURL)
|
||||
.navigationTitle(Unlocalized.appName)
|
||||
.themePrimaryView()
|
||||
}
|
||||
|
||||
private var hiddenSceneView: some View {
|
||||
SceneView(
|
||||
alertType: $alertType,
|
||||
didHandleSubreddit: $didHandleSubreddit
|
||||
)
|
||||
}
|
||||
|
||||
private var mainView: some View {
|
||||
List {
|
||||
let headers = sortedHeaders
|
||||
if !headers.isEmpty {
|
||||
// FIXME: iPad multitasking, navigation binding does not clear on pop without Section
|
||||
Section {
|
||||
ForEach(sortedHeaders, content: profileRow(forHeader:))
|
||||
.onDelete(perform: removeProfiles)
|
||||
} header: {
|
||||
Text(L10n.Global.Strings.profiles)
|
||||
}
|
||||
}
|
||||
}.themeAnimation(on: profileManager.headers)
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
// FIXME: layout, moving active profile on top breaks row animation (content flashes on Mac)
|
||||
// .sorted {
|
||||
// if profileManager.isActiveProfile($0.id) {
|
||||
// return true
|
||||
// } else if profileManager.isActiveProfile($1.id) {
|
||||
// return false
|
||||
// } else {
|
||||
// return $0 < $1
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -213,6 +332,65 @@ extension OrganizerView {
|
|||
}
|
||||
|
||||
extension OrganizerView {
|
||||
private func presentIfActiveProfile(_ id: UUID) {
|
||||
guard id == profileManager.activeHeader?.id 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.activeHeader?.id 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
|
||||
}
|
||||
|
|
|
@ -69,6 +69,7 @@ extension VPNManager {
|
|||
currentState?.vpnStatus = .disconnecting
|
||||
await Task.maybeWait(forMilliseconds: 1000)
|
||||
currentState?.vpnStatus = .disconnected
|
||||
currentState?.dataCount = nil
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue