diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index af62482d1283..270ecdabc9d9 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -531,6 +531,7 @@ 7A6389EB2B7FAD7A008E77E1 /* SettingsFieldValidationErrorContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6389EA2B7FAD7A008E77E1 /* SettingsFieldValidationErrorContentView.swift */; }; 7A6389ED2B7FADA1008E77E1 /* SettingsFieldValidationErrorConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6389EC2B7FADA1008E77E1 /* SettingsFieldValidationErrorConfiguration.swift */; }; 7A6389F82B864CDF008E77E1 /* LocationNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6389F72B864CDF008E77E1 /* LocationNode.swift */; }; + 7A6652B82BB44C3E0042D848 /* LocationDiffableDataSourceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6652B62BB44B120042D848 /* LocationDiffableDataSourceProtocol.swift */; }; 7A6B4F592AB8412E00123853 /* TunnelMonitorTimings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6B4F582AB8412E00123853 /* TunnelMonitorTimings.swift */; }; 7A6F2FA52AFA3CB2006D0856 /* AccountExpiryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6F2FA42AFA3CB2006D0856 /* AccountExpiryTests.swift */; }; 7A6F2FA72AFBB9AE006D0856 /* AccountExpiry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6F2FA62AFBB9AE006D0856 /* AccountExpiry.swift */; }; @@ -818,6 +819,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,7 +827,12 @@ 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 */; }; F03580252A13842C00E5DAFD /* IncreasedHitButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F03580242A13842C00E5DAFD /* IncreasedHitButton.swift */; }; + F04413612BA45CD70018A6EE /* CustomListLocationNodeBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F04413602BA45CD70018A6EE /* CustomListLocationNodeBuilder.swift */; }; + F04413622BA45CE30018A6EE /* CustomListLocationNodeBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F04413602BA45CD70018A6EE /* CustomListLocationNodeBuilder.swift */; }; F04F95A12B21D24400431E08 /* shadowsocks.h in Headers */ = {isa = PBXBuildFile; fileRef = F04F95A02B21D24400431E08 /* shadowsocks.h */; settings = {ATTRIBUTES = (Private, ); }; }; F04FBE612A8379EE009278D7 /* AppPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = F04FBE602A8379EE009278D7 /* AppPreferences.swift */; }; F050AE4E2B70D7F8003F4EDB /* LocationCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE4D2B70D7F8003F4EDB /* LocationCellViewModel.swift */; }; @@ -1789,6 +1796,7 @@ 7A6389EA2B7FAD7A008E77E1 /* SettingsFieldValidationErrorContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsFieldValidationErrorContentView.swift; sourceTree = ""; }; 7A6389EC2B7FADA1008E77E1 /* SettingsFieldValidationErrorConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsFieldValidationErrorConfiguration.swift; sourceTree = ""; }; 7A6389F72B864CDF008E77E1 /* LocationNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationNode.swift; sourceTree = ""; }; + 7A6652B62BB44B120042D848 /* LocationDiffableDataSourceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationDiffableDataSourceProtocol.swift; sourceTree = ""; }; 7A6B4F582AB8412E00123853 /* TunnelMonitorTimings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelMonitorTimings.swift; sourceTree = ""; }; 7A6F2FA42AFA3CB2006D0856 /* AccountExpiryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountExpiryTests.swift; sourceTree = ""; }; 7A6F2FA62AFBB9AE006D0856 /* AccountExpiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountExpiry.swift; sourceTree = ""; }; @@ -1972,6 +1980,7 @@ E1187ABB289BBB850024E748 /* OutOfTimeContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutOfTimeContentView.swift; sourceTree = ""; }; E158B35F285381C60002F069 /* String+AccountFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+AccountFormatting.swift"; sourceTree = ""; }; E1FD0DF428AA7CE400299DB4 /* StatusActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActivityView.swift; sourceTree = ""; }; + F006CCFB2B99CC8400C6C2AC /* EditLocationsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditLocationsCoordinator.swift; sourceTree = ""; }; F0164EB92B4456D30020268D /* AccessMethodRepositoryStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodRepositoryStub.swift; sourceTree = ""; }; F0164EBB2B482E430020268D /* AppStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStorage.swift; sourceTree = ""; }; F0164EBD2B4BFF940020268D /* ShadowsocksLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowsocksLoader.swift; sourceTree = ""; }; @@ -1979,7 +1988,11 @@ F0164ED02B4F2DCB0020268D /* AccessMethodIterator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodIterator.swift; sourceTree = ""; }; F028A5692A34D4E700C0CAA3 /* RedeemVoucherViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RedeemVoucherViewController.swift; sourceTree = ""; }; F028A56B2A34D8E600C0CAA3 /* AddCreditSucceededViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddCreditSucceededViewController.swift; sourceTree = ""; }; + F02F419A2B9723AE00625A4F /* AddLocationsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddLocationsViewController.swift; sourceTree = ""; }; + F02F419B2B9723AE00625A4F /* AddLocationsDataSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddLocationsDataSource.swift; sourceTree = ""; }; + F02F419C2B9723AF00625A4F /* AddLocationsCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddLocationsCoordinator.swift; sourceTree = ""; }; F03580242A13842C00E5DAFD /* IncreasedHitButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncreasedHitButton.swift; sourceTree = ""; }; + F04413602BA45CD70018A6EE /* CustomListLocationNodeBuilder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomListLocationNodeBuilder.swift; sourceTree = ""; }; F04F95A02B21D24400431E08 /* shadowsocks.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = shadowsocks.h; sourceTree = ""; }; F04FBE602A8379EE009278D7 /* AppPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPreferences.swift; sourceTree = ""; }; F050AE4D2B70D7F8003F4EDB /* LocationCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationCellViewModel.swift; sourceTree = ""; }; @@ -2430,12 +2443,14 @@ isa = PBXGroup; children = ( F050AE5F2B73A41E003F4EDB /* AllLocationDataSource.swift */, + F04413602BA45CD70018A6EE /* CustomListLocationNodeBuilder.swift */, F050AE612B74DBAC003F4EDB /* CustomListsDataSource.swift */, F0A92B3B2B8E44F900DC7B37 /* InMemoryCustomListRepository.swift */, 5888AD82227B11080051EB06 /* LocationCell.swift */, F050AE4D2B70D7F8003F4EDB /* LocationCellViewModel.swift */, 583DA21325FA4B5C00318683 /* LocationDataSource.swift */, F050AE5D2B739A73003F4EDB /* LocationDataSourceProtocol.swift */, + 7A6652B62BB44B120042D848 /* LocationDiffableDataSourceProtocol.swift */, 7A6389F72B864CDF008E77E1 /* LocationNode.swift */, F050AE512B70DFC0003F4EDB /* LocationSection.swift */, F0BE65362B9F136A005CC385 /* LocationSectionHeaderView.swift */, @@ -3504,6 +3519,9 @@ isa = PBXGroup; children = ( 7A6389D72B7E3BD6008E77E1 /* AddCustomListCoordinator.swift */, + F02F419C2B9723AF00625A4F /* AddLocationsCoordinator.swift */, + F02F419B2B9723AE00625A4F /* AddLocationsDataSource.swift */, + F02F419A2B9723AE00625A4F /* AddLocationsViewController.swift */, 7A6389D22B7E3BD6008E77E1 /* CustomListCellConfiguration.swift */, 7A6389D42B7E3BD6008E77E1 /* CustomListDataSourceConfiguration.swift */, 7A6389DA2B7E3BD6008E77E1 /* CustomListInteractor.swift */, @@ -3513,6 +3531,7 @@ 7A6389E62B7E42BE008E77E1 /* CustomListViewController.swift */, 7A6389D32B7E3BD6008E77E1 /* CustomListViewModel.swift */, 7A6389E42B7E4247008E77E1 /* EditCustomListCoordinator.swift */, + F006CCFB2B99CC8400C6C2AC /* EditLocationsCoordinator.swift */, 7AB2B66F2BA1EB8C00B03E3B /* ListCustomListCoordinator.swift */, 7AB2B66E2BA1EB8C00B03E3B /* ListCustomListViewController.swift */, ); @@ -4951,6 +4970,7 @@ A9A5FA2D2ACB05160083449F /* DurationTests.swift in Sources */, A9A5FA2E2ACB05160083449F /* FileCacheTests.swift in Sources */, A9A5FA2F2ACB05160083449F /* FixedWidthIntegerArithmeticsTests.swift in Sources */, + F04413622BA45CE30018A6EE /* CustomListLocationNodeBuilder.swift in Sources */, A9A5FA302ACB05160083449F /* InputTextFormatterTests.swift in Sources */, F0B0E6972AFE6E7E001DC66B /* XCTest+Async.swift in Sources */, 449EB9FF2B95FF2500DFA4EB /* AccountMock.swift in Sources */, @@ -5224,6 +5244,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 */, @@ -5267,6 +5288,7 @@ 7A28826A2BA8336600FD9F20 /* VPNSettingsCoordinator.swift in Sources */, 7A6389DE2B7E3BD6008E77E1 /* CustomListItemIdentifier.swift in Sources */, 58C76A082A33850E00100D75 /* ApplicationTarget.swift in Sources */, + F04413612BA45CD70018A6EE /* CustomListLocationNodeBuilder.swift in Sources */, 58CEB3042AFD36CE00E6E088 /* SwitchCellContentView.swift in Sources */, F07BF2622A26279100042943 /* RedeemVoucherOperation.swift in Sources */, 585E820327F3285E00939F0E /* SendStoreReceiptOperation.swift in Sources */, @@ -5300,6 +5322,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 */, @@ -5329,6 +5352,7 @@ 58607A4D2947287800BC467D /* AccountExpiryInAppNotificationProvider.swift in Sources */, 58C8191829FAA2C400DEB1B4 /* NotificationConfiguration.swift in Sources */, 58FF9FE82B07650A00E4C97D /* ButtonCellContentConfiguration.swift in Sources */, + 7A6652B82BB44C3E0042D848 /* LocationDiffableDataSourceProtocol.swift in Sources */, 5827B0A82B0F49EF00CCBBA1 /* ProxyConfigurationInteractorProtocol.swift in Sources */, 7A5869B92B56E7F000640D27 /* IPOverrideViewControllerDelegate.swift in Sources */, 586C0D7A2B039CE300E7CDD7 /* ShadowsocksCipherPicker.swift in Sources */, @@ -5433,11 +5457,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 1ddd40663dca..25d7d2d270a9 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/ApplicationCoordinator.swift b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift index 627183b1f743..0c2b8678f8de 100644 --- a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift @@ -43,14 +43,6 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo */ private let secondaryNavigationContainer = RootContainerViewController() - private var customListRepository: CustomListRepositoryProtocol { - #if DEBUG - InMemoryCustomListRepository() - #else - CustomListRepository() - #endif - } - /// Posts `preferredAccountNumber` notification when user inputs the account number instead of voucher code private let preferredAccountNumberSubject = PassthroughSubject() @@ -719,7 +711,7 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo navigationController: navigationController, tunnelManager: tunnelManager, relayCacheTracker: relayCacheTracker, - customListRepository: customListRepository + customListRepository: CustomListRepository() ) locationCoordinator.didFinish = { [weak self] _ in diff --git a/ios/MullvadVPN/Coordinators/CustomLists/AddCustomListCoordinator.swift b/ios/MullvadVPN/Coordinators/CustomLists/AddCustomListCoordinator.swift index 69fb742c4778..bbbf45ad54bc 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(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(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 000000000000..feb5bd415e5b --- /dev/null +++ b/ios/MullvadVPN/Coordinators/CustomLists/AddLocationsCoordinator.swift @@ -0,0 +1,61 @@ +// +// AddLocationsCoordinator.swift +// MullvadVPN +// +// Created by Mojgan on 2024-03-04. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +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 000000000000..048d3e51fa1a --- /dev/null +++ b/ios/MullvadVPN/Coordinators/CustomLists/AddLocationsDataSource.swift @@ -0,0 +1,223 @@ +// +// AddLocationsDataSource.swift +// MullvadVPN +// +// Created by Mojgan on 2024-02-29. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import MullvadSettings +import MullvadTypes +import UIKit + +class AddLocationsDataSource: + UITableViewDiffableDataSource, + LocationDiffableDataSourceProtocol { + private var customListLocationNode: CustomListLocationNode + private let nodes: [LocationNode] + var didUpdateCustomList: ((CustomListLocationNode) -> Void)? + let tableView: UITableView + let sections: [LocationSection] + + init( + tableView: UITableView, + allLocationNodes: [LocationNode], + customList: CustomList + ) { + self.tableView = tableView + self.nodes = allLocationNodes + + self.customListLocationNode = CustomListLocationNodeBuilder( + customList: customList, + allLocations: self.nodes + ).customListLocationNode + + let sections: [LocationSection] = [.customLists] + self.sections = sections + + super.init(tableView: tableView) { _, indexPath, itemIdentifier in + let cell = tableView.dequeueReusableView( + withIdentifier: sections[indexPath.section], + for: indexPath + ) as! LocationCell // swiftlint:disable:this force_cast + cell.configure(item: itemIdentifier, behavior: .add) + cell.selectionStyle = .none + return cell + } + + tableView.delegate = self + tableView.registerReusableViews(from: LocationSection.self) + defaultRowAnimation = .fade + reloadWithSelectedLocations() + } + + func nodeShowsChildren(_ node: LocationNode) -> Bool { + isLocationInCustomList(node: node) + } + + func nodeShouldBeSelected(_ node: LocationNode) -> Bool { + customListLocationNode.children.contains(node) + } + + private func reloadWithSelectedLocations() { + var locationsList: [LocationCellViewModel] = [] + nodes.forEach { node in + let viewModel = LocationCellViewModel( + section: .customLists, + node: node, + isSelected: customListLocationNode.children.contains(node) + ) + locationsList.append(viewModel) + + // Determine if the node should be expanded. + guard isLocationInCustomList(node: node) else { + return + } + + // Only parents with partially selected children should be expanded. + node.forEachDescendant { descendantNode in + if customListLocationNode.children.contains(descendantNode) { + descendantNode.forEachAncestor { descendantParentNode in + descendantParentNode.showsChildren = true + } + } + } + + locationsList.append(contentsOf: recursivelyCreateCellViewModelTree( + for: node, + in: .customLists, + indentationLevel: 1 + )) + } + updateDataSnapshot(with: [locationsList]) + } + + 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! LocationCell + cell.delegate = self + return cell + } +} + +extension AddLocationsDataSource: UITableViewDelegate { + func tableView(_ tableView: UITableView, indentationLevelForRowAt indexPath: IndexPath) -> Int { + itemIdentifier(for: indexPath)?.indentationLevel ?? 0 + } +} + +extension AddLocationsDataSource: LocationCellDelegate { + func toggleExpanding(cell: LocationCell) { + let items = toggledItems(for: cell).first!.map { item in + var item = item + if containsChild(parent: customListLocationNode, child: item.node) { + item.isSelected = true + } + return item + } + + updateDataSnapshot(with: [items], reloadExisting: true, completion: { + if let indexPath = self.tableView.indexPath(for: cell), + let item = self.itemIdentifier(for: indexPath) { + self.scroll(to: item, animated: true) + } + }) + } + + func toggleSelecting(cell: LocationCell) { + 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) + }) + } +} + +// MARK: - Toggle selection in table view + +fileprivate extension [LocationCellViewModel] { + 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: [LocationCellViewModel]) { + 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: [LocationCellViewModel], 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 000000000000..c728982fdbbc --- /dev/null +++ b/ios/MullvadVPN/Coordinators/CustomLists/AddLocationsViewController.swift @@ -0,0 +1,96 @@ +// +// 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 + return tableView + }() + + 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 + addConstraints() + setUpDataSource() + } + + override func didMove(toParent parent: UIViewController?) { + super.didMove(toParent: parent) + + if parent == nil { + delegate?.didBack() + } + } + + private func addConstraints() { + view.addConstrainedSubviews([tableView]) { + tableView.pinEdgesToSuperview() + } + } + + private func setUpDataSource() { + dataSource = AddLocationsDataSource( + tableView: tableView, + allLocationNodes: 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/CustomListDataSourceConfiguration.swift b/ios/MullvadVPN/Coordinators/CustomLists/CustomListDataSourceConfiguration.swift index faf4e1776408..77bbefd8bc6c 100644 --- a/ios/MullvadVPN/Coordinators/CustomLists/CustomListDataSourceConfiguration.swift +++ b/ios/MullvadVPN/Coordinators/CustomLists/CustomListDataSourceConfiguration.swift @@ -63,6 +63,21 @@ class CustomListDataSourceConfiguration: NSObject { } extension CustomListDataSourceConfiguration: UITableViewDelegate { + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + return nil + } + + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + let sectionIdentifier = dataSource.snapshot().sectionIdentifiers[section] + + return switch sectionIdentifier { + case .name: + 16 + default: + UITableView.automaticDimension + } + } + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { UIMetrics.SettingsCell.customListsCellHeight } diff --git a/ios/MullvadVPN/Coordinators/CustomLists/CustomListViewController.swift b/ios/MullvadVPN/Coordinators/CustomLists/CustomListViewController.swift index 43ad9ed259c4..4e5891658dcd 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 d8677161bc01..5545f1bc951b 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 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( - 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() + + addChild(coordinator) } } diff --git a/ios/MullvadVPN/Coordinators/CustomLists/EditLocationsCoordinator.swift b/ios/MullvadVPN/Coordinators/CustomLists/EditLocationsCoordinator.swift new file mode 100644 index 000000000000..9255a2bc29db --- /dev/null +++ b/ios/MullvadVPN/Coordinators/CustomLists/EditLocationsCoordinator.swift @@ -0,0 +1,60 @@ +// +// EditLocationsCoordinator.swift +// MullvadVPN +// +// Created by Mojgan on 2024-03-07. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +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 842d9544e6ff..fbdab2fba849 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.reconnectTunnel(selectNewRelay: true) } } diff --git a/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListViewController.swift b/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListViewController.swift index 25a8e374e6ff..78a14a298ca5 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() snapshot.appendSections([.default]) @@ -87,9 +102,8 @@ class ListCustomListViewController: UIViewController { tableView.backgroundColor = .secondaryColor tableView.separatorColor = .secondaryColor tableView.separatorInset = .zero - tableView.contentInset.top = 16 + tableView.separatorStyle = .singleLine tableView.rowHeight = UIMetrics.SettingsCell.customListsCellHeight - tableView.registerReusableViews(from: CellReuseIdentifier.self) } @@ -126,7 +140,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 +151,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 8da5a6dca285..0f808438a3ee 100644 --- a/ios/MullvadVPN/Coordinators/LocationCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/LocationCoordinator.swift @@ -15,8 +15,8 @@ import UIKit class LocationCoordinator: Coordinator, Presentable, Presenting { private let tunnelManager: TunnelManager private let relayCacheTracker: RelayCacheTracker + private let customListRepository: CustomListRepositoryProtocol private var cachedRelays: CachedRelays? - private var customListRepository: CustomListRepositoryProtocol let navigationController: UINavigationController @@ -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/UI appearance/UIMetrics.swift b/ios/MullvadVPN/UI appearance/UIMetrics.swift index f4d306590c2d..c3fbe48cffd2 100644 --- a/ios/MullvadVPN/UI appearance/UIMetrics.swift +++ b/ios/MullvadVPN/UI appearance/UIMetrics.swift @@ -173,7 +173,7 @@ extension UIMetrics { static let contentInsets = UIEdgeInsets(top: 24, left: 24, bottom: 24, right: 24) /// Common layout margins for location cell presentation - static let locationCellLayoutMargins = NSDirectionalEdgeInsets(top: 16, leading: 28, bottom: 16, trailing: 12) + static let locationCellLayoutMargins = NSDirectionalEdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 12) /// Layout margins used by content heading displayed below the large navigation title. static let contentHeadingLayoutMargins = NSDirectionalEdgeInsets(top: 8, leading: 24, bottom: 24, trailing: 24) diff --git a/ios/MullvadVPN/View controllers/SelectLocation/AllLocationDataSource.swift b/ios/MullvadVPN/View controllers/SelectLocation/AllLocationDataSource.swift index a6e9e1bab06d..f2f955968fb1 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/AllLocationDataSource.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/AllLocationDataSource.swift @@ -63,7 +63,8 @@ class AllLocationDataSource: LocationDataSourceProtocol { let countryNode = CountryLocationNode( name: serverLocation.country, code: LocationNode.combineNodeCodes([countryCode]), - locations: [location] + locations: [location], + isActive: relay.active ) if !rootNode.children.contains(countryNode) { @@ -75,7 +76,8 @@ class AllLocationDataSource: LocationDataSourceProtocol { let cityNode = CityLocationNode( name: serverLocation.city, code: LocationNode.combineNodeCodes([countryCode, cityCode]), - locations: [location] + locations: [location], + isActive: relay.active ) if let countryNode = rootNode.countryFor(code: countryCode), @@ -89,7 +91,8 @@ class AllLocationDataSource: LocationDataSourceProtocol { let hostNode = HostLocationNode( name: relay.hostname, code: LocationNode.combineNodeCodes([hostCode]), - locations: [location] + locations: [location], + isActive: relay.active ) if let countryNode = rootNode.countryFor(code: countryCode), diff --git a/ios/MullvadVPN/View controllers/SelectLocation/CustomListLocationNodeBuilder.swift b/ios/MullvadVPN/View controllers/SelectLocation/CustomListLocationNodeBuilder.swift new file mode 100644 index 000000000000..66e4ddbb5a6c --- /dev/null +++ b/ios/MullvadVPN/View controllers/SelectLocation/CustomListLocationNodeBuilder.swift @@ -0,0 +1,49 @@ +// +// CustomListLocationNodeBuilder.swift +// MullvadVPN +// +// Created by Mojgan on 2024-03-14. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadSettings + +struct CustomListLocationNodeBuilder { + let customList: CustomList + let allLocations: [LocationNode] + + var customListLocationNode: CustomListLocationNode { + let listNode = CustomListLocationNode( + name: customList.name, + code: customList.name.lowercased(), + locations: customList.locations, + customList: customList + ) + + listNode.children = listNode.locations.compactMap { location in + let rootNode = RootLocationNode(children: allLocations) + + return switch location { + case let .country(countryCode): + rootNode + .countryFor(code: countryCode)? + .copy(withParent: listNode) + + case let .city(countryCode, cityCode): + rootNode + .countryFor(code: countryCode)? + .cityFor(codes: [countryCode, cityCode])? + .copy(withParent: listNode) + + case let .hostname(countryCode, cityCode, hostCode): + rootNode + .countryFor(code: countryCode)? + .cityFor(codes: [countryCode, cityCode])? + .hostFor(code: hostCode)? + .copy(withParent: listNode) + } + } + return listNode + } +} diff --git a/ios/MullvadVPN/View controllers/SelectLocation/CustomListsDataSource.swift b/ios/MullvadVPN/View controllers/SelectLocation/CustomListsDataSource.swift index dd041642cd32..6d8f8989fab4 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/CustomListsDataSource.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/CustomListsDataSource.swift @@ -26,17 +26,9 @@ class CustomListsDataSource: LocationDataSourceProtocol { /// Constructs a collection of node trees by copying each matching counterpart /// from the complete list of nodes created in ``AllLocationDataSource``. func reload(allLocationNodes: [LocationNode], isFiltered: Bool) { - nodes = repository.fetchAll().compactMap { customList in - let listNode = CustomListLocationNode( - name: customList.name, - code: customList.name.lowercased(), - locations: customList.locations, - customList: customList - ) - - listNode.children = customList.locations.compactMap { location in - copy(location, from: allLocationNodes, withParent: listNode) - } + nodes = repository.fetchAll().compactMap { list in + let customListWrapper = CustomListLocationNodeBuilder(customList: list, allLocations: allLocationNodes) + let listNode = customListWrapper.customListLocationNode listNode.forEachDescendant { node in // Each item in a section in a diffable data source needs to be unique. @@ -74,32 +66,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 7123e19a24d0..4a72fa61e170 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/InMemoryCustomListRepository.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/InMemoryCustomListRepository.swift @@ -16,7 +16,7 @@ class InMemoryCustomListRepository: CustomListRepositoryProtocol { CustomList( id: UUID(uuidString: "F17948CB-18E2-4F84-82CD-5780F94216DB")!, name: "Netflix", - locations: [.city("al", "tia")] + locations: [.hostname("al", "tia", "al-tia-wg-001")] ), CustomList( id: UUID(uuidString: "4104C603-B35D-4A64-8865-96C0BF33D57F")!, @@ -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/LocationCell.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationCell.swift index dfdd791de1fe..24a1ce6facde 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationCell.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationCell.swift @@ -9,7 +9,8 @@ import UIKit protocol LocationCellDelegate: AnyObject { - func toggle(cell: LocationCell) + func toggleExpanding(cell: LocationCell) + func toggleSelecting(cell: LocationCell) } class LocationCell: UITableViewCell { @@ -38,6 +39,14 @@ class LocationCell: UITableViewCell { return imageView }() + private let checkboxButton: UIButton = { + let button = UIButton() + button.setImage(UIImage(systemName: "checkmark.square.fill"), for: .selected) + button.setImage(UIImage(systemName: "square"), for: .normal) + button.tintColor = .white + return button + }() + private let collapseButton: UIButton = { let button = UIButton(type: .custom) button.accessibilityIdentifier = .collapseButton @@ -46,6 +55,16 @@ class LocationCell: UITableViewCell { return button }() + private var locationLabelLeadingMargin: CGFloat { + switch behavior { + case .add: + 0 + case .select: + 12 + } + } + + private var behavior: LocationCellBehavior = .select private let chevronDown = UIImage(resource: .iconChevronDown) private let chevronUp = UIImage(resource: .iconChevronUp) @@ -106,7 +125,7 @@ class LocationCell: UITableViewCell { override func setSelected(_ selected: Bool, animated: Bool) { super.setSelected(selected, animated: animated) - updateTickImage() + updateLeadingImage() updateStatusIndicatorColor() } @@ -122,6 +141,7 @@ class LocationCell: UITableViewCell { selectedBackgroundView = UIView() selectedBackgroundView?.backgroundColor = UIColor.Cell.Background.selected + checkboxButton.addTarget(self, action: #selector(toggleCheckboxButton(_:)), for: .touchUpInside) collapseButton.addTarget(self, action: #selector(handleCollapseButton(_:)), for: .touchUpInside) [locationLabel, tickImageView, statusIndicator, collapseButton].forEach { subview in @@ -135,38 +155,53 @@ class LocationCell: UITableViewCell { updateBackgroundColor() setLayoutMargins() - NSLayoutConstraint.activate([ - tickImageView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), - tickImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), - - statusIndicator.widthAnchor.constraint(equalToConstant: 16), - statusIndicator.heightAnchor.constraint(equalTo: statusIndicator.widthAnchor), - statusIndicator.centerXAnchor.constraint(equalTo: tickImageView.centerXAnchor), - statusIndicator.centerYAnchor.constraint(equalTo: tickImageView.centerYAnchor), - + contentView.addConstrainedSubviews([ + tickImageView, + statusIndicator, + locationLabel, + collapseButton, + checkboxButton, + ]) { + tickImageView.pinEdgesToSuperviewMargins(PinnableEdges([.leading(0)])) + tickImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor) + + statusIndicator.widthAnchor.constraint(equalToConstant: 16) + statusIndicator.heightAnchor.constraint(equalTo: statusIndicator.widthAnchor) + statusIndicator.centerXAnchor.constraint(equalTo: tickImageView.centerXAnchor) + statusIndicator.centerYAnchor.constraint(equalTo: tickImageView.centerYAnchor) + + checkboxButton.pinEdgesToSuperview(PinnableEdges([.top(0), .bottom(0)])) + checkboxButton.trailingAnchor.constraint(equalTo: locationLabel.leadingAnchor, constant: 14) + checkboxButton.widthAnchor.constraint( + equalToConstant: UIMetrics.contentLayoutMargins.leading + UIMetrics.contentLayoutMargins.trailing + 24 + ) + + locationLabel.pinEdgesToSuperviewMargins(PinnableEdges([.top(0), .bottom(0)])) locationLabel.leadingAnchor.constraint( equalTo: statusIndicator.trailingAnchor, - constant: 12 - ), + constant: locationLabelLeadingMargin + ) locationLabel.trailingAnchor.constraint(lessThanOrEqualTo: collapseButton.leadingAnchor) - .withPriority(.defaultHigh), - locationLabel.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor), - locationLabel.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor), - - collapseButton.widthAnchor - .constraint( - equalToConstant: UIMetrics.contentLayoutMargins.leading + UIMetrics - .contentLayoutMargins.trailing + 24 - ), - collapseButton.topAnchor.constraint(equalTo: contentView.topAnchor), - collapseButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - collapseButton.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - ]) + .withPriority(.defaultHigh) + + collapseButton.widthAnchor.constraint( + equalToConstant: UIMetrics.contentLayoutMargins.leading + UIMetrics.contentLayoutMargins.trailing + 24 + ) + collapseButton.pinEdgesToSuperview(.all().excluding(.leading)) + } } - private func updateTickImage() { - statusIndicator.isHidden = isSelected - tickImageView.isHidden = !isSelected + private func updateLeadingImage() { + switch behavior { + case .add: + checkboxButton.isHidden = false + statusIndicator.isHidden = true + tickImageView.isHidden = true + case .select: + checkboxButton.isHidden = true + statusIndicator.isHidden = isSelected + tickImageView.isHidden = !isSelected + } } private func updateStatusIndicatorColor() { @@ -221,11 +256,11 @@ class LocationCell: UITableViewCell { } @objc private func handleCollapseButton(_ sender: UIControl) { - delegate?.toggle(cell: self) + delegate?.toggleExpanding(cell: self) } @objc private func toggleCollapseAccessibilityAction() -> Bool { - delegate?.toggle(cell: self) + delegate?.toggleExpanding(cell: self) return true } @@ -262,13 +297,32 @@ class LocationCell: UITableViewCell { accessibilityCustomActions = nil } } + + @objc private func toggleCheckboxButton(_ sender: UIControl) { + delegate?.toggleSelecting(cell: self) + } } extension LocationCell { - func configureCell(item: LocationCellViewModel) { + enum LocationCellBehavior { + case add + case select + } + + func configure(item: LocationCellViewModel, behavior: LocationCellBehavior) { accessibilityIdentifier = item.node.code + isDisabled = !item.node.isActive locationLabel.text = item.node.name showsCollapseControl = !item.node.children.isEmpty isExpanded = item.node.showsChildren + checkboxButton.isSelected = item.isSelected + checkboxButton.tintColor = item.isSelected ? .successColor : .white + + setBehavior(behavior) + } + + 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 2425413fdd7d..df0a3ba62c15 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationCellViewModel.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationCellViewModel.swift @@ -12,6 +12,7 @@ struct LocationCellViewModel: Hashable { let section: LocationSection let node: LocationNode var indentationLevel = 0 + var isSelected = false func hash(into hasher: inout Hasher) { hasher.combine(section) @@ -20,6 +21,38 @@ struct LocationCellViewModel: Hashable { static func == (lhs: Self, rhs: Self) -> Bool { lhs.node == rhs.node && - lhs.section == rhs.section + lhs.section == rhs.section && + lhs.isSelected == rhs.isSelected + } +} + +extension [LocationCellViewModel] { + mutating func addSubNodes(from item: LocationCellViewModel, at indexPath: IndexPath) { + let section = LocationSection.allCases[indexPath.section] + let row = indexPath.row + 1 + + let locations = item.node.children.map { + LocationCellViewModel( + section: section, + 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) + } } } diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift index d237ef1d15a9..8340dbf40ed5 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift @@ -12,12 +12,15 @@ import MullvadSettings import MullvadTypes import UIKit -final class LocationDataSource: UITableViewDiffableDataSource { +final class LocationDataSource: + UITableViewDiffableDataSource, + LocationDiffableDataSourceProtocol { private var currentSearchString = "" - private let tableView: UITableView private var dataSources: [LocationDataSourceProtocol] = [] private var selectedItem: LocationCellViewModel? private var hasFilter = false + let tableView: UITableView + let sections: [LocationSection] var didSelectRelayLocations: ((UserSelectedRelays) -> Void)? var didTapEditCustomLists: (() -> Void)? @@ -29,22 +32,26 @@ final class LocationDataSource: UITableViewDiffableDataSource IndexPath? { - selectedItem.flatMap { indexPath(for: $0) } + func nodeShowsChildren(_ node: LocationNode) -> Bool { + node.showsChildren } - private func updateDataSnapshot( - with list: [[LocationCellViewModel]], - reloadExisting: Bool = false, - animated: Bool = false, - completion: (() -> Void)? = nil - ) { - var snapshot = NSDiffableDataSourceSnapshot() - let sections = LocationSection.allCases - - snapshot.appendSections(sections) - for (index, section) in sections.enumerated() { - let items = list[index] - - snapshot.appendItems(items, toSection: section) - - if reloadExisting { - snapshot.reconfigureOrReloadItems(items) - } - } - - DispatchQueue.main.async { - self.apply(snapshot, animatingDifferences: animated, completion: completion) - } + func nodeShouldBeSelected(_ node: LocationNode) -> Bool { + false } - private func registerClasses() { - LocationSection.allCases.forEach { - tableView.register($0.cell.reusableViewClass, forCellReuseIdentifier: $0.cell.reuseIdentifier) - } + private func indexPathForSelectedRelay() -> IndexPath? { + selectedItem.flatMap { indexPath(for: $0) } } private func mapSelectedItem(from selectedRelays: UserSelectedRelays?) { @@ -170,11 +154,13 @@ final class LocationDataSource: UITableViewDiffableDataSource [LocationCellViewModel] { - var viewModels = [LocationCellViewModel]() - - for childNode in node.children where !childNode.isHiddenFromSearch { - viewModels.append( - LocationCellViewModel( - section: section, - node: childNode, - indentationLevel: indentationLevel - ) - ) - - if childNode.showsChildren { - viewModels.append( - contentsOf: recursivelyCreateCellViewModelTree( - for: childNode, - in: section, - indentationLevel: indentationLevel + 1 - ) - ) - } - } - - return viewModels - } - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { // swiftlint:disable:next force_cast let cell = super.tableView(tableView, cellForRowAt: indexPath) as! LocationCell @@ -248,7 +206,7 @@ final class LocationDataSource: UITableViewDiffableDataSource UIView? { - switch LocationSection.allCases[section] { + switch sections[section] { case .allLocations: return LocationSectionHeaderView( configuration: LocationSectionHeaderView.Configuration(name: LocationSection.allLocations.description) @@ -270,7 +228,7 @@ extension LocationDataSource: UITableViewDelegate { } func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - switch LocationSection.allCases[section] { + switch sections[section] { case .allLocations: return .zero case .customLists: @@ -309,60 +267,6 @@ extension LocationDataSource: UITableViewDelegate { didSelectRelayLocations?(relayLocations) } -} - -extension LocationDataSource: LocationCellDelegate { - func toggle(cell: LocationCell) { - guard let indexPath = tableView.indexPath(for: cell), - let item = itemIdentifier(for: indexPath) else { return } - - let sections = LocationSection.allCases - let section = sections[indexPath.section] - let isExpanded = item.node.showsChildren - var locationList = snapshot().itemIdentifiers(inSection: section) - - item.node.showsChildren = !isExpanded - - if !isExpanded { - locationList.addSubNodes(from: item, at: indexPath) - } else { - locationList.recursivelyRemoveSubNodes(from: item.node) - } - - let list = sections.enumerated().map { index, section in - index == indexPath.section - ? locationList - : snapshot().itemIdentifiers(inSection: section) - } - - updateDataSnapshot(with: list, reloadExisting: true, completion: { - self.scroll(to: item, animated: true) - }) - } -} - -extension LocationDataSource { - private func scroll(to item: LocationCellViewModel, 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: LocationCellViewModel( - section: LocationSection.allCases[indexPath.section], - node: last - )), - let lastVisibleIndexPath = visibleIndexPaths.last, - lastInsertedIndexPath >= lastVisibleIndexPath { - tableView.scrollToRow(at: lastInsertedIndexPath, at: .bottom, animated: animated) - } - } - } - } private func scrollToTop(animated: Bool) { tableView.setContentOffset(.zero, animated: animated) @@ -375,28 +279,19 @@ extension LocationDataSource { } } -private extension [LocationCellViewModel] { - mutating func addSubNodes(from item: LocationCellViewModel, at indexPath: IndexPath) { - let section = LocationSection.allCases[indexPath.section] - let row = indexPath.row + 1 +extension LocationDataSource: LocationCellDelegate { + func toggleExpanding(cell: LocationCell) { + guard let indexPath = tableView.indexPath(for: cell), + let item = itemIdentifier(for: indexPath) else { return } - let locations = item.node.children.map { - LocationCellViewModel(section: section, node: $0, indentationLevel: item.indentationLevel + 1) - } + let items = toggledItems(for: cell) - if row < count { - insert(contentsOf: locations, at: row) - } else { - append(contentsOf: locations) - } + updateDataSnapshot(with: items, reloadExisting: true, completion: { + self.scroll(to: item, animated: true) + }) } - mutating func recursivelyRemoveSubNodes(from node: LocationNode) { - for node in node.children { - node.showsChildren = false - removeAll(where: { node == $0.node }) - - recursivelyRemoveSubNodes(from: node) - } + func toggleSelecting(cell: LocationCell) { + // No op. } } diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationDiffableDataSourceProtocol.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationDiffableDataSourceProtocol.swift new file mode 100644 index 000000000000..0450be0a81e8 --- /dev/null +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationDiffableDataSourceProtocol.swift @@ -0,0 +1,119 @@ +// +// LocationDiffableDataSourceProtocol.swift +// MullvadVPNUITests +// +// Created by Jon Petersson on 2024-03-27. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import MullvadTypes +import UIKit + +protocol LocationDiffableDataSourceProtocol: UITableViewDiffableDataSource { + var tableView: UITableView { get } + var sections: [LocationSection] { get } + func nodeShowsChildren(_ node: LocationNode) -> Bool + func nodeShouldBeSelected(_ node: LocationNode) -> Bool +} + +extension LocationDiffableDataSourceProtocol { + func scroll(to item: LocationCellViewModel, 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: LocationCellViewModel( + section: sections[indexPath.section], + node: last + )), + let lastVisibleIndexPath = visibleIndexPaths.last, + lastInsertedIndexPath >= lastVisibleIndexPath { + tableView.scrollToRow(at: lastInsertedIndexPath, at: .bottom, animated: animated) + } + } + } + } + + func toggledItems(for cell: LocationCell) -> [[LocationCellViewModel]] { + guard let indexPath = tableView.indexPath(for: cell), + let item = itemIdentifier(for: indexPath) else { return [[]] } + + let section = sections[indexPath.section] + let isExpanded = item.node.showsChildren + var locationList = snapshot().itemIdentifiers(inSection: section) + + item.node.showsChildren = !isExpanded + + if !isExpanded { + locationList.addSubNodes(from: item, at: indexPath) + } else { + locationList.removeSubNodes(from: item.node) + } + + return sections.enumerated().map { index, section in + index == indexPath.section + ? locationList + : snapshot().itemIdentifiers(inSection: section) + } + } + + func updateDataSnapshot( + with list: [[LocationCellViewModel]], + reloadExisting: Bool = false, + animated: Bool = false, + completion: (() -> Void)? = nil + ) { + var snapshot = NSDiffableDataSourceSnapshot() + + snapshot.appendSections(sections) + for (index, section) in sections.enumerated() { + let items = list[index] + + snapshot.appendItems(items, toSection: section) + + if reloadExisting { + snapshot.reconfigureOrReloadItems(items) + } + } + + DispatchQueue.main.async { + self.apply(snapshot, animatingDifferences: animated, completion: completion) + } + } + + func recursivelyCreateCellViewModelTree( + for node: LocationNode, + in section: LocationSection, + indentationLevel: Int + ) -> [LocationCellViewModel] { + var viewModels = [LocationCellViewModel]() + + for childNode in node.children where !childNode.isHiddenFromSearch { + viewModels.append( + LocationCellViewModel( + section: section, + node: childNode, + indentationLevel: indentationLevel, + isSelected: nodeShouldBeSelected(childNode) + ) + ) + + if nodeShowsChildren(childNode) { + viewModels.append( + contentsOf: recursivelyCreateCellViewModelTree( + for: childNode, + in: section, + indentationLevel: indentationLevel + 1 + ) + ) + } + } + + return viewModels + } +} diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationNode.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationNode.swift index ed639cc219b1..fbf2fbf8fbf5 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationNode.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationNode.swift @@ -13,6 +13,7 @@ class LocationNode { let name: String var code: String var locations: [RelayLocation] + var isActive: Bool weak var parent: LocationNode? var children: [LocationNode] var showsChildren: Bool @@ -22,6 +23,7 @@ class LocationNode { name: String, code: String, locations: [RelayLocation] = [], + isActive: Bool = true, parent: LocationNode? = nil, children: [LocationNode] = [], showsChildren: Bool = false, @@ -30,6 +32,7 @@ class LocationNode { self.name = name self.code = code self.locations = locations + self.isActive = isActive self.parent = parent self.children = children self.showsChildren = showsChildren @@ -77,6 +80,10 @@ extension LocationNode { static func combineNodeCodes(_ codes: [String]) -> String { codes.joined(separator: "-") } + + var flattened: [LocationNode] { + children + children.flatMap { $0.flattened } + } } extension LocationNode { @@ -87,6 +94,7 @@ extension LocationNode { name: name, code: code, locations: locations, + isActive: isActive, parent: parent, children: [], showsChildren: showsChildren, @@ -133,6 +141,7 @@ class CustomListLocationNode: LocationNode { name: String, code: String, locations: [RelayLocation] = [], + isActive: Bool = true, parent: LocationNode? = nil, children: [LocationNode] = [], showsChildren: Bool = false, @@ -145,6 +154,7 @@ class CustomListLocationNode: LocationNode { name: name, code: code, locations: locations, + isActive: isActive, parent: parent, children: children, showsChildren: showsChildren, diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationSection.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationSection.swift index 6ebf676adb9a..51c4d00305ef 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationSection.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationSection.swift @@ -7,7 +7,7 @@ // import Foundation -enum LocationSection: Int, Hashable, CustomStringConvertible, CaseIterable { +enum LocationSection: String, Hashable, CustomStringConvertible, CaseIterable, CellIdentifierProtocol { case customLists case allLocations @@ -28,8 +28,8 @@ enum LocationSection: Int, Hashable, CustomStringConvertible, CaseIterable { } } - var cell: Cell { - .locationCell + var cellClass: AnyClass { + LocationCell.self } static var allCases: [LocationSection] { @@ -40,20 +40,3 @@ enum LocationSection: Int, Hashable, CustomStringConvertible, CaseIterable { #endif } } - -extension LocationSection { - enum Cell: String, CaseIterable { - case locationCell - - var reusableViewClass: AnyClass { - switch self { - case .locationCell: - return LocationCell.self - } - } - - var reuseIdentifier: String { - self.rawValue - } - } -} diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationSectionHeaderView.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationSectionHeaderView.swift index 49c9cbce2002..4a137d9cc1bb 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationSectionHeaderView.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationSectionHeaderView.swift @@ -74,7 +74,9 @@ class LocationSectionHeaderView: UIView, UIContentView { private func applyAppearance() { backgroundColor = .primaryColor - directionalLayoutMargins = NSDirectionalEdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 24) + + let leadingInset = UIMetrics.locationCellLayoutMargins.leading + 6 + directionalLayoutMargins = NSDirectionalEdgeInsets(top: 8, leading: leadingInset, bottom: 8, trailing: 24) } } diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift index 2b3a1f8c150b..6b27418aa513 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() {