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() ?? "" }