Rewrite provider views (#740)
Resolve some flickering and state inconsistency due to overextended observation of VPNProviderManager. Narrow down its scope to VPNProviderServerView. The downside of that, for now, is that servers are loaded "lazily late", but this flow will make region selection from home easier. Finally, show filters in popover on iPad.
This commit is contained in:
parent
0221aea6b6
commit
5d2e24792c
|
@ -41,7 +41,7 @@
|
|||
"kind" : "remoteSourceControl",
|
||||
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
|
||||
"state" : {
|
||||
"revision" : "046a7594fa9823407dd405ba700b1b81506ce9af"
|
||||
"revision" : "9a43e23e9134c3e93926271b2d630be607433fa0"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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: "046a7594fa9823407dd405ba700b1b81506ce9af"),
|
||||
.package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "9a43e23e9134c3e93926271b2d630be607433fa0"),
|
||||
// .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"),
|
||||
|
|
|
@ -146,7 +146,7 @@ extension OnDemandModule.Policy: LocalizableEntity {
|
|||
|
||||
extension VPNServer {
|
||||
public var region: String {
|
||||
[provider.countryCodes.first?.localizedAsRegionCode, provider.area]
|
||||
[provider.countryCode.localizedAsRegionCode, provider.area]
|
||||
.compactMap { $0 }
|
||||
.joined(separator: " - ")
|
||||
}
|
||||
|
|
|
@ -63,8 +63,7 @@ extension AppContext {
|
|||
tunnelEnvironment: env,
|
||||
registry: registry,
|
||||
providerManager: ProviderManager(
|
||||
repository: InMemoryProviderRepository(),
|
||||
vpnRepository: InMemoryVPNProviderRepository()
|
||||
repository: InMemoryProviderRepository()
|
||||
),
|
||||
constants: .shared
|
||||
)
|
||||
|
|
|
@ -31,7 +31,7 @@ struct ProviderContentModifier<Entity, ProviderRows>: ViewModifier where Entity:
|
|||
@EnvironmentObject
|
||||
private var providerManager: ProviderManager
|
||||
|
||||
var apis: [APIMapper] = API.shared
|
||||
let apis: [APIMapper]
|
||||
|
||||
@Binding
|
||||
var providerId: ProviderID?
|
||||
|
|
|
@ -1,45 +0,0 @@
|
|||
//
|
||||
// VPNFiltersModifier.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 10/9/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 PassepartoutKit
|
||||
import SwiftUI
|
||||
|
||||
struct VPNFiltersModifier<Configuration>: ViewModifier where Configuration: Decodable {
|
||||
|
||||
@ObservedObject
|
||||
var manager: VPNProviderManager
|
||||
|
||||
@State
|
||||
var isFiltersPresented = false
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
contentView(with: content)
|
||||
.onChange(of: manager.parameters.filters) { _ in
|
||||
Task {
|
||||
manager.applyFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -27,13 +27,70 @@ import AppLibrary
|
|||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
|
||||
struct VPNFiltersView<Configuration>: View where Configuration: Decodable {
|
||||
struct VPNFiltersView<Configuration>: View where Configuration: ProviderConfigurationIdentifiable & Decodable {
|
||||
|
||||
@ObservedObject
|
||||
var manager: VPNProviderManager
|
||||
var manager: VPNProviderManager<Configuration>
|
||||
|
||||
@Binding
|
||||
var filters: VPNFilters
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
debugChanges()
|
||||
return Subview(
|
||||
filters: $filters,
|
||||
categories: categories,
|
||||
countries: countries,
|
||||
presets: presets
|
||||
)
|
||||
.onChange(of: filters, perform: manager.applyFilters)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
let categories: [String]
|
||||
|
||||
let countries: [(code: String, description: String)]
|
||||
|
||||
let presets: [VPNPreset<Configuration>]
|
||||
|
||||
var body: some View {
|
||||
debugChanges()
|
||||
return Form {
|
||||
Section {
|
||||
categoryPicker
|
||||
countryPicker
|
||||
|
@ -51,10 +108,11 @@ struct VPNFiltersView<Configuration>: View where Configuration: Decodable {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension VPNFiltersView {
|
||||
private extension VPNFiltersView.Subview {
|
||||
var categoryPicker: some View {
|
||||
Picker(Strings.Global.category, selection: $manager.parameters.filters.categoryName) {
|
||||
Picker(Strings.Global.category, selection: $filters.categoryName) {
|
||||
Text(Strings.Global.any)
|
||||
.tag(nil as String?)
|
||||
ForEach(categories, id: \.self) {
|
||||
|
@ -65,7 +123,7 @@ private extension VPNFiltersView {
|
|||
}
|
||||
|
||||
var countryPicker: some View {
|
||||
Picker(Strings.Global.country, selection: $manager.parameters.filters.countryCode) {
|
||||
Picker(Strings.Global.country, selection: $filters.countryCode) {
|
||||
Text(Strings.Global.any)
|
||||
.tag(nil as String?)
|
||||
ForEach(countries, id: \.code) {
|
||||
|
@ -75,10 +133,8 @@ private extension VPNFiltersView {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var presetPicker: some View {
|
||||
if manager.allPresets.count > 1 {
|
||||
Picker(Strings.Views.Provider.Vpn.preset, selection: $manager.parameters.filters.presetId) {
|
||||
Picker(Strings.Views.Provider.Vpn.preset, selection: $filters.presetId) {
|
||||
Text(Strings.Global.any)
|
||||
.tag(nil as String?)
|
||||
ForEach(presets, id: \.presetId) {
|
||||
|
@ -87,54 +143,19 @@ private extension VPNFiltersView {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var clearFiltersButton: some View {
|
||||
Button(Strings.Views.Provider.clearFilters, role: .destructive) {
|
||||
Task {
|
||||
manager.resetFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension VPNFiltersView {
|
||||
var categories: [String] {
|
||||
let allCategories = manager
|
||||
.allServers
|
||||
.values
|
||||
.map(\.provider.categoryName)
|
||||
|
||||
return Set(allCategories)
|
||||
.sorted()
|
||||
}
|
||||
|
||||
var countries: [(code: String, description: String)] {
|
||||
let allCodes = manager
|
||||
.allServers
|
||||
.values
|
||||
.flatMap(\.provider.countryCodes)
|
||||
|
||||
return Set(allCodes)
|
||||
.map {
|
||||
(code: $0, description: $0.localizedAsRegionCode ?? $0)
|
||||
}
|
||||
.sorted {
|
||||
$0.description < $1.description
|
||||
}
|
||||
}
|
||||
|
||||
var presets: [VPNPreset<Configuration>] {
|
||||
manager
|
||||
.presets(ofType: Configuration.self)
|
||||
.sorted {
|
||||
$0.description < $1.description
|
||||
filters = VPNFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
VPNFiltersView<String>(manager: VPNProviderManager())
|
||||
VPNFiltersView<OpenVPN.Configuration>(
|
||||
manager: VPNProviderManager(),
|
||||
filters: .constant(VPNFilters())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,8 +28,11 @@ import PassepartoutKit
|
|||
import SwiftUI
|
||||
import UtilsLibrary
|
||||
|
||||
@MainActor
|
||||
struct VPNProviderContentModifier<Configuration, ProviderRows>: ViewModifier where Configuration: ProviderConfigurationIdentifiable & Codable, ProviderRows: View {
|
||||
|
||||
var apis: [APIMapper] = API.shared
|
||||
|
||||
@Binding
|
||||
var providerId: ProviderID?
|
||||
|
||||
|
@ -43,12 +46,11 @@ struct VPNProviderContentModifier<Configuration, ProviderRows>: ViewModifier whe
|
|||
@ViewBuilder
|
||||
let providerRows: ProviderRows
|
||||
|
||||
@StateObject
|
||||
private var vpnProviderManager = VPNProviderManager()
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
debugChanges()
|
||||
return content
|
||||
.modifier(ProviderContentModifier(
|
||||
apis: apis,
|
||||
providerId: $providerId,
|
||||
entityType: VPNEntity<Configuration>.self,
|
||||
isRequired: isRequired,
|
||||
|
@ -64,10 +66,15 @@ struct VPNProviderContentModifier<Configuration, ProviderRows>: ViewModifier whe
|
|||
private extension VPNProviderContentModifier {
|
||||
var providerServerRow: some View {
|
||||
NavigationLink {
|
||||
providerId.map {
|
||||
VPNProviderServerView<Configuration>(
|
||||
manager: vpnProviderManager,
|
||||
apis: apis,
|
||||
providerId: $0,
|
||||
configurationType: Configuration.self,
|
||||
selectedEntity: selectedEntity,
|
||||
onSelect: onSelectServer
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text(Strings.Global.server)
|
||||
|
@ -79,27 +86,13 @@ private extension VPNProviderContentModifier {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func onSelectProvider(manager: ProviderManager, providerId: ProviderID?, isInitial: Bool) {
|
||||
guard let providerId else {
|
||||
return
|
||||
}
|
||||
let initialEntity = isInitial ? selectedEntity : nil
|
||||
|
||||
private extension VPNProviderContentModifier {
|
||||
func onSelectProvider(manager: ProviderManager, providerId: ProviderID?, isInitial: Bool) {
|
||||
if !isInitial {
|
||||
selectedEntity = nil
|
||||
}
|
||||
let view = manager.vpnView(
|
||||
for: providerId,
|
||||
configurationType: OpenVPN.Configuration.self,
|
||||
initialParameters: .init(
|
||||
sorting: [
|
||||
.localizedCountry,
|
||||
.area,
|
||||
.hostname
|
||||
]
|
||||
)
|
||||
)
|
||||
vpnProviderManager.setView(view, filteringWith: initialEntity?.server.provider)
|
||||
}
|
||||
|
||||
func onSelectServer(server: VPNServer, preset: VPNPreset<Configuration>) {
|
||||
|
|
|
@ -26,27 +26,85 @@
|
|||
import AppLibrary
|
||||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
import UtilsLibrary
|
||||
|
||||
struct VPNProviderServerView<Configuration>: View where Configuration: ProviderConfigurationIdentifiable & Codable {
|
||||
|
||||
@EnvironmentObject
|
||||
private var providerManager: ProviderManager
|
||||
|
||||
@Environment(\.dismiss)
|
||||
private var dismiss
|
||||
|
||||
@ObservedObject
|
||||
var manager: VPNProviderManager
|
||||
let apis: [APIMapper]
|
||||
|
||||
let providerId: ProviderID
|
||||
|
||||
let configurationType: Configuration.Type
|
||||
|
||||
var selectedEntity: VPNEntity<Configuration>?
|
||||
|
||||
let onSelect: (_ server: VPNServer, _ preset: VPNPreset<Configuration>) -> Void
|
||||
|
||||
@StateObject
|
||||
private var manager = VPNProviderManager<Configuration>(sorting: [
|
||||
.localizedCountry,
|
||||
.area,
|
||||
.hostname
|
||||
])
|
||||
|
||||
@State
|
||||
private var filters = VPNFilters()
|
||||
|
||||
@StateObject
|
||||
private var errorHandler: ErrorHandler = .default()
|
||||
|
||||
var body: some View {
|
||||
serversView
|
||||
.modifier(VPNFiltersModifier<Configuration>(manager: manager))
|
||||
debugChanges()
|
||||
return Subview(
|
||||
manager: manager,
|
||||
filters: $filters,
|
||||
onSelect: selectServer
|
||||
)
|
||||
.withErrorHandler(errorHandler)
|
||||
.navigationTitle(Strings.Global.servers)
|
||||
.onLoad {
|
||||
Task {
|
||||
do {
|
||||
manager.repository = try await providerManager.vpnRepository(
|
||||
from: apis,
|
||||
for: providerId,
|
||||
configurationType: Configuration.self
|
||||
)
|
||||
if let selectedEntity {
|
||||
filters = VPNFilters(with: selectedEntity.server.provider)
|
||||
} else {
|
||||
filters = VPNFilters()
|
||||
}
|
||||
manager.applyFilters(filters)
|
||||
} catch {
|
||||
pp_log(.app, .error, "Unable to load VPN repository: \(error)")
|
||||
errorHandler.handle(error, title: Strings.Global.servers)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
extension VPNProviderServerView {
|
||||
func compatiblePreset(with server: VPNServer) -> VPNPreset<Configuration>? {
|
||||
manager
|
||||
.presets
|
||||
.first {
|
||||
if let supportedIds = server.provider.supportedPresetIds {
|
||||
return supportedIds.contains($0.presetId)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
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 ?? [])")
|
||||
|
@ -58,24 +116,16 @@ extension VPNProviderServerView {
|
|||
}
|
||||
}
|
||||
|
||||
private extension VPNProviderServerView {
|
||||
func compatiblePreset(with server: VPNServer) -> VPNPreset<Configuration>? {
|
||||
manager
|
||||
.presets(ofType: Configuration.self)
|
||||
.first {
|
||||
if let supportedIds = server.provider.supportedPresetIds {
|
||||
return supportedIds.contains($0.presetId)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
|
||||
NavigationStack {
|
||||
VPNProviderServerView<OpenVPN.Configuration>(manager: VPNProviderManager()) { _, _ in
|
||||
VPNProviderServerView<OpenVPN.Configuration>(
|
||||
apis: [API.bundled],
|
||||
providerId: .protonvpn,
|
||||
configurationType: OpenVPN.Configuration.self
|
||||
) { _, _ in
|
||||
}
|
||||
}
|
||||
.withMockEnvironment()
|
||||
|
|
|
@ -1,55 +0,0 @@
|
|||
//
|
||||
// VPNFiltersModifier+iOS.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 10/9/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/>.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// FIXME: ###, providers UI, iPadOS show filters in popover
|
||||
|
||||
extension VPNFiltersModifier {
|
||||
func contentView(with content: Content) -> some View {
|
||||
content
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .bottomBar) {
|
||||
Button {
|
||||
isFiltersPresented = true
|
||||
} label: {
|
||||
ThemeImage(.filters)
|
||||
}
|
||||
.themeModal(isPresented: $isFiltersPresented) {
|
||||
NavigationStack {
|
||||
VPNFiltersView<Configuration>(manager: manager)
|
||||
.navigationTitle(Strings.Global.filters)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
|
@ -25,22 +25,72 @@
|
|||
|
||||
#if os(iOS)
|
||||
|
||||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
|
||||
// FIXME: ###, providers UI, iOS server rows + country flags
|
||||
|
||||
extension VPNProviderServerView {
|
||||
struct Subview: View {
|
||||
|
||||
@ViewBuilder
|
||||
var serversView: some View {
|
||||
@ObservedObject
|
||||
var manager: VPNProviderManager<Configuration>
|
||||
|
||||
@Binding
|
||||
var filters: VPNFilters
|
||||
|
||||
let onSelect: (VPNServer) -> Void
|
||||
|
||||
@State
|
||||
private var isFiltersPresented = false
|
||||
|
||||
var body: some View {
|
||||
listView
|
||||
.disabled(manager.isFiltering)
|
||||
.toolbar {
|
||||
filtersItem
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension VPNProviderServerView.Subview {
|
||||
var listView: some View {
|
||||
List {
|
||||
// FIXME: ###, providers UI, iOS server rows + country flags
|
||||
if manager.isFiltering {
|
||||
ProgressView()
|
||||
} else {
|
||||
ForEach(manager.filteredServers, id: \.id) { server in
|
||||
Button("\(server.hostname ?? server.id) \(server.provider.countryCodes)") {
|
||||
selectServer(server)
|
||||
Button("\(server.hostname ?? server.id) \(server.provider.countryCode)") {
|
||||
onSelect(server)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.themeAnimation(on: manager.isFiltering, category: .providers)
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
.navigationTitle(Strings.Global.filters)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
|
@ -1,40 +0,0 @@
|
|||
//
|
||||
// VPNFiltersModifier+macOS.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 10/9/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/>.
|
||||
//
|
||||
|
||||
#if os(macOS)
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension VPNFiltersModifier {
|
||||
func contentView(with content: Content) -> some View {
|
||||
VStack {
|
||||
VPNFiltersView<Configuration>(manager: manager)
|
||||
.padding()
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
|
@ -25,14 +25,33 @@
|
|||
|
||||
#if os(macOS)
|
||||
|
||||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
|
||||
// FIXME: ###, providers UI, macOS country flags
|
||||
|
||||
extension VPNProviderServerView {
|
||||
struct Subview: View {
|
||||
|
||||
@ViewBuilder
|
||||
var serversView: some View {
|
||||
@ObservedObject
|
||||
var manager: VPNProviderManager<Configuration>
|
||||
|
||||
@Binding
|
||||
var filters: VPNFilters
|
||||
|
||||
let onSelect: (VPNServer) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
filtersView
|
||||
tableView
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension VPNProviderServerView.Subview {
|
||||
var tableView: some View {
|
||||
Table(manager.filteredServers) {
|
||||
TableColumn(Strings.Global.region) { server in
|
||||
Text(server.region)
|
||||
|
@ -43,13 +62,22 @@ extension VPNProviderServerView {
|
|||
|
||||
TableColumn("") { server in
|
||||
Button {
|
||||
selectServer(server)
|
||||
onSelect(server)
|
||||
} label: {
|
||||
Text(Strings.Views.Provider.selectServer)
|
||||
}
|
||||
}
|
||||
.width(min: 100.0, max: 100.0)
|
||||
}
|
||||
.disabled(manager.isFiltering)
|
||||
}
|
||||
|
||||
var filtersView: some View {
|
||||
VPNFiltersView(
|
||||
manager: manager,
|
||||
filters: $filters
|
||||
)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -102,6 +102,27 @@ struct ThemeItemModalModifier<Modal, T>: ViewModifier where Modal: View, T: Iden
|
|||
}
|
||||
}
|
||||
|
||||
struct ThemeBooleanPopoverModifier<Popover>: ViewModifier where Popover: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var theme: Theme
|
||||
|
||||
@Binding
|
||||
var isPresented: Bool
|
||||
|
||||
@ViewBuilder
|
||||
let popover: Popover
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.popover(isPresented: $isPresented) {
|
||||
popover
|
||||
.frame(minWidth: theme.popoverSize?.width, minHeight: theme.popoverSize?.height)
|
||||
.themeLockScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ThemeConfirmationModifier: ViewModifier {
|
||||
|
||||
@Binding
|
||||
|
|
|
@ -31,6 +31,7 @@ extension Theme {
|
|||
public convenience init() {
|
||||
self.init(dummy: ())
|
||||
animationCategories = [.diagnostics, .modules, .profiles, .providers]
|
||||
popoverSize = .init(width: 400.0, height: 400.0)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -40,6 +40,8 @@ public final class Theme: ObservableObject {
|
|||
|
||||
var secondaryModalSize: CGSize?
|
||||
|
||||
var popoverSize: CGSize?
|
||||
|
||||
var relevantWeight: Font.Weight = .semibold
|
||||
|
||||
var titleColor: Color = .primary
|
||||
|
@ -163,6 +165,16 @@ extension View {
|
|||
))
|
||||
}
|
||||
|
||||
public func themePopover<Content>(
|
||||
isPresented: Binding<Bool>,
|
||||
content: @escaping () -> Content
|
||||
) -> some View where Content: View {
|
||||
modifier(ThemeBooleanPopoverModifier(
|
||||
isPresented: isPresented,
|
||||
popover: content
|
||||
))
|
||||
}
|
||||
|
||||
public func themeConfirmation(isPresented: Binding<Bool>, title: String, action: @escaping () -> Void) -> some View {
|
||||
modifier(ThemeConfirmationModifier(isPresented: isPresented, title: title, action: action))
|
||||
}
|
||||
|
|
|
@ -209,8 +209,7 @@ private extension ProfileManager {
|
|||
// FIXME: #705, store providers to Core Data
|
||||
extension ProviderManager {
|
||||
static let shared = ProviderManager(
|
||||
repository: InMemoryProviderRepository(),
|
||||
vpnRepository: InMemoryVPNProviderRepository()
|
||||
repository: InMemoryProviderRepository()
|
||||
)
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue