summaryrefslogtreecommitdiffhomepage
path: root/ios/MullvadVPN/View controllers/Tunnel
diff options
context:
space:
mode:
authorJon Petersson <jon.petersson@mullvad.net>2024-12-04 10:18:22 +0100
committerJon Petersson <jon.petersson@mullvad.net>2024-12-13 14:31:41 +0100
commitc61b7d4e5a3a891562416692ee8d5b94de1e8549 (patch)
treee85b0edcfb08238a5714f59ddaa1a4b03662359e /ios/MullvadVPN/View controllers/Tunnel
parent9573f3ed7a449f2aeb9f8efe0c6f4335ed0c326c (diff)
downloadmullvadvpn-c61b7d4e5a3a891562416692ee8d5b94de1e8549.tar.xz
mullvadvpn-c61b7d4e5a3a891562416692ee8d5b94de1e8549.zip
Add state to new connection view
Diffstat (limited to 'ios/MullvadVPN/View controllers/Tunnel')
-rw-r--r--ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ActivityIndicator.swift48
-rw-r--r--ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView.swift135
-rw-r--r--ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionViewViewModel.swift135
-rw-r--r--ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FI_TunnelViewController.swift198
-rw-r--r--ios/MullvadVPN/View controllers/Tunnel/TunnelControlView.swift37
5 files changed, 478 insertions, 75 deletions
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 0000000000..9b42bab8e6
--- /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 3f3d4e473b..03980fb361 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 0000000000..29a4748b41
--- /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 0000000000..b70c3a9ffa
--- /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<ConnectionView>?
+
+ 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 5c9d6970f5..88c933493b 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
-}