// // ActiveProfileView.swift // Passepartout // // Created by Davide De Rosa on 11/1/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 CommonUtils import PassepartoutKit import SwiftUI struct ActiveProfileView: View { @EnvironmentObject private var theme: Theme @EnvironmentObject private var providerManager: ProviderManager let profile: Profile? @ObservedObject var tunnel: ExtendedTunnel @Binding var isSwitching: Bool @FocusState.Binding var focusedField: ProfileView.Field? @ObservedObject var interactiveManager: InteractiveManager @ObservedObject var errorHandler: ErrorHandler var body: some View { VStack(spacing: .zero) { VStack { VStack { currentProfileView statusView } .padding(.bottom) profile.map { detailView(for: $0) } .padding(.bottom) Group { toggleConnectionButton switchProfileButton } .clipShape(RoundedRectangle(cornerRadius: 50)) } .padding(.horizontal, 100) .padding(.top, 50) Spacer() } } } private extension ActiveProfileView { var currentProfileView: some View { Text(profile?.name ?? Strings.Views.Profiles.Rows.notInstalled) .font(.title) .fontWeight(.bold) .frame(maxWidth: .infinity, alignment: .leading) } var statusView: some View { ConnectionStatusText(tunnel: tunnel) .font(.title2) .frame(maxWidth: .infinity, alignment: .leading) .foregroundStyle(tunnel.statusColor(theme)) .brightness(0.2) } func detailView(for profile: Profile) -> some View { VStack(spacing: 10) { if let connectionType = profile.localizedDescription(optionalStyle: .connectionType) { DetailRowView(title: Strings.Global.protocol) { Text(connectionType) } } if let pair = profile.selectedProvider { if let metadata = providerManager.provider(withId: pair.selection.id) { DetailRowView(title: Strings.Global.provider) { Text(metadata.description) } } if let entity = pair.selection.entity { DetailRowView(title: Strings.Global.country) { ThemeCountryText(entity.header.countryCode) } } } if let otherList = profile.localizedDescription(optionalStyle: .nonConnectionTypes) { DetailRowView(title: otherList) { EmptyView() } } } .font(.title3) } var toggleConnectionButton: some View { TunnelToggleButton( tunnel: tunnel, profile: profile, nextProfileId: .constant(nil), interactiveManager: interactiveManager, errorHandler: errorHandler, onProviderEntityRequired: onProviderEntityRequired, onPurchaseRequired: onPurchaseRequired, label: { Text($0 ? Strings.Global.connect : Strings.Global.disconnect) .frame(maxWidth: .infinity) .padding(.vertical, 10) } ) .background(toggleConnectionColor) .fontWeight(.bold) .focused($focusedField, equals: .connect) } var toggleConnectionColor: Color { switch tunnel.status { case .inactive: return theme.activeColor default: return theme.errorColor } } var switchProfileButton: some View { Button { isSwitching.toggle() } label: { Text(Strings.Global.select) .frame(maxWidth: .infinity) .padding(.vertical, 10) } .focused($focusedField, equals: .switchProfile) } } // MARK: - private extension ActiveProfileView { func onProviderEntityRequired(_ profile: Profile) { // FIXME: #788, TV missing provider entity } func onPurchaseRequired(_ features: Set) { // FIXME: #788, TV purchase required } } // MARK: - Subviews private struct DetailRowView: View where Content: View { let title: String @ViewBuilder let content: Content var body: some View { HStack { Text(title) .fontWeight(.light) Spacer() content } } } // MARK: - Previews #Preview("Host") { let profile: Profile = { do { let moduleBuilder = OpenVPNModule.Builder() let module = try moduleBuilder.tryBuild() let builder = Profile.Builder( name: "Host", modules: [module], activatingModules: true ) return try builder.tryBuild() } catch { fatalError(error.localizedDescription) } }() HStack { ContentPreview(profile: profile) .frame(maxWidth: .infinity) VStack {} .frame(maxWidth: .infinity) } } #Preview("Provider") { let profile: Profile = { do { var moduleBuilder = OpenVPNModule.Builder() moduleBuilder.providerId = .mullvad let module = try moduleBuilder.tryBuild() let builder = Profile.Builder( name: "Provider", modules: [module], activatingModules: true ) return try builder.tryBuild() } catch { fatalError(error.localizedDescription) } }() HStack { ContentPreview(profile: profile) .frame(maxWidth: .infinity) VStack {} .frame(maxWidth: .infinity) } .task { try? await ProviderManager.mock.fetchIndex(from: [API.bundled]) } } private struct ContentPreview: View { let profile: Profile @State private var isSwitching = false @FocusState private var focusedField: ProfileView.Field? var body: some View { ActiveProfileView( profile: profile, tunnel: .mock, isSwitching: $isSwitching, focusedField: $focusedField, interactiveManager: InteractiveManager(), errorHandler: .default() ) .withMockEnvironment() } }