Optimize redraws in provider servers views (#760)
This commit is contained in:
parent
df4e3465f5
commit
61e8d8e2f7
|
@ -41,7 +41,7 @@
|
|||
"kind" : "remoteSourceControl",
|
||||
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
|
||||
"state" : {
|
||||
"revision" : "3934b7a4e64624d499f0d52d9053560554bd4be8"
|
||||
"revision" : "79ff98a69c87cc90ef213e00ab02c9d90d63baaf"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -28,7 +28,7 @@ let package = Package(
|
|||
],
|
||||
dependencies: [
|
||||
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", from: "0.9.0"),
|
||||
.package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "3934b7a4e64624d499f0d52d9053560554bd4be8"),
|
||||
.package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "79ff98a69c87cc90ef213e00ab02c9d90d63baaf"),
|
||||
// .package(path: "../../../passepartoutkit-source"),
|
||||
.package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", from: "0.9.1"),
|
||||
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", revision: "031863a1cd683962a7dfe68e20b91fa820a1ecce"),
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
//
|
||||
// ProviderFavoritesManager.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 10/26/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 <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import CommonLibrary
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class ProviderFavoritesManager: ObservableObject {
|
||||
private let defaults: UserDefaults
|
||||
|
||||
private var allFavorites: ProviderFavoriteServers
|
||||
|
||||
var moduleId: UUID {
|
||||
didSet {
|
||||
guard let rawValue = defaults.string(forKey: AppPreference.moduleFavoriteServers.key) else {
|
||||
allFavorites = ProviderFavoriteServers()
|
||||
return
|
||||
}
|
||||
allFavorites = ProviderFavoriteServers(rawValue: rawValue) ?? ProviderFavoriteServers()
|
||||
}
|
||||
}
|
||||
|
||||
var serverIds: Set<String> {
|
||||
get {
|
||||
allFavorites.servers(forModuleWithID: moduleId)
|
||||
}
|
||||
set {
|
||||
objectWillChange.send()
|
||||
allFavorites.setServers(newValue, forModuleWithID: moduleId)
|
||||
}
|
||||
}
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
allFavorites = ProviderFavoriteServers()
|
||||
moduleId = UUID()
|
||||
}
|
||||
|
||||
func save() {
|
||||
defaults.set(allFavorites.rawValue, forKey: AppPreference.moduleFavoriteServers.key)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,91 @@
|
|||
//
|
||||
// VPNFiltersView+Model.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 10/26/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 <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import PassepartoutKit
|
||||
|
||||
extension VPNFiltersView {
|
||||
|
||||
@MainActor
|
||||
final class Model: ObservableObject {
|
||||
private(set) var categories: [String]
|
||||
|
||||
private(set) var countries: [(code: String, description: String)]
|
||||
|
||||
private(set) var presets: [AnyVPNPreset]
|
||||
|
||||
@Published
|
||||
var filters = VPNFilters()
|
||||
|
||||
@Published
|
||||
var onlyShowsFavorites = false
|
||||
|
||||
let filtersDidChange = PassthroughSubject<VPNFilters, Never>()
|
||||
|
||||
let onlyShowsFavoritesDidChange = PassthroughSubject<Bool, Never>()
|
||||
|
||||
init(
|
||||
categories: [String] = [],
|
||||
countries: [(code: String, description: String)] = [],
|
||||
presets: [AnyVPNPreset] = []
|
||||
) {
|
||||
self.categories = categories
|
||||
self.countries = countries
|
||||
self.presets = presets
|
||||
}
|
||||
|
||||
func load<C>(
|
||||
with vpnManager: VPNProviderManager<C>,
|
||||
initialFilters: VPNFilters?
|
||||
) {
|
||||
categories = vpnManager
|
||||
.allCategoryNames
|
||||
.sorted()
|
||||
|
||||
countries = vpnManager
|
||||
.allCountryCodes
|
||||
.map {
|
||||
(code: $0, description: $0.localizedAsRegionCode ?? $0)
|
||||
}
|
||||
.sorted {
|
||||
$0.description < $1.description
|
||||
}
|
||||
|
||||
presets = vpnManager
|
||||
.allPresets
|
||||
.values
|
||||
.sorted {
|
||||
$0.description < $1.description
|
||||
}
|
||||
|
||||
if let initialFilters {
|
||||
filters = initialFilters
|
||||
}
|
||||
|
||||
objectWillChange.send()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -24,131 +24,49 @@
|
|||
//
|
||||
|
||||
import AppLibrary
|
||||
import CommonLibrary
|
||||
import Combine
|
||||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
|
||||
struct VPNFiltersView<Configuration>: View where Configuration: ProviderConfigurationIdentifiable & Decodable {
|
||||
struct VPNFiltersView: View {
|
||||
|
||||
@ObservedObject
|
||||
var manager: VPNProviderManager<Configuration>
|
||||
|
||||
@Binding
|
||||
var filters: VPNFilters
|
||||
|
||||
@Binding
|
||||
var onlyShowsFavorites: Bool
|
||||
|
||||
let favorites: Set<String>
|
||||
var model: Model
|
||||
|
||||
var body: some View {
|
||||
debugChanges()
|
||||
return Subview(
|
||||
filters: $filters,
|
||||
onlyShowsFavorites: $onlyShowsFavorites,
|
||||
categories: categories,
|
||||
countries: countries,
|
||||
presets: presets,
|
||||
favorites: favorites
|
||||
)
|
||||
.disabled(manager.isFiltering)
|
||||
.onChange(of: filters) { filters in
|
||||
Task {
|
||||
await manager.applyFilters(filters)
|
||||
}
|
||||
}
|
||||
.onChange(of: favorites) {
|
||||
if onlyShowsFavorites {
|
||||
filters.serverIds = $0
|
||||
Task {
|
||||
await manager.applyFilters(filters)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: onlyShowsFavorites) {
|
||||
filters.serverIds = $0 ? favorites : nil
|
||||
Task {
|
||||
await manager.applyFilters(filters)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension VPNFiltersView {
|
||||
var categories: [String] {
|
||||
manager
|
||||
.allCategoryNames
|
||||
.sorted()
|
||||
}
|
||||
|
||||
var countries: [(code: String, description: String)] {
|
||||
manager
|
||||
.allCountryCodes
|
||||
.map {
|
||||
(code: $0, description: $0.localizedAsRegionCode ?? $0)
|
||||
}
|
||||
.sorted {
|
||||
$0.description < $1.description
|
||||
}
|
||||
}
|
||||
|
||||
var presets: [VPNPreset<Configuration>] {
|
||||
manager
|
||||
.presets
|
||||
.sorted {
|
||||
$0.description < $1.description
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private extension VPNFiltersView {
|
||||
struct Subview: View {
|
||||
|
||||
@Binding
|
||||
var filters: VPNFilters
|
||||
|
||||
@Binding
|
||||
var onlyShowsFavorites: Bool
|
||||
|
||||
let categories: [String]
|
||||
|
||||
let countries: [(code: String, description: String)]
|
||||
|
||||
let presets: [VPNPreset<Configuration>]
|
||||
|
||||
let favorites: Set<String>
|
||||
|
||||
var body: some View {
|
||||
debugChanges()
|
||||
return Form {
|
||||
Section {
|
||||
categoryPicker
|
||||
countryPicker
|
||||
presetPicker
|
||||
favoritesToggle
|
||||
return Form {
|
||||
Section {
|
||||
categoryPicker
|
||||
countryPicker
|
||||
presetPicker
|
||||
favoritesToggle
|
||||
#if os(iOS)
|
||||
clearFiltersButton
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
clearFiltersButton
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
#else
|
||||
HStack {
|
||||
Spacer()
|
||||
clearFiltersButton
|
||||
}
|
||||
#endif
|
||||
HStack {
|
||||
Spacer()
|
||||
clearFiltersButton
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.onChange(of: model.filters) {
|
||||
model.filtersDidChange.send($0)
|
||||
}
|
||||
.onChange(of: model.onlyShowsFavorites) {
|
||||
model.onlyShowsFavoritesDidChange.send($0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension VPNFiltersView.Subview {
|
||||
private extension VPNFiltersView {
|
||||
var categoryPicker: some View {
|
||||
Picker(Strings.Global.category, selection: $filters.categoryName) {
|
||||
Picker(Strings.Global.category, selection: $model.filters.categoryName) {
|
||||
Text(Strings.Global.any)
|
||||
.tag(nil as String?)
|
||||
ForEach(categories, id: \.self) {
|
||||
ForEach(model.categories, id: \.self) {
|
||||
Text($0.capitalized)
|
||||
.tag($0 as String?)
|
||||
}
|
||||
|
@ -156,10 +74,10 @@ private extension VPNFiltersView.Subview {
|
|||
}
|
||||
|
||||
var countryPicker: some View {
|
||||
Picker(Strings.Global.country, selection: $filters.countryCode) {
|
||||
Picker(Strings.Global.country, selection: $model.filters.countryCode) {
|
||||
Text(Strings.Global.any)
|
||||
.tag(nil as String?)
|
||||
ForEach(countries, id: \.code) {
|
||||
ForEach(model.countries, id: \.code) {
|
||||
Text($0.description)
|
||||
.tag($0.code as String?)
|
||||
}
|
||||
|
@ -167,10 +85,10 @@ private extension VPNFiltersView.Subview {
|
|||
}
|
||||
|
||||
var presetPicker: some View {
|
||||
Picker(Strings.Providers.Vpn.preset, selection: $filters.presetId) {
|
||||
Picker(Strings.Providers.Vpn.preset, selection: $model.filters.presetId) {
|
||||
Text(Strings.Global.any)
|
||||
.tag(nil as String?)
|
||||
ForEach(presets, id: \.presetId) {
|
||||
ForEach(model.presets, id: \.presetId) {
|
||||
Text($0.description)
|
||||
.tag($0.presetId as String?)
|
||||
}
|
||||
|
@ -178,24 +96,19 @@ private extension VPNFiltersView.Subview {
|
|||
}
|
||||
|
||||
var favoritesToggle: some View {
|
||||
Toggle(Strings.Providers.onlyFavorites, isOn: $onlyShowsFavorites)
|
||||
Toggle(Strings.Providers.onlyFavorites, isOn: $model.onlyShowsFavorites)
|
||||
}
|
||||
|
||||
var clearFiltersButton: some View {
|
||||
Button(Strings.Providers.clearFilters, role: .destructive) {
|
||||
onlyShowsFavorites = false
|
||||
filters = VPNFilters()
|
||||
model.filters = VPNFilters()
|
||||
model.onlyShowsFavorites = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
VPNFiltersView<OpenVPN.Configuration>(
|
||||
manager: VPNProviderManager(),
|
||||
filters: .constant(VPNFilters()),
|
||||
onlyShowsFavorites: .constant(false),
|
||||
favorites: []
|
||||
)
|
||||
VPNFiltersView(model: .init())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,19 +24,13 @@
|
|||
//
|
||||
|
||||
import AppLibrary
|
||||
import Combine
|
||||
import CommonLibrary
|
||||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
import UtilsLibrary
|
||||
|
||||
struct VPNProviderServerView<Configuration>: View where Configuration: ProviderConfigurationIdentifiable & Codable {
|
||||
|
||||
@EnvironmentObject
|
||||
private var providerManager: ProviderManager
|
||||
|
||||
@AppStorage(AppPreference.moduleFavoriteServers.key)
|
||||
var allFavorites = ModuleFavoriteServers()
|
||||
|
||||
var apis: [APIMapper] = API.shared
|
||||
|
||||
let moduleId: UUID
|
||||
|
@ -51,65 +45,160 @@ struct VPNProviderServerView<Configuration>: View where Configuration: ProviderC
|
|||
|
||||
let selectTitle: String
|
||||
|
||||
let onSelect: (_ server: VPNServer, _ preset: VPNPreset<Configuration>) -> Void
|
||||
let onSelect: (VPNServer, VPNPreset<Configuration>) -> Void
|
||||
|
||||
@StateObject
|
||||
private var manager = VPNProviderManager<Configuration>(sorting: [
|
||||
private var vpnManager = VPNProviderManager<Configuration>(sorting: [
|
||||
.localizedCountry,
|
||||
.area,
|
||||
.hostname
|
||||
])
|
||||
|
||||
@State
|
||||
private var filters = VPNFilters()
|
||||
|
||||
@State
|
||||
private var onlyShowsFavorites = false
|
||||
@StateObject
|
||||
private var filtersViewModel = VPNFiltersView.Model()
|
||||
|
||||
@StateObject
|
||||
private var errorHandler: ErrorHandler = .default()
|
||||
|
||||
var body: some View {
|
||||
debugChanges()
|
||||
return Subview(
|
||||
manager: manager,
|
||||
selectedServer: selectedEntity?.server,
|
||||
filters: $filters,
|
||||
onlyShowsFavorites: $onlyShowsFavorites,
|
||||
favorites: favoritesBinding,
|
||||
return contentView
|
||||
.navigationTitle(Strings.Global.servers)
|
||||
.themeNavigationDetail()
|
||||
.withErrorHandler(errorHandler)
|
||||
}
|
||||
}
|
||||
|
||||
extension VPNProviderServerView {
|
||||
var serversView: ServersView {
|
||||
ServersView(
|
||||
vpnManager: vpnManager,
|
||||
filtersViewModel: filtersViewModel,
|
||||
apis: apis,
|
||||
moduleId: moduleId,
|
||||
providerId: providerId,
|
||||
selectedServerId: selectedEntity?.server.id,
|
||||
initialFilters: {
|
||||
guard let selectedEntity, filtersWithSelection else {
|
||||
return nil
|
||||
}
|
||||
return VPNFilters(with: selectedEntity.server.provider)
|
||||
}(),
|
||||
selectTitle: selectTitle,
|
||||
onSelect: selectServer
|
||||
onSelect: onSelect,
|
||||
errorHandler: errorHandler
|
||||
)
|
||||
.withErrorHandler(errorHandler)
|
||||
.navigationTitle(Strings.Global.servers)
|
||||
.themeNavigationDetail()
|
||||
.onLoad {
|
||||
Task {
|
||||
}
|
||||
|
||||
var filtersView: some View {
|
||||
VPNFiltersView(model: filtersViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subviews
|
||||
|
||||
extension VPNProviderServerView {
|
||||
struct ServersView: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var providerManager: ProviderManager
|
||||
|
||||
let vpnManager: VPNProviderManager<Configuration>
|
||||
|
||||
let filtersViewModel: VPNFiltersView.Model
|
||||
|
||||
let apis: [APIMapper]
|
||||
|
||||
let moduleId: UUID
|
||||
|
||||
let providerId: ProviderID
|
||||
|
||||
let selectedServerId: String?
|
||||
|
||||
let initialFilters: VPNFilters?
|
||||
|
||||
let selectTitle: String
|
||||
|
||||
let onSelect: (VPNServer, VPNPreset<Configuration>) -> Void
|
||||
|
||||
@ObservedObject
|
||||
var errorHandler: ErrorHandler
|
||||
|
||||
@State
|
||||
private var servers: [VPNServer] = []
|
||||
|
||||
@State
|
||||
private var isFiltering = false
|
||||
|
||||
@State
|
||||
private var onlyShowsFavorites = false
|
||||
|
||||
@StateObject
|
||||
private var favoritesManager = ProviderFavoritesManager()
|
||||
|
||||
var body: some View {
|
||||
debugChanges()
|
||||
return ServersSubview(
|
||||
servers: filteredServers,
|
||||
selectedServerId: selectedServerId,
|
||||
isFiltering: isFiltering,
|
||||
filtersViewModel: filtersViewModel,
|
||||
favoritesManager: favoritesManager,
|
||||
selectTitle: selectTitle,
|
||||
onSelect: onSelectServer
|
||||
)
|
||||
.task {
|
||||
do {
|
||||
manager.repository = try await providerManager.vpnRepository(
|
||||
favoritesManager.moduleId = moduleId
|
||||
vpnManager.repository = try await providerManager.vpnRepository(
|
||||
from: apis,
|
||||
for: providerId
|
||||
)
|
||||
if let selectedEntity, filtersWithSelection {
|
||||
filters = VPNFilters(with: selectedEntity.server.provider)
|
||||
} else {
|
||||
filters = VPNFilters()
|
||||
}
|
||||
await manager.applyFilters(filters)
|
||||
filtersViewModel.load(
|
||||
with: vpnManager,
|
||||
initialFilters: initialFilters
|
||||
)
|
||||
await reloadServers(filters: filtersViewModel.filters)
|
||||
} catch {
|
||||
pp_log(.app, .error, "Unable to load VPN repository: \(error)")
|
||||
errorHandler.handle(error, title: Strings.Global.servers)
|
||||
}
|
||||
}
|
||||
.onReceive(filtersViewModel.filtersDidChange) { newValue in
|
||||
Task {
|
||||
await reloadServers(filters: newValue)
|
||||
}
|
||||
}
|
||||
.onReceive(filtersViewModel.onlyShowsFavoritesDidChange) { newValue in
|
||||
onlyShowsFavorites = newValue
|
||||
}
|
||||
.onDisappear {
|
||||
favoritesManager.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
private extension VPNProviderServerView.ServersView {
|
||||
var filteredServers: [VPNServer] {
|
||||
if onlyShowsFavorites {
|
||||
return servers.filter {
|
||||
favoritesManager.serverIds.contains($0.serverId)
|
||||
}
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
func reloadServers(filters: VPNFilters) async {
|
||||
isFiltering = true
|
||||
await Task {
|
||||
servers = await vpnManager.filteredServers(with: filters)
|
||||
isFiltering = false
|
||||
}.value
|
||||
}
|
||||
|
||||
extension VPNProviderServerView {
|
||||
func compatiblePreset(with server: VPNServer) -> VPNPreset<Configuration>? {
|
||||
manager
|
||||
vpnManager
|
||||
.presets
|
||||
.first {
|
||||
if let supportedIds = server.provider.supportedPresetIds {
|
||||
|
@ -119,18 +208,10 @@ extension VPNProviderServerView {
|
|||
}
|
||||
}
|
||||
|
||||
var favoritesBinding: Binding<Set<String>> {
|
||||
Binding {
|
||||
allFavorites.servers(forModuleWithID: moduleId)
|
||||
} set: {
|
||||
allFavorites.setServers($0, forModuleWithID: moduleId)
|
||||
}
|
||||
}
|
||||
|
||||
func selectServer(_ server: VPNServer) {
|
||||
func onSelectServer(_ server: VPNServer) {
|
||||
guard let preset = compatiblePreset(with: server) else {
|
||||
pp_log(.app, .error, "Unable to find a compatible preset. Supported IDs: \(server.provider.supportedPresetIds ?? [])")
|
||||
assertionFailure("No compatible presets for server \(server.serverId) (manager=\(manager.providerId), configuration=\(Configuration.providerConfigurationIdentifier), supported=\(server.provider.supportedPresetIds ?? []))")
|
||||
assertionFailure("No compatible presets for server \(server.serverId) (provider=\(vpnManager.providerId), configuration=\(Configuration.providerConfigurationIdentifier), supported=\(server.provider.supportedPresetIds ?? []))")
|
||||
return
|
||||
}
|
||||
onSelect(server, preset)
|
||||
|
|
|
@ -29,76 +29,129 @@ import PassepartoutKit
|
|||
import SwiftUI
|
||||
|
||||
extension VPNProviderServerView {
|
||||
struct Subview: View {
|
||||
var contentView: some View {
|
||||
serversView
|
||||
.modifier(FiltersItemModifier {
|
||||
filtersView
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ObservedObject
|
||||
var manager: VPNProviderManager<Configuration>
|
||||
private extension VPNProviderServerView {
|
||||
struct FiltersItemModifier<FiltersContent>: ViewModifier where FiltersContent: View {
|
||||
|
||||
let selectedServer: VPNServer?
|
||||
|
||||
@Binding
|
||||
var filters: VPNFilters
|
||||
|
||||
@Binding
|
||||
var onlyShowsFavorites: Bool
|
||||
|
||||
@Binding
|
||||
var favorites: Set<String>
|
||||
|
||||
// unused
|
||||
let selectTitle: String
|
||||
|
||||
let onSelect: (VPNServer) -> Void
|
||||
@ViewBuilder
|
||||
let filtersContent: FiltersContent
|
||||
|
||||
@State
|
||||
private var isFiltersPresented = false
|
||||
private var isPresented = false
|
||||
|
||||
var body: some View {
|
||||
listView
|
||||
.disabled(manager.isFiltering)
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.toolbar {
|
||||
filtersItem
|
||||
Button {
|
||||
isPresented = true
|
||||
} label: {
|
||||
ThemeImage(.filters)
|
||||
}
|
||||
.themePopover(isPresented: $isPresented) {
|
||||
filtersContent
|
||||
.modifier(FiltersViewModifier(isPresented: $isPresented))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FiltersViewModifier: ViewModifier {
|
||||
|
||||
@Binding
|
||||
var isPresented: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
NavigationStack {
|
||||
content
|
||||
.navigationTitle(Strings.Global.filters)
|
||||
.themeNavigationDetail()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button {
|
||||
isPresented = false
|
||||
} label: {
|
||||
ThemeImage(.close)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension VPNProviderServerView.Subview {
|
||||
var listView: some View {
|
||||
ZStack {
|
||||
if manager.isFiltering {
|
||||
ProgressView()
|
||||
} else if !manager.filteredServers.isEmpty {
|
||||
List {
|
||||
Section {
|
||||
ForEach(countryCodes, id: \.self, content: countryView)
|
||||
} header: {
|
||||
Text(filters.categoryName ?? Strings.Providers.Vpn.Category.any)
|
||||
}
|
||||
// MARK: - Subviews
|
||||
|
||||
extension VPNProviderServerView {
|
||||
struct ServersSubview: View {
|
||||
let servers: [VPNServer]
|
||||
|
||||
let selectedServerId: String?
|
||||
|
||||
let isFiltering: Bool
|
||||
|
||||
@ObservedObject
|
||||
var filtersViewModel: VPNFiltersView.Model
|
||||
|
||||
@ObservedObject
|
||||
var favoritesManager: ProviderFavoritesManager
|
||||
|
||||
let selectTitle: String
|
||||
|
||||
let onSelect: (VPNServer) -> Void
|
||||
|
||||
var body: some View {
|
||||
debugChanges()
|
||||
return ZStack {
|
||||
if isFiltering || !servers.isEmpty {
|
||||
listView
|
||||
} else {
|
||||
emptyView
|
||||
}
|
||||
} else {
|
||||
Text(Strings.Providers.Vpn.noServers)
|
||||
.themeEmptyMessage()
|
||||
}
|
||||
.themeAnimation(on: isFiltering, category: .providers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension VPNProviderServerView.ServersSubview {
|
||||
var listView: some View {
|
||||
List {
|
||||
Section {
|
||||
if isFiltering {
|
||||
ProgressView()
|
||||
.id(UUID())
|
||||
} else {
|
||||
ForEach(countryCodes, id: \.self, content: countryView)
|
||||
}
|
||||
} header: {
|
||||
Text(filtersViewModel.filters.categoryName ?? Strings.Providers.Vpn.Category.any)
|
||||
}
|
||||
}
|
||||
.themeAnimation(on: manager.isFiltering, category: .providers)
|
||||
}
|
||||
|
||||
var emptyView: some View {
|
||||
Text(Strings.Providers.Vpn.noServers)
|
||||
.themeEmptyMessage()
|
||||
}
|
||||
}
|
||||
|
||||
private extension VPNProviderServerView.ServersSubview {
|
||||
var countryCodes: [String] {
|
||||
manager
|
||||
.allCountryCodes
|
||||
.sorted {
|
||||
guard let region1 = $0.localizedAsRegionCode,
|
||||
let region2 = $1.localizedAsRegionCode else {
|
||||
return $0 < $1
|
||||
}
|
||||
return region1 < region2
|
||||
}
|
||||
filtersViewModel
|
||||
.countries
|
||||
.map(\.code)
|
||||
}
|
||||
|
||||
func countryServers(for code: String) -> [VPNServer]? {
|
||||
manager
|
||||
.filteredServers
|
||||
servers
|
||||
.filter {
|
||||
$0.provider.countryCode == code
|
||||
}
|
||||
|
@ -125,48 +178,23 @@ private extension VPNProviderServerView.Subview {
|
|||
} label: {
|
||||
HStack {
|
||||
ThemeImage(.marked)
|
||||
.opacity(server.id == selectedServer?.id ? 1.0 : 0.0)
|
||||
.opacity(server.id == selectedServerId ? 1.0 : 0.0)
|
||||
VStack(alignment: .leading) {
|
||||
if let area = server.provider.area {
|
||||
Text(area)
|
||||
.font(.headline)
|
||||
Text(server.provider.serverId)
|
||||
.font(.subheadline)
|
||||
} else {
|
||||
Text(server.provider.serverId)
|
||||
.font(.headline)
|
||||
}
|
||||
Text(server.provider.serverId)
|
||||
.font(.subheadline)
|
||||
}
|
||||
Spacer()
|
||||
FavoriteToggle(value: server.serverId, selection: $favorites)
|
||||
FavoriteToggle(
|
||||
value: server.serverId,
|
||||
selection: $favoritesManager.serverIds
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var filtersItem: some ToolbarContent {
|
||||
ToolbarItem {
|
||||
Button {
|
||||
isFiltersPresented = true
|
||||
} label: {
|
||||
ThemeImage(.filters)
|
||||
}
|
||||
.themePopover(isPresented: $isFiltersPresented, content: filtersView)
|
||||
}
|
||||
}
|
||||
|
||||
func filtersView() -> some View {
|
||||
NavigationStack {
|
||||
VPNFiltersView(
|
||||
manager: manager,
|
||||
filters: $filters,
|
||||
onlyShowsFavorites: $onlyShowsFavorites,
|
||||
favorites: favorites
|
||||
)
|
||||
.navigationTitle(Strings.Global.filters)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
|
|
@ -29,21 +29,30 @@ import PassepartoutKit
|
|||
import SwiftUI
|
||||
|
||||
extension VPNProviderServerView {
|
||||
struct Subview: View {
|
||||
var contentView: some View {
|
||||
VStack {
|
||||
filtersView
|
||||
.padding()
|
||||
serversView
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subviews
|
||||
|
||||
extension VPNProviderServerView {
|
||||
struct ServersSubview: View {
|
||||
let servers: [VPNServer]
|
||||
|
||||
let selectedServerId: String?
|
||||
|
||||
let isFiltering: Bool
|
||||
|
||||
@ObservedObject
|
||||
var manager: VPNProviderManager<Configuration>
|
||||
var filtersViewModel: VPNFiltersView.Model
|
||||
|
||||
let selectedServer: VPNServer?
|
||||
|
||||
@Binding
|
||||
var filters: VPNFilters
|
||||
|
||||
@Binding
|
||||
var onlyShowsFavorites: Bool
|
||||
|
||||
@Binding
|
||||
var favorites: Set<String>
|
||||
@ObservedObject
|
||||
var favoritesManager: ProviderFavoritesManager
|
||||
|
||||
let selectTitle: String
|
||||
|
||||
|
@ -53,60 +62,40 @@ extension VPNProviderServerView {
|
|||
private var hoveringServerId: String?
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
filtersView
|
||||
tableView
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension VPNProviderServerView.Subview {
|
||||
var tableView: some View {
|
||||
Table(manager.filteredServers) {
|
||||
TableColumn("") { server in
|
||||
ThemeImage(.marked)
|
||||
.opacity(server.id == selectedServer?.id ? 1.0 : 0.0)
|
||||
}
|
||||
.width(10.0)
|
||||
|
||||
TableColumn(Strings.Global.region) { server in
|
||||
HStack {
|
||||
ThemeCountryFlag(code: server.provider.countryCode)
|
||||
Text(server.region)
|
||||
debugChanges()
|
||||
return Table(servers) {
|
||||
TableColumn("") { server in
|
||||
ThemeImage(.marked)
|
||||
.opacity(server.id == selectedServerId ? 1.0 : 0.0)
|
||||
}
|
||||
}
|
||||
.width(10.0)
|
||||
|
||||
TableColumn(Strings.Global.address, value: \.address)
|
||||
|
||||
TableColumn("") { server in
|
||||
FavoriteToggle(value: server.serverId, selection: $favorites)
|
||||
.opacity(favorites.contains(server.serverId) || server.serverId == hoveringServerId ? 1.0 : 0.0)
|
||||
.onHover {
|
||||
hoveringServerId = $0 ? server.serverId : nil
|
||||
TableColumn(Strings.Global.region) { server in
|
||||
HStack {
|
||||
ThemeCountryFlag(code: server.provider.countryCode)
|
||||
Text(server.region)
|
||||
}
|
||||
}
|
||||
.width(20.0)
|
||||
}
|
||||
|
||||
TableColumn("") { server in
|
||||
Button {
|
||||
onSelect(server)
|
||||
} label: {
|
||||
Text(selectTitle)
|
||||
TableColumn(Strings.Global.address, value: \.address)
|
||||
|
||||
TableColumn("") { server in
|
||||
FavoriteToggle(
|
||||
value: server.serverId,
|
||||
selection: $favoritesManager.serverIds
|
||||
)
|
||||
}
|
||||
.width(20.0)
|
||||
|
||||
TableColumn("") { server in
|
||||
Button {
|
||||
onSelect(server)
|
||||
} label: {
|
||||
Text(selectTitle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(manager.isFiltering)
|
||||
}
|
||||
|
||||
var filtersView: some View {
|
||||
VPNFiltersView(
|
||||
manager: manager,
|
||||
filters: $filters,
|
||||
onlyShowsFavorites: $onlyShowsFavorites,
|
||||
favorites: favorites
|
||||
)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -107,6 +107,12 @@ struct ThemeBooleanPopoverModifier<Popover>: ViewModifier where Popover: View {
|
|||
@EnvironmentObject
|
||||
private var theme: Theme
|
||||
|
||||
@Environment(\.horizontalSizeClass)
|
||||
private var hsClass
|
||||
|
||||
@Environment(\.verticalSizeClass)
|
||||
private var vsClass
|
||||
|
||||
@Binding
|
||||
var isPresented: Bool
|
||||
|
||||
|
@ -114,12 +120,20 @@ struct ThemeBooleanPopoverModifier<Popover>: ViewModifier where Popover: View {
|
|||
let popover: Popover
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.popover(isPresented: $isPresented) {
|
||||
popover
|
||||
.frame(minWidth: theme.popoverSize?.width, minHeight: theme.popoverSize?.height)
|
||||
.themeLockScreen()
|
||||
}
|
||||
if hsClass == .regular && vsClass == .regular {
|
||||
content
|
||||
.popover(isPresented: $isPresented) {
|
||||
popover
|
||||
.frame(minWidth: theme.popoverSize?.width, minHeight: theme.popoverSize?.height)
|
||||
.themeLockScreen()
|
||||
}
|
||||
} else {
|
||||
content
|
||||
.sheet(isPresented: $isPresented) {
|
||||
popover
|
||||
.themeLockScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -31,6 +31,9 @@ struct FavoriteToggle<ID>: View where ID: Hashable {
|
|||
@Binding
|
||||
var selection: Set<ID>
|
||||
|
||||
@State
|
||||
private var hover: ID?
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
if selection.contains(value) {
|
||||
|
@ -40,6 +43,20 @@ struct FavoriteToggle<ID>: View where ID: Hashable {
|
|||
}
|
||||
} label: {
|
||||
ThemeImage(selection.contains(value) ? .favoriteOn : .favoriteOff)
|
||||
.opacity(opacity)
|
||||
}
|
||||
.onHover {
|
||||
hover = $0 ? value : nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension FavoriteToggle {
|
||||
var opacity: Double {
|
||||
#if os(iOS)
|
||||
1.0
|
||||
#else
|
||||
selection.contains(value) || value == hover ? 1.0 : 0.0
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// ModuleFavoriteServers.swift
|
||||
// ProviderFavoriteServers.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 10/25/24.
|
||||
|
@ -25,7 +25,7 @@
|
|||
|
||||
import SwiftUI
|
||||
|
||||
public struct ModuleFavoriteServers {
|
||||
public struct ProviderFavoriteServers {
|
||||
private var map: [UUID: Set<String>]
|
||||
|
||||
public init() {
|
||||
|
@ -41,7 +41,7 @@ public struct ModuleFavoriteServers {
|
|||
}
|
||||
}
|
||||
|
||||
extension ModuleFavoriteServers: RawRepresentable {
|
||||
extension ProviderFavoriteServers: RawRepresentable {
|
||||
public var rawValue: String {
|
||||
(try? JSONEncoder().encode(map))?.base64EncodedString() ?? ""
|
||||
}
|
Loading…
Reference in New Issue