summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorBug Magnet <marco.nikic@mullvad.net>2024-04-05 09:48:00 +0200
committerBug Magnet <marco.nikic@mullvad.net>2024-04-05 09:48:00 +0200
commit848a1a2bcb967369c26afa212be7c9751ddaa4d3 (patch)
tree55c4c698cffbdc606b08a0ae99c3aa527e47c896
parentb86fc46209e9335235a12f4872570cf5d021aa5d (diff)
parent0993e71c079c8c07af8a589d829829ea354cef0c (diff)
downloadmullvadvpn-848a1a2bcb967369c26afa212be7c9751ddaa4d3.tar.xz
mullvadvpn-848a1a2bcb967369c26afa212be7c9751ddaa4d3.zip
Merge branch 'adjust-ui-to-allow-adding-elements-to-a-custom-list-ios-490'
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj26
-rw-r--r--ios/MullvadVPN/Classes/AccessbilityIdentifier.swift1
-rw-r--r--ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift10
-rw-r--r--ios/MullvadVPN/Coordinators/CustomLists/AddCustomListCoordinator.swift46
-rw-r--r--ios/MullvadVPN/Coordinators/CustomLists/AddLocationsCoordinator.swift61
-rw-r--r--ios/MullvadVPN/Coordinators/CustomLists/AddLocationsDataSource.swift223
-rw-r--r--ios/MullvadVPN/Coordinators/CustomLists/AddLocationsViewController.swift96
-rw-r--r--ios/MullvadVPN/Coordinators/CustomLists/CustomListDataSourceConfiguration.swift15
-rw-r--r--ios/MullvadVPN/Coordinators/CustomLists/CustomListViewController.swift20
-rw-r--r--ios/MullvadVPN/Coordinators/CustomLists/EditCustomListCoordinator.swift50
-rw-r--r--ios/MullvadVPN/Coordinators/CustomLists/EditLocationsCoordinator.swift60
-rw-r--r--ios/MullvadVPN/Coordinators/CustomLists/ListCustomListCoordinator.swift33
-rw-r--r--ios/MullvadVPN/Coordinators/CustomLists/ListCustomListViewController.swift24
-rw-r--r--ios/MullvadVPN/Coordinators/LocationCoordinator.swift46
-rw-r--r--ios/MullvadVPN/UI appearance/UIMetrics.swift2
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/AllLocationDataSource.swift9
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/CustomListLocationNodeBuilder.swift49
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/CustomListsDataSource.swift42
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/InMemoryCustomListRepository.swift4
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationCell.swift112
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationCellViewModel.swift35
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift181
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationDiffableDataSourceProtocol.swift119
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationNode.swift10
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationSection.swift23
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationSectionHeaderView.swift4
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift8
27 files changed, 982 insertions, 327 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index af62482d12..270ecdabc9 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 = "<group>"; };
7A6389EC2B7FADA1008E77E1 /* SettingsFieldValidationErrorConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsFieldValidationErrorConfiguration.swift; sourceTree = "<group>"; };
7A6389F72B864CDF008E77E1 /* LocationNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationNode.swift; sourceTree = "<group>"; };
+ 7A6652B62BB44B120042D848 /* LocationDiffableDataSourceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationDiffableDataSourceProtocol.swift; sourceTree = "<group>"; };
7A6B4F582AB8412E00123853 /* TunnelMonitorTimings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelMonitorTimings.swift; sourceTree = "<group>"; };
7A6F2FA42AFA3CB2006D0856 /* AccountExpiryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountExpiryTests.swift; sourceTree = "<group>"; };
7A6F2FA62AFBB9AE006D0856 /* AccountExpiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountExpiry.swift; sourceTree = "<group>"; };
@@ -1972,6 +1980,7 @@
E1187ABB289BBB850024E748 /* OutOfTimeContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutOfTimeContentView.swift; sourceTree = "<group>"; };
E158B35F285381C60002F069 /* String+AccountFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+AccountFormatting.swift"; sourceTree = "<group>"; };
E1FD0DF428AA7CE400299DB4 /* StatusActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActivityView.swift; sourceTree = "<group>"; };
+ F006CCFB2B99CC8400C6C2AC /* EditLocationsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditLocationsCoordinator.swift; sourceTree = "<group>"; };
F0164EB92B4456D30020268D /* AccessMethodRepositoryStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodRepositoryStub.swift; sourceTree = "<group>"; };
F0164EBB2B482E430020268D /* AppStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStorage.swift; sourceTree = "<group>"; };
F0164EBD2B4BFF940020268D /* ShadowsocksLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowsocksLoader.swift; sourceTree = "<group>"; };
@@ -1979,7 +1988,11 @@
F0164ED02B4F2DCB0020268D /* AccessMethodIterator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodIterator.swift; sourceTree = "<group>"; };
F028A5692A34D4E700C0CAA3 /* RedeemVoucherViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RedeemVoucherViewController.swift; sourceTree = "<group>"; };
F028A56B2A34D8E600C0CAA3 /* AddCreditSucceededViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddCreditSucceededViewController.swift; sourceTree = "<group>"; };
+ F02F419A2B9723AE00625A4F /* AddLocationsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddLocationsViewController.swift; sourceTree = "<group>"; };
+ F02F419B2B9723AE00625A4F /* AddLocationsDataSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddLocationsDataSource.swift; sourceTree = "<group>"; };
+ F02F419C2B9723AF00625A4F /* AddLocationsCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddLocationsCoordinator.swift; sourceTree = "<group>"; };
F03580242A13842C00E5DAFD /* IncreasedHitButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncreasedHitButton.swift; sourceTree = "<group>"; };
+ F04413602BA45CD70018A6EE /* CustomListLocationNodeBuilder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomListLocationNodeBuilder.swift; sourceTree = "<group>"; };
F04F95A02B21D24400431E08 /* shadowsocks.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = shadowsocks.h; sourceTree = "<group>"; };
F04FBE602A8379EE009278D7 /* AppPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPreferences.swift; sourceTree = "<group>"; };
F050AE4D2B70D7F8003F4EDB /* LocationCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationCellViewModel.swift; sourceTree = "<group>"; };
@@ -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 1ddd40663d..25d7d2d270 100644
--- a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift
+++ b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift
@@ -63,6 +63,7 @@ public enum AccessibilityIdentifier: String {
// Views
case accountView
+ case addLocationsView
case alertContainerView
case alertTitle
case changeLogAlert
diff --git a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift
index 627183b1f7..0c2b8678f8 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<String, Never>()
@@ -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 69fb742c47..bbbf45ad54 100644
--- a/ios/MullvadVPN/Coordinators/CustomLists/AddCustomListCoordinator.swift
+++ b/ios/MullvadVPN/Coordinators/CustomLists/AddCustomListCoordinator.swift
@@ -14,26 +14,28 @@ import UIKit
class AddCustomListCoordinator: Coordinator, Presentable, Presenting {
let navigationController: UINavigationController
let interactor: CustomListInteractorProtocol
+ let nodes: [LocationNode]
+ let subject = CurrentValueSubject<CustomListViewModel, Never>(
+ CustomListViewModel(id: UUID(), name: "", locations: [], tableSections: [.name, .addLocations])
+ )
var presentedViewController: UIViewController {
navigationController
}
- var didFinish: (() -> Void)?
+ var didFinish: ((AddCustomListCoordinator) -> Void)?
init(
navigationController: UINavigationController,
- interactor: CustomListInteractorProtocol
+ interactor: CustomListInteractorProtocol,
+ nodes: [LocationNode]
) {
self.navigationController = navigationController
self.interactor = interactor
+ self.nodes = nodes
}
func start() {
- let subject = CurrentValueSubject<CustomListViewModel, Never>(
- CustomListViewModel(id: UUID(), name: "", locations: [], tableSections: [.name, .addLocations])
- )
-
let controller = CustomListViewController(
interactor: interactor,
subject: subject,
@@ -57,8 +59,11 @@ class AddCustomListCoordinator: Coordinator, Presentable, Presenting {
controller.navigationItem.leftBarButtonItem = UIBarButtonItem(
systemItem: .cancel,
- primaryAction: UIAction(handler: { _ in
- self.didFinish?()
+ primaryAction: UIAction(handler: { [weak self] _ in
+ guard let self else {
+ return
+ }
+ didFinish?(self)
})
)
@@ -68,14 +73,33 @@ class AddCustomListCoordinator: Coordinator, Presentable, Presenting {
extension AddCustomListCoordinator: CustomListViewControllerDelegate {
func customListDidSave(_ list: CustomList) {
- didFinish?()
+ didFinish?(self)
}
func customListDidDelete(_ list: CustomList) {
// No op.
}
- func showLocations() {
- // TODO: Show view controller for locations.
+ func showLocations(_ list: CustomList) {
+ let coordinator = AddLocationsCoordinator(
+ navigationController: navigationController,
+ nodes: nodes,
+ customList: list
+ )
+
+ coordinator.didFinish = { [weak self] locationsCoordinator, customList in
+ guard let self else { return }
+ subject.send(CustomListViewModel(
+ id: customList.id,
+ name: customList.name,
+ locations: customList.locations,
+ tableSections: subject.value.tableSections
+ ))
+ locationsCoordinator.removeFromParent()
+ }
+
+ coordinator.start()
+
+ addChild(coordinator)
}
}
diff --git a/ios/MullvadVPN/Coordinators/CustomLists/AddLocationsCoordinator.swift b/ios/MullvadVPN/Coordinators/CustomLists/AddLocationsCoordinator.swift
new file mode 100644
index 0000000000..feb5bd415e
--- /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 0000000000..048d3e51fa
--- /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<LocationSection, LocationCellViewModel>,
+ 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 0000000000..c728982fdb
--- /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 faf4e17764..77bbefd8bc 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 43ad9ed259..4e5891658d 100644
--- a/ios/MullvadVPN/Coordinators/CustomLists/CustomListViewController.swift
+++ b/ios/MullvadVPN/Coordinators/CustomLists/CustomListViewController.swift
@@ -13,7 +13,7 @@ import UIKit
protocol CustomListViewControllerDelegate: AnyObject {
func customListDidSave(_ list: CustomList)
func customListDidDelete(_ list: CustomList)
- func showLocations()
+ func showLocations(_ list: CustomList)
}
class CustomListViewController: UIViewController {
@@ -45,8 +45,8 @@ class CustomListViewController: UIViewController {
value: "Save",
comment: ""
),
- primaryAction: UIAction { _ in
- self.onSave()
+ primaryAction: UIAction { [weak self] _ in
+ self?.onSave()
}
)
barButtonItem.style = .done
@@ -101,14 +101,15 @@ class CustomListViewController: UIViewController {
}
private func configureDataSource() {
- cellConfiguration.onDelete = {
- self.onDelete()
+ cellConfiguration.onDelete = { [weak self] in
+ self?.onDelete()
}
dataSource = DataSource(
tableView: tableView,
- cellProvider: { _, indexPath, itemIdentifier in
- self.cellConfiguration.dequeueCell(
+ cellProvider: { [weak self] _, indexPath, itemIdentifier in
+ guard let self else { return nil }
+ return cellConfiguration.dequeueCell(
at: indexPath,
for: itemIdentifier,
validationErrors: self.validationErrors
@@ -116,14 +117,15 @@ class CustomListViewController: UIViewController {
}
)
- dataSourceConfiguration?.didSelectItem = { item in
+ dataSourceConfiguration?.didSelectItem = { [weak self] item in
+ guard let self else { return }
self.view.endEditing(false)
switch item {
case .name, .deleteList:
break
case .addLocations, .editLocations:
- self.delegate?.showLocations()
+ delegate?.showLocations(self.subject.value.customList)
}
}
diff --git a/ios/MullvadVPN/Coordinators/CustomLists/EditCustomListCoordinator.swift b/ios/MullvadVPN/Coordinators/CustomLists/EditCustomListCoordinator.swift
index d8677161bc..5545f1bc95 100644
--- a/ios/MullvadVPN/Coordinators/CustomLists/EditCustomListCoordinator.swift
+++ b/ios/MullvadVPN/Coordinators/CustomLists/EditCustomListCoordinator.swift
@@ -19,33 +19,34 @@ class EditCustomListCoordinator: Coordinator, Presentable, Presenting {
let navigationController: UINavigationController
let customListInteractor: CustomListInteractorProtocol
let customList: CustomList
+ let nodes: [LocationNode]
+ let subject: CurrentValueSubject<CustomListViewModel, Never>
var presentedViewController: UIViewController {
navigationController
}
- var didFinish: ((FinishAction, CustomList) -> Void)?
+ var didFinish: ((EditCustomListCoordinator, FinishAction, CustomList) -> Void)?
init(
navigationController: UINavigationController,
customListInteractor: CustomListInteractorProtocol,
- customList: CustomList
+ customList: CustomList,
+ nodes: [LocationNode]
) {
self.navigationController = navigationController
self.customListInteractor = customListInteractor
self.customList = customList
+ self.nodes = nodes
+ self.subject = CurrentValueSubject(CustomListViewModel(
+ id: customList.id,
+ name: customList.name,
+ locations: customList.locations,
+ tableSections: [.name, .editLocations, .deleteList]
+ ))
}
func start() {
- let subject = CurrentValueSubject<CustomListViewModel, Never>(
- CustomListViewModel(
- id: customList.id,
- name: customList.name,
- locations: customList.locations,
- tableSections: [.name, .editLocations, .deleteList]
- )
- )
-
let controller = CustomListViewController(
interactor: customListInteractor,
subject: subject,
@@ -66,14 +67,33 @@ class EditCustomListCoordinator: Coordinator, Presentable, Presenting {
extension EditCustomListCoordinator: CustomListViewControllerDelegate {
func customListDidSave(_ list: CustomList) {
- didFinish?(.save, list)
+ didFinish?(self, .save, list)
}
func customListDidDelete(_ list: CustomList) {
- didFinish?(.delete, list)
+ didFinish?(self, .delete, list)
}
- func showLocations() {
- // TODO: Show view controller for locations.
+ func showLocations(_ list: CustomList) {
+ let coordinator = EditLocationsCoordinator(
+ navigationController: navigationController,
+ nodes: nodes,
+ customList: list
+ )
+
+ coordinator.didFinish = { [weak self] locationsCoordinator, customList in
+ guard let self else { return }
+ subject.send(CustomListViewModel(
+ id: customList.id,
+ name: customList.name,
+ locations: customList.locations,
+ tableSections: subject.value.tableSections
+ ))
+ locationsCoordinator.removeFromParent()
+ }
+
+ coordinator.start()
+
+ addChild(coordinator)
}
}
diff --git a/ios/MullvadVPN/Coordinators/CustomLists/EditLocationsCoordinator.swift b/ios/MullvadVPN/Coordinators/CustomLists/EditLocationsCoordinator.swift
new file mode 100644
index 0000000000..9255a2bc29
--- /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 842d9544e6..fbdab2fba8 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 25a8e374e6..78a14a298c 100644
--- a/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListViewController.swift
+++ b/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListViewController.swift
@@ -22,7 +22,7 @@ private enum CellReuseIdentifier: String, CaseIterable, CellIdentifierProtocol {
var cellClass: AnyClass {
switch self {
- case .default: BasicCell.self
+ default: BasicCell.self
}
}
}
@@ -35,6 +35,21 @@ class ListCustomListViewController: UIViewController {
private var fetchedItems: [CustomList] = []
private var tableView = UITableView(frame: .zero, style: .plain)
+ private let emptyListLabel: UILabel = {
+ let textLabel = UILabel()
+ textLabel.font = .preferredFont(forTextStyle: .title2)
+ textLabel.textColor = .secondaryTextColor
+ textLabel.textAlignment = .center
+ textLabel.numberOfLines = .zero
+ textLabel.lineBreakStrategy = []
+ textLabel.text = NSLocalizedString(
+ "CustomList",
+ value: "No custom list to display",
+ comment: ""
+ )
+ return textLabel
+ }()
+
var didSelectItem: ((CustomList) -> Void)?
var didFinish: (() -> Void)?
@@ -60,7 +75,7 @@ class ListCustomListViewController: UIViewController {
func updateDataSource(reloadExisting: Bool, animated: Bool = true) {
fetchedItems = interactor.fetchAll()
-
+ tableView.backgroundView = fetchedItems.isEmpty ? emptyListLabel : nil
var snapshot = NSDiffableDataSourceSnapshot<SectionIdentifier, ItemIdentifier>()
snapshot.appendSections([.default])
@@ -87,9 +102,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 8da5a6dca2..0f808438a3 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 f4d306590c..c3fbe48cff 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 a6e9e1bab0..f2f955968f 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 0000000000..66e4ddbb5a
--- /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 dd041642cd..6d8f8989fa 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 7123e19a24..4a72fa61e1 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 dfdd791de1..24a1ce6fac 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),
+ 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),
+ 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),
+ .withPriority(.defaultHigh)
- 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),
- ])
+ 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 2425413fdd..df0a3ba62c 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 d237ef1d15..8340dbf40e 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<LocationSection, LocationCellViewModel> {
+final class LocationDataSource:
+ UITableViewDiffableDataSource<LocationSection, LocationCellViewModel>,
+ 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<LocationSection, L
) {
self.tableView = tableView
+ let sections: [LocationSection] = LocationSection.allCases
+ self.sections = sections
+
#if DEBUG
self.dataSources.append(customLists)
#endif
self.dataSources.append(allLocations)
super.init(tableView: tableView) { _, indexPath, itemIdentifier in
- let reuseIdentifier = LocationSection.Cell.locationCell.reuseIdentifier
- // swiftlint:disable:next force_cast
- let cell = tableView.dequeueReusableCell(withIdentifier: reuseIdentifier, for: indexPath) as! LocationCell
- cell.configureCell(item: itemIdentifier)
+ let cell = tableView.dequeueReusableView(
+ withIdentifier: sections[indexPath.section],
+ for: indexPath
+ ) as! LocationCell // swiftlint:disable:this force_cast
+ cell.configure(item: itemIdentifier, behavior: .select)
return cell
}
tableView.delegate = self
+ tableView.registerReusableViews(from: LocationSection.self)
defaultRowAnimation = .fade
- registerClasses()
}
func setRelays(_ response: REST.ServerRelaysResponse, selectedRelays: UserSelectedRelays?, filter: RelayFilter) {
@@ -70,7 +77,7 @@ final class LocationDataSource: UITableViewDiffableDataSource<LocationSection, L
func filterRelays(by searchString: String, scrollToSelected: Bool = true) {
currentSearchString = searchString
- let list = LocationSection.allCases.enumerated().map { index, section in
+ let list = sections.enumerated().map { index, section in
dataSources[index]
.search(by: searchString)
.flatMap { node in
@@ -108,39 +115,16 @@ final class LocationDataSource: UITableViewDiffableDataSource<LocationSection, L
filterRelays(by: currentSearchString, scrollToSelected: false)
}
- private func indexPathForSelectedRelay() -> 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<LocationSection, LocationCellViewModel>()
- 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<LocationSection, L
let rootNode = selectedItem.node.root
+ // Exit early if no changes to the node tree are necessary.
guard selectedItem.node != rootNode else {
completion?()
return
}
+ // Make sure we have an index path for the selected item.
guard let indexPath = indexPath(for: LocationCellViewModel(
section: selectedItem.section,
node: rootNode
@@ -185,16 +171,18 @@ final class LocationDataSource: UITableViewDiffableDataSource<LocationSection, L
node.showsChildren = true
}
+ // Construct node tree.
let nodesToAdd = recursivelyCreateCellViewModelTree(
for: rootNode,
in: selectedItem.section,
indentationLevel: 1
)
+ // Insert the new node tree below the select item.
var snapshotItems = snapshot().itemIdentifiers(inSection: selectedItem.section)
snapshotItems.insert(contentsOf: nodesToAdd, at: indexPath.row + 1)
- let list = LocationSection.allCases.enumerated().map { index, section in
+ let list = sections.enumerated().map { index, section in
index == indexPath.section
? snapshotItems
: snapshot().itemIdentifiers(inSection: section)
@@ -208,36 +196,6 @@ final class LocationDataSource: UITableViewDiffableDataSource<LocationSection, L
)
}
- private 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
- )
- )
-
- 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<LocationSection, L
extension LocationDataSource: UITableViewDelegate {
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> 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 0000000000..0450be0a81
--- /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<LocationSection, LocationCellViewModel> {
+ 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<LocationSection, LocationCellViewModel>()
+
+ 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 ed639cc219..fbf2fbf8fb 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 6ebf676adb..51c4d00305 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 49c9cbce20..4a137d9cc1 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 2b3a1f8c15..6b27418aa5 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift
@@ -13,7 +13,7 @@ import MullvadTypes
import UIKit
protocol LocationViewControllerDelegate: AnyObject {
- func didRequestRouteToCustomLists(_ controller: LocationViewController)
+ func didRequestRouteToCustomLists(_ controller: LocationViewController, nodes: [LocationNode])
}
final class LocationViewController: UIViewController {
@@ -139,7 +139,7 @@ final class LocationViewController: UIViewController {
dataSource?.didTapEditCustomLists = { [weak self] in
guard let self else { return }
- delegate?.didRequestRouteToCustomLists(self)
+ delegate?.didRequestRouteToCustomLists(self, nodes: allLocationDataSource.nodes)
}
if let cachedRelays {
@@ -151,11 +151,11 @@ final class LocationViewController: UIViewController {
tableView.backgroundColor = view.backgroundColor
tableView.separatorColor = .secondaryColor
tableView.separatorInset = .zero
- tableView.estimatedRowHeight = 53
+ tableView.rowHeight = 56
+ tableView.sectionHeaderHeight = 56
tableView.indicatorStyle = .white
tableView.keyboardDismissMode = .onDrag
tableView.accessibilityIdentifier = .selectLocationTableView
- tableView.sectionHeaderHeight = 56.0
}
private func setUpTopContent() {