Save provider favorite servers (#758)

Fixes #706
This commit is contained in:
Davide 2024-10-26 13:29:26 +02:00 committed by GitHub
parent 3abde3851a
commit df4e3465f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 229 additions and 10 deletions

View File

@ -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

View File

@ -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 %@";

View File

@ -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

View File

@ -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,

View File

@ -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: []
)
}
}

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -36,6 +36,8 @@ extension Theme {
case disclose
case editableSectionEdit
case editableSectionRemove
case favoriteOff
case favoriteOn
case filters
case footerAdd
case hide

View File

@ -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"

View File

@ -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)
}
}
}

View File

@ -32,6 +32,8 @@ public enum AppPreference: String {
case logsPrivateData
case moduleFavoriteServers
case profilesLayout
public var key: String {

View File

@ -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)) ?? [:]
}
}