From 0438746ffd574e4860e3b729d56ea8a55f2d1f72 Mon Sep 17 00:00:00 2001 From: Jon Petersson Date: Wed, 24 Apr 2024 09:14:19 +0200 Subject: [PATCH] Update view model when switching between entry and exit location --- ios/MullvadVPN.xcodeproj/project.pbxproj | 4 + .../Coordinators/LocationCoordinator.swift | 18 ++- .../RelayFilter/RelayFilterView.swift | 2 +- .../SelectLocation/LocationCell.swift | 12 +- .../LocationCellViewModel.swift | 5 +- .../SelectLocation/LocationDataSource.swift | 84 +++++++++---- .../LocationViewController.swift | 14 ++- .../LocationViewControllerWrapper.swift | 111 ++++++++++++++---- .../SelectLocation/RelaySelection.swift | 60 ++++++++++ 9 files changed, 252 insertions(+), 58 deletions(-) create mode 100644 ios/MullvadVPN/View controllers/SelectLocation/RelaySelection.swift diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index d86ba9907b7d..30ca9d1aaa7c 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -494,6 +494,7 @@ 7A3353912AAA014400F0A71C /* SimulatorVPNConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3353902AAA014400F0A71C /* SimulatorVPNConnection.swift */; }; 7A3353932AAA089000F0A71C /* SimulatorTunnelInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3353922AAA089000F0A71C /* SimulatorTunnelInfo.swift */; }; 7A3353972AAA0F8600F0A71C /* OperationBlockObserverSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3353962AAA0F8600F0A71C /* OperationBlockObserverSupport.swift */; }; + 7A3EFAAB2BDFDAE800318736 /* RelaySelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3EFAAA2BDFDAE800318736 /* RelaySelection.swift */; }; 7A3FD1B52AD4465A0042BEA6 /* AppMessageHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3FD1B42AD4465A0042BEA6 /* AppMessageHandlerTests.swift */; }; 7A3FD1B72AD54ABD0042BEA6 /* AnyTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58BDEB982A98F4ED00F578F2 /* AnyTransport.swift */; }; 7A3FD1B82AD54AE60042BEA6 /* TimeServerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58BDEB9A2A98F58600F578F2 /* TimeServerProxy.swift */; }; @@ -1772,6 +1773,7 @@ 7A3353902AAA014400F0A71C /* SimulatorVPNConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulatorVPNConnection.swift; sourceTree = ""; }; 7A3353922AAA089000F0A71C /* SimulatorTunnelInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulatorTunnelInfo.swift; sourceTree = ""; }; 7A3353962AAA0F8600F0A71C /* OperationBlockObserverSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationBlockObserverSupport.swift; sourceTree = ""; }; + 7A3EFAAA2BDFDAE800318736 /* RelaySelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelaySelection.swift; sourceTree = ""; }; 7A3FD1B42AD4465A0042BEA6 /* AppMessageHandlerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppMessageHandlerTests.swift; sourceTree = ""; }; 7A42DEC82A05164100B209BE /* SettingsInputCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsInputCell.swift; sourceTree = ""; }; 7A516C2D2B6D357500BBD33D /* URL+Scoping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Scoping.swift"; sourceTree = ""; }; @@ -2647,6 +2649,7 @@ F0BE65362B9F136A005CC385 /* LocationSectionHeaderView.swift */, 5888AD86227B17950051EB06 /* LocationViewController.swift */, 7AB3BEB42BD7A6CB00E34384 /* LocationViewControllerWrapper.swift */, + 7A3EFAAA2BDFDAE800318736 /* RelaySelection.swift */, ); path = SelectLocation; sourceTree = ""; @@ -5503,6 +5506,7 @@ 5878F50029CDA742003D4BE2 /* UIView+AutoLayoutBuilder.swift in Sources */, 7A28826D2BAAC9DE00FD9F20 /* IPOverrideHeaderView.swift in Sources */, A98502032B627B120061901E /* LocalNetworkProbe.swift in Sources */, + 7A3EFAAB2BDFDAE800318736 /* RelaySelection.swift in Sources */, 583FE01029C0F532006E85F9 /* CustomSplitViewController.swift in Sources */, 7A6F2FA92AFD0842006D0856 /* CustomDNSDataSource.swift in Sources */, 58EF580B25D69D7A00AEBA94 /* ProblemReportSubmissionOverlayView.swift in Sources */, diff --git a/ios/MullvadVPN/Coordinators/LocationCoordinator.swift b/ios/MullvadVPN/Coordinators/LocationCoordinator.swift index 58e828ef1289..3f22e872192c 100644 --- a/ios/MullvadVPN/Coordinators/LocationCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/LocationCoordinator.swift @@ -56,7 +56,7 @@ class LocationCoordinator: Coordinator, Presentable, Presenting { func start() { let locationViewControllerWrapper = LocationViewControllerWrapper( customListRepository: customListRepository, - selectedRelays: tunnelManager.settings.relayConstraints.locations.value + selectedRelays: createSelectedRelays() ) locationViewControllerWrapper.delegate = self @@ -75,6 +75,20 @@ class LocationCoordinator: Coordinator, Presentable, Presenting { navigationController.pushViewController(locationViewControllerWrapper, animated: false) } + private func createSelectedRelays() -> RelaySelection { + var selectedRelays = RelaySelection( + entry: UserSelectedRelays(locations: [.country("se")]), + exit: tunnelManager.settings.relayConstraints.locations.value, + currentContext: nil + ) + + #if DEBUG + selectedRelays.currentContext = .exit + #endif + + return selectedRelays + } + private func makeRelayFilterCoordinator(forModalPresentation isModalPresentation: Bool) -> RelayFilterCoordinator { let navigationController = CustomNavigationController() @@ -161,8 +175,6 @@ extension LocationCoordinator: LocationViewControllerWrapperDelegate { tunnelManager.updateSettings([.relayConstraints(relayConstraints)]) { self.tunnelManager.startTunnel() } - - didFinish?(self) } func didUpdateFilter(filter: RelayFilter) { diff --git a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterView.swift b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterView.swift index 5a952b146b43..60a752032206 100644 --- a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterView.swift +++ b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterView.swift @@ -94,7 +94,7 @@ class RelayFilterView: UIView { contentContainer.spacing = UIMetrics.FilterView.labelSpacing addConstrainedSubviews([contentContainer]) { - contentContainer.pinEdges(.init([.top(4), .bottom(0)]), to: self) + contentContainer.pinEdges(.init([.top(7), .bottom(0)]), to: self) contentContainer.pinEdges(.init([.leading(4), .trailing(4)]), to: layoutMarginsGuide) } } diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationCell.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationCell.swift index ba4de11c17ed..985d95a08d4b 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationCell.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationCell.swift @@ -65,6 +65,7 @@ class LocationCell: UITableViewCell { private var behavior: LocationCellBehavior = .select private let chevronDown = UIImage(resource: .iconChevronDown) private let chevronUp = UIImage(resource: .iconChevronUp) + private var isMultipHopSelection = false var isDisabled = false { didSet { @@ -244,7 +245,7 @@ class LocationCell: UITableViewCell { } private func statusIndicatorColor() -> UIColor { - if isDisabled { + if isDisabled && !isMultipHopSelection { return UIColor.RelayStatusIndicator.inactiveColor } else if isHighlighted { return UIColor.RelayStatusIndicator.highlightColor @@ -318,9 +319,18 @@ extension LocationCell { checkboxButton.isSelected = item.isSelected checkboxButton.tintColor = item.isSelected ? .successColor : .white + setMultihopSelection(item.multihopContext) setBehavior(behavior) } + func setMultihopSelection(_ context: RelaySelection.MultihopContext?) { + if let context { + isMultipHopSelection = true + isDisabled = true + locationLabel.text! += " (\(context.description))" + } + } + private func setBehavior(_ newBehavior: LocationCellBehavior) { self.behavior = newBehavior updateLeadingImage() diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationCellViewModel.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationCellViewModel.swift index 14b7745efd27..df3ce21cce3c 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationCellViewModel.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationCellViewModel.swift @@ -13,13 +13,14 @@ struct LocationCellViewModel: Hashable { let node: LocationNode var indentationLevel = 0 var isSelected = false + var multihopContext: RelaySelection.MultihopContext? func hash(into hasher: inout Hasher) { hasher.combine(node) hasher.combine(node.children.count) hasher.combine(section) hasher.combine(isSelected) - hasher.combine(indentationLevel) + hasher.combine(multihopContext) } static func == (lhs: Self, rhs: Self) -> Bool { @@ -27,7 +28,7 @@ struct LocationCellViewModel: Hashable { lhs.node.children.count == rhs.node.children.count && lhs.section == rhs.section && lhs.isSelected == rhs.isSelected && - lhs.indentationLevel == rhs.indentationLevel + lhs.multihopContext == rhs.multihopContext } } diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift index 3aa69604e494..5c3f3e23c545 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift @@ -17,7 +17,8 @@ final class LocationDataSource: LocationDiffableDataSourceProtocol { private var currentSearchString = "" private var dataSources: [LocationDataSourceProtocol] = [] - private var selectedItem: LocationCellViewModel? + private var selection: LocationCellViewModel? + private var multiHopSelection: LocationCellViewModel? let tableView: UITableView let sections: [LocationSection] @@ -50,7 +51,7 @@ final class LocationDataSource: defaultRowAnimation = .fade } - func setRelays(_ response: REST.ServerRelaysResponse, selectedRelays: UserSelectedRelays?, filter: RelayFilter) { + func setRelays(_ response: REST.ServerRelaysResponse, selectedRelays: RelaySelection, filter: RelayFilter) { let allLocationsDataSource = dataSources.first(where: { $0 is AllLocationDataSource }) as? AllLocationDataSource @@ -64,7 +65,7 @@ final class LocationDataSource: allLocationsDataSource?.reload(response, relays: relays) customListsDataSource?.reload(allLocationNodes: allLocationsDataSource?.nodes ?? []) - mapSelectedItem(from: selectedRelays) + setSelectedRelays(selectedRelays) filterRelays(by: currentSearchString) } @@ -81,8 +82,9 @@ final class LocationDataSource: } updateDataSnapshot(with: list, reloadExisting: !searchString.isEmpty) { + self.tableView.reloadData() if searchString.isEmpty { - self.setSelectedItem(self.selectedItem, animated: false, completion: { + self.updateSelection(self.selection, animated: false, completion: { self.scrollToSelectedRelay() }) } else { @@ -92,7 +94,7 @@ final class LocationDataSource: } /// Refreshes the custom list section and keeps all modifications intact (selection and expanded states). - func refreshCustomLists(selectedRelays: UserSelectedRelays?) { + func refreshCustomLists(selectedRelays: RelaySelection) { guard let allLocationsDataSource = dataSources.first(where: { $0 is AllLocationDataSource }) as? AllLocationDataSource, let customListsDataSource = @@ -110,7 +112,7 @@ final class LocationDataSource: customListsDataSource.reload(allLocationNodes: allLocationsDataSource.nodes) // Reapply current selection. - mapSelectedItem(from: selectedRelays) + setSelectedRelays(selectedRelays) // Reapply current search filter. let searchResultNodes = dataSources[0].search(by: currentSearchString) @@ -133,6 +135,16 @@ final class LocationDataSource: ], reloadExisting: true) } + func setSelectedRelays(_ selectedRelays: RelaySelection) { + let contextScopedRelays = selectedRelays.relaysFromCurrentContext + + selection = mapSelection(from: contextScopedRelays.current) + multiHopSelection = mapMultihopSelection(from: contextScopedRelays.inverted) + multiHopSelection?.multihopContext = selectedRelays.invertedContext + + tableView.reloadData() + } + func scrollToSelectedRelay() { indexPathForSelectedRelay().flatMap { tableView.scrollToRow(at: $0, at: .middle, animated: false) @@ -146,14 +158,14 @@ final class LocationDataSource: // Called from `LocationDiffableDataSourceProtocol`. func nodeShouldBeSelected(_ node: LocationNode) -> Bool { - false + false // N/A } private func indexPathForSelectedRelay() -> IndexPath? { - selectedItem.flatMap { indexPath(for: $0) } + selection.flatMap { indexPath(for: $0) } } - private func mapSelectedItem(from selectedRelays: UserSelectedRelays?) { + private func mapSelection(from selectedRelays: UserSelectedRelays?) -> LocationCellViewModel? { let allLocationsDataSource = dataSources.first(where: { $0 is AllLocationDataSource }) as? AllLocationDataSource @@ -165,7 +177,7 @@ final class LocationDataSource: if let customListSelection = selectedRelays.customListSelection, let customList = customListsDataSource?.customList(by: customListSelection.listId), let selectedNode = customListsDataSource?.node(by: selectedRelays, for: customList) { - selectedItem = LocationCellViewModel( + return LocationCellViewModel( section: .customLists, node: selectedNode, indentationLevel: selectedNode.hierarchyLevel @@ -173,47 +185,59 @@ final class LocationDataSource: // Look for a matching all locations node. } else if let location = selectedRelays.locations.first, let selectedNode = allLocationsDataSource?.node(by: location) { - selectedItem = LocationCellViewModel( + return LocationCellViewModel( section: .allLocations, node: selectedNode, indentationLevel: selectedNode.hierarchyLevel ) } } + + return nil + } + + private func mapMultihopSelection(from selectedRelays: UserSelectedRelays?) -> LocationCellViewModel? { + // Multihop selection doesn't apply to anything but single hosts. + guard + selectedRelays?.locations.count == 1, + case .hostname = selectedRelays?.locations.first + else { return nil } + + return mapSelection(from: selectedRelays) } - private func setSelectedItem(_ item: LocationCellViewModel?, animated: Bool, completion: (() -> Void)? = nil) { - selectedItem = item - guard let selectedItem else { return } + private func updateSelection(_ item: LocationCellViewModel?, animated: Bool, completion: (() -> Void)? = nil) { + selection = item + guard let selection else { return } - let rootNode = selectedItem.node.root + let rootNode = selection.node.root // Exit early if no changes to the node tree should be made. - guard selectedItem.node != rootNode else { + guard selection.node != rootNode else { completion?() return } // Make sure we have an index path for the selected item. guard let indexPath = indexPath(for: LocationCellViewModel( - section: selectedItem.section, + section: selection.section, node: rootNode )) else { return } // Walk tree backwards to determine which nodes should be expanded. - selectedItem.node.forEachAncestor { node in + selection.node.forEachAncestor { node in node.showsChildren = true } // Construct node tree. let nodesToAdd = recursivelyCreateCellViewModelTree( for: rootNode, - in: selectedItem.section, + in: selection.section, indentationLevel: 1 ) // Insert the new node tree below the selected item. - var snapshotItems = snapshot().itemIdentifiers(inSection: selectedItem.section) + var snapshotItems = snapshot().itemIdentifiers(inSection: selection.section) snapshotItems.insert(contentsOf: nodesToAdd, at: indexPath.row + 1) let list = sections.enumerated().map { index, section in @@ -233,6 +257,11 @@ final class LocationDataSource: override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { // swiftlint:disable:next force_cast let cell = super.tableView(tableView, cellForRowAt: indexPath) as! LocationCell + + if itemIdentifier(for: indexPath)?.node == multiHopSelection?.node { + cell.setMultihopSelection(multiHopSelection?.multihopContext) + } + cell.delegate = self return cell } @@ -271,7 +300,8 @@ extension LocationDataSource: UITableViewDelegate { } func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { - itemIdentifier(for: indexPath)?.node.isActive ?? false + guard let item = itemIdentifier(for: indexPath) else { return false } + return (item.node != multiHopSelection?.node) && item.node.isActive } func tableView(_ tableView: UITableView, indentationLevelForRowAt indexPath: IndexPath) -> Int { @@ -279,15 +309,17 @@ extension LocationDataSource: UITableViewDelegate { } func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - if let item = itemIdentifier(for: indexPath), item == selectedItem { - tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) + if let item = itemIdentifier(for: indexPath), item == selection { + cell.setSelected(true, animated: false) } } func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { if let indexPath = indexPathForSelectedRelay() { - tableView.deselectRow(at: indexPath, animated: false) - selectedItem = nil + if let cell = tableView.cellForRow(at: indexPath) { + cell.setSelected(false, animated: false) + } + selection = nil } return indexPath @@ -295,7 +327,7 @@ extension LocationDataSource: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard let item = itemIdentifier(for: indexPath) else { return } - selectedItem = item + selection = item var customListSelection: UserSelectedRelays.CustomListSelection? if let topmostNode = item.node.root as? CustomListLocationNode { diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift index 38188122ffa6..70e2af5a9970 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift @@ -25,7 +25,7 @@ final class LocationViewController: UIViewController { private var dataSource: LocationDataSource? private var cachedRelays: CachedRelays? private var filter = RelayFilter() - private var selectedRelays: UserSelectedRelays? + private var selectedRelays: RelaySelection weak var delegate: LocationViewControllerDelegate? var customListRepository: CustomListRepositoryProtocol @@ -37,9 +37,10 @@ final class LocationViewController: UIViewController { return (filter.ownership == .any) && (filter.providers == .any) } - init(customListRepository: CustomListRepositoryProtocol, selectedRelays: UserSelectedRelays?) { + init(customListRepository: CustomListRepositoryProtocol, selectedRelays: RelaySelection) { self.customListRepository = customListRepository self.selectedRelays = selectedRelays + super.init(nibName: nil, bundle: nil) } @@ -55,7 +56,7 @@ final class LocationViewController: UIViewController { view.accessibilityIdentifier = .selectLocationView view.backgroundColor = .secondaryColor - setUpDataSources() + setUpDataSource() setUpTableView() setUpTopContent() @@ -97,9 +98,14 @@ final class LocationViewController: UIViewController { dataSource?.refreshCustomLists(selectedRelays: selectedRelays) } + func setSelectedRelays(_ selectedRelays: RelaySelection) { + self.selectedRelays = selectedRelays + dataSource?.setSelectedRelays(selectedRelays) + } + // MARK: - Private - private func setUpDataSources() { + private func setUpDataSource() { dataSource = LocationDataSource( tableView: tableView, allLocations: AllLocationDataSource(), diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift index d9c1dcff88f9..65ff4dbe213f 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift @@ -19,20 +19,36 @@ protocol LocationViewControllerWrapperDelegate: AnyObject { } final class LocationViewControllerWrapper: UIViewController { - private let locationViewController: LocationViewController + enum SegmentedControlOption: Int { + case entry, exit + } + + private let entryLocationViewController: LocationViewController? + private let exitLocationViewController: LocationViewController private let segmentedControl = UISegmentedControl() + private let locationViewContainer = UIStackView() + private var selectedRelays: RelaySelection weak var delegate: LocationViewControllerWrapperDelegate? - init(customListRepository: CustomListRepositoryProtocol, selectedRelays: UserSelectedRelays?) { - locationViewController = LocationViewController( + init(customListRepository: CustomListRepositoryProtocol, selectedRelays: RelaySelection) { + entryLocationViewController = LocationViewController( customListRepository: customListRepository, selectedRelays: selectedRelays ) + exitLocationViewController = LocationViewController( + customListRepository: customListRepository, + selectedRelays: selectedRelays + ) + + self.selectedRelays = selectedRelays + super.init(nibName: nil, bundle: nil) - locationViewController.delegate = self + updateViewControllers { + $0.delegate = self + } } var didFinish: (() -> Void)? @@ -50,14 +66,25 @@ final class LocationViewControllerWrapper: UIViewController { setUpNavigation() setUpSegmentedControl() addSubviews() + swapViewController() } func setCachedRelays(_ cachedRelays: CachedRelays, filter: RelayFilter) { - locationViewController.setCachedRelays(cachedRelays, filter: filter) + updateViewControllers { + $0.setCachedRelays(cachedRelays, filter: filter) + } } func refreshCustomLists() { - locationViewController.refreshCustomLists() + updateViewControllers { + $0.refreshCustomLists() + } + } + + private func updateViewControllers(callback: (LocationViewController) -> Void) { + [entryLocationViewController, exitLocationViewController] + .compactMap { $0 } + .forEach { callback($0) } } private func setUpNavigation() { @@ -104,39 +131,70 @@ final class LocationViewControllerWrapper: UIViewController { tableName: "SelectLocation", value: "Entry", comment: "" - ), at: 0, animated: false) + ), at: SegmentedControlOption.entry.rawValue, animated: false) segmentedControl.insertSegment(withTitle: NSLocalizedString( "MULTIHOP_TAB_EXIT", tableName: "SelectLocation", value: "Exit", comment: "" - ), at: 1, animated: false) + ), at: SegmentedControlOption.exit.rawValue, animated: false) - segmentedControl.selectedSegmentIndex = 0 + segmentedControl.selectedSegmentIndex = selectedRelays.currentContext?.rawValue ?? 0 segmentedControl.addTarget(self, action: #selector(segmentedControlDidChange), for: .valueChanged) } private func addSubviews() { - addChild(locationViewController) - locationViewController.didMove(toParent: self) - - view.addConstrainedSubviews([segmentedControl, locationViewController.view]) { + view.addConstrainedSubviews([segmentedControl, locationViewContainer]) { segmentedControl.heightAnchor.constraint(equalToConstant: 44) segmentedControl.pinEdgesToSuperviewMargins(PinnableEdges([.top(0), .leading(8), .trailing(8)])) - locationViewController.view.pinEdgesToSuperview(.all().excluding(.top)) + locationViewContainer.pinEdgesToSuperview(.all().excluding(.top)) - #if DEBUG - locationViewController.view.topAnchor.constraint(equalTo: segmentedControl.bottomAnchor, constant: 4) - #else - locationViewController.view.pinEdgeToSuperviewMargin(.top(0)) - #endif + if selectedRelays.currentContext == nil { + locationViewContainer.pinEdgeToSuperviewMargin(.top(0)) + } else { + locationViewContainer.topAnchor.constraint(equalTo: segmentedControl.bottomAnchor, constant: 4) + } } } @objc private func segmentedControlDidChange(sender: UISegmentedControl) { - refreshCustomLists() + switch segmentedControl.selectedSegmentIndex { + case SegmentedControlOption.entry.rawValue: + selectedRelays.currentContext = .entry + case SegmentedControlOption.exit.rawValue: + selectedRelays.currentContext = .exit + default: + break + } + + swapViewController() + } + + func swapViewController() { + locationViewContainer.arrangedSubviews.forEach { view in + view.removeFromSuperview() + } + + var currentViewController: LocationViewController? + + switch selectedRelays.currentContext { + case .entry: + exitLocationViewController.removeFromParent() + currentViewController = entryLocationViewController + case .exit, .none: + entryLocationViewController?.removeFromParent() + currentViewController = exitLocationViewController + } + + guard let currentViewController else { return } + + currentViewController.setSelectedRelays(selectedRelays) + addChild(currentViewController) + currentViewController.didMove(toParent: self) + + locationViewContainer.addArrangedSubview(currentViewController.view) } } @@ -146,7 +204,18 @@ extension LocationViewControllerWrapper: LocationViewControllerDelegate { } func didSelectRelays(relays: UserSelectedRelays) { - delegate?.didSelectRelays(relays: relays) + switch selectedRelays.currentContext { + case .entry: + selectedRelays.entry = relays + delegate?.didSelectRelays(relays: relays) + + // Trigger change in segmented control, which in turn triggers view controller swap. + segmentedControl.selectedSegmentIndex = SegmentedControlOption.exit.rawValue + segmentedControl.sendActions(for: .valueChanged) + case .exit, .none: + delegate?.didSelectRelays(relays: relays) + didFinish?() + } } func didUpdateFilter(filter: RelayFilter) { diff --git a/ios/MullvadVPN/View controllers/SelectLocation/RelaySelection.swift b/ios/MullvadVPN/View controllers/SelectLocation/RelaySelection.swift new file mode 100644 index 000000000000..996ad5678812 --- /dev/null +++ b/ios/MullvadVPN/View controllers/SelectLocation/RelaySelection.swift @@ -0,0 +1,60 @@ +// +// RelaySelection.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-04-29. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import MullvadTypes + +struct RelaySelection { + enum MultihopContext: Int, CustomStringConvertible { + case entry, exit + + var description: String { + switch self { + case .entry: + NSLocalizedString( + "MULTIHOP_ENTRY", + tableName: "SelectLocation", + value: "Entry", + comment: "" + ) + case .exit: + NSLocalizedString( + "MULTIHOP_EXIT", + tableName: "SelectLocation", + value: "Exit", + comment: "" + ) + } + } + } + + var entry: UserSelectedRelays? + var exit: UserSelectedRelays? + var currentContext: MultihopContext? + + var invertedContext: MultihopContext? { + switch currentContext { + case .entry: + .exit + case .exit: + .entry + case nil: + nil + } + } + + var relaysFromCurrentContext: (current: UserSelectedRelays?, inverted: UserSelectedRelays?) { + switch currentContext { + case .entry: + (current: entry, inverted: exit) + case .exit: + (current: exit, inverted: entry) + case nil: + (current: exit, inverted: nil) + } + } +}