diff options
16 files changed, 719 insertions, 106 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index f14b01f8ca..e56b72cf8e 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -818,6 +818,7 @@ E1187ABD289BBB850024E748 /* OutOfTimeContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1187ABB289BBB850024E748 /* OutOfTimeContentView.swift */; }; E158B360285381C60002F069 /* String+AccountFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = E158B35F285381C60002F069 /* String+AccountFormatting.swift */; }; E1FD0DF528AA7CE400299DB4 /* StatusActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FD0DF428AA7CE400299DB4 /* StatusActivityView.swift */; }; + F006CCFC2B99CC8400C6C2AC /* EditLocationsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F006CCFB2B99CC8400C6C2AC /* EditLocationsCoordinator.swift */; }; F0164EBA2B4456D30020268D /* AccessMethodRepositoryStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0164EB92B4456D30020268D /* AccessMethodRepositoryStub.swift */; }; F0164EBC2B482E430020268D /* AppStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0164EBB2B482E430020268D /* AppStorage.swift */; }; F0164EBE2B4BFF940020268D /* ShadowsocksLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0164EBD2B4BFF940020268D /* ShadowsocksLoader.swift */; }; @@ -825,6 +826,9 @@ F0164ED12B4F2DCB0020268D /* AccessMethodIterator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0164ED02B4F2DCB0020268D /* AccessMethodIterator.swift */; }; F028A56A2A34D4E700C0CAA3 /* RedeemVoucherViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F028A5692A34D4E700C0CAA3 /* RedeemVoucherViewController.swift */; }; F028A56C2A34D8E600C0CAA3 /* AddCreditSucceededViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F028A56B2A34D8E600C0CAA3 /* AddCreditSucceededViewController.swift */; }; + F02F41A02B9723AF00625A4F /* AddLocationsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02F419A2B9723AE00625A4F /* AddLocationsViewController.swift */; }; + F02F41A12B9723AF00625A4F /* AddLocationsDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02F419B2B9723AE00625A4F /* AddLocationsDataSource.swift */; }; + F02F41A22B9723AF00625A4F /* AddLocationsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02F419C2B9723AF00625A4F /* AddLocationsCoordinator.swift */; }; F02F41A42B9723AF00625A4F /* AddLocationCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02F419E2B9723AF00625A4F /* AddLocationCellViewModel.swift */; }; F02F41A52B9723AF00625A4F /* AddLocationCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02F419F2B9723AF00625A4F /* AddLocationCell.swift */; }; F03580252A13842C00E5DAFD /* IncreasedHitButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F03580242A13842C00E5DAFD /* IncreasedHitButton.swift */; }; @@ -1976,6 +1980,7 @@ E1187ABB289BBB850024E748 /* OutOfTimeContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutOfTimeContentView.swift; sourceTree = "<group>"; }; E158B35F285381C60002F069 /* String+AccountFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+AccountFormatting.swift"; sourceTree = "<group>"; }; E1FD0DF428AA7CE400299DB4 /* StatusActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActivityView.swift; sourceTree = "<group>"; }; + F006CCFB2B99CC8400C6C2AC /* EditLocationsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditLocationsCoordinator.swift; sourceTree = "<group>"; }; F0164EB92B4456D30020268D /* AccessMethodRepositoryStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodRepositoryStub.swift; sourceTree = "<group>"; }; F0164EBB2B482E430020268D /* AppStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStorage.swift; sourceTree = "<group>"; }; F0164EBD2B4BFF940020268D /* ShadowsocksLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowsocksLoader.swift; sourceTree = "<group>"; }; @@ -1983,6 +1988,9 @@ F0164ED02B4F2DCB0020268D /* AccessMethodIterator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodIterator.swift; sourceTree = "<group>"; }; F028A5692A34D4E700C0CAA3 /* RedeemVoucherViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RedeemVoucherViewController.swift; sourceTree = "<group>"; }; F028A56B2A34D8E600C0CAA3 /* AddCreditSucceededViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddCreditSucceededViewController.swift; sourceTree = "<group>"; }; + F02F419A2B9723AE00625A4F /* AddLocationsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddLocationsViewController.swift; sourceTree = "<group>"; }; + F02F419B2B9723AE00625A4F /* AddLocationsDataSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddLocationsDataSource.swift; sourceTree = "<group>"; }; + F02F419C2B9723AF00625A4F /* AddLocationsCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddLocationsCoordinator.swift; sourceTree = "<group>"; }; F02F419E2B9723AF00625A4F /* AddLocationCellViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddLocationCellViewModel.swift; sourceTree = "<group>"; }; F02F419F2B9723AF00625A4F /* AddLocationCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddLocationCell.swift; sourceTree = "<group>"; }; F03580242A13842C00E5DAFD /* IncreasedHitButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncreasedHitButton.swift; sourceTree = "<group>"; }; @@ -3514,6 +3522,9 @@ 7A6389D72B7E3BD6008E77E1 /* AddCustomListCoordinator.swift */, F02F419F2B9723AF00625A4F /* AddLocationCell.swift */, F02F419E2B9723AF00625A4F /* AddLocationCellViewModel.swift */, + F02F419C2B9723AF00625A4F /* AddLocationsCoordinator.swift */, + F02F419B2B9723AE00625A4F /* AddLocationsDataSource.swift */, + F02F419A2B9723AE00625A4F /* AddLocationsViewController.swift */, 7A6389D22B7E3BD6008E77E1 /* CustomListCellConfiguration.swift */, 7A6389D42B7E3BD6008E77E1 /* CustomListDataSourceConfiguration.swift */, 7A6389DA2B7E3BD6008E77E1 /* CustomListInteractor.swift */, @@ -3523,6 +3534,7 @@ 7A6389E62B7E42BE008E77E1 /* CustomListViewController.swift */, 7A6389D32B7E3BD6008E77E1 /* CustomListViewModel.swift */, 7A6389E42B7E4247008E77E1 /* EditCustomListCoordinator.swift */, + F006CCFB2B99CC8400C6C2AC /* EditLocationsCoordinator.swift */, 7AB2B66F2BA1EB8C00B03E3B /* ListCustomListCoordinator.swift */, 7AB2B66E2BA1EB8C00B03E3B /* ListCustomListViewController.swift */, ); @@ -5236,6 +5248,7 @@ 58EE2E3B272FF814003BFF93 /* SettingsDataSourceDelegate.swift in Sources */, 5823FA5426CE49F700283BF8 /* TunnelObserver.swift in Sources */, 5888AD87227B17950051EB06 /* LocationViewController.swift in Sources */, + F006CCFC2B99CC8400C6C2AC /* EditLocationsCoordinator.swift in Sources */, 58293FB3251241B4005D0BB5 /* CustomTextView.swift in Sources */, 586A950E290125F3007BAF2B /* ProductsRequestOperation.swift in Sources */, 7AF9BE902A39F26000DBFEDB /* Collection+Sorting.swift in Sources */, @@ -5313,6 +5326,7 @@ 5878A26F2907E7E00096FC88 /* ProblemReportInteractor.swift in Sources */, 7AB4CCBB2B691BBB006037F5 /* IPOverrideInteractor.swift in Sources */, 7A3353912AAA014400F0A71C /* SimulatorVPNConnection.swift in Sources */, + F02F41A22B9723AF00625A4F /* AddLocationsCoordinator.swift in Sources */, F028A56A2A34D4E700C0CAA3 /* RedeemVoucherViewController.swift in Sources */, 7A5869C52B5A899C00640D27 /* MethodSettingsCellConfiguration.swift in Sources */, 58E11188292FA11F009FCA84 /* SettingsMigrationUIHandler.swift in Sources */, @@ -5447,11 +5461,13 @@ 5827B0C52B14D3E800CCBBA1 /* NSDiffableDataSourceSnapshot+Reconfigure.swift in Sources */, 58A8EE5E2976DB00009C0F8D /* StorePaymentManagerError+Display.swift in Sources */, 58A8EE5A2976BFBB009C0F8D /* SKError+Localized.swift in Sources */, + F02F41A12B9723AF00625A4F /* AddLocationsDataSource.swift in Sources */, 58EFC76E2AFB3BDA00E9F4CB /* ListAccessMethodCoordinator.swift in Sources */, 5827B0B92B14A1C700CCBBA1 /* MethodTestingStatusCellContentConfiguration.swift in Sources */, 7A42DEC92A05164100B209BE /* SettingsInputCell.swift in Sources */, 5803B4B22940A48700C23744 /* TunnelStore.swift in Sources */, 586A950F29012BEE007BAF2B /* AddressCacheTracker.swift in Sources */, + F02F41A02B9723AF00625A4F /* AddLocationsViewController.swift in Sources */, 587B753D2666468F00DEF7E9 /* NotificationController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift index 1ddd40663d..25d7d2d270 100644 --- a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift +++ b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift @@ -63,6 +63,7 @@ public enum AccessibilityIdentifier: String { // Views case accountView + case addLocationsView case alertContainerView case alertTitle case changeLogAlert diff --git a/ios/MullvadVPN/Coordinators/CustomLists/AddCustomListCoordinator.swift b/ios/MullvadVPN/Coordinators/CustomLists/AddCustomListCoordinator.swift index 69fb742c47..bbbf45ad54 100644 --- a/ios/MullvadVPN/Coordinators/CustomLists/AddCustomListCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/CustomLists/AddCustomListCoordinator.swift @@ -14,26 +14,28 @@ import UIKit class AddCustomListCoordinator: Coordinator, Presentable, Presenting { let navigationController: UINavigationController let interactor: CustomListInteractorProtocol + let nodes: [LocationNode] + let subject = CurrentValueSubject<CustomListViewModel, Never>( + CustomListViewModel(id: UUID(), name: "", locations: [], tableSections: [.name, .addLocations]) + ) var presentedViewController: UIViewController { navigationController } - var didFinish: (() -> Void)? + var didFinish: ((AddCustomListCoordinator) -> Void)? init( navigationController: UINavigationController, - interactor: CustomListInteractorProtocol + interactor: CustomListInteractorProtocol, + nodes: [LocationNode] ) { self.navigationController = navigationController self.interactor = interactor + self.nodes = nodes } func start() { - let subject = CurrentValueSubject<CustomListViewModel, Never>( - CustomListViewModel(id: UUID(), name: "", locations: [], tableSections: [.name, .addLocations]) - ) - let controller = CustomListViewController( interactor: interactor, subject: subject, @@ -57,8 +59,11 @@ class AddCustomListCoordinator: Coordinator, Presentable, Presenting { controller.navigationItem.leftBarButtonItem = UIBarButtonItem( systemItem: .cancel, - primaryAction: UIAction(handler: { _ in - self.didFinish?() + primaryAction: UIAction(handler: { [weak self] _ in + guard let self else { + return + } + didFinish?(self) }) ) @@ -68,14 +73,33 @@ class AddCustomListCoordinator: Coordinator, Presentable, Presenting { extension AddCustomListCoordinator: CustomListViewControllerDelegate { func customListDidSave(_ list: CustomList) { - didFinish?() + didFinish?(self) } func customListDidDelete(_ list: CustomList) { // No op. } - func showLocations() { - // TODO: Show view controller for locations. + func showLocations(_ list: CustomList) { + let coordinator = AddLocationsCoordinator( + navigationController: navigationController, + nodes: nodes, + customList: list + ) + + coordinator.didFinish = { [weak self] locationsCoordinator, customList in + guard let self else { return } + subject.send(CustomListViewModel( + id: customList.id, + name: customList.name, + locations: customList.locations, + tableSections: subject.value.tableSections + )) + locationsCoordinator.removeFromParent() + } + + coordinator.start() + + addChild(coordinator) } } diff --git a/ios/MullvadVPN/Coordinators/CustomLists/AddLocationsCoordinator.swift b/ios/MullvadVPN/Coordinators/CustomLists/AddLocationsCoordinator.swift new file mode 100644 index 0000000000..d7b306d978 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/CustomLists/AddLocationsCoordinator.swift @@ -0,0 +1,63 @@ +// +// AddLocationsCoordinator.swift +// MullvadVPN +// +// Created by Mojgan on 2024-03-04. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Combine +import Foundation +import MullvadSettings +import MullvadTypes +import Routing +import UIKit + +class AddLocationsCoordinator: Coordinator, Presentable, Presenting { + private let navigationController: UINavigationController + private let nodes: [LocationNode] + private var customList: CustomList + + var didFinish: ((AddLocationsCoordinator, CustomList) -> Void)? + + var presentedViewController: UIViewController { + navigationController + } + + init( + navigationController: UINavigationController, + nodes: [LocationNode], + customList: CustomList + ) { + self.navigationController = navigationController + self.nodes = nodes + self.customList = customList + } + + func start() { + let controller = AddLocationsViewController( + allLocationsNodes: nodes, + customList: customList + ) + controller.delegate = self + + controller.navigationItem.title = NSLocalizedString( + "ADD_LOCATIONS_NAVIGATION_TITLE", + tableName: "AddLocations", + value: "Add locations", + comment: "" + ) + + navigationController.pushViewController(controller, animated: true) + } +} + +extension AddLocationsCoordinator: AddLocationsViewControllerDelegate { + func didUpdateSelectedLocations(locations: [RelayLocation]) { + customList.locations = locations + } + + func didBack() { + didFinish?(self, customList) + } +} diff --git a/ios/MullvadVPN/Coordinators/CustomLists/AddLocationsDataSource.swift b/ios/MullvadVPN/Coordinators/CustomLists/AddLocationsDataSource.swift new file mode 100644 index 0000000000..3b2125811f --- /dev/null +++ b/ios/MullvadVPN/Coordinators/CustomLists/AddLocationsDataSource.swift @@ -0,0 +1,318 @@ +// +// AddLocationsDataSource.swift +// MullvadVPN +// +// Created by Mojgan on 2024-02-29. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadSettings +import MullvadTypes +import UIKit + +enum AddLocationsSectionIdentifier: String, Hashable, CaseIterable, CellIdentifierProtocol { + case `default` + + var cellClass: AnyClass { + switch self { + case .default: AddLocationCell.self + } + } +} + +class AddLocationsDataSource: UITableViewDiffableDataSource<AddLocationsSectionIdentifier, AddLocationCellViewModel> { + private let tableView: UITableView + private let nodes: [LocationNode] + private var customListLocationNode: CustomListLocationNode + var didUpdateCustomList: ((CustomListLocationNode) -> Void)? + + init( + tableView: UITableView, + allLocations: [LocationNode], + customList: CustomList + ) { + self.tableView = tableView + self.nodes = allLocations + + self.customListLocationNode = CustomListLocationNodeBuilder( + customList: customList, + allLocations: self.nodes + ).customListLocationNode + + super.init(tableView: tableView) { _, indexPath, itemIdentifier in + let cell = tableView.dequeueReusableView( + withIdentifier: AddLocationsSectionIdentifier.allCases[indexPath.section], + for: indexPath + // swiftlint:disable:next force_cast + ) as! AddLocationCell + cell.configure(item: itemIdentifier) + cell.selectionStyle = .none + return cell + } + + tableView.delegate = self + tableView.registerReusableViews(from: AddLocationsSectionIdentifier.self) + defaultRowAnimation = .fade + reloadWithSelectedLocations() + } + + private func reloadWithSelectedLocations() { + var locationsList: [AddLocationCellViewModel] = [] + nodes.forEach { node in + let viewModel = AddLocationCellViewModel( + node: node, + isSelected: customListLocationNode.children.contains(node) + ) + locationsList.append(viewModel) + + // Determine if the node should be expanded. + guard isLocationInCustomList(node: node) else { + return + } + + // Walk tree backwards to determine which nodes should be expanded. + node.forEachAncestor { node in + node.showsChildren = true + } + + locationsList.append(contentsOf: recursivelyCreateCellViewModelTree( + for: node, + in: .default, + indentationLevel: 1 + )) + } + updateDataSnapshot(with: [locationsList]) + } + + private func updateDataSnapshot( + with list: [[AddLocationCellViewModel]], + animated: Bool = false, + completion: (() -> Void)? = nil + ) { + var snapshot = NSDiffableDataSourceSnapshot<AddLocationsSectionIdentifier, AddLocationCellViewModel>() + let sections = AddLocationsSectionIdentifier.allCases + + snapshot.appendSections(sections) + + for (index, section) in sections.enumerated() { + let items = list[index] + + snapshot.appendItems(items, toSection: section) + } + apply(snapshot, animatingDifferences: animated, completion: completion) + } + + private func recursivelyCreateCellViewModelTree( + for node: LocationNode, + in section: AddLocationsSectionIdentifier, + indentationLevel: Int + ) -> [AddLocationCellViewModel] { + var viewModels = [AddLocationCellViewModel]() + for childNode in node.children { + viewModels.append( + AddLocationCellViewModel( + node: childNode, + indentationLevel: indentationLevel, + isSelected: customListLocationNode.children.contains(childNode) + ) + ) + + let indentationLevel = indentationLevel + 1 + + // Walk tree forward to determine which nodes should be expanded. + if isLocationInCustomList(node: childNode) { + viewModels.append( + contentsOf: recursivelyCreateCellViewModelTree( + for: childNode, + in: section, + indentationLevel: indentationLevel + ) + ) + } + } + + return viewModels + } + + private func isLocationInCustomList(node: LocationNode) -> Bool { + customListLocationNode.children.contains(where: { containsChild(parent: node, child: $0) }) + } + + private func containsChild(parent: LocationNode, child: LocationNode) -> Bool { + parent.flattened.contains(child) + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + // swiftlint:disable:next force_cast + let cell = super.tableView(tableView, cellForRowAt: indexPath) as! AddLocationCell + cell.delegate = self + return cell + } +} + +extension AddLocationsDataSource: UITableViewDelegate { + func tableView(_ tableView: UITableView, indentationLevelForRowAt indexPath: IndexPath) -> Int { + itemIdentifier(for: indexPath)?.indentationLevel ?? 0 + } +} + +extension AddLocationsDataSource: AddLocationCellDelegate { + func toggleExpanding(cell: AddLocationCell) { + guard let indexPath = tableView.indexPath(for: cell), + let item = itemIdentifier(for: indexPath) else { return } + let isExpanded = item.node.showsChildren + + item.node.showsChildren = !isExpanded + + var locationList = snapshot().itemIdentifiers + + if !isExpanded { + locationList.addSubNodes(from: item, at: indexPath) + } else { + locationList.removeSubNodes(from: item.node) + } + + updateDataSnapshot(with: [locationList], animated: true, completion: { + self.scroll(to: item, animated: true) + }) + } + + func toggleSelection(cell: AddLocationCell) { + guard let index = tableView.indexPath(for: cell)?.row else { return } + + var locationList = snapshot().itemIdentifiers + let item = locationList[index] + let isSelected = !item.isSelected + locationList[index].isSelected = isSelected + + locationList.deselectAncestors(from: item.node) + locationList.toggleSelectionSubNodes(from: item.node, isSelected: isSelected) + + if isSelected { + customListLocationNode.add(selectedLocation: item.node) + } else { + customListLocationNode.remove(selectedLocation: item.node, with: locationList) + } + updateDataSnapshot(with: [locationList], completion: { + self.didUpdateCustomList?(self.customListLocationNode) + }) + } +} + +extension AddLocationsDataSource { + private func scroll(to item: AddLocationCellViewModel, animated: Bool) { + guard + let visibleIndexPaths = tableView.indexPathsForVisibleRows, + let indexPath = indexPath(for: item) + else { return } + + if item.node.children.count > visibleIndexPaths.count { + tableView.scrollToRow(at: indexPath, at: .top, animated: animated) + } else { + if let last = item.node.children.last { + if let lastInsertedIndexPath = self.indexPath(for: AddLocationCellViewModel( + node: last, + isSelected: false + )), + let lastVisibleIndexPath = visibleIndexPaths.last, + lastInsertedIndexPath >= lastVisibleIndexPath { + tableView.scrollToRow(at: lastInsertedIndexPath, at: .bottom, animated: animated) + } + } + } + } +} + +// MARK: - Toggle expanding + +fileprivate extension [AddLocationCellViewModel] { + mutating func addSubNodes(from item: AddLocationCellViewModel, at indexPath: IndexPath) { + let row = indexPath.row + 1 + let locations = item.node.children.map { + AddLocationCellViewModel(node: $0, indentationLevel: item.indentationLevel + 1, isSelected: item.isSelected) + } + + if row < count { + insert(contentsOf: locations, at: row) + } else { + append(contentsOf: locations) + } + } + + mutating func removeSubNodes(from node: LocationNode) { + for node in node.children { + node.showsChildren = false + removeAll(where: { node == $0.node }) + removeSubNodes(from: node) + } + } +} + +// MARK: - Toggle selection in table view + +fileprivate extension [AddLocationCellViewModel] { + mutating func deselectAncestors(from node: LocationNode?) { + node?.forEachAncestor { parent in + guard let index = firstIndex(where: { $0.node == parent }) else { + return + } + self[index].isSelected = false + } + } + + mutating func toggleSelectionSubNodes(from node: LocationNode, isSelected: Bool) { + node.forEachDescendant { child in + guard let index = firstIndex(where: { $0.node == child }) else { + return + } + self[index].isSelected = isSelected + } + } +} + +// MARK: - Update custom list + +fileprivate extension CustomListLocationNode { + func remove(selectedLocation: LocationNode, with locationList: [AddLocationCellViewModel]) { + if let index = children.firstIndex(of: selectedLocation) { + children.remove(at: index) + } + removeAncestors(node: selectedLocation) + addSiblings(from: locationList, for: selectedLocation) + } + + func add(selectedLocation: LocationNode) { + children.append(selectedLocation) + removeSubNodes(node: selectedLocation) + } + + private func removeSubNodes(node: LocationNode) { + node.forEachDescendant { child in + // removing children if they are already added to custom list + if let index = children.firstIndex(of: child) { + children.remove(at: index) + } + } + } + + private func removeAncestors(node: LocationNode) { + node.forEachAncestor { parent in + if let index = children.firstIndex(of: parent) { + children.remove(at: index) + } + } + } + + private func addSiblings(from locationList: [AddLocationCellViewModel], for node: LocationNode) { + guard let parent = node.parent else { return } + parent.children.forEach { child in + // adding siblings if they are already selected in snapshot + if let item = locationList.first(where: { $0.node == child }), + item.isSelected && !children.contains(child) { + children.append(child) + } + } + addSiblings(from: locationList, for: parent) + } +} diff --git a/ios/MullvadVPN/Coordinators/CustomLists/AddLocationsViewController.swift b/ios/MullvadVPN/Coordinators/CustomLists/AddLocationsViewController.swift new file mode 100644 index 0000000000..8fc9928d8c --- /dev/null +++ b/ios/MullvadVPN/Coordinators/CustomLists/AddLocationsViewController.swift @@ -0,0 +1,108 @@ +// +// AddLocationsViewController.swift +// MullvadVPN +// +// Created by Mojgan on 2024-02-29. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import MullvadSettings +import MullvadTypes +import UIKit + +protocol AddLocationsViewControllerDelegate: AnyObject { + func didUpdateSelectedLocations(locations: [RelayLocation]) + func didBack() +} + +class AddLocationsViewController: UIViewController { + private var dataSource: AddLocationsDataSource? + private let nodes: [LocationNode] + private let customList: CustomList + + weak var delegate: AddLocationsViewControllerDelegate? + private let tableView: UITableView = { + let tableView = UITableView() + tableView.separatorColor = .secondaryColor + tableView.separatorInset = .zero + tableView.rowHeight = 56 + tableView.indicatorStyle = .white + tableView.accessibilityIdentifier = .addLocationsView + tableView.allowsMultipleSelection = true + tableView.tableHeaderView = nil + tableView.sectionHeaderHeight = .zero + return tableView + }() + + private lazy var backBarButton: UIBarButtonItem = { + let backBarButton = UIBarButtonItem( + primaryAction: UIAction( + image: UIImage(resource: .iconBack), + handler: { [weak self] _ in + guard let self else { return } + delegate?.didBack() + navigationController?.popViewController(animated: true) + } + ) + ) + backBarButton.style = .done + + return backBarButton + }() + + init( + allLocationsNodes: [LocationNode], + customList: CustomList + ) { + self.nodes = allLocationsNodes + self.customList = customList + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + tableView.backgroundColor = view.backgroundColor + view.backgroundColor = .secondaryColor + navigationItem.leftBarButtonItem = backBarButton + addConstraints() + setUpDataSource() + } + + private func addConstraints() { + view.addConstrainedSubviews([tableView]) { + tableView.pinEdgesToSuperview() + } + } + + private func setUpDataSource() { + dataSource = AddLocationsDataSource( + tableView: tableView, + allLocations: nodes.copy(), + customList: customList + ) + + dataSource?.didUpdateCustomList = { [weak self] customListLocationNode in + guard let self else { return } + delegate?.didUpdateSelectedLocations( + locations: customListLocationNode.children.reduce([]) { partialResult, locationNode in + partialResult + locationNode.locations + } + ) + } + } +} + +fileprivate extension [LocationNode] { + func copy() -> Self { + map { + let copy = $0.copy() + copy.showsChildren = false + copy.flattened.forEach { $0.showsChildren = false } + return copy + } + } +} diff --git a/ios/MullvadVPN/Coordinators/CustomLists/CustomListViewController.swift b/ios/MullvadVPN/Coordinators/CustomLists/CustomListViewController.swift index 43ad9ed259..4e5891658d 100644 --- a/ios/MullvadVPN/Coordinators/CustomLists/CustomListViewController.swift +++ b/ios/MullvadVPN/Coordinators/CustomLists/CustomListViewController.swift @@ -13,7 +13,7 @@ import UIKit protocol CustomListViewControllerDelegate: AnyObject { func customListDidSave(_ list: CustomList) func customListDidDelete(_ list: CustomList) - func showLocations() + func showLocations(_ list: CustomList) } class CustomListViewController: UIViewController { @@ -45,8 +45,8 @@ class CustomListViewController: UIViewController { value: "Save", comment: "" ), - primaryAction: UIAction { _ in - self.onSave() + primaryAction: UIAction { [weak self] _ in + self?.onSave() } ) barButtonItem.style = .done @@ -101,14 +101,15 @@ class CustomListViewController: UIViewController { } private func configureDataSource() { - cellConfiguration.onDelete = { - self.onDelete() + cellConfiguration.onDelete = { [weak self] in + self?.onDelete() } dataSource = DataSource( tableView: tableView, - cellProvider: { _, indexPath, itemIdentifier in - self.cellConfiguration.dequeueCell( + cellProvider: { [weak self] _, indexPath, itemIdentifier in + guard let self else { return nil } + return cellConfiguration.dequeueCell( at: indexPath, for: itemIdentifier, validationErrors: self.validationErrors @@ -116,14 +117,15 @@ class CustomListViewController: UIViewController { } ) - dataSourceConfiguration?.didSelectItem = { item in + dataSourceConfiguration?.didSelectItem = { [weak self] item in + guard let self else { return } self.view.endEditing(false) switch item { case .name, .deleteList: break case .addLocations, .editLocations: - self.delegate?.showLocations() + delegate?.showLocations(self.subject.value.customList) } } diff --git a/ios/MullvadVPN/Coordinators/CustomLists/EditCustomListCoordinator.swift b/ios/MullvadVPN/Coordinators/CustomLists/EditCustomListCoordinator.swift index d8677161bc..9e753f22d6 100644 --- a/ios/MullvadVPN/Coordinators/CustomLists/EditCustomListCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/CustomLists/EditCustomListCoordinator.swift @@ -19,33 +19,34 @@ class EditCustomListCoordinator: Coordinator, Presentable, Presenting { let navigationController: UINavigationController let customListInteractor: CustomListInteractorProtocol let customList: CustomList + let nodes: [LocationNode] + let subject: CurrentValueSubject<CustomListViewModel, Never> var presentedViewController: UIViewController { navigationController } - var didFinish: ((FinishAction, CustomList) -> Void)? + var didFinish: ((EditCustomListCoordinator, FinishAction, CustomList) -> Void)? init( navigationController: UINavigationController, customListInteractor: CustomListInteractorProtocol, - customList: CustomList + customList: CustomList, + nodes: [LocationNode] ) { self.navigationController = navigationController self.customListInteractor = customListInteractor self.customList = customList + self.nodes = nodes + self.subject = CurrentValueSubject(CustomListViewModel( + id: customList.id, + name: customList.name, + locations: customList.locations, + tableSections: [.name, .editLocations, .deleteList] + )) } func start() { - let subject = CurrentValueSubject<CustomListViewModel, Never>( - CustomListViewModel( - id: customList.id, - name: customList.name, - locations: customList.locations, - tableSections: [.name, .editLocations, .deleteList] - ) - ) - let controller = CustomListViewController( interactor: customListInteractor, subject: subject, @@ -66,14 +67,33 @@ class EditCustomListCoordinator: Coordinator, Presentable, Presenting { extension EditCustomListCoordinator: CustomListViewControllerDelegate { func customListDidSave(_ list: CustomList) { - didFinish?(.save, list) + didFinish?(self, .save, list) } func customListDidDelete(_ list: CustomList) { - didFinish?(.delete, list) + didFinish?(self, .delete, list) } - func showLocations() { - // TODO: Show view controller for locations. + func showLocations(_ list: CustomList) { + let coordinator = EditLocationsCoordinator( + navigationController: navigationController, + nodes: nodes, + customList: list + ) + + coordinator.didFinish = { [weak self] locationsCoordinator, customList in + guard let self else { return } + subject.send(CustomListViewModel( + id: customList.id, + name: customList.name, + locations: customList.locations, + tableSections: subject.value.tableSections + )) + locationsCoordinator.removeFromParent() + } + + coordinator.start() + + coordinator.addChild(coordinator) } } diff --git a/ios/MullvadVPN/Coordinators/CustomLists/EditLocationsCoordinator.swift b/ios/MullvadVPN/Coordinators/CustomLists/EditLocationsCoordinator.swift new file mode 100644 index 0000000000..c9687c31ea --- /dev/null +++ b/ios/MullvadVPN/Coordinators/CustomLists/EditLocationsCoordinator.swift @@ -0,0 +1,61 @@ +// +// EditLocationsCoordinator.swift +// MullvadVPN +// +// Created by Mojgan on 2024-03-07. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadSettings +import MullvadTypes +import Routing +import UIKit + +class EditLocationsCoordinator: Coordinator, Presentable, Presenting { + private let navigationController: UINavigationController + private let nodes: [LocationNode] + private var customList: CustomList + + var didFinish: ((EditLocationsCoordinator, CustomList) -> Void)? + + var presentedViewController: UIViewController { + navigationController + } + + init( + navigationController: UINavigationController, + nodes: [LocationNode], + customList: CustomList + ) { + self.navigationController = navigationController + self.nodes = nodes + self.customList = customList + } + + func start() { + let controller = AddLocationsViewController( + allLocationsNodes: nodes, + customList: customList + ) + controller.delegate = self + + controller.navigationItem.title = NSLocalizedString( + "EDIT_LOCATIONS_NAVIGATION_TITLE", + tableName: "EditLocations", + value: "Edit locations", + comment: "" + ) + navigationController.pushViewController(controller, animated: true) + } +} + +extension EditLocationsCoordinator: AddLocationsViewControllerDelegate { + func didUpdateSelectedLocations(locations: [RelayLocation]) { + customList.locations = locations + } + + func didBack() { + didFinish?(self, customList) + } +} diff --git a/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListCoordinator.swift b/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListCoordinator.swift index 842d9544e6..4753c74f72 100644 --- a/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListCoordinator.swift @@ -16,47 +16,52 @@ class ListCustomListCoordinator: Coordinator, Presentable, Presenting { let interactor: CustomListInteractorProtocol let tunnelManager: TunnelManager let listViewController: ListCustomListViewController + let nodes: [LocationNode] var presentedViewController: UIViewController { navigationController } - var didFinish: (() -> Void)? + var didFinish: ((ListCustomListCoordinator) -> Void)? init( navigationController: UINavigationController, interactor: CustomListInteractorProtocol, - tunnelManager: TunnelManager + tunnelManager: TunnelManager, + nodes: [LocationNode] ) { self.navigationController = navigationController self.interactor = interactor self.tunnelManager = tunnelManager + self.nodes = nodes listViewController = ListCustomListViewController(interactor: interactor) } func start() { - listViewController.didFinish = didFinish - listViewController.didSelectItem = { - self.edit(list: $0) + listViewController.didFinish = { [weak self] in + guard let self else { return } + didFinish?(self) + } + listViewController.didSelectItem = { [weak self] in + self?.edit(list: $0) } navigationController.pushViewController(listViewController, animated: false) } private func edit(list: CustomList) { - // Remove previous edit coordinator to prevent accumulation. - childCoordinators.filter { $0 is EditCustomListCoordinator }.forEach { $0.removeFromParent() } - let coordinator = EditCustomListCoordinator( navigationController: navigationController, customListInteractor: interactor, - customList: list + customList: list, + nodes: nodes ) - coordinator.didFinish = { action, list in - self.popToList() - coordinator.removeFromParent() + coordinator.didFinish = { [weak self] editCustomListCoordinator, action, list in + guard let self else { return } + popToList() + editCustomListCoordinator.removeFromParent() self.updateRelayConstraints(for: action, in: list) self.listViewController.updateDataSource(reloadExisting: action == .save) @@ -86,8 +91,8 @@ class ListCustomListCoordinator: Coordinator, Presentable, Presenting { relayConstraints.locations = .only(UserSelectedRelays(locations: [])) } - tunnelManager.updateSettings([.relayConstraints(relayConstraints)]) { - self.tunnelManager.startTunnel() + tunnelManager.updateSettings([.relayConstraints(relayConstraints)]) { [weak self] in + self?.tunnelManager.startTunnel() } } diff --git a/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListViewController.swift b/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListViewController.swift index 25a8e374e6..460cc5e2f7 100644 --- a/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListViewController.swift +++ b/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListViewController.swift @@ -22,7 +22,7 @@ private enum CellReuseIdentifier: String, CaseIterable, CellIdentifierProtocol { var cellClass: AnyClass { switch self { - case .default: BasicCell.self + default: BasicCell.self } } } @@ -35,6 +35,21 @@ class ListCustomListViewController: UIViewController { private var fetchedItems: [CustomList] = [] private var tableView = UITableView(frame: .zero, style: .plain) + private let emptyListLabel: UILabel = { + let textLabel = UILabel() + textLabel.font = .preferredFont(forTextStyle: .title2) + textLabel.textColor = .secondaryTextColor + textLabel.textAlignment = .center + textLabel.numberOfLines = .zero + textLabel.lineBreakStrategy = [] + textLabel.text = NSLocalizedString( + "CustomList", + value: "No custom list to display", + comment: "" + ) + return textLabel + }() + var didSelectItem: ((CustomList) -> Void)? var didFinish: (() -> Void)? @@ -60,7 +75,7 @@ class ListCustomListViewController: UIViewController { func updateDataSource(reloadExisting: Bool, animated: Bool = true) { fetchedItems = interactor.fetchAll() - + tableView.backgroundView = fetchedItems.isEmpty ? emptyListLabel : nil var snapshot = NSDiffableDataSourceSnapshot<SectionIdentifier, ItemIdentifier>() snapshot.appendSections([.default]) @@ -87,9 +102,9 @@ class ListCustomListViewController: UIViewController { tableView.backgroundColor = .secondaryColor tableView.separatorColor = .secondaryColor tableView.separatorInset = .zero + tableView.separatorStyle = .singleLine tableView.contentInset.top = 16 tableView.rowHeight = UIMetrics.SettingsCell.customListsCellHeight - tableView.registerReusableViews(from: CellReuseIdentifier.self) } @@ -126,7 +141,6 @@ class ListCustomListViewController: UIViewController { ) -> UITableViewCell { let cell = tableView.dequeueReusableView(withIdentifier: CellReuseIdentifier.default, for: indexPath) let item = fetchedItems[indexPath.row] - var contentConfiguration = ListCellContentConfiguration() contentConfiguration.text = item.name cell.contentConfiguration = contentConfiguration @@ -138,7 +152,6 @@ class ListCustomListViewController: UIViewController { if let cell = cell as? CustomCellDisclosureHandling { cell.disclosureType = .chevron } - return cell } } diff --git a/ios/MullvadVPN/Coordinators/LocationCoordinator.swift b/ios/MullvadVPN/Coordinators/LocationCoordinator.swift index 8da5a6dca2..53e164274e 100644 --- a/ios/MullvadVPN/Coordinators/LocationCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/LocationCoordinator.swift @@ -127,31 +127,35 @@ class LocationCoordinator: Coordinator, Presentable, Presenting { return relayFilterCoordinator } - private func showAddCustomList() { + private func showAddCustomList(nodes: [LocationNode]) { let coordinator = AddCustomListCoordinator( navigationController: CustomNavigationController(), - interactor: CustomListInteractor(repository: customListRepository) + interactor: CustomListInteractor( + repository: customListRepository + ), + nodes: nodes ) - coordinator.didFinish = { - coordinator.dismiss(animated: true) - self.locationViewController?.refreshCustomLists() + coordinator.didFinish = { [weak self] addCustomListCoordinator in + addCustomListCoordinator.dismiss(animated: true) + self?.locationViewController?.refreshCustomLists() } coordinator.start() presentChild(coordinator, animated: true) } - private func showEditCustomLists() { + private func showEditCustomLists(nodes: [LocationNode]) { let coordinator = ListCustomListCoordinator( navigationController: CustomNavigationController(), interactor: CustomListInteractor(repository: customListRepository), - tunnelManager: tunnelManager + tunnelManager: tunnelManager, + nodes: nodes ) - coordinator.didFinish = { - coordinator.dismiss(animated: true) - self.locationViewController?.refreshCustomLists() + coordinator.didFinish = { [weak self] listCustomListCoordinator in + listCustomListCoordinator.dismiss(animated: true) + self?.locationViewController?.refreshCustomLists() } coordinator.start() @@ -181,7 +185,7 @@ extension LocationCoordinator: RelayCacheTrackerObserver { } extension LocationCoordinator: LocationViewControllerDelegate { - func didRequestRouteToCustomLists(_ controller: LocationViewController) { + func didRequestRouteToCustomLists(_ controller: LocationViewController, nodes: [LocationNode]) { let actionSheet = UIAlertController( title: NSLocalizedString( "CUSTOM_LIST_ACTION_SHEET_TITLE", @@ -190,7 +194,7 @@ extension LocationCoordinator: LocationViewControllerDelegate { comment: "" ), message: nil, - preferredStyle: .actionSheet + preferredStyle: UIDevice.current.userInterfaceIdiom == .pad ? .alert : .actionSheet ) actionSheet.addAction(UIAlertAction( @@ -201,12 +205,11 @@ extension LocationCoordinator: LocationViewControllerDelegate { comment: "" ), style: .default, - handler: { _ in - self.showAddCustomList() + handler: { [weak self] _ in + self?.showAddCustomList(nodes: nodes) } )) - - actionSheet.addAction(UIAlertAction( + let editAction = UIAlertAction( title: NSLocalizedString( "CUSTOM_LIST_ACTION_SHEET_EDIT_LISTS_BUTTON", tableName: "CustomLists", @@ -214,10 +217,13 @@ extension LocationCoordinator: LocationViewControllerDelegate { comment: "" ), style: .default, - handler: { _ in - self.showEditCustomLists() + handler: { [weak self] _ in + self?.showEditCustomLists(nodes: nodes) } - )) + ) + editAction.isEnabled = !customListRepository.fetchAll().isEmpty + + actionSheet.addAction(editAction) actionSheet.addAction(UIAlertAction( title: NSLocalizedString( diff --git a/ios/MullvadVPN/View controllers/SelectLocation/CustomListsDataSource.swift b/ios/MullvadVPN/View controllers/SelectLocation/CustomListsDataSource.swift index dd041642cd..e9cad7cf1c 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/CustomListsDataSource.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/CustomListsDataSource.swift @@ -74,32 +74,4 @@ class CustomListsDataSource: LocationDataSourceProtocol { func customList(by id: UUID) -> CustomList? { repository.fetch(by: id) } - - private func copy( - _ location: RelayLocation, - from allLocationNodes: [LocationNode], - withParent parentNode: LocationNode - ) -> LocationNode? { - let rootNode = RootLocationNode(children: allLocationNodes) - - return switch location { - case let .country(countryCode): - rootNode - .countryFor(code: countryCode)? - .copy(withParent: parentNode) - - case let .city(countryCode, cityCode): - rootNode - .countryFor(code: countryCode)? - .cityFor(codes: [countryCode, cityCode])? - .copy(withParent: parentNode) - - case let .hostname(countryCode, cityCode, hostCode): - rootNode - .countryFor(code: countryCode)? - .cityFor(codes: [countryCode, cityCode])? - .hostFor(code: hostCode)? - .copy(withParent: parentNode) - } - } } diff --git a/ios/MullvadVPN/View controllers/SelectLocation/InMemoryCustomListRepository.swift b/ios/MullvadVPN/View controllers/SelectLocation/InMemoryCustomListRepository.swift index 7123e19a24..600692b8d5 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/InMemoryCustomListRepository.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/InMemoryCustomListRepository.swift @@ -29,7 +29,7 @@ class InMemoryCustomListRepository: CustomListRepositoryProtocol { ), ] - func save(list: MullvadSettings.CustomList) throws { + func save(list: CustomList) throws { if let index = customRelayLists.firstIndex(where: { $0.id == list.id }) { customRelayLists[index] = list } else if customRelayLists.contains(where: { $0.name == list.name }) { diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationNode.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationNode.swift index ed639cc219..ac9277d63a 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationNode.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationNode.swift @@ -77,6 +77,10 @@ extension LocationNode { static func combineNodeCodes(_ codes: [String]) -> String { codes.joined(separator: "-") } + + var flattened: [LocationNode] { + children + children.flatMap { $0.flattened } + } } extension LocationNode { diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift index 2b3a1f8c15..6b27418aa5 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift @@ -13,7 +13,7 @@ import MullvadTypes import UIKit protocol LocationViewControllerDelegate: AnyObject { - func didRequestRouteToCustomLists(_ controller: LocationViewController) + func didRequestRouteToCustomLists(_ controller: LocationViewController, nodes: [LocationNode]) } final class LocationViewController: UIViewController { @@ -139,7 +139,7 @@ final class LocationViewController: UIViewController { dataSource?.didTapEditCustomLists = { [weak self] in guard let self else { return } - delegate?.didRequestRouteToCustomLists(self) + delegate?.didRequestRouteToCustomLists(self, nodes: allLocationDataSource.nodes) } if let cachedRelays { @@ -151,11 +151,11 @@ final class LocationViewController: UIViewController { tableView.backgroundColor = view.backgroundColor tableView.separatorColor = .secondaryColor tableView.separatorInset = .zero - tableView.estimatedRowHeight = 53 + tableView.rowHeight = 56 + tableView.sectionHeaderHeight = 56 tableView.indicatorStyle = .white tableView.keyboardDismissMode = .onDrag tableView.accessibilityIdentifier = .selectLocationTableView - tableView.sectionHeaderHeight = 56.0 } private func setUpTopContent() { |
