From 7692f33558a1b125adeb4722830a0b9632431898 Mon Sep 17 00:00:00 2001 From: Andrew Bulhak Date: Fri, 3 Jan 2025 16:56:55 +0100 Subject: [PATCH] Split ConnectionView, improve previews, attempt animation --- ios/MullvadVPN.xcodeproj/project.pbxproj | 42 ++- .../ChipView/ChipContainerView.swift | 22 +- .../FeatureIndicators/ConnectionView.swift | 283 ------------------ .../ConnectionView/ButtonPanel.swift | 90 ++++++ .../ConnectionView/ConnectionView.swift | 71 +++++ .../ConnectionViewViewModel.swift | 0 .../ConnectionView/DetailsContainer.swift | 61 ++++ .../ConnectionView/DetailsView.swift | 64 ++++ .../ConnectionView/HeaderView.swift | 67 +++++ .../ConnectionViewComponentPreview.swift | 69 +++++ .../Preview}/ConnectionViewPreview.swift | 6 +- 11 files changed, 470 insertions(+), 305 deletions(-) delete mode 100644 ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView.swift create mode 100644 ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ButtonPanel.swift create mode 100644 ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ConnectionView.swift rename ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/{ => ConnectionView}/ConnectionViewViewModel.swift (100%) create mode 100644 ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/DetailsContainer.swift create mode 100644 ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/DetailsView.swift create mode 100644 ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/HeaderView.swift create mode 100644 ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/Preview/ConnectionViewComponentPreview.swift rename ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/{ => ConnectionView/Preview}/ConnectionViewPreview.swift (87%) diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index ca8ff157378b..60c2b60a18ba 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -43,6 +43,9 @@ 44075DFB2CDA4F7400F61139 /* UDPOverTCPObfuscationSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44075DFA2CDA4F7400F61139 /* UDPOverTCPObfuscationSettingsViewModel.swift */; }; 440E5AB02CDBD67D00B09614 /* StatefulPreviewWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 440E5AAF2CDBD67D00B09614 /* StatefulPreviewWrapper.swift */; }; 440E5AB42CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 440E5AB32CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift */; }; + 4419AA892D282687001B13C9 /* DetailsContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4419AA882D282687001B13C9 /* DetailsContainer.swift */; }; + 4419AA8B2D2826E5001B13C9 /* DetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4419AA8A2D2826E5001B13C9 /* DetailsView.swift */; }; + 4419AA8E2D2828A4001B13C9 /* HeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4419AA8D2D2828A4001B13C9 /* HeaderView.swift */; }; 4422C0712CCFF6790001A385 /* UDPOverTCPObfuscationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4422C0702CCFF6790001A385 /* UDPOverTCPObfuscationSettingsView.swift */; }; 4424CDD32CDBD4A6009D8C9F /* SingleChoiceList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4424CDD22CDBD4A6009D8C9F /* SingleChoiceList.swift */; }; 447F3D8A2CDE1853006E3462 /* ShadowsocksObfuscationSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 447F3D882CDE1852006E3462 /* ShadowsocksObfuscationSettingsViewModel.swift */; }; @@ -52,6 +55,8 @@ 4495ECD52D131A4800A7358B /* ShadowsocksObfuscationSettingsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4495ECD42D131A3E00A7358B /* ShadowsocksObfuscationSettingsPage.swift */; }; 449872E12B7BBC5400094DDC /* TunnelSettingsUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449872E02B7BBC5400094DDC /* TunnelSettingsUpdate.swift */; }; 449872E42B7CB96300094DDC /* TunnelSettingsUpdateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449872E32B7CB96300094DDC /* TunnelSettingsUpdateTests.swift */; }; + 449E9A6D2D283A2500F8574A /* ConnectionViewComponentPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449E9A6C2D283A2500F8574A /* ConnectionViewComponentPreview.swift */; }; + 449E9A6F2D283C7400F8574A /* ButtonPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449E9A6E2D283C7400F8574A /* ButtonPanel.swift */; }; 449EBA262B975B9700DFA4EB /* EphemeralPeerReceiving.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449EBA252B975B9700DFA4EB /* EphemeralPeerReceiving.swift */; }; 44B02E3B2BC5732D008EDF34 /* LoggingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44B02E3A2BC5732D008EDF34 /* LoggingTests.swift */; }; 44B02E3C2BC5B8A5008EDF34 /* Bundle+ProductVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5891BF1B25E3E3EB006D6FB0 /* Bundle+ProductVersion.swift */; }; @@ -1442,6 +1447,9 @@ 44075DFA2CDA4F7400F61139 /* UDPOverTCPObfuscationSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UDPOverTCPObfuscationSettingsViewModel.swift; sourceTree = ""; }; 440E5AAF2CDBD67D00B09614 /* StatefulPreviewWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatefulPreviewWrapper.swift; sourceTree = ""; }; 440E5AB32CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelObfuscationSettingsWatchingObservableObject.swift; sourceTree = ""; }; + 4419AA882D282687001B13C9 /* DetailsContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailsContainer.swift; sourceTree = ""; }; + 4419AA8A2D2826E5001B13C9 /* DetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailsView.swift; sourceTree = ""; }; + 4419AA8D2D2828A4001B13C9 /* HeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderView.swift; sourceTree = ""; }; 4422C0702CCFF6790001A385 /* UDPOverTCPObfuscationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UDPOverTCPObfuscationSettingsView.swift; sourceTree = ""; }; 4424CDD22CDBD4A6009D8C9F /* SingleChoiceList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleChoiceList.swift; sourceTree = ""; }; 447F3D882CDE1852006E3462 /* ShadowsocksObfuscationSettingsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShadowsocksObfuscationSettingsViewModel.swift; sourceTree = ""; }; @@ -1452,6 +1460,8 @@ 4495ECD42D131A3E00A7358B /* ShadowsocksObfuscationSettingsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowsocksObfuscationSettingsPage.swift; sourceTree = ""; }; 449872E02B7BBC5400094DDC /* TunnelSettingsUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelSettingsUpdate.swift; sourceTree = ""; }; 449872E32B7CB96300094DDC /* TunnelSettingsUpdateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelSettingsUpdateTests.swift; sourceTree = ""; }; + 449E9A6C2D283A2500F8574A /* ConnectionViewComponentPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionViewComponentPreview.swift; sourceTree = ""; }; + 449E9A6E2D283C7400F8574A /* ButtonPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonPanel.swift; sourceTree = ""; }; 449EB9FC2B95F8AD00DFA4EB /* DeviceMock.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceMock.swift; sourceTree = ""; }; 449EB9FE2B95FF2500DFA4EB /* AccountMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountMock.swift; sourceTree = ""; }; 449EBA252B975B9700DFA4EB /* EphemeralPeerReceiving.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EphemeralPeerReceiving.swift; sourceTree = ""; }; @@ -2692,6 +2702,20 @@ path = Protocols; sourceTree = ""; }; + 4419AA862D28264D001B13C9 /* ConnectionView */ = { + isa = PBXGroup; + children = ( + 449E9A6B2D2839FD00F8574A /* Preview */, + 7AA130982CFF365A00640DF9 /* ConnectionView.swift */, + 7A0EAEA32D06DF8200D3EB8B /* ConnectionViewViewModel.swift */, + 4419AA882D282687001B13C9 /* DetailsContainer.swift */, + 4419AA8A2D2826E5001B13C9 /* DetailsView.swift */, + 4419AA8D2D2828A4001B13C9 /* HeaderView.swift */, + 449E9A6E2D283C7400F8574A /* ButtonPanel.swift */, + ); + path = ConnectionView; + sourceTree = ""; + }; 4422C06F2CCFF6520001A385 /* Obfuscation */ = { isa = PBXGroup; children = ( @@ -2732,6 +2756,15 @@ path = MullvadSettings; sourceTree = ""; }; + 449E9A6B2D2839FD00F8574A /* Preview */ = { + isa = PBXGroup; + children = ( + 7AF84F472D12C9CF00C72690 /* ConnectionViewPreview.swift */, + 449E9A6C2D283A2500F8574A /* ConnectionViewComponentPreview.swift */, + ); + path = Preview; + sourceTree = ""; + }; 449EBA242B975B7C00DFA4EB /* Protocols */ = { isa = PBXGroup; children = ( @@ -4083,11 +4116,9 @@ 7AA130972CFF364F00640DF9 /* FeatureIndicators */ = { isa = PBXGroup; children = ( + 4419AA862D28264D001B13C9 /* ConnectionView */, F0ADF1CF2D01B50B00299F09 /* ChipView */, 7AFBE3862D084C96002335FC /* ActivityIndicator.swift */, - 7AA130982CFF365A00640DF9 /* ConnectionView.swift */, - 7AF84F472D12C9CF00C72690 /* ConnectionViewPreview.swift */, - 7A0EAEA32D06DF8200D3EB8B /* ConnectionViewViewModel.swift */, F0B4957B2D03154200CFEC2A /* FeatureIndicatorsView.swift */, F0ADF1D22D01B6B400299F09 /* FeatureIndicatorsViewModel.swift */, 7AFBE3882D08915D002335FC /* FI_TunnelViewController.swift */, @@ -5904,6 +5935,7 @@ 58421030282D8A3C00F24E46 /* UpdateAccountDataOperation.swift in Sources */, 7A8A190A2CE5FFE9000BCB5B /* SettingsDAITAView.swift in Sources */, F0E8E4C92A604E7400ED26A3 /* AccountDeletionInteractor.swift in Sources */, + 449E9A6D2D283A2500F8574A /* ConnectionViewComponentPreview.swift in Sources */, 7A5869952B32E9C700640D27 /* LinkButton.swift in Sources */, F09A297D2A9F8A9B00EA3B6F /* RedeemVoucherContentView.swift in Sources */, 5803B4B02940A47300C23744 /* TunnelConfiguration.swift in Sources */, @@ -6038,6 +6070,7 @@ 7A6F2FAB2AFD3097006D0856 /* CustomDNSCellFactory.swift in Sources */, 58A99ED3240014A0006599E9 /* TermsOfServiceViewController.swift in Sources */, 7A6000FE2B628E9F001CF0D9 /* ListCellContentView.swift in Sources */, + 4419AA8B2D2826E5001B13C9 /* DetailsView.swift in Sources */, 58CCA0162242560B004F3011 /* UIColor+Palette.swift in Sources */, 587CBFE322807F530028DED3 /* UIColor+Helpers.swift in Sources */, 7A9CCCBE2A96302800DD6A34 /* AccountDeletionCoordinator.swift in Sources */, @@ -6101,6 +6134,7 @@ 587EB66A270EFACB00123C75 /* CharacterSet+IPAddress.swift in Sources */, 5888AD83227B11080051EB06 /* LocationCell.swift in Sources */, 5891BF1C25E3E3EB006D6FB0 /* Bundle+ProductVersion.swift in Sources */, + 4419AA892D282687001B13C9 /* DetailsContainer.swift in Sources */, 5878A26F2907E7E00096FC88 /* ProblemReportInteractor.swift in Sources */, 7AB4CCBB2B691BBB006037F5 /* IPOverrideInteractor.swift in Sources */, 7A3353912AAA014400F0A71C /* SimulatorVPNConnection.swift in Sources */, @@ -6152,6 +6186,8 @@ 58CE5E64224146200008646E /* AppDelegate.swift in Sources */, F0DA87492A9CBA9F006044F1 /* AccountDeviceRow.swift in Sources */, 58FF9FE42B075BDD00E4C97D /* EditAccessMethodItemIdentifier.swift in Sources */, + 449E9A6F2D283C7400F8574A /* ButtonPanel.swift in Sources */, + 4419AA8E2D2828A4001B13C9 /* HeaderView.swift in Sources */, 5878A27329091D6D0096FC88 /* TunnelBlockObserver.swift in Sources */, 7A27E3D12CC299F90088BCFF /* VPNSettingsDetailsButtonItem.swift in Sources */, A9E034642ABB302000E59A5A /* UIEdgeInsets+Extensions.swift in Sources */, diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipContainerView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipContainerView.swift index e64874ad22ad..333761fa30ab 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipContainerView.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipContainerView.swift @@ -86,18 +86,12 @@ struct ChipContainerView: View where ViewModel: ChipViewModelProtocol } } -#Preview("Normal") { - ChipContainerView( - viewModel: MockFeatureIndicatorsViewModel(), - isExpanded: .constant(false) - ) - .background(UIColor.secondaryColor.color) -} - -#Preview("Expanded") { - ChipContainerView( - viewModel: MockFeatureIndicatorsViewModel(), - isExpanded: .constant(true) - ) - .background(UIColor.secondaryColor.color) +#Preview("Tap to expand") { + StatefulPreviewWrapper(false) { isExpanded in + ChipContainerView( + viewModel: MockFeatureIndicatorsViewModel(), + isExpanded: isExpanded + ) + .background(UIColor.secondaryColor.color) + } } diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView.swift deleted file mode 100644 index d066bb47f21d..000000000000 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView.swift +++ /dev/null @@ -1,283 +0,0 @@ -// -// ConnectionView.swift -// MullvadVPN -// -// Created by Jon Petersson on 2024-12-03. -// Copyright © 2024 Mullvad VPN AB. All rights reserved. -// - -import SwiftUI - -typealias ButtonAction = (ConnectionViewViewModel.TunnelAction) -> Void - -struct ConnectionView: View { - @StateObject var connectionViewModel: ConnectionViewViewModel - @StateObject var indicatorsViewModel: FeatureIndicatorsViewModel - - @State private(set) var isExpanded = false - - var action: ButtonAction? - var onContentUpdate: (() -> Void)? - - var body: some View { - Spacer() - VStack(spacing: 22) { - if connectionViewModel.showsActivityIndicator { - CustomProgressView(style: .large) - } - - ZStack { - BlurView(style: .dark) - - VStack(alignment: .leading, spacing: 16) { - ConnectionHeader(viewModel: connectionViewModel, isExpanded: $isExpanded) - - if connectionViewModel.showConnectionDetails { - ConnectionDetailsContainer( - viewModel: connectionViewModel, - indicatorsViewModel: indicatorsViewModel, - isExpanded: $isExpanded - ) - } - - ButtonPanel(viewModel: connectionViewModel, action: action) - } - .padding(16) - } - .cornerRadius(12) - .padding(16) - } - .padding(.bottom, 8) // Adding some spacing so as not to overlap with the map legal link. - .accessibilityIdentifier(AccessibilityIdentifier.connectionView.asString) - .onChange(of: isExpanded) { _ in - onContentUpdate?() - } - .onReceive(connectionViewModel.combinedState) { _, _ in - onContentUpdate?() - - if !connectionViewModel.showConnectionDetails { - isExpanded = false - } - } - } -} - -#Preview("ConnectionView (Normal)") { - ConnectionViewPreview(configuration: .normal).make() -} - -#Preview("ConnectionView (Normal, no indicators)") { - ConnectionViewPreview(configuration: .normalNoIndicators).make() -} - -#Preview("ConnectionView (Expanded)") { - ConnectionViewPreview(configuration: .expanded).make() -} - -#Preview("ConnectionView (Expanded, no indicators)") { - ConnectionViewPreview(configuration: .expandedNoIndicators).make() -} - -private struct ConnectionHeader: View { - @StateObject var viewModel: ConnectionViewViewModel - @Binding var isExpanded: Bool - - var body: some View { - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 0) { - Text(viewModel.localizedTitleForSecureLabel) - .textCase(.uppercase) - .font(.title3.weight(.semibold)) - .foregroundStyle(viewModel.textColorForSecureLabel.color) - .accessibilityIdentifier(viewModel.accessibilityIdForSecureLabel.asString) - - if let countryAndCity = viewModel.titleForCountryAndCity { - Text(countryAndCity) - .font(.title3.weight(.semibold)) - .foregroundStyle(UIColor.primaryTextColor.color) - .padding(.top, 4) - } - - if let server = viewModel.titleForServer { - Text(server) - .font(.body) - .foregroundStyle(UIColor.primaryTextColor.color.opacity(0.6)) - .padding(.top, 2) - } - } - .accessibilityLabel(viewModel.localizedAccessibilityLabelForSecureLabel) - - if viewModel.showConnectionDetails { - Spacer() - Image(.iconChevron) - .renderingMode(.template) - .rotationEffect(isExpanded ? .degrees(-90) : .degrees(90)) - .foregroundStyle(.white) - .transaction { transaction in - transaction.animation = nil - } - } - } - .accessibilityIdentifier(AccessibilityIdentifier.relayStatusCollapseButton.asString) - .contentShape(Rectangle()) - .onTapGesture { - isExpanded.toggle() - } - } -} - -private struct ConnectionDetailsContainer: View { - @StateObject var viewModel: ConnectionViewViewModel - @StateObject var indicatorsViewModel: FeatureIndicatorsViewModel - @Binding var isExpanded: Bool - - @State private var scrollViewHeight: CGFloat = 0 - - var body: some View { - if isExpanded { - Divider() - .background(UIColor.secondaryTextColor.color) - } - - // This geometry reader is somewhat of a workaround. It's "smart" in that it takes up as much - // space as it can and thereby helps the view to understand the maximum allowed height when - // placed in a UIKit context. If ConnectionView would ever be placed as a subview of SwiftUI - // parent, this reader could probably be removed. - GeometryReader { _ in - ScrollView { - VStack(spacing: 16) { - if !indicatorsViewModel.chips.isEmpty { - FeatureIndicatorsView( - viewModel: indicatorsViewModel, - isExpanded: $isExpanded - ) - } - - if isExpanded { - ConnectionDetails(viewModel: viewModel) - } - } - .sizeOfView { scrollViewHeight = $0.height } - } - } - .frame(maxHeight: scrollViewHeight) - } -} - -private struct ConnectionDetails: View { - @StateObject var viewModel: ConnectionViewViewModel - @State private var columnWidth: CGFloat = 0 - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text(LocalizedStringKey("Connection details")) - .font(.footnote.weight(.semibold)) - .foregroundStyle(UIColor.primaryTextColor.color.opacity(0.6)) - Spacer() - } - - VStack(alignment: .leading, spacing: 0) { - if let inAddress = viewModel.inAddress { - connectionDetailRow(title: LocalizedStringKey("In"), value: inAddress) - .accessibilityIdentifier(AccessibilityIdentifier.connectionPanelInAddressRow.asString) - } - if viewModel.tunnelIsConnected { - if let outAddressIpv4 = viewModel.outAddressIpv4 { - connectionDetailRow(title: LocalizedStringKey("Out IPv4"), value: outAddressIpv4) - .accessibilityIdentifier(AccessibilityIdentifier.connectionPanelOutAddressRow.asString) - } - if let outAddressIpv6 = viewModel.outAddressIpv6 { - connectionDetailRow(title: LocalizedStringKey("Out IPv6"), value: outAddressIpv6) - .accessibilityIdentifier(AccessibilityIdentifier.connectionPanelOutAddressRow.asString) - } - } - } - } - } - - @ViewBuilder - private func connectionDetailRow(title: LocalizedStringKey, value: String) -> some View { - HStack(alignment: .top, spacing: 8) { - Text(title) - .font(.subheadline) - .foregroundStyle(UIColor.primaryTextColor.color.opacity(0.6)) - .frame(minWidth: columnWidth, alignment: .leading) - .sizeOfView { columnWidth = max(columnWidth, $0.width) } - Text(value) - .font(.subheadline) - .foregroundStyle(UIColor.primaryTextColor.color) - } - } -} - -private struct ButtonPanel: View { - @StateObject var viewModel: ConnectionViewViewModel - var action: ButtonAction? - - var body: some View { - VStack(spacing: 16) { - locationButton(with: action) - .disabled(viewModel.disableButtons) - actionButton(with: action) - .disabled(viewModel.disableButtons) - } - } - - @ViewBuilder - private func locationButton(with action: ButtonAction?) -> some View { - switch viewModel.tunnelStatus.state { - case .connecting, .connected, .reconnecting, .waitingForConnectivity, .negotiatingEphemeralPeer, .error: - SplitMainButton( - text: viewModel.localizedTitleForSelectLocationButton, - image: .iconReload, - style: .default, - primaryAction: { action?(.selectLocation) }, - secondaryAction: { action?(.reconnect) } - ) - .accessibilityIdentifier(AccessibilityIdentifier.selectLocationButton.asString) - case .disconnecting, .pendingReconnect, .disconnected: - MainButton( - text: viewModel.localizedTitleForSelectLocationButton, - style: .default, - action: { action?(.selectLocation) } - ) - .accessibilityIdentifier(AccessibilityIdentifier.selectLocationButton.asString) - } - } - - @ViewBuilder - private func actionButton(with action: ButtonAction?) -> some View { - switch viewModel.actionButton { - case .connect: - MainButton( - text: LocalizedStringKey("Connect"), - style: .success, - action: { action?(.connect) } - ) - .accessibilityIdentifier(AccessibilityIdentifier.connectButton.asString) - case .disconnect: - MainButton( - text: LocalizedStringKey("Disconnect"), - style: .danger, - action: { action?(.disconnect) } - ) - .accessibilityIdentifier(AccessibilityIdentifier.disconnectButton.asString) - case .cancel: - MainButton( - text: LocalizedStringKey( - viewModel.tunnelStatus.state == .waitingForConnectivity(.noConnection) - ? "Disconnect" - : "Cancel" - ), - style: .danger, - action: { action?(.cancel) } - ) - .accessibilityIdentifier( - viewModel.tunnelStatus.state == .waitingForConnectivity(.noConnection) - ? AccessibilityIdentifier.disconnectButton.asString - : AccessibilityIdentifier.cancelButton.asString - ) - } - } -} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ButtonPanel.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ButtonPanel.swift new file mode 100644 index 000000000000..bfbe4370418d --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ButtonPanel.swift @@ -0,0 +1,90 @@ +// +// ButtonPanel.swift +// MullvadVPN +// +// Created by Andrew Bulhak on 2025-01-03. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +extension ConnectionView { + internal struct ButtonPanel: View { + typealias Action = (ConnectionViewViewModel.TunnelAction) -> Void + + @StateObject var viewModel: ConnectionViewViewModel + var action: Action? + + var body: some View { + VStack(spacing: 16) { + locationButton(with: action) + .disabled(viewModel.disableButtons) + actionButton(with: action) + .disabled(viewModel.disableButtons) + } + } + + @ViewBuilder + private func locationButton(with action: Action?) -> some View { + switch viewModel.tunnelStatus.state { + case .connecting, .connected, .reconnecting, .waitingForConnectivity, .negotiatingEphemeralPeer, .error: + SplitMainButton( + text: viewModel.localizedTitleForSelectLocationButton, + image: .iconReload, + style: .default, + primaryAction: { action?(.selectLocation) }, + secondaryAction: { action?(.reconnect) } + ) + .accessibilityIdentifier(AccessibilityIdentifier.selectLocationButton.asString) + case .disconnecting, .pendingReconnect, .disconnected: + MainButton( + text: viewModel.localizedTitleForSelectLocationButton, + style: .default, + action: { action?(.selectLocation) } + ) + .accessibilityIdentifier(AccessibilityIdentifier.selectLocationButton.asString) + } + } + + @ViewBuilder + private func actionButton(with action: Action?) -> some View { + switch viewModel.actionButton { + case .connect: + MainButton( + text: LocalizedStringKey("Connect"), + style: .success, + action: { action?(.connect) } + ) + .accessibilityIdentifier(AccessibilityIdentifier.connectButton.asString) + case .disconnect: + MainButton( + text: LocalizedStringKey("Disconnect"), + style: .danger, + action: { action?(.disconnect) } + ) + .accessibilityIdentifier(AccessibilityIdentifier.disconnectButton.asString) + case .cancel: + MainButton( + text: LocalizedStringKey( + viewModel.tunnelStatus.state == .waitingForConnectivity(.noConnection) + ? "Disconnect" + : "Cancel" + ), + style: .danger, + action: { action?(.cancel) } + ) + .accessibilityIdentifier( + viewModel.tunnelStatus.state == .waitingForConnectivity(.noConnection) + ? AccessibilityIdentifier.disconnectButton.asString + : AccessibilityIdentifier.cancelButton.asString + ) + } + } + } +} + +#Preview { + ConnectionViewComponentPreview(showIndicators: true, isExpanded: true) { _, vm, _ in + ConnectionView.ButtonPanel(viewModel: vm, action: nil) + } +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ConnectionView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ConnectionView.swift new file mode 100644 index 000000000000..929e0b17a3f2 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ConnectionView.swift @@ -0,0 +1,71 @@ +// +// ConnectionView.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-12-03. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +struct ConnectionView: View { + @StateObject var connectionViewModel: ConnectionViewViewModel + @StateObject var indicatorsViewModel: FeatureIndicatorsViewModel + + @State private(set) var isExpanded = false + + var action: ButtonPanel.Action? + var onContentUpdate: (() -> Void)? + + var body: some View { + Spacer() + VStack(spacing: 22) { + if connectionViewModel.showsActivityIndicator { + CustomProgressView(style: .large) + } + + ZStack { + BlurView(style: .dark) + + VStack(alignment: .leading, spacing: 16) { + HeaderView(viewModel: connectionViewModel, isExpanded: $isExpanded) + + if connectionViewModel.showConnectionDetails { + DetailsContainer( + viewModel: connectionViewModel, + indicatorsViewModel: indicatorsViewModel, + isExpanded: $isExpanded + ) + } + + ButtonPanel(viewModel: connectionViewModel, action: action) + } + .padding(16) + } + .cornerRadius(12) + .padding(16) + } + .padding(.bottom, 8) // Adding some spacing so as not to overlap with the map legal link. + .accessibilityIdentifier(AccessibilityIdentifier.connectionView.asString) + .onChange(of: isExpanded) { _ in + onContentUpdate?() + } + .onReceive(connectionViewModel.combinedState) { _, _ in + onContentUpdate?() + + if !connectionViewModel.showConnectionDetails { +// withAnimation { + isExpanded = false +// } + } + } + } +} + +#Preview("ConnectionView (Indicators)") { + ConnectionViewPreview(configuration: .normal).make() +} + +#Preview("ConnectionView (No indicators)") { + ConnectionViewPreview(configuration: .normalNoIndicators).make() +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionViewViewModel.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ConnectionViewViewModel.swift similarity index 100% rename from ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionViewViewModel.swift rename to ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ConnectionViewViewModel.swift diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/DetailsContainer.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/DetailsContainer.swift new file mode 100644 index 000000000000..7da9aef5b8f6 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/DetailsContainer.swift @@ -0,0 +1,61 @@ +// +// ConnectionDetailsContainer.swift +// MullvadVPN +// +// Created by Andrew Bulhak on 2025-01-03. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +extension ConnectionView { + internal struct DetailsContainer: View { + @StateObject var viewModel: ConnectionViewViewModel + @StateObject var indicatorsViewModel: FeatureIndicatorsViewModel + @Binding var isExpanded: Bool + + @State private var scrollViewHeight: CGFloat = 0 + + var body: some View { +// if isExpanded { + Divider() + .background(UIColor.secondaryTextColor.color) + .opacity(isExpanded ? 1.0 : 0.0) +// } + + // This geometry reader is somewhat of a workaround. It's "smart" in that it takes up as much + // space as it can and thereby helps the view to understand the maximum allowed height when + // placed in a UIKit context. If ConnectionView would ever be placed as a subview of SwiftUI + // parent, this reader could probably be removed. + GeometryReader { _ in + ScrollView { + VStack(spacing: 16) { + if !indicatorsViewModel.chips.isEmpty { + FeatureIndicatorsView( + viewModel: indicatorsViewModel, + isExpanded: $isExpanded + ) + } + + if isExpanded { + DetailsView(viewModel: viewModel) + .transition(.move(edge: .bottom)) + } + } + .sizeOfView { scrollViewHeight = $0.height } + } + } + .frame(maxHeight: scrollViewHeight) + } + } +} + +#Preview { + ConnectionViewComponentPreview(showIndicators: true, isExpanded: true) { indicatorModel, viewModel, isExpanded in + ConnectionView.DetailsContainer( + viewModel: viewModel, + indicatorsViewModel: indicatorModel, + isExpanded: isExpanded + ) + } +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/DetailsView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/DetailsView.swift new file mode 100644 index 000000000000..25559757a1ce --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/DetailsView.swift @@ -0,0 +1,64 @@ +// +// ConnectionDetails.swift +// MullvadVPN +// +// Created by Andrew Bulhak on 2025-01-03. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +extension ConnectionView { + internal struct DetailsView: View { + @StateObject var viewModel: ConnectionViewViewModel + @State private var columnWidth: CGFloat = 0 + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(LocalizedStringKey("Connection details")) + .font(.footnote.weight(.semibold)) + .foregroundStyle(UIColor.primaryTextColor.color.opacity(0.6)) + Spacer() + } + + VStack(alignment: .leading, spacing: 0) { + if let inAddress = viewModel.inAddress { + connectionDetailRow(title: LocalizedStringKey("In"), value: inAddress) + .accessibilityIdentifier(AccessibilityIdentifier.connectionPanelInAddressRow.asString) + } + if viewModel.tunnelIsConnected { + if let outAddressIpv4 = viewModel.outAddressIpv4 { + connectionDetailRow(title: LocalizedStringKey("Out IPv4"), value: outAddressIpv4) + .accessibilityIdentifier(AccessibilityIdentifier.connectionPanelOutAddressRow.asString) + } + if let outAddressIpv6 = viewModel.outAddressIpv6 { + connectionDetailRow(title: LocalizedStringKey("Out IPv6"), value: outAddressIpv6) + .accessibilityIdentifier(AccessibilityIdentifier.connectionPanelOutAddressRow.asString) + } + } + } + } + } + + @ViewBuilder + private func connectionDetailRow(title: LocalizedStringKey, value: String) -> some View { + HStack(alignment: .top, spacing: 8) { + Text(title) + .font(.subheadline) + .foregroundStyle(UIColor.primaryTextColor.color.opacity(0.6)) + .frame(minWidth: columnWidth, alignment: .leading) + .sizeOfView { columnWidth = max(columnWidth, $0.width) } + Text(value) + .font(.subheadline) + .foregroundStyle(UIColor.primaryTextColor.color) + } + } + } +} + +#Preview { + ConnectionViewComponentPreview(showIndicators: true, isExpanded: true) { _, vm, _ in + ConnectionView.DetailsView(viewModel: vm) + } +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/HeaderView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/HeaderView.swift new file mode 100644 index 000000000000..5ec0eb9316cb --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/HeaderView.swift @@ -0,0 +1,67 @@ +// +// HeaderView.swift +// MullvadVPN +// +// Created by Andrew Bulhak on 2025-01-03. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +extension ConnectionView { + internal struct HeaderView: View { + @StateObject var viewModel: ConnectionViewViewModel + @Binding var isExpanded: Bool + + var body: some View { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 0) { + Text(viewModel.localizedTitleForSecureLabel) + .textCase(.uppercase) + .font(.title3.weight(.semibold)) + .foregroundStyle(viewModel.textColorForSecureLabel.color) + .accessibilityIdentifier(viewModel.accessibilityIdForSecureLabel.asString) + + if let countryAndCity = viewModel.titleForCountryAndCity { + Text(countryAndCity) + .font(.title3.weight(.semibold)) + .foregroundStyle(UIColor.primaryTextColor.color) + .padding(.top, 4) + } + + if let server = viewModel.titleForServer { + Text(server) + .font(.body) + .foregroundStyle(UIColor.primaryTextColor.color.opacity(0.6)) + .padding(.top, 2) + } + } + .accessibilityLabel(viewModel.localizedAccessibilityLabelForSecureLabel) + + if viewModel.showConnectionDetails { + Spacer() + Image(.iconChevron) + .renderingMode(.template) + .rotationEffect(isExpanded ? .degrees(-90) : .degrees(90)) + .foregroundStyle(.white) + .transaction { transaction in + transaction.animation = nil + } + } + } + .accessibilityIdentifier(AccessibilityIdentifier.relayStatusCollapseButton.asString) + .contentShape(Rectangle()) + .onTapGesture { +// withAnimation { + isExpanded.toggle() +// } + } + } + } +} + +#Preview { + ConnectionViewComponentPreview(showIndicators: true, isExpanded: true) { _, vm, isExpanded in + ConnectionView.HeaderView(viewModel: vm, isExpanded: isExpanded) + } +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/Preview/ConnectionViewComponentPreview.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/Preview/ConnectionViewComponentPreview.swift new file mode 100644 index 000000000000..89b3fb0aaf17 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/Preview/ConnectionViewComponentPreview.swift @@ -0,0 +1,69 @@ +// +// ConnectionViewComponentPreview.swift +// MullvadVPN +// +// Created by Andrew Bulhak on 2025-01-03. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +import MullvadMockData +import MullvadSettings +import MullvadTypes +import PacketTunnelCore +import SwiftUI + +struct ConnectionViewComponentPreview: View { + let showIndicators: Bool + + private var tunnelSettings: LatestTunnelSettings { + LatestTunnelSettings( + wireGuardObfuscation: WireGuardObfuscationSettings(state: showIndicators ? .udpOverTcp : .off), + tunnelQuantumResistance: showIndicators ? .on : .off, + tunnelMultihopState: showIndicators ? .on : .off, + daita: DAITASettings(daitaState: showIndicators ? .on : .off) + ) + } + + private let viewModel = ConnectionViewViewModel( + tunnelStatus: TunnelStatus( + observedState: .connected(ObservedConnectionState( + selectedRelays: SelectedRelaysStub.selectedRelays, + relayConstraints: RelayConstraints(entryLocations: .any, exitLocations: .any, port: .any, filter: .any), + networkReachability: .reachable, + connectionAttemptCount: 0, + transportLayer: .udp, + remotePort: 80, + isPostQuantum: true, + isDaitaEnabled: true + )), + state: .connected(SelectedRelaysStub.selectedRelays, isPostQuantum: true, isDaita: true) + ) + ) + + var content: (FeatureIndicatorsViewModel, ConnectionViewViewModel, Binding) -> Content + + @State var isExpanded: Bool + + init( + showIndicators: Bool, + isExpanded: Bool, + content: @escaping (FeatureIndicatorsViewModel, ConnectionViewViewModel, Binding) -> Content + ) { + self.showIndicators = showIndicators + self._isExpanded = State(wrappedValue: isExpanded) + self.content = content + } + + var body: some View { + VStack { + content( + FeatureIndicatorsViewModel( + tunnelSettings: tunnelSettings, + ipOverrides: [] + ), + viewModel, + $isExpanded + ) + }.background(UIColor.secondaryColor.color) + } +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionViewPreview.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/Preview/ConnectionViewPreview.swift similarity index 87% rename from ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionViewPreview.swift rename to ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/Preview/ConnectionViewPreview.swift index 0575d4658fde..4f2fd49a924a 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionViewPreview.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/Preview/ConnectionViewPreview.swift @@ -14,7 +14,7 @@ import SwiftUI struct ConnectionViewPreview { enum Configuration { - case normal, normalNoIndicators, expanded, expandedNoIndicators + case normal, normalNoIndicators } private let configuration: Configuration @@ -54,10 +54,6 @@ struct ConnectionViewPreview { connectionView(with: populatedTunnelSettings, viewModel: viewModel) case .normalNoIndicators: connectionView(with: LatestTunnelSettings(), viewModel: viewModel) - case .expanded: - connectionView(with: populatedTunnelSettings, viewModel: viewModel, isExpanded: true) - case .expandedNoIndicators: - connectionView(with: LatestTunnelSettings(), viewModel: viewModel, isExpanded: true) } } .background(UIColor.secondaryColor.color)