// // VPNProviderServerView+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 . // #if os(iOS) import CommonAPI import CommonLibrary import PassepartoutKit import SwiftUI extension VPNProviderServerView { var contentView: some View { serversView .modifier(FiltersItemModifier { filtersView }) } } private extension VPNProviderServerView { struct FiltersItemModifier: ViewModifier where FiltersContent: View { @ViewBuilder let filtersContent: FiltersContent @State private var isPresented = false 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 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 let selectTitle: String let onSelect: (VPNServer) -> Void var body: some View { debugChanges() return listView .themeAnimation(on: isFiltering, category: .providers) } } } private extension VPNProviderServerView.ServersSubview { var listView: some View { List { Section { Toggle(Strings.Providers.onlyFavorites, isOn: $filtersViewModel.onlyShowsFavorites) } Group { if isFiltering || !servers.isEmpty { if isFiltering { ProgressView() .id(UUID()) } else { ForEach(countryCodes, id: \.self, content: countryView) } } else { emptyView } } .themeSection( header: filtersViewModel.filters.categoryName ?? Strings.Providers.Vpn.Category.any ) } } var emptyView: some View { Text(Strings.Providers.Vpn.noServers) } } private extension VPNProviderServerView.ServersSubview { var countryCodes: [String] { filtersViewModel .countries .map(\.code) } func countryServers(for code: String) -> [VPNServer]? { servers .filter { $0.provider.countryCode == code } .nilIfEmpty } func countryView(for code: String) -> some View { countryServers(for: code) .map { servers in DisclosureGroup { ForEach(servers, id: \.id, content: serverView) } label: { ThemeCountryText(code) } } } func serverView(for server: VPNServer) -> some View { Button { onSelect(server) } label: { HStack { ThemeImage(.marked) .opaque(server.id == selectedServerId) VStack(alignment: .leading) { if let area = server.provider.area { Text(area) .font(.headline) } Text(server.hostname ?? server.serverId) .font(.subheadline) .truncationMode(.middle) } Spacer() FavoriteToggle( value: server.serverId, selection: $favoritesManager.serverIds ) } } } } // MARK: - Preview #Preview { NavigationStack { VPNProviderServerView( apis: [API.bundled], moduleId: UUID(), providerId: .tunnelbear, configurationType: OpenVPN.Configuration.self, selectedEntity: nil, filtersWithSelection: false, selectTitle: "Select", onSelect: { _, _ in } ) } .withMockEnvironment() } #endif