parent
3abde3851a
commit
df4e3465f5
|
@ -461,6 +461,8 @@ public enum Strings {
|
|||
}
|
||||
/// None
|
||||
public static let noProvider = Strings.tr("Localizable", "providers.no_provider", fallback: "None")
|
||||
/// Only favorites
|
||||
public static let onlyFavorites = Strings.tr("Localizable", "providers.only_favorites", fallback: "Only favorites")
|
||||
/// Refresh infrastructure
|
||||
public static let refreshInfrastructure = Strings.tr("Localizable", "providers.refresh_infrastructure", fallback: "Refresh infrastructure")
|
||||
/// Select
|
||||
|
|
|
@ -228,6 +228,7 @@
|
|||
"providers.no_provider" = "None";
|
||||
"providers.select_provider" = "Select a provider";
|
||||
"providers.select_entity" = "Select";
|
||||
"providers.only_favorites" = "Only favorites";
|
||||
"providers.clear_filters" = "Clear filters";
|
||||
"providers.refresh_infrastructure" = "Refresh infrastructure";
|
||||
"providers.last_updated" = "Last updated on %@";
|
||||
|
|
|
@ -48,6 +48,7 @@ extension ProviderEntityViewProviding where Self: ProviderCompatibleModule, Enti
|
|||
} as? VPNEntity<EntityType.Configuration>
|
||||
|
||||
return VPNProviderServerCoordinator(
|
||||
moduleId: id,
|
||||
providerId: provider.id,
|
||||
selectedEntity: selectedEntity,
|
||||
onSelect: onSelect
|
||||
|
|
|
@ -128,6 +128,7 @@ private extension OpenVPNView {
|
|||
case .providerServer:
|
||||
providerId.wrappedValue.map {
|
||||
VPNProviderServerView(
|
||||
moduleId: module.id,
|
||||
providerId: $0,
|
||||
configurationType: OpenVPN.Configuration.self,
|
||||
selectedEntity: providerEntity.wrappedValue,
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
//
|
||||
|
||||
import AppLibrary
|
||||
import CommonLibrary
|
||||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
|
||||
|
@ -35,15 +36,41 @@ struct VPNFiltersView<Configuration>: View where Configuration: ProviderConfigur
|
|||
@Binding
|
||||
var filters: VPNFilters
|
||||
|
||||
@Binding
|
||||
var onlyShowsFavorites: Bool
|
||||
|
||||
let favorites: Set<String>
|
||||
|
||||
var body: some View {
|
||||
debugChanges()
|
||||
return Subview(
|
||||
filters: $filters,
|
||||
onlyShowsFavorites: $onlyShowsFavorites,
|
||||
categories: categories,
|
||||
countries: countries,
|
||||
presets: presets
|
||||
presets: presets,
|
||||
favorites: favorites
|
||||
)
|
||||
.onChange(of: filters, perform: manager.applyFilters)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -82,12 +109,17 @@ private extension VPNFiltersView {
|
|||
@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 {
|
||||
|
@ -95,6 +127,7 @@ private extension VPNFiltersView {
|
|||
categoryPicker
|
||||
countryPicker
|
||||
presetPicker
|
||||
favoritesToggle
|
||||
#if os(iOS)
|
||||
clearFiltersButton
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
@ -144,8 +177,13 @@ private extension VPNFiltersView.Subview {
|
|||
}
|
||||
}
|
||||
|
||||
var favoritesToggle: some View {
|
||||
Toggle(Strings.Providers.onlyFavorites, isOn: $onlyShowsFavorites)
|
||||
}
|
||||
|
||||
var clearFiltersButton: some View {
|
||||
Button(Strings.Providers.clearFilters, role: .destructive) {
|
||||
onlyShowsFavorites = false
|
||||
filters = VPNFilters()
|
||||
}
|
||||
}
|
||||
|
@ -155,7 +193,9 @@ private extension VPNFiltersView.Subview {
|
|||
NavigationStack {
|
||||
VPNFiltersView<OpenVPN.Configuration>(
|
||||
manager: VPNProviderManager(),
|
||||
filters: .constant(VPNFilters())
|
||||
filters: .constant(VPNFilters()),
|
||||
onlyShowsFavorites: .constant(false),
|
||||
favorites: []
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,6 +32,8 @@ struct VPNProviderServerCoordinator<Configuration>: View where Configuration: Pr
|
|||
@Environment(\.dismiss)
|
||||
private var dismiss
|
||||
|
||||
let moduleId: UUID
|
||||
|
||||
let providerId: ProviderID
|
||||
|
||||
let selectedEntity: VPNEntity<Configuration>?
|
||||
|
@ -44,6 +46,7 @@ struct VPNProviderServerCoordinator<Configuration>: View where Configuration: Pr
|
|||
var body: some View {
|
||||
NavigationStack {
|
||||
VPNProviderServerView(
|
||||
moduleId: moduleId,
|
||||
providerId: providerId,
|
||||
configurationType: Configuration.self,
|
||||
selectedEntity: selectedEntity,
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
//
|
||||
|
||||
import AppLibrary
|
||||
import CommonLibrary
|
||||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
import UtilsLibrary
|
||||
|
@ -33,8 +34,13 @@ struct VPNProviderServerView<Configuration>: View where Configuration: ProviderC
|
|||
@EnvironmentObject
|
||||
private var providerManager: ProviderManager
|
||||
|
||||
@AppStorage(AppPreference.moduleFavoriteServers.key)
|
||||
var allFavorites = ModuleFavoriteServers()
|
||||
|
||||
var apis: [APIMapper] = API.shared
|
||||
|
||||
let moduleId: UUID
|
||||
|
||||
let providerId: ProviderID
|
||||
|
||||
let configurationType: Configuration.Type
|
||||
|
@ -57,6 +63,9 @@ struct VPNProviderServerView<Configuration>: View where Configuration: ProviderC
|
|||
@State
|
||||
private var filters = VPNFilters()
|
||||
|
||||
@State
|
||||
private var onlyShowsFavorites = false
|
||||
|
||||
@StateObject
|
||||
private var errorHandler: ErrorHandler = .default()
|
||||
|
||||
|
@ -66,6 +75,8 @@ struct VPNProviderServerView<Configuration>: View where Configuration: ProviderC
|
|||
manager: manager,
|
||||
selectedServer: selectedEntity?.server,
|
||||
filters: $filters,
|
||||
onlyShowsFavorites: $onlyShowsFavorites,
|
||||
favorites: favoritesBinding,
|
||||
selectTitle: selectTitle,
|
||||
onSelect: selectServer
|
||||
)
|
||||
|
@ -84,7 +95,7 @@ struct VPNProviderServerView<Configuration>: View where Configuration: ProviderC
|
|||
} else {
|
||||
filters = VPNFilters()
|
||||
}
|
||||
manager.applyFilters(filters)
|
||||
await manager.applyFilters(filters)
|
||||
} catch {
|
||||
pp_log(.app, .error, "Unable to load VPN repository: \(error)")
|
||||
errorHandler.handle(error, title: Strings.Global.servers)
|
||||
|
@ -108,6 +119,14 @@ extension VPNProviderServerView {
|
|||
}
|
||||
}
|
||||
|
||||
var favoritesBinding: Binding<Set<String>> {
|
||||
Binding {
|
||||
allFavorites.servers(forModuleWithID: moduleId)
|
||||
} set: {
|
||||
allFavorites.setServers($0, forModuleWithID: moduleId)
|
||||
}
|
||||
}
|
||||
|
||||
func selectServer(_ server: VPNServer) {
|
||||
guard let preset = compatiblePreset(with: server) else {
|
||||
pp_log(.app, .error, "Unable to find a compatible preset. Supported IDs: \(server.provider.supportedPresetIds ?? [])")
|
||||
|
@ -124,6 +143,7 @@ extension VPNProviderServerView {
|
|||
NavigationStack {
|
||||
VPNProviderServerView(
|
||||
apis: [API.bundled],
|
||||
moduleId: UUID(),
|
||||
providerId: .protonvpn,
|
||||
configurationType: OpenVPN.Configuration.self,
|
||||
selectedEntity: nil,
|
||||
|
|
|
@ -39,6 +39,12 @@ extension VPNProviderServerView {
|
|||
@Binding
|
||||
var filters: VPNFilters
|
||||
|
||||
@Binding
|
||||
var onlyShowsFavorites: Bool
|
||||
|
||||
@Binding
|
||||
var favorites: Set<String>
|
||||
|
||||
// unused
|
||||
let selectTitle: String
|
||||
|
||||
|
@ -82,7 +88,11 @@ private extension VPNProviderServerView.Subview {
|
|||
manager
|
||||
.allCountryCodes
|
||||
.sorted {
|
||||
$0.localizedAsRegionCode! < $1.localizedAsRegionCode!
|
||||
guard let region1 = $0.localizedAsRegionCode,
|
||||
let region2 = $1.localizedAsRegionCode else {
|
||||
return $0 < $1
|
||||
}
|
||||
return region1 < region2
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -103,7 +113,7 @@ private extension VPNProviderServerView.Subview {
|
|||
} label: {
|
||||
HStack {
|
||||
ThemeCountryFlag(code: code)
|
||||
Text(code.localizedAsRegionCode!)
|
||||
Text(code.localizedAsRegionCode ?? code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -114,10 +124,21 @@ private extension VPNProviderServerView.Subview {
|
|||
onSelect(server)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(server.hostname ?? server.serverId)
|
||||
Spacer()
|
||||
ThemeImage(.marked)
|
||||
.opacity(server.id == selectedServer?.id ? 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)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
FavoriteToggle(value: server.serverId, selection: $favorites)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -137,7 +158,9 @@ private extension VPNProviderServerView.Subview {
|
|||
NavigationStack {
|
||||
VPNFiltersView(
|
||||
manager: manager,
|
||||
filters: $filters
|
||||
filters: $filters,
|
||||
onlyShowsFavorites: $onlyShowsFavorites,
|
||||
favorites: favorites
|
||||
)
|
||||
.navigationTitle(Strings.Global.filters)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
|
@ -152,6 +175,7 @@ private extension VPNProviderServerView.Subview {
|
|||
NavigationStack {
|
||||
VPNProviderServerView(
|
||||
apis: [API.bundled],
|
||||
moduleId: UUID(),
|
||||
providerId: .tunnelbear,
|
||||
configurationType: OpenVPN.Configuration.self,
|
||||
selectedEntity: nil,
|
||||
|
|
|
@ -39,10 +39,19 @@ extension VPNProviderServerView {
|
|||
@Binding
|
||||
var filters: VPNFilters
|
||||
|
||||
@Binding
|
||||
var onlyShowsFavorites: Bool
|
||||
|
||||
@Binding
|
||||
var favorites: Set<String>
|
||||
|
||||
let selectTitle: String
|
||||
|
||||
let onSelect: (VPNServer) -> Void
|
||||
|
||||
@State
|
||||
private var hoveringServerId: String?
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
filtersView
|
||||
|
@ -70,6 +79,15 @@ private extension VPNProviderServerView.Subview {
|
|||
|
||||
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
|
||||
Button {
|
||||
onSelect(server)
|
||||
|
@ -84,7 +102,9 @@ private extension VPNProviderServerView.Subview {
|
|||
var filtersView: some View {
|
||||
VPNFiltersView(
|
||||
manager: manager,
|
||||
filters: $filters
|
||||
filters: $filters,
|
||||
onlyShowsFavorites: $onlyShowsFavorites,
|
||||
favorites: favorites
|
||||
)
|
||||
.padding()
|
||||
}
|
||||
|
@ -96,6 +116,7 @@ private extension VPNProviderServerView.Subview {
|
|||
NavigationStack {
|
||||
VPNProviderServerView(
|
||||
apis: [API.bundled],
|
||||
moduleId: UUID(),
|
||||
providerId: .tunnelbear,
|
||||
configurationType: OpenVPN.Configuration.self,
|
||||
selectedEntity: nil,
|
||||
|
|
|
@ -36,6 +36,8 @@ extension Theme {
|
|||
case disclose
|
||||
case editableSectionEdit
|
||||
case editableSectionRemove
|
||||
case favoriteOff
|
||||
case favoriteOn
|
||||
case filters
|
||||
case footerAdd
|
||||
case hide
|
||||
|
|
|
@ -89,6 +89,8 @@ public final class Theme: ObservableObject {
|
|||
case .disclose: return "chevron.down"
|
||||
case .editableSectionEdit: return "arrow.up.arrow.down"
|
||||
case .editableSectionRemove: return "trash"
|
||||
case .favoriteOff: return "star"
|
||||
case .favoriteOn: return "star.fill"
|
||||
case .filters: return "line.3.horizontal.decrease"
|
||||
case .footerAdd: return "plus.circle"
|
||||
case .hide: return "eye.slash"
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
//
|
||||
// ThemeFavoriteToggle.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 10/25/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 SwiftUI
|
||||
|
||||
struct FavoriteToggle<ID>: View where ID: Hashable {
|
||||
let value: ID
|
||||
|
||||
@Binding
|
||||
var selection: Set<ID>
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
if selection.contains(value) {
|
||||
selection.remove(value)
|
||||
} else {
|
||||
selection.insert(value)
|
||||
}
|
||||
} label: {
|
||||
ThemeImage(selection.contains(value) ? .favoriteOn : .favoriteOff)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -32,6 +32,8 @@ public enum AppPreference: String {
|
|||
|
||||
case logsPrivateData
|
||||
|
||||
case moduleFavoriteServers
|
||||
|
||||
case profilesLayout
|
||||
|
||||
public var key: String {
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
//
|
||||
// ModuleFavoriteServers.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 10/25/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 SwiftUI
|
||||
|
||||
public struct ModuleFavoriteServers {
|
||||
private var map: [UUID: Set<String>]
|
||||
|
||||
public init() {
|
||||
map = [:]
|
||||
}
|
||||
|
||||
public func servers(forModuleWithID moduleId: UUID) -> Set<String> {
|
||||
map[moduleId] ?? []
|
||||
}
|
||||
|
||||
public mutating func setServers(_ servers: Set<String>, forModuleWithID moduleId: UUID) {
|
||||
map[moduleId] = servers
|
||||
}
|
||||
}
|
||||
|
||||
extension ModuleFavoriteServers: RawRepresentable {
|
||||
public var rawValue: String {
|
||||
(try? JSONEncoder().encode(map))?.base64EncodedString() ?? ""
|
||||
}
|
||||
|
||||
public init?(rawValue: String) {
|
||||
guard let data = Data(base64Encoded: rawValue) else {
|
||||
return nil
|
||||
}
|
||||
map = (try? JSONDecoder().decode([UUID: Set<String>].self, from: data)) ?? [:]
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue