diff options
| author | Bug Magnet <marco.nikic@mullvad.net> | 2024-02-21 09:31:42 +0100 |
|---|---|---|
| committer | Bug Magnet <marco.nikic@mullvad.net> | 2024-02-21 09:31:42 +0100 |
| commit | 2adee191e165e1536c33da5a40710947c5386c8d (patch) | |
| tree | 085a4869b563324cd6eca978f57004e3ca7761fd | |
| parent | e204cc78d748d40e7eab0cf0c0768107b19079cf (diff) | |
| parent | aba9565ec4a107fda9ad0c77ee9cae17b97cc048 (diff) | |
| download | mullvadvpn-2adee191e165e1536c33da5a40710947c5386c8d.tar.xz mullvadvpn-2adee191e165e1536c33da5a40710947c5386c8d.zip | |
Merge branch 'refactoring-select-location-list-view-ios-483'
12 files changed, 601 insertions, 400 deletions
diff --git a/ios/MullvadTypes/RelayLocation.swift b/ios/MullvadTypes/RelayLocation.swift index 3018d157c4..b797e69d3c 100644 --- a/ios/MullvadTypes/RelayLocation.swift +++ b/ios/MullvadTypes/RelayLocation.swift @@ -63,7 +63,7 @@ public enum RelayLocation: Codable, Hashable, CustomDebugStringConvertible { } /// A list of `RelayLocation` items preceding the given one in the relay tree - public var ascendants: [RelayLocation] { + public var ancestors: [RelayLocation] { switch self { case let .hostname(country, city, _): return [.country(country), .city(country, city)] diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 17a4b9ae7f..6495711e90 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -789,6 +789,13 @@ F050AE582B7376C6003F4EDB /* CustomListRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE562B7376C6003F4EDB /* CustomListRepository.swift */; }; F050AE5A2B7376F4003F4EDB /* CustomList.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE592B7376F4003F4EDB /* CustomList.swift */; }; F050AE5C2B73797D003F4EDB /* CustomListRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE5B2B73797D003F4EDB /* CustomListRepositoryTests.swift */; }; + F050AE4C2B70D5A7003F4EDB /* SelectLocationNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE4B2B70D5A7003F4EDB /* SelectLocationNode.swift */; }; + F050AE4E2B70D7F8003F4EDB /* LocationCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE4D2B70D7F8003F4EDB /* LocationCellViewModel.swift */; }; + F050AE502B70DC4F003F4EDB /* SelectLocationNodeProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE4F2B70DC4F003F4EDB /* SelectLocationNodeProtocol.swift */; }; + F050AE522B70DFC0003F4EDB /* SelectLocationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE512B70DFC0003F4EDB /* SelectLocationSection.swift */; }; + F050AE5E2B739A73003F4EDB /* LocationDataSourceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE5D2B739A73003F4EDB /* LocationDataSourceProtocol.swift */; }; + F050AE602B73A41E003F4EDB /* AllLocationDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE5F2B73A41E003F4EDB /* AllLocationDataSource.swift */; }; + F050AE622B74DBAC003F4EDB /* CustomListsDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE612B74DBAC003F4EDB /* CustomListsDataSource.swift */; }; F05F39942B21C6C6006E60A7 /* relays.json in Resources */ = {isa = PBXBuildFile; fileRef = 58F3C0A524A50155003E76BE /* relays.json */; }; F05F39972B21C735006E60A7 /* RelayCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5820675A26E6576800655B05 /* RelayCache.swift */; }; F05F39982B21C73C006E60A7 /* CachedRelays.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585DA87626B024A600B8C587 /* CachedRelays.swift */; }; @@ -1898,6 +1905,13 @@ F050AE562B7376C6003F4EDB /* CustomListRepository.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomListRepository.swift; sourceTree = "<group>"; }; F050AE592B7376F4003F4EDB /* CustomList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomList.swift; sourceTree = "<group>"; }; F050AE5B2B73797D003F4EDB /* CustomListRepositoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomListRepositoryTests.swift; sourceTree = "<group>"; }; + F050AE4B2B70D5A7003F4EDB /* SelectLocationNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationNode.swift; sourceTree = "<group>"; }; + F050AE4D2B70D7F8003F4EDB /* LocationCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationCellViewModel.swift; sourceTree = "<group>"; }; + F050AE4F2B70DC4F003F4EDB /* SelectLocationNodeProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationNodeProtocol.swift; sourceTree = "<group>"; }; + F050AE512B70DFC0003F4EDB /* SelectLocationSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationSection.swift; sourceTree = "<group>"; }; + F050AE5D2B739A73003F4EDB /* LocationDataSourceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationDataSourceProtocol.swift; sourceTree = "<group>"; }; + F050AE5F2B73A41E003F4EDB /* AllLocationDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllLocationDataSource.swift; sourceTree = "<group>"; }; + F050AE612B74DBAC003F4EDB /* CustomListsDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomListsDataSource.swift; sourceTree = "<group>"; }; F06045E52B231EB700B2D37A /* URLSessionTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionTransport.swift; sourceTree = "<group>"; }; F06045E92B23217E00B2D37A /* ShadowsocksTransport.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShadowsocksTransport.swift; sourceTree = "<group>"; }; F06045EB2B2322A500B2D37A /* Jittered.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Jittered.swift; sourceTree = "<group>"; }; @@ -2324,9 +2338,16 @@ 583FE01729C196F3006E85F9 /* SelectLocation */ = { isa = PBXGroup; children = ( + F050AE5F2B73A41E003F4EDB /* AllLocationDataSource.swift */, + F050AE612B74DBAC003F4EDB /* CustomListsDataSource.swift */, 58435AC129CB2A350099C71B /* LocationCellFactory.swift */, + F050AE4D2B70D7F8003F4EDB /* LocationCellViewModel.swift */, 583DA21325FA4B5C00318683 /* LocationDataSource.swift */, + F050AE5D2B739A73003F4EDB /* LocationDataSourceProtocol.swift */, 5888AD82227B11080051EB06 /* SelectLocationCell.swift */, + F050AE512B70DFC0003F4EDB /* SelectLocationSection.swift */, + F050AE4B2B70D5A7003F4EDB /* SelectLocationNode.swift */, + F050AE4F2B70DC4F003F4EDB /* SelectLocationNodeProtocol.swift */, 5888AD86227B17950051EB06 /* SelectLocationViewController.swift */, ); path = SelectLocation; @@ -4995,6 +5016,7 @@ 58968FAE28743E2000B799DC /* TunnelInteractor.swift in Sources */, 7A1A26472A29CF0800B978AA /* RelayFilterDataSource.swift in Sources */, 5864AF0929C78850005B0CD9 /* PreferencesCellFactory.swift in Sources */, + F050AE4E2B70D7F8003F4EDB /* LocationCellViewModel.swift in Sources */, 58CEB30C2AFD586600E6E088 /* DynamicBackgroundConfiguration.swift in Sources */, 587B7536266528A200DEF7E9 /* NotificationManager.swift in Sources */, 5820EDA9288FE064006BF4E4 /* DeviceManagementInteractor.swift in Sources */, @@ -5008,6 +5030,7 @@ 5871FB96254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift in Sources */, F0E8E4C52A60499100ED26A3 /* AccountDeletionViewController.swift in Sources */, 7A9CCCC12A96302800DD6A34 /* AccountCoordinator.swift in Sources */, + F050AE502B70DC4F003F4EDB /* SelectLocationNodeProtocol.swift in Sources */, 58FEEB58260B662E00A621A8 /* AutomaticKeyboardResponder.swift in Sources */, 5846227326E22A160035F7C2 /* StorePaymentObserver.swift in Sources */, F0E3618B2A4ADD2F00AEEF2B /* WelcomeContentView.swift in Sources */, @@ -5049,6 +5072,7 @@ 7A9CCCBE2A96302800DD6A34 /* AccountDeletionCoordinator.swift in Sources */, 588527B4276B4F2F00BAA373 /* SetAccountOperation.swift in Sources */, 58FF9FE02B075ABC00E4C97D /* EditAccessMethodViewController.swift in Sources */, + F050AE622B74DBAC003F4EDB /* CustomListsDataSource.swift in Sources */, F0DA87472A9CB9A2006044F1 /* AccountExpiryRow.swift in Sources */, 585CA70F25F8C44600B47C62 /* UIMetrics.swift in Sources */, E1187ABD289BBB850024E748 /* OutOfTimeContentView.swift in Sources */, @@ -5081,6 +5105,7 @@ 063F026628FFE11C001FA09F /* RESTCreateApplePaymentResponse+Localization.swift in Sources */, 58DF28A52417CB4B00E836B0 /* StorePaymentManager.swift in Sources */, 583DA21425FA4B5C00318683 /* LocationDataSource.swift in Sources */, + F050AE602B73A41E003F4EDB /* AllLocationDataSource.swift in Sources */, 587EB6742714520600123C75 /* PreferencesDataSourceDelegate.swift in Sources */, 582BB1AF229566420055B6EF /* SettingsCell.swift in Sources */, 7AF9BE8E2A331C7B00DBFEDB /* RelayFilterViewModel.swift in Sources */, @@ -5186,7 +5211,9 @@ 584592612639B4A200EF967F /* TermsOfServiceContentView.swift in Sources */, 5875960A26F371FC00BF6711 /* Tunnel+Messaging.swift in Sources */, 586C0D912B03D8A400E7CDD7 /* AccessMethodHeaderFooterReuseIdentifier.swift in Sources */, + F050AE4C2B70D5A7003F4EDB /* SelectLocationNode.swift in Sources */, 7A2960F62A963F7500389B82 /* AlertCoordinator.swift in Sources */, + F050AE522B70DFC0003F4EDB /* SelectLocationSection.swift in Sources */, 063687BA28EB234F00BE7161 /* PacketTunnelTransport.swift in Sources */, A9C342C12ACC37E30045F00E /* TunnelStatusBlockObserver.swift in Sources */, 587425C12299833500CA2045 /* RootContainerViewController.swift in Sources */, @@ -5206,6 +5233,7 @@ 5827B0BF2B14B37D00CCBBA1 /* Publisher+PreviousValue.swift in Sources */, 7A9CCCB62A96302800DD6A34 /* OutOfTimeCoordinator.swift in Sources */, 5827B0AA2B0F4C9100CCBBA1 /* EditAccessMethodViewControllerDelegate.swift in Sources */, + F050AE5E2B739A73003F4EDB /* LocationDataSourceProtocol.swift in Sources */, 7A5869A82B5140C200640D27 /* MethodSettingsValidationErrorContentView.swift in Sources */, A99E5EE22B762ED30033F241 /* ProblemReportViewController+ViewManagement.swift in Sources */, 7A5869A22B502EA800640D27 /* MethodSettingsSectionIdentifier.swift in Sources */, diff --git a/ios/MullvadVPN/View controllers/SelectLocation/AllLocationDataSource.swift b/ios/MullvadVPN/View controllers/SelectLocation/AllLocationDataSource.swift new file mode 100644 index 0000000000..33bfe57593 --- /dev/null +++ b/ios/MullvadVPN/View controllers/SelectLocation/AllLocationDataSource.swift @@ -0,0 +1,93 @@ +// +// AllLocationDataSource.swift +// MullvadVPN +// +// Created by Mojgan on 2024-02-07. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadREST +import MullvadTypes + +class AllLocationDataSource: LocationDataSourceProtocol { + var nodeByLocation = [RelayLocation: SelectLocationNode]() + private var locationList = [RelayLocation]() + + func search(by text: String) -> [RelayLocation] { + guard !text.isEmpty else { + return locationList + } + + var filteredLocations: [RelayLocation] = [] + locationList.forEach { location in + guard let countryNode = nodeByLocation[location] else { return } + countryNode.showsChildren = false + + if countryNode.displayName.fuzzyMatch(text) { + filteredLocations.append(countryNode.location) + } + + countryNode.children.forEach { cityNode in + cityNode.showsChildren = false + + let relaysContainSearchString = cityNode.children + .contains(where: { $0.displayName.fuzzyMatch(text) }) + + if cityNode.displayName.fuzzyMatch(text) || relaysContainSearchString { + if !filteredLocations.contains(countryNode.location) { + filteredLocations.append(countryNode.location) + } + + filteredLocations.append(cityNode.location) + countryNode.showsChildren = true + + if relaysContainSearchString { + filteredLocations.append(contentsOf: cityNode.children.map { $0.location }) + cityNode.showsChildren = true + } + } + } + } + + return filteredLocations + } + + func reload( + _ response: REST.ServerRelaysResponse, + relays: [REST.ServerRelay] + ) -> [RelayLocation] { + nodeByLocation.removeAll() + let rootNode = self.makeRootNode(name: SelectLocationSection.allLocations.description) + + for relay in relays { + guard case let .city(countryCode, cityCode) = RelayLocation(dashSeparatedString: relay.location), + let serverLocation = response.locations[relay.location] else { continue } + + let relayLocation = RelayLocation.hostname(countryCode, cityCode, relay.hostname) + + for ancestorOrSelf in relayLocation.ancestors + [relayLocation] { + guard !nodeByLocation.keys.contains(ancestorOrSelf) else { + continue + } + + // Maintain the `showsChildren` state when transitioning between relay lists + let wasShowingChildren = nodeByLocation[ancestorOrSelf]?.showsChildren ?? false + + let node = createNode( + root: rootNode, + ancestorOrSelf: ancestorOrSelf, + serverLocation: serverLocation, + relay: relay, + wasShowingChildren: wasShowingChildren + ) + nodeByLocation[ancestorOrSelf] = node + } + } + + rootNode.sortChildrenRecursive() + rootNode.computeActiveChildrenRecursive() + locationList = rootNode.flatRelayLocationList() + return locationList + } +} diff --git a/ios/MullvadVPN/View controllers/SelectLocation/CustomListsDataSource.swift b/ios/MullvadVPN/View controllers/SelectLocation/CustomListsDataSource.swift new file mode 100644 index 0000000000..897e68b9c3 --- /dev/null +++ b/ios/MullvadVPN/View controllers/SelectLocation/CustomListsDataSource.swift @@ -0,0 +1,27 @@ +// +// CustomListsDataSource.swift +// MullvadVPN +// +// Created by Mojgan on 2024-02-08. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadREST +import MullvadTypes + +class CustomListsDataSource: LocationDataSourceProtocol { + var nodeByLocation = [RelayLocation: SelectLocationNode]() + private var locationList = [RelayLocation]() + + func search(by text: String) -> [RelayLocation] { + [] + } + + func reload( + _ response: REST.ServerRelaysResponse, + relays: [REST.ServerRelay] + ) -> [RelayLocation] { + locationList + } +} diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationCellFactory.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationCellFactory.swift index 46e7d97aac..5151752d09 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationCellFactory.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationCellFactory.swift @@ -10,22 +10,26 @@ import MullvadTypes import UIKit protocol LocationCellEventHandler { - func collapseCell(for item: RelayLocation) + func toggleCell(for item: LocationCellViewModel) + func node(for item: LocationCellViewModel) -> SelectLocationNode? } final class LocationCellFactory: CellFactoryProtocol { - var nodeByLocation = [RelayLocation: LocationDataSource.Node]() var delegate: LocationCellEventHandler? let tableView: UITableView + let reuseIdentifier: String - init(tableView: UITableView, nodeByLocation: [RelayLocation: LocationDataSource.Node]) { + init( + tableView: UITableView, + reuseIdentifier: String + ) { self.tableView = tableView - self.nodeByLocation = nodeByLocation + self.reuseIdentifier = reuseIdentifier } - func makeCell(for item: RelayLocation, indexPath: IndexPath) -> UITableViewCell { + func makeCell(for item: LocationCellViewModel, indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell( - withIdentifier: LocationDataSource.CellReuseIdentifiers.locationCell.rawValue, + withIdentifier: reuseIdentifier, for: indexPath ) @@ -34,9 +38,9 @@ final class LocationCellFactory: CellFactoryProtocol { return cell } - func configureCell(_ cell: UITableViewCell, item: RelayLocation, indexPath: IndexPath) { + func configureCell(_ cell: UITableViewCell, item: LocationCellViewModel, indexPath: IndexPath) { guard let cell = cell as? SelectLocationCell, - let node = nodeByLocation[item] else { return } + let node = delegate?.node(for: item) else { return } cell.accessibilityIdentifier = node.location.stringRepresentation cell.isDisabled = !node.isActive @@ -44,7 +48,7 @@ final class LocationCellFactory: CellFactoryProtocol { cell.showsCollapseControl = node.isCollapsible cell.isExpanded = node.showsChildren cell.didCollapseHandler = { [weak self] _ in - self?.delegate?.collapseCell(for: item) + self?.delegate?.toggleCell(for: item) } } } diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationCellViewModel.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationCellViewModel.swift new file mode 100644 index 0000000000..2b27f8e0ed --- /dev/null +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationCellViewModel.swift @@ -0,0 +1,14 @@ +// +// LocationCellViewModel.swift +// MullvadVPN +// +// Created by Mojgan on 2024-02-05. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import MullvadTypes + +struct LocationCellViewModel: Hashable { + let group: SelectLocationSection + let location: RelayLocation +} diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift index e3891d214d..1a026e54e9 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift @@ -6,59 +6,32 @@ // Copyright © 2021 Mullvad VPN AB. All rights reserved. // +import Combine import MullvadREST import MullvadTypes import UIKit -protocol LocationDataSourceItemProtocol { - var location: RelayLocation { get } - var displayName: String { get } - var showsChildren: Bool { get } - var isActive: Bool { get } - - var isCollapsible: Bool { get } - var indentationLevel: Int { get } -} - -final class LocationDataSource: UITableViewDiffableDataSource<Int, RelayLocation> { - enum CellReuseIdentifiers: String, CaseIterable { - case locationCell - - var reusableViewClass: AnyClass { - switch self { - case .locationCell: - return SelectLocationCell.self - } - } - } - - private var nodeByLocation = [RelayLocation: Node]() - private var locationList = [RelayLocation]() +final class LocationDataSource: UITableViewDiffableDataSource<SelectLocationSection, LocationCellViewModel> { private var currentSearchString = "" - private let tableView: UITableView private let locationCellFactory: LocationCellFactory + private var dataSources: [LocationDataSourceProtocol] = [] - private class func makeRootNode() -> Node { - Node( - type: .root, - location: RelayLocation.country("#root"), - displayName: "", - showsChildren: true, - isActive: true, - children: [] - ) - } - - var selectedRelayLocation: RelayLocation? + var selectedRelayLocation: LocationCellViewModel? var didSelectRelayLocation: ((RelayLocation) -> Void)? - init(tableView: UITableView) { + init( + tableView: UITableView, + allLocations: LocationDataSourceProtocol, + customLists: LocationDataSourceProtocol + ) { self.tableView = tableView + self.dataSources.append(customLists) + self.dataSources.append(allLocations) let locationCellFactory = LocationCellFactory( tableView: tableView, - nodeByLocation: nodeByLocation + reuseIdentifier: SelectLocationSection.Cell.locationCell.reuseIdentifier ) self.locationCellFactory = locationCellFactory @@ -77,282 +50,131 @@ final class LocationDataSource: UITableViewDiffableDataSource<Int, RelayLocation let relays = response.wireguard.relays.filter { relay in return RelaySelector.relayMatchesFilter(relay, filter: filter) } - - let rootNode = Self.makeRootNode() - nodeByLocation.removeAll() - - for relay in relays { - guard case let .city(countryCode, cityCode) = RelayLocation(dashSeparatedString: relay.location), - let serverLocation = response.locations[relay.location] else { continue } - - let relayLocation = RelayLocation.hostname(countryCode, cityCode, relay.hostname) - - for ascendantOrSelf in relayLocation.ascendants + [relayLocation] { - guard !nodeByLocation.keys.contains(ascendantOrSelf) else { - continue - } - - // Maintain the `showsChildren` state when transitioning between relay lists - let wasShowingChildren = nodeByLocation[ascendantOrSelf]?.showsChildren ?? false - - let node = createNode( - ascendantOrSelf: ascendantOrSelf, - serverLocation: serverLocation, - relay: relay, - rootNode: rootNode, - wasShowingChildren: wasShowingChildren - ) - nodeByLocation[ascendantOrSelf] = node - } + var list: [[LocationCellViewModel]] = [] + for section in 0 ..< dataSources.count { + list.append( + dataSources[section] + .reload(response, relays: relays) + .map { LocationCellViewModel(group: SelectLocationSection.allCases[section], location: $0) } + ) } - - rootNode.sortChildrenRecursive() - rootNode.computeActiveChildrenRecursive() - locationList = rootNode.flatRelayLocationList() - filterRelays(by: currentSearchString) } func indexPathForSelectedRelay() -> IndexPath? { - selectedRelayLocation.flatMap { indexPath(for: $0) } + selectedRelayLocation.flatMap { + indexPath(for: $0) + } } func filterRelays(by searchString: String) { currentSearchString = searchString - if currentSearchString.isEmpty { - return resetLocationList() - } - - var filteredLocations = [RelayLocation]() - - locationList.forEach { location in - guard let countryNode = nodeByLocation[location] else { return } - countryNode.showsChildren = false - - if searchString.isEmpty || countryNode.displayName.fuzzyMatch(searchString) { - filteredLocations.append(countryNode.location) - } - - for cityNode in countryNode.children { - cityNode.showsChildren = false - - let relaysContainSearchString = cityNode.children.contains(where: { node in - node.displayName.fuzzyMatch(searchString) - }) - - if cityNode.displayName.fuzzyMatch(searchString) || relaysContainSearchString { - if !filteredLocations.contains(countryNode.location) { - filteredLocations.append(countryNode.location) - } - - filteredLocations.append(cityNode.location) - countryNode.showsChildren = true - - if relaysContainSearchString { - filteredLocations.append(contentsOf: cityNode.children.map { $0.location }) - cityNode.showsChildren = true - } - } - } - } - - updateDataSnapshot(with: filteredLocations, reloadExisting: true) { [weak self] in - self?.scrollToTop(animated: false) + let list = SelectLocationSection.allCases.enumerated().map { section, group in + dataSources[section] + .search(by: searchString) + .map { LocationCellViewModel(group: group, location: $0) } } - } - - private func createNode( - ascendantOrSelf: RelayLocation, - serverLocation: REST.ServerLocation, - relay: REST.ServerRelay, - rootNode: Node, - wasShowingChildren: Bool - ) -> Node { - let node: Node - - switch ascendantOrSelf { - case .country: - node = Node( - type: .country, - location: ascendantOrSelf, - displayName: serverLocation.country, - showsChildren: wasShowingChildren, - isActive: true, - children: [] - ) - rootNode.addChild(node) - case let .city(countryCode, _): - node = Node( - type: .city, - location: ascendantOrSelf, - displayName: serverLocation.city, - showsChildren: wasShowingChildren, - isActive: true, - children: [] - ) - nodeByLocation[.country(countryCode)]!.addChild(node) + updateDataSnapshot(with: list, reloadExisting: !searchString.isEmpty) - case let .hostname(countryCode, cityCode, _): - node = Node( - type: .relay, - location: ascendantOrSelf, - displayName: relay.hostname, - showsChildren: false, - isActive: relay.active, - children: [] - ) - nodeByLocation[.city(countryCode, cityCode)]!.addChild(node) + if searchString.isEmpty { + setSelectedRelayLocation(selectedRelayLocation, animated: false, completion: { + self.scrollToSelectedRelay() + }) + } else { + scrollToTop(animated: false) } - - return node } private func updateDataSnapshot( - with locations: [RelayLocation], + with list: [[LocationCellViewModel]], reloadExisting: Bool = false, animated: Bool = false, completion: (() -> Void)? = nil ) { - updateCellFactory(with: nodeByLocation) + var snapshot = NSDiffableDataSourceSnapshot<SelectLocationSection, LocationCellViewModel>() - var snapshot = NSDiffableDataSourceSnapshot<Int, RelayLocation>() - - snapshot.appendSections([0]) - snapshot.appendItems(locations) + let sections = SelectLocationSection.allCases + snapshot.appendSections(sections) + for (index, section) in sections.enumerated() { + snapshot.appendItems(list[index], toSection: section) + } if reloadExisting { - snapshot.reloadItems(locations) + snapshot.reloadSections(SelectLocationSection.allCases) } apply(snapshot, animatingDifferences: animated, completion: completion) } private func registerClasses() { - CellReuseIdentifiers.allCases.forEach { enumCase in + SelectLocationSection.allCases.forEach { tableView.register( - enumCase.reusableViewClass, - forCellReuseIdentifier: enumCase.rawValue + $0.cell.reusableViewClass, + forCellReuseIdentifier: $0.cell.reuseIdentifier ) } } - private func updateCellFactory(with nodeByLocation: [RelayLocation: Node]) { - locationCellFactory.nodeByLocation = nodeByLocation - } - private func setSelectedRelayLocation( - _ relayLocation: RelayLocation?, + _ relayLocation: LocationCellViewModel?, animated: Bool, completion: (() -> Void)? = nil ) { selectedRelayLocation = relayLocation - var locationList = snapshot().itemIdentifiers + guard let selectedRelayLocation else { return } - guard let selectedRelayLocation, - !locationList.contains(selectedRelayLocation) else { return } - - let selectedLocationTree = selectedRelayLocation.ascendants + [selectedRelayLocation] - - guard let topLocation = selectedLocationTree.first, - let topNode = nodeByLocation[topLocation], - let indexPath = indexPath(for: topLocation) - else { + let group = selectedRelayLocation.group + var locationList = snapshot().itemIdentifiers(inSection: group) + guard !locationList.contains(selectedRelayLocation) else { + completion?() return } + let selectedLocationTree = selectedRelayLocation.location.ancestors + [selectedRelayLocation.location] - selectedLocationTree.forEach { location in - nodeByLocation[location]?.showsChildren = true - } + guard let first = selectedLocationTree.first else { return } + let topLocation = LocationCellViewModel(group: group, location: first) - locationList.addLocations(topNode.flatRelayLocationList(), at: indexPath.row + 1) - updateDataSnapshot(with: locationList, reloadExisting: true, animated: animated, completion: completion) - } - - private func toggleChildren( - _ relayLocation: RelayLocation, - show: Bool, - animated: Bool - ) { - guard let node = nodeByLocation[relayLocation], - let indexPath = indexPath(for: node.location), - let cell = tableView.cellForRow(at: indexPath) else { return } - - node.showsChildren = show - locationCellFactory.configureCell(cell, item: node.location, indexPath: indexPath) - - var locationList = snapshot().itemIdentifiers - let locationsToEdit = node.flatRelayLocationList() - - if show { - locationList.addLocations(locationsToEdit, at: indexPath.row + 1) - } else { - locationsToEdit.forEach { nodeByLocation[$0]?.showsChildren = false } - locationList.removeLocations(locationsToEdit) + guard let indexPath = indexPath(for: topLocation), + let topNode = node(for: topLocation) else { + return } - updateDataSnapshot(with: locationList, animated: animated) { [weak self] in - guard let visibleIndexPaths = self?.tableView.indexPathsForVisibleRows else { return } - - let scrollToNodeTop = { - if let firstInsertedIndexPath = self?.indexPath(for: node.location) { - self?.tableView.scrollToRow( - at: firstInsertedIndexPath, - at: .top, - animated: animated - ) - } - } - - let scrollToNodeBottom = { - if let location = node.children.last?.location, - let lastInsertedIndexPath = self?.indexPath(for: location), - let lastVisibleIndexPath = visibleIndexPaths.last, - lastInsertedIndexPath >= lastVisibleIndexPath { - self?.tableView.scrollToRow( - at: lastInsertedIndexPath, - at: .bottom, - animated: animated - ) - } - } - - if node.children.count > visibleIndexPaths.count { - scrollToNodeTop() - } else { - scrollToNodeBottom() - } + selectedLocationTree.forEach { location in + node(for: LocationCellViewModel(group: group, location: location))?.showsChildren = true } - } - private func resetLocationList() { - nodeByLocation.values.forEach { $0.showsChildren = false } - - updateDataSnapshot(with: locationList, reloadExisting: true) - setSelectedRelayLocation(selectedRelayLocation, animated: false) + locationList.addLocations( + topNode.flatRelayLocationList().map { LocationCellViewModel(group: group, location: $0) }, + at: indexPath.row + 1 + ) - if let indexPath = indexPathForSelectedRelay() { - tableView.scrollToRow(at: indexPath, at: .middle, animated: false) + var list: [[LocationCellViewModel]] = Array(repeating: [], count: dataSources.count) + for index in 0 ..< list.count { + list[index] = (index == indexPath.section) + ? locationList + : snapshot().itemIdentifiers(inSection: SelectLocationSection.allCases[index]) } - } - private func item(for indexPath: IndexPath) -> LocationDataSourceItemProtocol? { - itemIdentifier(for: indexPath).flatMap { nodeByLocation[$0] } - } - - private func scrollToTop(animated: Bool) { - tableView.setContentOffset(.zero, animated: animated) + updateDataSnapshot( + with: list, + reloadExisting: true, + animated: animated, + completion: completion + ) } } extension LocationDataSource: UITableViewDelegate { func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { - item(for: indexPath)?.isActive ?? false + guard let item = itemIdentifier(for: indexPath) else { return false } + return node(for: item)?.isActive ?? false } func tableView(_ tableView: UITableView, indentationLevelForRowAt indexPath: IndexPath) -> Int { - item(for: indexPath)?.indentationLevel ?? 0 + guard let item = itemIdentifier(for: indexPath) else { return 0 } + return node(for: item)?.indentationLevel ?? 0 } func tableView( @@ -360,169 +182,109 @@ extension LocationDataSource: UITableViewDelegate { willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath ) { - if let item = item(for: indexPath), - item.location == selectedRelayLocation { + if let item = itemIdentifier(for: indexPath), + item == selectedRelayLocation { cell.setSelected(true, animated: false) } } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - guard let item = item(for: indexPath), - item.location != selectedRelayLocation - else { - return - } + itemIdentifier(for: indexPath) + .flatMap { item in + guard item.location != selectedRelayLocation?.location else { return } + didSelectRelayLocation?(item.location) - if let indexPath = indexPathForSelectedRelay(), - let cell = tableView.cellForRow(at: indexPath) { - cell.setSelected(false, animated: false) - } + setSelectedRelayLocation(item, animated: false) - setSelectedRelayLocation( - item.location, - animated: false - ) - - didSelectRelayLocation?(item.location) + indexPathForSelectedRelay().flatMap { + let cell = tableView.cellForRow(at: $0) + cell?.setSelected(false, animated: false) + } + } } } extension LocationDataSource: LocationCellEventHandler { - func collapseCell(for item: RelayLocation) { - guard let node = nodeByLocation[item] else { return } + func toggleCell(for item: LocationCellViewModel) { + indexPath(for: item).flatMap { indexPath in + guard let node = node(for: item), let cell = tableView.cellForRow(at: indexPath) else { return } - toggleChildren( - item, - show: !node.showsChildren, - animated: true - ) - } -} + let isExpanded = node.showsChildren + let group = SelectLocationSection.allCases[indexPath.section] -extension LocationDataSource { - enum NodeType { - case root - case country - case city - case relay - } + node.showsChildren = !isExpanded + locationCellFactory.configureCell( + cell, + item: LocationCellViewModel(group: group, location: node.location), + indexPath: indexPath + ) - class Node: LocationDataSourceItemProtocol { - let nodeType: NodeType - var location: RelayLocation - var displayName: String - var showsChildren: Bool - var isActive: Bool - var children: [Node] + var locationList = snapshot().itemIdentifiers(inSection: group) + let locationsToEdit = node.flatRelayLocationList().map { LocationCellViewModel(group: group, location: $0) } - var isCollapsible: Bool { - switch nodeType { - case .country, .city: - return true - case .root, .relay: - return false + if !isExpanded { + locationList.addLocations(locationsToEdit, at: indexPath.row + 1) + } else { + locationsToEdit.forEach { self.node(for: $0)?.showsChildren = false } + locationList.removeLocations(locationsToEdit) } - } - var indentationLevel: Int { - switch nodeType { - case .root, .country: - return 0 - case .city: - return 1 - case .relay: - return 2 + var list: [[LocationCellViewModel]] = Array(repeating: [], count: dataSources.count) + for index in 0 ..< list.count { + list[index] = (index == indexPath.section) + ? locationList + : snapshot().itemIdentifiers(inSection: SelectLocationSection.allCases[index]) } - } - init( - type: NodeType, - location: RelayLocation, - displayName: String, - showsChildren: Bool, - isActive: Bool, - children: [Node] - ) { - nodeType = type - self.location = location - self.displayName = displayName - self.showsChildren = showsChildren - self.isActive = isActive - self.children = children + updateDataSnapshot(with: list, completion: { + self.scroll(to: item, animated: true) + }) } + } - func addChild(_ child: Node) { - children.append(child) + func node(for item: LocationCellViewModel) -> SelectLocationNode? { + guard let sectionIndex = SelectLocationSection.allCases.firstIndex(of: item.group) else { + return nil } + return dataSources[sectionIndex].nodeByLocation[item.location] + } +} - func sortChildrenRecursive() { - sortChildren() - children.forEach { node in - node.sortChildrenRecursive() - } - } +extension LocationDataSource { + private func scroll(to location: LocationCellViewModel, animated: Bool) { + guard let visibleIndexPaths = tableView.indexPathsForVisibleRows, + let indexPath = indexPath(for: location), + let node = node(for: location) else { return } - func computeActiveChildrenRecursive() { - switch nodeType { - case .root, .country: - for node in children { - node.computeActiveChildrenRecursive() + if node.children.count > visibleIndexPaths.count { + tableView.scrollToRow(at: indexPath, at: .top, animated: animated) + } else { + node.children.last.flatMap { last in + if let lastInsertedIndexPath = self.indexPath(for: LocationCellViewModel( + group: SelectLocationSection.allCases[indexPath.section], + location: last.location + )), + let lastVisibleIndexPath = visibleIndexPaths.last, + lastInsertedIndexPath >= lastVisibleIndexPath { + tableView.scrollToRow(at: lastInsertedIndexPath, at: .bottom, animated: animated) } - fallthrough - case .city: - isActive = children.contains(where: { node -> Bool in - node.isActive - }) - case .relay: - break - } - } - - func flatRelayLocationList(includeHiddenChildren: Bool = false) -> [RelayLocation] { - children.reduce(into: []) { array, node in - Self.flatten(node: node, into: &array, includeHiddenChildren: includeHiddenChildren) } } + } - private func sortChildren() { - switch nodeType { - case .root, .country: - children.sort { a, b -> Bool in - lexicalSortComparator(a.displayName, b.displayName) - } - case .city: - children.sort { a, b -> Bool in - fileSortComparator( - a.location.stringRepresentation, - b.location.stringRepresentation - ) - } - case .relay: - break - } - } + private func scrollToTop(animated: Bool) { + tableView.setContentOffset(.zero, animated: animated) + } - private class func flatten(node: Node, into array: inout [RelayLocation], includeHiddenChildren: Bool) { - array.append(node.location) - if includeHiddenChildren || node.showsChildren { - for child in node.children { - Self.flatten(node: child, into: &array, includeHiddenChildren: includeHiddenChildren) - } - } + private func scrollToSelectedRelay() { + indexPathForSelectedRelay().flatMap { + tableView.scrollToRow(at: $0, at: .middle, animated: false) } } } -private func lexicalSortComparator(_ a: String, _ b: String) -> Bool { - a.localizedCaseInsensitiveCompare(b) == .orderedAscending -} - -private func fileSortComparator(_ a: String, _ b: String) -> Bool { - a.localizedStandardCompare(b) == .orderedAscending -} - -private extension [RelayLocation] { - mutating func addLocations(_ locations: [RelayLocation], at index: Int) { +private extension [LocationCellViewModel] { + mutating func addLocations(_ locations: [LocationCellViewModel], at index: Int) { if index < count { insert(contentsOf: locations, at: index) } else { @@ -530,11 +292,9 @@ private extension [RelayLocation] { } } - mutating func removeLocations(_ locations: [RelayLocation]) { + mutating func removeLocations(_ locations: [LocationCellViewModel]) { removeAll(where: { location in locations.contains(location) }) } - - // swiftlint:disable:next file_length } diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSourceProtocol.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSourceProtocol.swift new file mode 100644 index 0000000000..6511f4bd44 --- /dev/null +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSourceProtocol.swift @@ -0,0 +1,68 @@ +// +// LocationDataSourceProtocol.swift +// MullvadVPN +// +// Created by Mojgan on 2024-02-07. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadREST +import MullvadTypes +import UIKit + +protocol LocationDataSourceProtocol { + var nodeByLocation: [RelayLocation: SelectLocationNode] { get } + + func search(by text: String) -> [RelayLocation] + + func reload( + _ response: REST.ServerRelaysResponse, + relays: [REST.ServerRelay] + ) -> [RelayLocation] +} + +extension LocationDataSourceProtocol { + func makeRootNode(name: String) -> SelectLocationNode { + SelectLocationNode(nodeType: .root, location: .country("#root"), displayName: name) + } + + func createNode( + root: SelectLocationNode, + ancestorOrSelf: RelayLocation, + serverLocation: REST.ServerLocation, + relay: REST.ServerRelay, + wasShowingChildren: Bool + ) -> SelectLocationNode { + let node: SelectLocationNode + + switch ancestorOrSelf { + case .country: + node = SelectLocationNode( + nodeType: .country, + location: ancestorOrSelf, + displayName: serverLocation.country, + showsChildren: wasShowingChildren + ) + root.addChild(node) + case let .city(countryCode, _): + node = SelectLocationNode( + nodeType: .city, + location: ancestorOrSelf, + displayName: serverLocation.city, + showsChildren: wasShowingChildren + ) + nodeByLocation[.country(countryCode)]!.addChild(node) + + case let .hostname(countryCode, cityCode, _): + node = SelectLocationNode( + nodeType: .relay, + location: ancestorOrSelf, + displayName: relay.hostname, + isActive: relay.active + ) + nodeByLocation[.city(countryCode, cityCode)]!.addChild(node) + } + return node + } +} diff --git a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationNode.swift b/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationNode.swift new file mode 100644 index 0000000000..789075d15f --- /dev/null +++ b/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationNode.swift @@ -0,0 +1,123 @@ +// +// SelectLocationNode.swift +// MullvadVPN +// +// Created by Mojgan on 2024-02-05. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import MullvadTypes + +enum LocationNodeType { + case root + case country + case city + case relay +} + +class SelectLocationNode: SelectLocationNodeProtocol { + var children: [SelectLocationNode] + var showsChildren: Bool + var nodeType: LocationNodeType + var location: RelayLocation + var displayName: String + var isActive: Bool + + init( + nodeType: LocationNodeType, + location: RelayLocation, + displayName: String = "", + isActive: Bool = true, + showsChildren: Bool = false, + children: [SelectLocationNode] = [] + ) { + self.showsChildren = showsChildren + self.nodeType = nodeType + self.location = location + self.displayName = displayName + self.isActive = isActive + self.children = children + } + + var isCollapsible: Bool { + switch nodeType { + case .country, .city: + return true + case .root, .relay: + return false + } + } + + var indentationLevel: Int { + switch nodeType { + case .root, .country: + return 0 + case .city: + return 1 + case .relay: + return 2 + } + } + + func addChild(_ child: SelectLocationNode) { + children.append(child) + } + + func sortChildrenRecursive() { + sortChildren() + children.forEach { node in + node.sortChildrenRecursive() + } + } + + func computeActiveChildrenRecursive() { + switch nodeType { + case .root, .country: + for node in children { + node.computeActiveChildrenRecursive() + } + fallthrough + case .city: + isActive = children.contains(where: { node -> Bool in + node.isActive + }) + case .relay: + break + } + } + + func flatRelayLocationList(includeHiddenChildren: Bool = false) -> [RelayLocation] { + children.reduce(into: []) { array, node in + Self.flatten(node: node, into: &array, includeHiddenChildren: includeHiddenChildren) + } + } + + private func sortChildren() { + switch nodeType { + case .root, .country: + children.sort { a, b -> Bool in + a.displayName.localizedCaseInsensitiveCompare(b.displayName) == .orderedAscending + } + case .city: + children.sort { a, b -> Bool in + a.location.stringRepresentation + .localizedStandardCompare(b.location.stringRepresentation) == .orderedAscending + } + case .relay: + break + } + } + + private class func flatten( + node: SelectLocationNode, + into array: inout [RelayLocation], + includeHiddenChildren: Bool + ) { + array.append(node.location) + if includeHiddenChildren || node.showsChildren { + for child in node.children { + Self.flatten(node: child, into: &array, includeHiddenChildren: includeHiddenChildren) + } + } + } +} diff --git a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationNodeProtocol.swift b/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationNodeProtocol.swift new file mode 100644 index 0000000000..2d45f3f2d8 --- /dev/null +++ b/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationNodeProtocol.swift @@ -0,0 +1,18 @@ +// +// SelectLocationNodeProtocol.swift +// MullvadVPN +// +// Created by Mojgan on 2024-02-05. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import MullvadTypes + +protocol SelectLocationNodeProtocol { + var location: RelayLocation { get } + var displayName: String { get } + var showsChildren: Bool { get } + var isActive: Bool { get } + var isCollapsible: Bool { get } + var indentationLevel: Int { get } +} diff --git a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationSection.swift b/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationSection.swift new file mode 100644 index 0000000000..2e0984c809 --- /dev/null +++ b/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationSection.swift @@ -0,0 +1,59 @@ +// +// SelectLocationSectionGroup.swift +// MullvadVPN +// +// Created by Mojgan on 2024-02-05. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +enum SelectLocationSection: Hashable, CustomStringConvertible, CaseIterable { + case customLists + case allLocations + + var description: String { + switch self { + case .customLists: + return NSLocalizedString( + "SELECT_LOCATION_ADD_CUSTOM_LISTS", + value: "Custom lists", + comment: "" + ) + case .allLocations: + return NSLocalizedString( + "SELECT_LOCATION_ALL_LOCATIONS", + value: "All locations", + comment: "" + ) + } + } + + var cell: Cell { + Cell.locationCell + } + + static var allCases: [SelectLocationSection] { + #if DEBUG + return [.customLists, .allLocations] + #else + return [.allLocations] + #endif + } +} + +extension SelectLocationSection { + enum Cell: String, CaseIterable { + case locationCell + + var reusableViewClass: AnyClass { + switch self { + case .locationCell: + return SelectLocationCell.self + } + } + + var reuseIdentifier: String { + self.rawValue + } + } +} diff --git a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewController.swift b/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewController.swift index 47e9a4309f..5f8f5d2145 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewController.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewController.swift @@ -114,12 +114,19 @@ final class SelectLocationViewController: UIViewController { // MARK: - Private private func setUpDataSource() { - dataSource = LocationDataSource(tableView: tableView) + dataSource = LocationDataSource( + tableView: tableView, + allLocations: AllLocationDataSource(), + customLists: CustomListsDataSource() + ) dataSource?.didSelectRelayLocation = { [weak self] location in self?.didSelectRelay?(location) } - dataSource?.selectedRelayLocation = relayLocation + dataSource?.selectedRelayLocation = relayLocation.flatMap { LocationCellViewModel( + group: .allLocations, + location: $0 + ) } if let cachedRelays { dataSource?.setRelays(cachedRelays.relays, filter: filter) |
