diff --git a/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index caacf3e01..f04d177ec 100644
--- a/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -41,7 +41,7 @@
"kind" : "remoteSourceControl",
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
"state" : {
- "revision" : "3934b7a4e64624d499f0d52d9053560554bd4be8"
+ "revision" : "79ff98a69c87cc90ef213e00ab02c9d90d63baaf"
}
},
{
diff --git a/Passepartout/Library/Package.swift b/Passepartout/Library/Package.swift
index 988a32f9d..5d7d5a361 100644
--- a/Passepartout/Library/Package.swift
+++ b/Passepartout/Library/Package.swift
@@ -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"),
diff --git a/Passepartout/Library/Sources/AppUI/Business/ProviderFavoritesManager.swift b/Passepartout/Library/Sources/AppUI/Business/ProviderFavoritesManager.swift
new file mode 100644
index 000000000..afea8e846
--- /dev/null
+++ b/Passepartout/Library/Sources/AppUI/Business/ProviderFavoritesManager.swift
@@ -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 .
+//
+
+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 {
+ 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)
+ }
+}
diff --git a/Passepartout/Library/Sources/AppUI/Views/Provider/VPNFiltersView+Model.swift b/Passepartout/Library/Sources/AppUI/Views/Provider/VPNFiltersView+Model.swift
new file mode 100644
index 000000000..57c8dbf3d
--- /dev/null
+++ b/Passepartout/Library/Sources/AppUI/Views/Provider/VPNFiltersView+Model.swift
@@ -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 .
+//
+
+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()
+
+ let onlyShowsFavoritesDidChange = PassthroughSubject()
+
+ init(
+ categories: [String] = [],
+ countries: [(code: String, description: String)] = [],
+ presets: [AnyVPNPreset] = []
+ ) {
+ self.categories = categories
+ self.countries = countries
+ self.presets = presets
+ }
+
+ func load(
+ with vpnManager: VPNProviderManager,
+ 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()
+ }
+ }
+}
diff --git a/Passepartout/Library/Sources/AppUI/Views/Provider/VPNFiltersView.swift b/Passepartout/Library/Sources/AppUI/Views/Provider/VPNFiltersView.swift
index aad14d2f4..f16cfd009 100644
--- a/Passepartout/Library/Sources/AppUI/Views/Provider/VPNFiltersView.swift
+++ b/Passepartout/Library/Sources/AppUI/Views/Provider/VPNFiltersView.swift
@@ -24,131 +24,49 @@
//
import AppLibrary
-import CommonLibrary
+import Combine
import PassepartoutKit
import SwiftUI
-struct VPNFiltersView: View where Configuration: ProviderConfigurationIdentifiable & Decodable {
+struct VPNFiltersView: View {
@ObservedObject
- var manager: VPNProviderManager
-
- @Binding
- var filters: VPNFilters
-
- @Binding
- var onlyShowsFavorites: Bool
-
- let favorites: Set
+ 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] {
- 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]
-
- let favorites: Set
-
- 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(
- manager: VPNProviderManager(),
- filters: .constant(VPNFilters()),
- onlyShowsFavorites: .constant(false),
- favorites: []
- )
+ VPNFiltersView(model: .init())
}
}
diff --git a/Passepartout/Library/Sources/AppUI/Views/Provider/VPNProviderServerView.swift b/Passepartout/Library/Sources/AppUI/Views/Provider/VPNProviderServerView.swift
index aaf2c6e03..e8741369d 100644
--- a/Passepartout/Library/Sources/AppUI/Views/Provider/VPNProviderServerView.swift
+++ b/Passepartout/Library/Sources/AppUI/Views/Provider/VPNProviderServerView.swift
@@ -24,19 +24,13 @@
//
import AppLibrary
+import Combine
import CommonLibrary
import PassepartoutKit
import SwiftUI
import UtilsLibrary
struct VPNProviderServerView: 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: View where Configuration: ProviderC
let selectTitle: String
- let onSelect: (_ server: VPNServer, _ preset: VPNPreset) -> Void
+ let onSelect: (VPNServer, VPNPreset) -> Void
@StateObject
- private var manager = VPNProviderManager(sorting: [
+ private var vpnManager = VPNProviderManager(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
+
+ 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) -> 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? {
- manager
+ vpnManager
.presets
.first {
if let supportedIds = server.provider.supportedPresetIds {
@@ -119,18 +208,10 @@ extension VPNProviderServerView {
}
}
- var favoritesBinding: Binding> {
- 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)
diff --git a/Passepartout/Library/Sources/AppUI/Views/Provider/iOS/VPNProviderServerView+iOS.swift b/Passepartout/Library/Sources/AppUI/Views/Provider/iOS/VPNProviderServerView+iOS.swift
index 59aac4653..204cb81fd 100644
--- a/Passepartout/Library/Sources/AppUI/Views/Provider/iOS/VPNProviderServerView+iOS.swift
+++ b/Passepartout/Library/Sources/AppUI/Views/Provider/iOS/VPNProviderServerView+iOS.swift
@@ -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
+private extension VPNProviderServerView {
+ struct FiltersItemModifier: ViewModifier where FiltersContent: View {
- let selectedServer: VPNServer?
+ @ViewBuilder
+ let filtersContent: FiltersContent
- @Binding
- var filters: VPNFilters
+ @State
+ private var isPresented = false
- @Binding
- var onlyShowsFavorites: Bool
+ func body(content: Content) -> some View {
+ content
+ .toolbar {
+ Button {
+ isPresented = true
+ } label: {
+ ThemeImage(.filters)
+ }
+ .themePopover(isPresented: $isPresented) {
+ filtersContent
+ .modifier(FiltersViewModifier(isPresented: $isPresented))
+ }
+ }
+ }
+ }
+
+ struct FiltersViewModifier: ViewModifier {
@Binding
- var favorites: Set
+ 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])
+ }
+ }
+}
+
+// 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
- // unused
let selectTitle: String
let onSelect: (VPNServer) -> Void
- @State
- private var isFiltersPresented = false
-
var body: some View {
- listView
- .disabled(manager.isFiltering)
- .toolbar {
- filtersItem
+ debugChanges()
+ return ZStack {
+ if isFiltering || !servers.isEmpty {
+ listView
+ } else {
+ emptyView
}
+ }
+ .themeAnimation(on: isFiltering, category: .providers)
}
}
}
-private extension VPNProviderServerView.Subview {
+private extension VPNProviderServerView.ServersSubview {
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)
- }
+ List {
+ Section {
+ if isFiltering {
+ ProgressView()
+ .id(UUID())
+ } else {
+ ForEach(countryCodes, id: \.self, content: countryView)
}
- } else {
- Text(Strings.Providers.Vpn.noServers)
- .themeEmptyMessage()
+ } 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
diff --git a/Passepartout/Library/Sources/AppUI/Views/Provider/macOS/VPNProviderServerView+macOS.swift b/Passepartout/Library/Sources/AppUI/Views/Provider/macOS/VPNProviderServerView+macOS.swift
index db90c01cf..fe35f2390 100644
--- a/Passepartout/Library/Sources/AppUI/Views/Provider/macOS/VPNProviderServerView+macOS.swift
+++ b/Passepartout/Library/Sources/AppUI/Views/Provider/macOS/VPNProviderServerView+macOS.swift
@@ -29,21 +29,30 @@ import PassepartoutKit
import SwiftUI
extension VPNProviderServerView {
- struct Subview: View {
+ var contentView: some View {
+ VStack {
+ filtersView
+ .padding()
+ serversView
+ }
+ }
+}
- @ObservedObject
- var manager: VPNProviderManager
+// MARK: - Subviews
+
+extension VPNProviderServerView {
+ struct ServersSubview: View {
+ let servers: [VPNServer]
- let selectedServer: VPNServer?
+ let selectedServerId: String?
- @Binding
- var filters: VPNFilters
+ let isFiltering: Bool
- @Binding
- var onlyShowsFavorites: Bool
+ @ObservedObject
+ var filtersViewModel: VPNFiltersView.Model
- @Binding
- var favorites: Set
+ @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)
+ debugChanges()
+ return Table(servers) {
+ TableColumn("") { server in
+ ThemeImage(.marked)
+ .opacity(server.id == selectedServerId ? 1.0 : 0.0)
+ }
+ .width(10.0)
- TableColumn(Strings.Global.region) { server in
- HStack {
- ThemeCountryFlag(code: server.provider.countryCode)
- Text(server.region)
+ TableColumn(Strings.Global.region) { server in
+ HStack {
+ ThemeCountryFlag(code: server.provider.countryCode)
+ Text(server.region)
+ }
}
- }
- TableColumn(Strings.Global.address, value: \.address)
+ 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
- }
- }
- .width(20.0)
+ TableColumn("") { server in
+ FavoriteToggle(
+ value: server.serverId,
+ selection: $favoritesManager.serverIds
+ )
+ }
+ .width(20.0)
- TableColumn("") { server in
- Button {
- onSelect(server)
- } label: {
- Text(selectTitle)
+ 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()
}
}
diff --git a/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+UI.swift b/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+UI.swift
index 4a6068903..dccf688f4 100644
--- a/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+UI.swift
+++ b/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+UI.swift
@@ -107,6 +107,12 @@ struct ThemeBooleanPopoverModifier: 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: 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()
+ }
+ }
}
}
diff --git a/Passepartout/Library/Sources/AppUI/Views/UI/FavoriteToggle.swift b/Passepartout/Library/Sources/AppUI/Views/UI/FavoriteToggle.swift
index aee05d42b..6cb0742cc 100644
--- a/Passepartout/Library/Sources/AppUI/Views/UI/FavoriteToggle.swift
+++ b/Passepartout/Library/Sources/AppUI/Views/UI/FavoriteToggle.swift
@@ -31,6 +31,9 @@ struct FavoriteToggle: View where ID: Hashable {
@Binding
var selection: Set
+ @State
+ private var hover: ID?
+
var body: some View {
Button {
if selection.contains(value) {
@@ -40,6 +43,20 @@ struct FavoriteToggle: 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
+ }
+}
diff --git a/Passepartout/Library/Sources/CommonLibrary/Domain/ModuleFavoriteServers.swift b/Passepartout/Library/Sources/CommonLibrary/Domain/ProviderFavoriteServers.swift
similarity index 92%
rename from Passepartout/Library/Sources/CommonLibrary/Domain/ModuleFavoriteServers.swift
rename to Passepartout/Library/Sources/CommonLibrary/Domain/ProviderFavoriteServers.swift
index cee578387..5dfecf312 100644
--- a/Passepartout/Library/Sources/CommonLibrary/Domain/ModuleFavoriteServers.swift
+++ b/Passepartout/Library/Sources/CommonLibrary/Domain/ProviderFavoriteServers.swift
@@ -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]
public init() {
@@ -41,7 +41,7 @@ public struct ModuleFavoriteServers {
}
}
-extension ModuleFavoriteServers: RawRepresentable {
+extension ProviderFavoriteServers: RawRepresentable {
public var rawValue: String {
(try? JSONEncoder().encode(map))?.base64EncodedString() ?? ""
}