diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 492410542419..fe09f04dafcd 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -464,6 +464,9 @@ 7A0C0F632A979C4A0058EFCE /* Coordinator+Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A0C0F622A979C4A0058EFCE /* Coordinator+Router.swift */; }; 7A0EAE9A2D01B41500D3EB8B /* MainButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A0EAE992D01B41500D3EB8B /* MainButtonStyle.swift */; }; 7A0EAE9E2D01BCBF00D3EB8B /* View+Size.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A0EAE9D2D01BCBF00D3EB8B /* View+Size.swift */; }; + 7A0EAEA02D0333CE00D3EB8B /* Color+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A0EAE9F2D0333CB00D3EB8B /* Color+Helpers.swift */; }; + 7A0EAEA22D033D5D00D3EB8B /* BlurView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A0EAEA12D033D5A00D3EB8B /* BlurView.swift */; }; + 7A0EAEA42D06DF8C00D3EB8B /* ConnectionViewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A0EAEA32D06DF8200D3EB8B /* ConnectionViewViewModel.swift */; }; 7A11DD0B2A9495D400098CD8 /* AppRoutes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5802EBC42A8E44AC00E5CE4C /* AppRoutes.swift */; }; 7A12D0762B062D5C00E9602D /* URLSessionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A12D0752B062D5C00E9602D /* URLSessionProtocol.swift */; }; 7A12D0772B062D6500E9602D /* URLSessionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A12D0752B062D5C00E9602D /* URLSessionProtocol.swift */; }; @@ -658,6 +661,8 @@ 7AF9BE972A41C71F00DBFEDB /* ChipViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE962A41C71F00DBFEDB /* ChipViewCell.swift */; }; 7AFBE38B2D09AAFF002335FC /* SinglehopPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AFBE38A2D09AAFF002335FC /* SinglehopPicker.swift */; }; 7AFBE38D2D09AB2E002335FC /* MultihopPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AFBE38C2D09AB2E002335FC /* MultihopPicker.swift */; }; + 7AFBE3872D084C9D002335FC /* ActivityIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AFBE3862D084C96002335FC /* ActivityIndicator.swift */; }; + 7AFBE3892D089163002335FC /* FI_TunnelViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AFBE3882D08915D002335FC /* FI_TunnelViewController.swift */; }; 850201DB2B503D7700EF8C96 /* RelayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 850201DA2B503D7700EF8C96 /* RelayTests.swift */; }; 850201DD2B503D8C00EF8C96 /* SelectLocationPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 850201DC2B503D8C00EF8C96 /* SelectLocationPage.swift */; }; 850201DF2B5040A500EF8C96 /* TunnelControlPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 850201DE2B5040A500EF8C96 /* TunnelControlPage.swift */; }; @@ -1843,6 +1848,9 @@ 7A0C0F622A979C4A0058EFCE /* Coordinator+Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Coordinator+Router.swift"; sourceTree = ""; }; 7A0EAE992D01B41500D3EB8B /* MainButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainButtonStyle.swift; sourceTree = ""; }; 7A0EAE9D2D01BCBF00D3EB8B /* View+Size.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Size.swift"; sourceTree = ""; }; + 7A0EAE9F2D0333CB00D3EB8B /* Color+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Helpers.swift"; sourceTree = ""; }; + 7A0EAEA12D033D5A00D3EB8B /* BlurView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurView.swift; sourceTree = ""; }; + 7A0EAEA32D06DF8200D3EB8B /* ConnectionViewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionViewViewModel.swift; sourceTree = ""; }; 7A12D0752B062D5C00E9602D /* URLSessionProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLSessionProtocol.swift; sourceTree = ""; }; 7A1A26422A2612AE00B978AA /* PaymentAlertPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentAlertPresenter.swift; sourceTree = ""; }; 7A1A26442A29CEF700B978AA /* RelayFilterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterViewController.swift; sourceTree = ""; }; @@ -2014,6 +2022,8 @@ 7AF9BE962A41C71F00DBFEDB /* ChipViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipViewCell.swift; sourceTree = ""; }; 7AFBE38A2D09AAFF002335FC /* SinglehopPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SinglehopPicker.swift; sourceTree = ""; }; 7AFBE38C2D09AB2E002335FC /* MultihopPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultihopPicker.swift; sourceTree = ""; }; + 7AFBE3862D084C96002335FC /* ActivityIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicator.swift; sourceTree = ""; }; + 7AFBE3882D08915D002335FC /* FI_TunnelViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FI_TunnelViewController.swift; sourceTree = ""; }; 85006A8E2B73EF67004AD8FB /* MullvadVPNUITestsSmoke.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MullvadVPNUITestsSmoke.xctestplan; sourceTree = ""; }; 850201DA2B503D7700EF8C96 /* RelayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayTests.swift; sourceTree = ""; }; 850201DC2B503D8C00EF8C96 /* SelectLocationPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationPage.swift; sourceTree = ""; }; @@ -3015,6 +3025,7 @@ isa = PBXGroup; children = ( 7A5869962B32EA4500640D27 /* AppButton.swift */, + 7A0EAEA12D033D5A00D3EB8B /* BlurView.swift */, 7A9FA1412A2E3306000B728D /* CheckboxView.swift */, 5868585424054096000B8131 /* CustomButton.swift */, 58ACF64C26567A4F00ACE4B7 /* CustomSwitch.swift */, @@ -3085,6 +3096,7 @@ 587EB669270EFACB00123C75 /* CharacterSet+IPAddress.swift */, 58E511E528DDDEAC00B0BCDE /* CodingErrors+CustomErrorDescription.swift */, 7AF9BE8F2A39F26000DBFEDB /* Collection+Sorting.swift */, + 7A0EAE9F2D0333CB00D3EB8B /* Color+Helpers.swift */, 7A0C0F622A979C4A0058EFCE /* Coordinator+Router.swift */, 5811DE4F239014550011EB53 /* NEVPNStatus+Debug.swift */, 58DFF7CF2B02560400F864E0 /* NSAttributedString+Extensions.swift */, @@ -3612,6 +3624,7 @@ A9D9A4C12C36D53C004088DD /* MullvadRustRuntimeTests */, 58CE5E61224146200008646E /* Products */, 584F991F2902CBDD001F858D /* Frameworks */, + 7A0EAE982D01B29E00D3EB8B /* Recovered References */, ); sourceTree = ""; }; @@ -3912,6 +3925,14 @@ path = Edit; sourceTree = ""; }; + 7A0EAE982D01B29E00D3EB8B /* Recovered References */ = { + isa = PBXGroup; + children = ( + 7AA1309C2D0072F900640DF9 /* View+Size.swift */, + ); + name = "Recovered References"; + sourceTree = ""; + }; 7A2960F72A964A3500389B82 /* Alert */ = { isa = PBXGroup; children = ( @@ -4046,7 +4067,10 @@ 7AA130972CFF364F00640DF9 /* FeatureIndicators */ = { isa = PBXGroup; children = ( + 7AFBE3862D084C96002335FC /* ActivityIndicator.swift */, 7AA130982CFF365A00640DF9 /* ConnectionView.swift */, + 7A0EAEA32D06DF8200D3EB8B /* ConnectionViewViewModel.swift */, + 7AFBE3882D08915D002335FC /* FI_TunnelViewController.swift */, ); path = FeatureIndicators; sourceTree = ""; @@ -5817,6 +5841,7 @@ F0ADC3742CD3C47400A1AD97 /* ChipFlowLayout.swift in Sources */, 587B75412668FD7800DEF7E9 /* AccountExpirySystemNotificationProvider.swift in Sources */, 587988C728A2A01F00E3DF54 /* AccountDataThrottling.swift in Sources */, + 7AFBE3872D084C9D002335FC /* ActivityIndicator.swift in Sources */, F04FBE612A8379EE009278D7 /* AppPreferences.swift in Sources */, 58DFF7D82B02774C00F864E0 /* ListItemPickerViewController.swift in Sources */, 5896CEF226972DEB00B0FAE8 /* AccountContentView.swift in Sources */, @@ -5838,6 +5863,7 @@ 7A8A19142CEF2548000BCB5B /* DAITATunnelSettingsViewModel.swift in Sources */, 7A8A18F92CE34EA8000BCB5B /* SettingsMultihopView.swift in Sources */, 44BB5F972BE527F4002520EB /* TunnelState+UI.swift in Sources */, + 7AFBE3892D089163002335FC /* FI_TunnelViewController.swift in Sources */, 7A11DD0B2A9495D400098CD8 /* AppRoutes.swift in Sources */, 5827B0902B0CAA0500CCBBA1 /* EditAccessMethodCoordinator.swift in Sources */, 5846227126E229F20035F7C2 /* StoreSubscription.swift in Sources */, @@ -5872,6 +5898,7 @@ 7AA1309B2D0048D800640DF9 /* MainButton.swift in Sources */, 58A1AA8C23F5584C009F7EA6 /* ConnectionPanelView.swift in Sources */, 5878A27B2909649A0096FC88 /* CustomOverlayRenderer.swift in Sources */, + 7A0EAEA02D0333CE00D3EB8B /* Color+Helpers.swift in Sources */, 7A8A19052CE4E9A9000BCB5B /* SwitchRowView.swift in Sources */, A91614D62B10B26B00F416EB /* TunnelControlViewModel.swift in Sources */, 7A5869972B32EA4500640D27 /* AppButton.swift in Sources */, @@ -6061,6 +6088,8 @@ 7A6F2FA92AFD0842006D0856 /* CustomDNSDataSource.swift in Sources */, 58EF580B25D69D7A00AEBA94 /* ProblemReportSubmissionOverlayView.swift in Sources */, 5892A45E265FABFF00890742 /* EmptyTableViewHeaderFooterView.swift in Sources */, + 7A0EAEA42D06DF8C00D3EB8B /* ConnectionViewViewModel.swift in Sources */, + 7A0EAEA22D033D5D00D3EB8B /* BlurView.swift in Sources */, 580909D32876D09A0078138D /* RevokedDeviceViewController.swift in Sources */, 58FF9FF02B07C4D300E4C97D /* PersistentAccessMethod+ViewModel.swift in Sources */, 7A8A19162CEF269E000BCB5B /* MultihopTunnelSettingsViewModel.swift in Sources */, diff --git a/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift b/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift index 5fa71da17c72..1e3e48e64c73 100644 --- a/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift @@ -11,7 +11,12 @@ import UIKit class TunnelCoordinator: Coordinator, Presenting { private let tunnelManager: TunnelManager + + #if DEBUG + private let controller: FI_TunnelViewController + #else private let controller: TunnelViewController + #endif private var tunnelObserver: TunnelObserver? @@ -35,7 +40,12 @@ class TunnelCoordinator: Coordinator, Presenting { tunnelManager: tunnelManager, outgoingConnectionService: outgoingConnectionService ) + + #if DEBUG + controller = FI_TunnelViewController(interactor: interactor) + #else controller = TunnelViewController(interactor: interactor) + #endif super.init() diff --git a/ios/MullvadVPN/Extensions/Color+Helpers.swift b/ios/MullvadVPN/Extensions/Color+Helpers.swift new file mode 100644 index 000000000000..89c886fc249b --- /dev/null +++ b/ios/MullvadVPN/Extensions/Color+Helpers.swift @@ -0,0 +1,16 @@ +// +// Color.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-12-06. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +extension Color { + /// Returns the color darker by the given percent (in range from 0..1) + func darkened(by percent: CGFloat) -> Color? { + UIColor(self).darkened(by: percent)?.color + } +} diff --git a/ios/MullvadVPN/TunnelManager/TunnelState+UI.swift b/ios/MullvadVPN/TunnelManager/TunnelState+UI.swift index e87afd87b37d..f02bf005e121 100644 --- a/ios/MullvadVPN/TunnelManager/TunnelState+UI.swift +++ b/ios/MullvadVPN/TunnelManager/TunnelState+UI.swift @@ -9,14 +9,18 @@ import UIKit extension TunnelState { + enum TunnelControlActionButton { + case connect + case disconnect + case cancel + } + var textColorForSecureLabel: UIColor { switch self { case .connecting, .reconnecting, .waitingForConnectivity(.noConnection), .negotiatingEphemeralPeer: .white - case .connected: .successColor - case .disconnecting, .disconnected, .pendingReconnect, .waitingForConnectivity(.noNetwork), .error: .dangerColor } @@ -65,6 +69,7 @@ extension TunnelState { comment: "" ) } + case let .connected(_, isPostQuantum, _): if isPostQuantum { NSLocalizedString( @@ -77,7 +82,7 @@ extension TunnelState { NSLocalizedString( "TUNNEL_STATE_CONNECTED", tableName: "Main", - value: "Secure connection", + value: "Connected", comment: "" ) } @@ -89,6 +94,7 @@ extension TunnelState { value: "Disconnecting", comment: "" ) + case .disconnecting(.reconnect), .pendingReconnect: NSLocalizedString( "TUNNEL_STATE_PENDING_RECONNECT", @@ -123,7 +129,7 @@ extension TunnelState { } } - var localizedTitleForSelectLocationButton: String? { + var localizedTitleForSelectLocationButton: String { switch self { case .disconnecting(.reconnect), .pendingReconnect: NSLocalizedString( @@ -159,24 +165,6 @@ extension TunnelState { } } - func secureConnectionLabel(isPostQuantum: Bool) -> String { - if isPostQuantum { - NSLocalizedString( - "TUNNEL_STATE_PQ_CONNECTING_ACCESSIBILITY_LABEL", - tableName: "Main", - value: "Creating quantum secure connection", - comment: "" - ) - } else { - NSLocalizedString( - "TUNNEL_STATE_CONNECTING_ACCESSIBILITY_LABEL", - tableName: "Main", - value: "Creating secure connection", - comment: "" - ) - } - } - var localizedAccessibilityLabel: String { switch self { case let .connecting(_, isPostQuantum, _): @@ -263,4 +251,69 @@ extension TunnelState { ) } } + + var actionButton: TunnelControlActionButton { + switch self { + case .disconnected, .disconnecting(.nothing), .waitingForConnectivity(.noNetwork): + .connect + case .connecting, .pendingReconnect, .disconnecting(.reconnect), .waitingForConnectivity(.noConnection): + .cancel + case .negotiatingEphemeralPeer: + .cancel + case .connected, .reconnecting, .error: + .disconnect + } + } + + var titleForCountryAndCity: String? { + guard isSecured, let tunnelRelays = relays else { + return nil + } + + return "\(tunnelRelays.exit.location.country), \(tunnelRelays.exit.location.city)" + } + + func titleForServer(daitaEnabled: Bool) -> String? { + guard isSecured, let tunnelRelays = relays else { + return nil + } + + let exitName = tunnelRelays.exit.hostname + let entryName = tunnelRelays.entry?.hostname + let usingDaita = daitaEnabled == true + + return if let entryName { + String(format: NSLocalizedString( + "CONNECT_PANEL_TITLE", + tableName: "Main", + value: "%@ via %@\(usingDaita ? " using DAITA" : "")", + comment: "" + ), exitName, entryName) + } else { + String(format: NSLocalizedString( + "CONNECT_PANEL_TITLE", + tableName: "Main", + value: "%@\(usingDaita ? " using DAITA" : "")", + comment: "" + ), exitName) + } + } + + func secureConnectionLabel(isPostQuantum: Bool) -> String { + if isPostQuantum { + NSLocalizedString( + "TUNNEL_STATE_PQ_CONNECTING_ACCESSIBILITY_LABEL", + tableName: "Main", + value: "Creating quantum secure connection", + comment: "" + ) + } else { + NSLocalizedString( + "TUNNEL_STATE_CONNECTING_ACCESSIBILITY_LABEL", + tableName: "Main", + value: "Creating secure connection", + comment: "" + ) + } + } } diff --git a/ios/MullvadVPN/TunnelManager/TunnelState.swift b/ios/MullvadVPN/TunnelManager/TunnelState.swift index ea55431d5a3e..b235f8255c86 100644 --- a/ios/MullvadVPN/TunnelManager/TunnelState.swift +++ b/ios/MullvadVPN/TunnelManager/TunnelState.swift @@ -84,8 +84,8 @@ enum TunnelState: Equatable, CustomStringConvertible { case let .connecting(tunnelRelays, isPostQuantum, isDaita): if let tunnelRelays { """ - connecting \(isPostQuantum ? "(PQ) " : "")\ - daita: \(isDaita) \ + connecting \(isPostQuantum ? "(PQ) " : ""), \ + daita: \(isDaita), \ to \(tunnelRelays.exit.hostname)\ \(tunnelRelays.entry.flatMap { " via \($0.hostname)" } ?? "") """ @@ -94,8 +94,8 @@ enum TunnelState: Equatable, CustomStringConvertible { } case let .connected(tunnelRelays, isPostQuantum, isDaita): """ - connected \(isPostQuantum ? "(PQ) " : "")\ - daita: \(isDaita) \ + connected \(isPostQuantum ? "(PQ) " : ""), \ + daita: \(isDaita), \ to \(tunnelRelays.exit.hostname)\ \(tunnelRelays.entry.flatMap { " via \($0.hostname)" } ?? "") """ @@ -105,8 +105,8 @@ enum TunnelState: Equatable, CustomStringConvertible { "disconnected" case let .reconnecting(tunnelRelays, isPostQuantum, isDaita): """ - reconnecting \(isPostQuantum ? "(PQ) " : "")\ - daita: \(isDaita) \ + reconnecting \(isPostQuantum ? "(PQ) " : ""), \ + daita: \(isDaita), \ to \(tunnelRelays.exit.hostname)\ \(tunnelRelays.entry.flatMap { " via \($0.hostname)" } ?? "") """ @@ -117,8 +117,8 @@ enum TunnelState: Equatable, CustomStringConvertible { case let .negotiatingEphemeralPeer(tunnelRelays, _, isPostQuantum, isDaita): """ negotiating key with exit relay: \(tunnelRelays.exit.hostname)\ - \(tunnelRelays.entry.flatMap { " via \($0.hostname)" } ?? "")\ - , isPostQuantum: \(isPostQuantum), isDaita: \(isDaita) + \(tunnelRelays.entry.flatMap { " via \($0.hostname)" } ?? ""), \ + isPostQuantum: \(isPostQuantum), isDaita: \(isDaita) """ } } diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ActivityIndicator.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ActivityIndicator.swift new file mode 100644 index 000000000000..9b42bab8e63e --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ActivityIndicator.swift @@ -0,0 +1,48 @@ +// +// ActivityIndicator.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-12-10. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +struct CustomProgressView: View { + var style: Style + @State private var angle: Double = 0 + + var body: some View { + Image(.iconSpinner) + .resizable() + .frame(width: style.size.width, height: style.size.height) + .rotationEffect(.degrees(angle)) + .onAppear { + withAnimation(Animation.linear(duration: 0.6).repeatForever(autoreverses: false)) { + angle = 360 + } + } + } +} + +#Preview { + CustomProgressView(style: .large) + .background(UIColor.secondaryColor.color) +} + +extension CustomProgressView { + enum Style { + case small, medium, large + + var size: CGSize { + switch self { + case .small: + CGSize(width: 16, height: 16) + case .medium: + CGSize(width: 20, height: 20) + case .large: + CGSize(width: 60, height: 60) + } + } + } +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView.swift index 3f3d4e473bde..03980fb361fd 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView.swift @@ -8,80 +8,133 @@ import SwiftUI -// TODO: Replace all hardcoded values with real values dependent on tunnel state. To be addressed in upcoming PR. +typealias ButtonAction = (ConnectionViewViewModel.TunnelControlAction) -> Void struct ConnectionView: View { + @StateObject var viewModel: ConnectionViewViewModel + + var action: ButtonAction? + var onContentUpdate: (() -> Void)? + var body: some View { - ZStack { - BlurView() + VStack(spacing: 22) { + if viewModel.showsActivityIndicator { + CustomProgressView(style: .large) + } + + ZStack { + BlurView(style: .dark) - VStack(alignment: .leading, spacing: 16) { - ConnectionPanel() - ButtonPanel() + VStack(alignment: .leading, spacing: 16) { + ConnectionPanel(viewModel: viewModel) + ButtonPanel(viewModel: viewModel, action: action) + } + .padding(16) } + .cornerRadius(12) .padding(16) } - .cornerRadius(12) - .padding(16) - // Importing UIView in SwitftUI (see BlurView) has sizing limitations, so we need to help the view - // understand its width constraints. - .frame(maxWidth: UIScreen.main.bounds.width) + .onReceive(viewModel.$tunnelState, perform: { _ in + onContentUpdate?() + }) + .onReceive(viewModel.$showsActivityIndicator, perform: { _ in + onContentUpdate?() + }) } } #Preview { - ConnectionView() - .background(UIColor.secondaryColor.color) -} - -private struct BlurView: View { - var body: some View { - Spacer() - .overlay { - VisualEffectView(effect: UIBlurEffect(style: .dark)) - .opacity(0.8) - } + ConnectionView(viewModel: ConnectionViewViewModel(tunnelState: .disconnected)) { action in + print(action) } + .background(UIColor.secondaryColor.color) } private struct ConnectionPanel: View { + @StateObject var viewModel: ConnectionViewViewModel + var body: some View { VStack(alignment: .leading) { - Text("Connected") + Text(viewModel.localizedTitleForSecureLabel) .textCase(.uppercase) .font(.title3.weight(.semibold)) - .foregroundStyle(UIColor.successColor.color) + .foregroundStyle(viewModel.textColorForSecureLabel.color) .padding(.bottom, 4) - Text("Country, City") - .font(.title3.weight(.semibold)) - .foregroundStyle(UIColor.primaryTextColor.color) - Text("Server") - .font(.body) - .foregroundStyle(UIColor.primaryTextColor.color.opacity(0.6)) + + if let countryAndCity = viewModel.titleForCountryAndCity, let server = viewModel.titleForServer { + Text(countryAndCity) + .font(.title3.weight(.semibold)) + .foregroundStyle(UIColor.primaryTextColor.color) + Text(server) + .font(.body) + .foregroundStyle(UIColor.primaryTextColor.color.opacity(0.6)) + } } + .accessibilityLabel(viewModel.localizedAccessibilityLabel) } } private struct ButtonPanel: View { + @StateObject var viewModel: ConnectionViewViewModel + var action: ButtonAction? + var body: some View { VStack(spacing: 16) { + locationButton(with: action) + actionButton(with: action) + } + } + + @ViewBuilder + private func locationButton(with action: ButtonAction?) -> some View { + switch viewModel.tunnelState { + case .connecting, .connected, .reconnecting, .waitingForConnectivity, .negotiatingEphemeralPeer, .error: SplitMainButton( - text: "Switch location", + text: viewModel.localizedTitleForSelectLocationButton, image: .iconReload, style: .default, - primaryAction: { - print("Switch location tapped") - }, secondaryAction: { - print("Reload tapped") - } + disabled: viewModel.disableButtons, + primaryAction: { action?(.selectLocation) }, + secondaryAction: { action?(.reconnect) } ) + case .disconnecting, .pendingReconnect, .disconnected: + MainButton( + text: viewModel.localizedTitleForSelectLocationButton, + style: .default, + disabled: viewModel.disableButtons, + action: { action?(.selectLocation) } + ) + } + } + @ViewBuilder + private func actionButton(with action: ButtonAction?) -> some View { + switch viewModel.actionButton { + case .connect: MainButton( - text: "Cancel", - style: .danger - ) { - print("Cancel tapped") - } + text: LocalizedStringKey("Connect"), + style: .success, + disabled: viewModel.disableButtons, + action: { action?(.connect) } + ) + case .disconnect: + MainButton( + text: LocalizedStringKey("Disconnect"), + style: .danger, + disabled: viewModel.disableButtons, + action: { action?(.disconnect) } + ) + case .cancel: + MainButton( + text: LocalizedStringKey( + viewModel.tunnelState == .waitingForConnectivity(.noConnection) + ? "Disconnect" + : "Cancel" + ), + style: .danger, + disabled: viewModel.disableButtons, + action: { action?(.cancel) } + ) } } } diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionViewViewModel.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionViewViewModel.swift new file mode 100644 index 000000000000..29a4748b4100 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionViewViewModel.swift @@ -0,0 +1,135 @@ +// +// ConnectionViewViewModel.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-12-09. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +class ConnectionViewViewModel: ObservableObject { + enum TunnelControlActionButton { + case connect + case disconnect + case cancel + } + + enum TunnelControlAction { + case connect + case disconnect + case cancel + case reconnect + case selectLocation + } + + @Published var tunnelState: TunnelState + @Published var showsActivityIndicator = false + + init(tunnelState: TunnelState) { + self.tunnelState = tunnelState + } +} + +extension ConnectionViewViewModel { + var textColorForSecureLabel: UIColor { + switch tunnelState { + case .connecting, .reconnecting, .waitingForConnectivity(.noConnection), .negotiatingEphemeralPeer: + .white + case .connected: + .successColor + case .disconnecting, .disconnected, .pendingReconnect, .waitingForConnectivity(.noNetwork), .error: + .dangerColor + } + } + + var disableButtons: Bool { + if case .waitingForConnectivity(.noNetwork) = tunnelState { + return true + } + + return false + } + + var localizedTitleForSecureLabel: LocalizedStringKey { + switch tunnelState { + case .connecting, .reconnecting, .negotiatingEphemeralPeer: + LocalizedStringKey("Connecting") + case .connected: + LocalizedStringKey("Connected") + case .disconnecting(.nothing): + LocalizedStringKey("Disconnecting") + case .disconnecting(.reconnect), .pendingReconnect: + LocalizedStringKey("Reconnecting") + case .disconnected: + LocalizedStringKey("Disconnected") + case .waitingForConnectivity(.noConnection), .error: + LocalizedStringKey("Blocked connection") + case .waitingForConnectivity(.noNetwork): + LocalizedStringKey("No network") + } + } + + var localizedTitleForSelectLocationButton: LocalizedStringKey { + switch tunnelState { + case .disconnecting, .pendingReconnect, .disconnected: + LocalizedStringKey("Select location") + case .connecting, .connected, .reconnecting, .waitingForConnectivity, .negotiatingEphemeralPeer, .error: + LocalizedStringKey("Switch location") + } + } + + var localizedAccessibilityLabel: LocalizedStringKey { + switch tunnelState { + case .disconnected, .waitingForConnectivity, .disconnecting, .pendingReconnect, .error: + localizedTitleForSecureLabel + case let .connected(tunnelInfo, _, _): + LocalizedStringKey("Connected to \(tunnelInfo.exit.location.city), \(tunnelInfo.exit.location.country)") + case let .connecting(tunnelInfo, _, _): + if let tunnelInfo { + LocalizedStringKey( + "Connecting to \(tunnelInfo.exit.location.city), \(tunnelInfo.exit.location.country)" + ) + } else { + localizedTitleForSecureLabel + } + case let .reconnecting(tunnelInfo, _, _), let .negotiatingEphemeralPeer(tunnelInfo, _, _, _): + LocalizedStringKey("Reconnecting to \(tunnelInfo.exit.location.city), \(tunnelInfo.exit.location.country)") + } + } + + var actionButton: TunnelControlActionButton { + switch tunnelState { + case .disconnected, .disconnecting(.nothing), .waitingForConnectivity(.noNetwork): + .connect + case .connecting, .pendingReconnect, .disconnecting(.reconnect), .waitingForConnectivity(.noConnection), + .negotiatingEphemeralPeer: + .cancel + case .connected, .reconnecting, .error: + .disconnect + } + } + + var titleForCountryAndCity: LocalizedStringKey? { + guard tunnelState.isSecured, let tunnelRelays = tunnelState.relays else { + return nil + } + + return LocalizedStringKey("\(tunnelRelays.exit.location.country), \(tunnelRelays.exit.location.city)") + } + + var titleForServer: LocalizedStringKey? { + guard tunnelState.isSecured, let tunnelRelays = tunnelState.relays else { + return nil + } + + let exitName = tunnelRelays.exit.hostname + let entryName = tunnelRelays.entry?.hostname + + return if let entryName { + LocalizedStringKey("\(exitName) via \(entryName)") + } else { + LocalizedStringKey("\(exitName)") + } + } +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FI_TunnelViewController.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FI_TunnelViewController.swift new file mode 100644 index 000000000000..b70c3a9ffa37 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FI_TunnelViewController.swift @@ -0,0 +1,198 @@ +// +// FI_TunnelViewController.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-12-10. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import MapKit +import MullvadLogging +import MullvadTypes +import SwiftUI + +// NOTE: This ViewController will replace TunnelViewController once feature indicators work is done. + +class FI_TunnelViewController: UIViewController, RootContainment { + private let logger = Logger(label: "TunnelViewController") + private let interactor: TunnelViewControllerInteractor + private var tunnelState: TunnelState = .disconnected + private var viewModel = ConnectionViewViewModel(tunnelState: .disconnected) + private var connectionView: ConnectionView + private var connectionController: UIHostingController? + + var shouldShowSelectLocationPicker: (() -> Void)? + var shouldShowCancelTunnelAlert: (() -> Void)? + + private let mapViewController = MapViewController() + + override var preferredStatusBarStyle: UIStatusBarStyle { + .lightContent + } + + var preferredHeaderBarPresentation: HeaderBarPresentation { + switch interactor.deviceState { + case .loggedIn, .revoked: + return HeaderBarPresentation( + style: tunnelState.isSecured ? .secured : .unsecured, + showsDivider: false + ) + case .loggedOut: + return HeaderBarPresentation(style: .default, showsDivider: true) + } + } + + var prefersHeaderBarHidden: Bool { + false + } + + init(interactor: TunnelViewControllerInteractor) { + self.interactor = interactor + connectionView = ConnectionView(viewModel: self.viewModel) + + super.init(nibName: nil, bundle: nil) + + // When content size is updated in SwiftUI we need to explicitly tell UIKit to + // update its view size. This is not necessary on iOS 16 where we can set + // hostingController.sizingOptions instead. + connectionView.onContentUpdate = { [weak self] in + self?.connectionController?.view.setNeedsUpdateConstraints() + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + interactor.didUpdateDeviceState = { [weak self] _, _ in + self?.setNeedsHeaderBarStyleAppearanceUpdate() + } + + interactor.didUpdateTunnelStatus = { [weak self] tunnelStatus in + self?.setTunnelState(tunnelStatus.state, animated: true) + self?.viewModel.tunnelState = tunnelStatus.state + self?.view.setNeedsLayout() + } + + connectionView.action = { [weak self] action in + switch action { + case .connect: + self?.interactor.startTunnel() + + case .cancel: + if case .waitingForConnectivity(.noConnection) = self?.interactor.tunnelStatus.state { + self?.shouldShowCancelTunnelAlert?() + } else { + self?.interactor.stopTunnel() + } + + case .disconnect: + self?.interactor.stopTunnel() + + case .reconnect: + self?.interactor.reconnectTunnel(selectNewRelay: true) + + case .selectLocation: + self?.shouldShowSelectLocationPicker?() + } + } + + addMapController() + addContentView() + + tunnelState = interactor.tunnelStatus.state + viewModel.tunnelState = tunnelState + + updateMap(animated: false) + } + + func setMainContentHidden(_ isHidden: Bool, animated: Bool) { + let actions = { + _ = self.connectionView.opacity(isHidden ? 0 : 1) + } + + if animated { + UIView.animate(withDuration: 0.25, animations: actions) + } else { + actions() + } + } + + // MARK: - Private + + private func setTunnelState(_ tunnelState: TunnelState, animated: Bool) { + self.tunnelState = tunnelState + setNeedsHeaderBarStyleAppearanceUpdate() + + guard isViewLoaded else { return } + + updateMap(animated: animated) + } + + private func updateMap(animated: Bool) { + switch tunnelState { + case let .connecting(tunnelRelays, _, _): + mapViewController.removeLocationMarker() + mapViewController.setCenter(tunnelRelays?.exit.location.geoCoordinate, animated: animated) + viewModel.showsActivityIndicator = true + + case let .reconnecting(tunnelRelays, _, _), let .negotiatingEphemeralPeer(tunnelRelays, _, _, _): + mapViewController.removeLocationMarker() + mapViewController.setCenter(tunnelRelays.exit.location.geoCoordinate, animated: animated) + viewModel.showsActivityIndicator = true + + case let .connected(tunnelRelays, _, _): + let center = tunnelRelays.exit.location.geoCoordinate + mapViewController.setCenter(center, animated: animated) { + self.viewModel.showsActivityIndicator = false + + // Connection can change during animation, so make sure we're still connected before adding marker. + if case .connected = self.tunnelState { + self.mapViewController.addLocationMarker(coordinate: center) + } + } + + case .pendingReconnect: + mapViewController.removeLocationMarker() + viewModel.showsActivityIndicator = true + + case .waitingForConnectivity, .error: + mapViewController.removeLocationMarker() + viewModel.showsActivityIndicator = false + + case .disconnected, .disconnecting: + mapViewController.removeLocationMarker() + mapViewController.setCenter(nil, animated: animated) + viewModel.showsActivityIndicator = false + } + } + + private func addMapController() { + let mapView = mapViewController.view! + + addChild(mapViewController) + mapViewController.didMove(toParent: self) + + view.addConstrainedSubviews([mapView]) { + mapView.pinEdgesToSuperview() + } + } + + private func addContentView() { + let connectionController = UIHostingController(rootView: connectionView) + self.connectionController = connectionController + + let connectionViewProxy = connectionController.view! + connectionViewProxy.backgroundColor = .clear + + addChild(connectionController) + connectionController.didMove(toParent: self) + + view.addConstrainedSubviews([connectionViewProxy]) { + connectionViewProxy.pinEdgesToSuperview(.all().excluding(.top)) + } + } +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/TunnelControlView.swift b/ios/MullvadVPN/View controllers/Tunnel/TunnelControlView.swift index 5c9d6970f537..88c933493b00 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/TunnelControlView.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/TunnelControlView.swift @@ -20,13 +20,6 @@ enum TunnelControlAction { case selectLocation } -private enum TunnelControlActionButton { - case connect - case disconnect - case cancel - case selectLocation -} - final class TunnelControlView: UIView { private let secureLabel = makeBoldTextLabel(ofSize: 20, numberOfLines: 0) private let cityLabel = makeBoldTextLabel(ofSize: 34) @@ -158,12 +151,11 @@ final class TunnelControlView: UIView { } private func updateActionButtons(tunnelState: TunnelState) { - let actionButtons = tunnelState.actionButtons(traitCollection: traitCollection) - let views = actionButtons.map { self.view(forActionButton: $0) } + let view = view(forActionButton: tunnelState.actionButton) updateButtonTitles(tunnelState: tunnelState) updateButtonEnabledStates(shouldEnableButtons: tunnelState.shouldEnableButtons) - setArrangedButtons(views) + setArrangedButtons([selectLocationButtonBlurView, view]) } private func updateSecureLabel(tunnelState: TunnelState) { @@ -351,7 +343,7 @@ final class TunnelControlView: UIView { } } - private func view(forActionButton actionButton: TunnelControlActionButton) -> UIView { + private func view(forActionButton actionButton: TunnelState.TunnelControlActionButton) -> UIView { switch actionButton { case .connect: return connectButton @@ -359,8 +351,6 @@ final class TunnelControlView: UIView { return splitDisconnectButton case .cancel: return cancelButtonBlurView - case .selectLocation: - return selectLocationButtonBlurView } } @@ -406,24 +396,3 @@ final class TunnelControlView: UIView { actionHandler?(.selectLocation) } } - -private extension TunnelState { - func actionButtons(traitCollection: UITraitCollection) -> [TunnelControlActionButton] { - switch self { - case .disconnected, .disconnecting(.nothing), .waitingForConnectivity(.noNetwork): - [.selectLocation, .connect] - - case .connecting, .pendingReconnect, .disconnecting(.reconnect), - .waitingForConnectivity(.noConnection): - [.selectLocation, .cancel] - - case .negotiatingEphemeralPeer: - [.selectLocation, .cancel] - - case .connected, .reconnecting, .error: - [.selectLocation, .disconnect] - } - } - - // swiftlint:disable:next file_length -} diff --git a/ios/MullvadVPN/Views/BlurView.swift b/ios/MullvadVPN/Views/BlurView.swift new file mode 100644 index 000000000000..eb976efb74a4 --- /dev/null +++ b/ios/MullvadVPN/Views/BlurView.swift @@ -0,0 +1,22 @@ +// +// BlurView.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-12-06. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +/// Blurred (background) view using a `UIBlurEffect`. +struct BlurView: View { + var style: UIBlurEffect.Style + + var body: some View { + Spacer() + .overlay { + VisualEffectView(effect: UIBlurEffect(style: style)) + .opacity(0.8) + } + } +} diff --git a/ios/MullvadVPN/Views/MainButton.swift b/ios/MullvadVPN/Views/MainButton.swift index a5d6f0f7186b..679b34a2cd9d 100644 --- a/ios/MullvadVPN/Views/MainButton.swift +++ b/ios/MullvadVPN/Views/MainButton.swift @@ -9,8 +9,9 @@ import SwiftUI struct MainButton: View { - var text: String + var text: LocalizedStringKey var style: MainButtonStyle.Style + var disabled = false var action: () -> Void @@ -22,13 +23,13 @@ struct MainButton: View { Spacer() } }) - .buttonStyle(MainButtonStyle(style)) + .buttonStyle(MainButtonStyle(style, disabled: disabled)) .cornerRadius(UIMetrics.MainButton.cornerRadius) } } #Preview { - MainButton(text: "Connect", style: .default) { + MainButton(text: "Connect", style: .success) { print("Tapped") } } diff --git a/ios/MullvadVPN/Views/MainButtonStyle.swift b/ios/MullvadVPN/Views/MainButtonStyle.swift index 06a32b560689..f638c87ac2b5 100644 --- a/ios/MullvadVPN/Views/MainButtonStyle.swift +++ b/ios/MullvadVPN/Views/MainButtonStyle.swift @@ -9,10 +9,12 @@ import SwiftUI struct MainButtonStyle: ButtonStyle { - @State var style: Style + var style: Style + @State var disabled: Bool - init(_ style: Style) { + init(_ style: Style, disabled: Bool = false) { self.style = style + self.disabled = disabled } func makeBody(configuration: Configuration) -> some View { @@ -22,9 +24,15 @@ struct MainButtonStyle: ButtonStyle { .foregroundColor( configuration.isPressed ? UIColor.secondaryTextColor.color - : UIColor.primaryTextColor.color + : disabled + ? UIColor.primaryTextColor.withAlphaComponent(0.2).color + : UIColor.primaryTextColor.color + ) + .background( + disabled + ? style.color.darkened(by: 0.6) + : style.color ) - .background(style.color) .font(.body.weight(.semibold)) } } diff --git a/ios/MullvadVPN/Views/SplitMainButton.swift b/ios/MullvadVPN/Views/SplitMainButton.swift index 59a5d1b2eafc..11336f424ba2 100644 --- a/ios/MullvadVPN/Views/SplitMainButton.swift +++ b/ios/MullvadVPN/Views/SplitMainButton.swift @@ -9,9 +9,10 @@ import SwiftUI struct SplitMainButton: View { - var text: String + var text: LocalizedStringKey var image: ImageResource var style: MainButtonStyle.Style + var disabled = false var primaryAction: () -> Void var secondaryAction: () -> Void @@ -37,14 +38,14 @@ struct SplitMainButton: View { .aspectRatio(1, contentMode: .fit) .sizeOfView { width = $0.width } } - .buttonStyle(MainButtonStyle(style)) + .buttonStyle(MainButtonStyle(style, disabled: disabled)) .cornerRadius(UIMetrics.MainButton.cornerRadius) } } #Preview { SplitMainButton( - text: "Connect", + text: "Select location", image: .iconReload, style: .default, primaryAction: { @@ -54,5 +55,4 @@ struct SplitMainButton: View { print("Tapped secondary") } ) - .frame(maxWidth: .infinity) } diff --git a/ios/MullvadVPN/Views/VisualEffectView.swift b/ios/MullvadVPN/Views/VisualEffectView.swift index 0cad1b06d710..f28b71506566 100644 --- a/ios/MullvadVPN/Views/VisualEffectView.swift +++ b/ios/MullvadVPN/Views/VisualEffectView.swift @@ -12,7 +12,9 @@ struct VisualEffectView: UIViewRepresentable { var effect: UIVisualEffect? func makeUIView(context: UIViewRepresentableContext) -> UIVisualEffectView { - UIVisualEffectView() + let view = UIVisualEffectView(effect: effect) + view.translatesAutoresizingMaskIntoConstraints = false + return view } func updateUIView(_ uiView: UIVisualEffectView, context: UIViewRepresentableContext) {