diff --git a/ios/.swiftlint.yml b/ios/.swiftlint.yml index 1d834d495456..496503001dcf 100644 --- a/ios/.swiftlint.yml +++ b/ios/.swiftlint.yml @@ -7,6 +7,7 @@ disabled_rules: - type_body_length - opening_brace # Differs from Google swift guidelines enforced by swiftformat - trailing_comma + - switch_case_alignment # Enables expressions such as [return switch location {}] opt_in_rules: - empty_count diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index b26f3c30971d..8ad8967c1da9 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -202,8 +202,8 @@ 588395602A9DEEA1008B63F6 /* WgAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5883955F2A9DEEA1008B63F6 /* WgAdapter.swift */; }; 588527B2276B3F0700BAA373 /* LoadTunnelConfigurationOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588527B1276B3F0700BAA373 /* LoadTunnelConfigurationOperation.swift */; }; 588527B4276B4F2F00BAA373 /* SetAccountOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588527B3276B4F2F00BAA373 /* SetAccountOperation.swift */; }; - 5888AD83227B11080051EB06 /* SelectLocationCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5888AD82227B11080051EB06 /* SelectLocationCell.swift */; }; - 5888AD87227B17950051EB06 /* SelectLocationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5888AD86227B17950051EB06 /* SelectLocationViewController.swift */; }; + 5888AD83227B11080051EB06 /* LocationCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5888AD82227B11080051EB06 /* LocationCell.swift */; }; + 5888AD87227B17950051EB06 /* LocationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5888AD86227B17950051EB06 /* LocationViewController.swift */; }; 588D7ED62AF3903F005DF40A /* ListAccessMethodViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588D7ED52AF3903F005DF40A /* ListAccessMethodViewController.swift */; }; 588D7EDC2AF3A55E005DF40A /* ListAccessMethodInteractorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588D7EDB2AF3A55E005DF40A /* ListAccessMethodInteractorProtocol.swift */; }; 588D7EDE2AF3A585005DF40A /* ListAccessMethodItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588D7EDD2AF3A585005DF40A /* ListAccessMethodItem.swift */; }; @@ -528,6 +528,7 @@ 7A6389E92B7F8FE2008E77E1 /* CustomListValidationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6389E82B7F8FE2008E77E1 /* CustomListValidationError.swift */; }; 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 */; }; 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 */; }; @@ -554,7 +555,7 @@ 7A9CCCB62A96302800DD6A34 /* OutOfTimeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA42A96302700DD6A34 /* OutOfTimeCoordinator.swift */; }; 7A9CCCB72A96302800DD6A34 /* RevokedCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA52A96302700DD6A34 /* RevokedCoordinator.swift */; }; 7A9CCCB82A96302800DD6A34 /* SetupAccountCompletedCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA62A96302700DD6A34 /* SetupAccountCompletedCoordinator.swift */; }; - 7A9CCCB92A96302800DD6A34 /* SelectLocationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA72A96302700DD6A34 /* SelectLocationCoordinator.swift */; }; + 7A9CCCB92A96302800DD6A34 /* LocationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA72A96302700DD6A34 /* LocationCoordinator.swift */; }; 7A9CCCBA2A96302800DD6A34 /* CreateAccountVoucherCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA82A96302700DD6A34 /* CreateAccountVoucherCoordinator.swift */; }; 7A9CCCBB2A96302800DD6A34 /* InAppPurchaseCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA92A96302700DD6A34 /* InAppPurchaseCoordinator.swift */; }; 7A9CCCBC2A96302800DD6A34 /* ChangeLogCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCAA2A96302700DD6A34 /* ChangeLogCoordinator.swift */; }; @@ -797,10 +798,8 @@ F03580252A13842C00E5DAFD /* IncreasedHitButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F03580242A13842C00E5DAFD /* IncreasedHitButton.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 */; }; - F050AE4C2B70D5A7003F4EDB /* SelectLocationNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE4B2B70D5A7003F4EDB /* SelectLocationNode.swift */; }; F050AE4E2B70D7F8003F4EDB /* LocationCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE4D2B70D7F8003F4EDB /* LocationCellViewModel.swift */; }; - F050AE502B70DC4F003F4EDB /* SelectLocationNodeProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE4F2B70DC4F003F4EDB /* SelectLocationNodeProtocol.swift */; }; - F050AE522B70DFC0003F4EDB /* SelectLocationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE512B70DFC0003F4EDB /* SelectLocationSection.swift */; }; + F050AE522B70DFC0003F4EDB /* LocationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE512B70DFC0003F4EDB /* LocationSection.swift */; }; F050AE572B7376C6003F4EDB /* CustomListRepositoryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE552B7376C5003F4EDB /* CustomListRepositoryProtocol.swift */; }; F050AE582B7376C6003F4EDB /* CustomListRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE562B7376C6003F4EDB /* CustomListRepository.swift */; }; F050AE5A2B7376F4003F4EDB /* CustomList.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE592B7376F4003F4EDB /* CustomList.swift */; }; @@ -1499,8 +1498,8 @@ 5883955F2A9DEEA1008B63F6 /* WgAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WgAdapter.swift; sourceTree = ""; }; 588527B1276B3F0700BAA373 /* LoadTunnelConfigurationOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadTunnelConfigurationOperation.swift; sourceTree = ""; }; 588527B3276B4F2F00BAA373 /* SetAccountOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetAccountOperation.swift; sourceTree = ""; }; - 5888AD82227B11080051EB06 /* SelectLocationCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationCell.swift; sourceTree = ""; }; - 5888AD86227B17950051EB06 /* SelectLocationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationViewController.swift; sourceTree = ""; }; + 5888AD82227B11080051EB06 /* LocationCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationCell.swift; sourceTree = ""; }; + 5888AD86227B17950051EB06 /* LocationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationViewController.swift; sourceTree = ""; }; 588D7ED52AF3903F005DF40A /* ListAccessMethodViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListAccessMethodViewController.swift; sourceTree = ""; }; 588D7ED72AF3A533005DF40A /* AccessMethodKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodKind.swift; sourceTree = ""; }; 588D7EDB2AF3A55E005DF40A /* ListAccessMethodInteractorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListAccessMethodInteractorProtocol.swift; sourceTree = ""; }; @@ -1756,6 +1755,7 @@ 7A6389E82B7F8FE2008E77E1 /* CustomListValidationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomListValidationError.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; @@ -1779,7 +1779,7 @@ 7A9CCCA42A96302700DD6A34 /* OutOfTimeCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutOfTimeCoordinator.swift; sourceTree = ""; }; 7A9CCCA52A96302700DD6A34 /* RevokedCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RevokedCoordinator.swift; sourceTree = ""; }; 7A9CCCA62A96302700DD6A34 /* SetupAccountCompletedCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SetupAccountCompletedCoordinator.swift; sourceTree = ""; }; - 7A9CCCA72A96302700DD6A34 /* SelectLocationCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SelectLocationCoordinator.swift; sourceTree = ""; }; + 7A9CCCA72A96302700DD6A34 /* LocationCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocationCoordinator.swift; sourceTree = ""; }; 7A9CCCA82A96302700DD6A34 /* CreateAccountVoucherCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateAccountVoucherCoordinator.swift; sourceTree = ""; }; 7A9CCCA92A96302700DD6A34 /* InAppPurchaseCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppPurchaseCoordinator.swift; sourceTree = ""; }; 7A9CCCAA2A96302700DD6A34 /* ChangeLogCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChangeLogCoordinator.swift; sourceTree = ""; }; @@ -1926,10 +1926,8 @@ F03580242A13842C00E5DAFD /* IncreasedHitButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncreasedHitButton.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 = ""; }; - F050AE4B2B70D5A7003F4EDB /* SelectLocationNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationNode.swift; sourceTree = ""; }; F050AE4D2B70D7F8003F4EDB /* LocationCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationCellViewModel.swift; sourceTree = ""; }; - F050AE4F2B70DC4F003F4EDB /* SelectLocationNodeProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationNodeProtocol.swift; sourceTree = ""; }; - F050AE512B70DFC0003F4EDB /* SelectLocationSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationSection.swift; sourceTree = ""; }; + F050AE512B70DFC0003F4EDB /* LocationSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSection.swift; sourceTree = ""; }; F050AE552B7376C5003F4EDB /* CustomListRepositoryProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomListRepositoryProtocol.swift; sourceTree = ""; }; F050AE562B7376C6003F4EDB /* CustomListRepository.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomListRepository.swift; sourceTree = ""; }; F050AE592B7376F4003F4EDB /* CustomList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomList.swift; sourceTree = ""; }; @@ -2362,16 +2360,14 @@ isa = PBXGroup; children = ( F050AE5F2B73A41E003F4EDB /* AllLocationDataSource.swift */, - F050AE612B74DBAC003F4EDB /* CustomListsDataSource.swift */, + 5888AD82227B11080051EB06 /* LocationCell.swift */, 58435AC129CB2A350099C71B /* LocationCellFactory.swift */, F050AE4D2B70D7F8003F4EDB /* LocationCellViewModel.swift */, 583DA21325FA4B5C00318683 /* LocationDataSource.swift */, F050AE5D2B739A73003F4EDB /* LocationDataSourceProtocol.swift */, - 5888AD82227B11080051EB06 /* SelectLocationCell.swift */, - F050AE512B70DFC0003F4EDB /* SelectLocationSection.swift */, - F050AE4B2B70D5A7003F4EDB /* SelectLocationNode.swift */, - F050AE4F2B70DC4F003F4EDB /* SelectLocationNodeProtocol.swift */, - 5888AD86227B17950051EB06 /* SelectLocationViewController.swift */, + 7A6389F72B864CDF008E77E1 /* LocationNode.swift */, + F050AE512B70DFC0003F4EDB /* LocationSection.swift */, + 5888AD86227B17950051EB06 /* LocationViewController.swift */, ); path = SelectLocation; sourceTree = ""; @@ -3057,13 +3053,13 @@ 7A9CCCAA2A96302700DD6A34 /* ChangeLogCoordinator.swift */, 7A9CCCA82A96302700DD6A34 /* CreateAccountVoucherCoordinator.swift */, 7A9CCCA92A96302700DD6A34 /* InAppPurchaseCoordinator.swift */, + 7A9CCCA72A96302700DD6A34 /* LocationCoordinator.swift */, 7A9CCCAB2A96302800DD6A34 /* LoginCoordinator.swift */, 7A9CCCA42A96302700DD6A34 /* OutOfTimeCoordinator.swift */, 7A9CCCAE2A96302800DD6A34 /* ProfileVoucherCoordinator.swift */, 7AF10EB32ADE85BC00C090B9 /* RelayFilterCoordinator.swift */, 7A9CCCA52A96302700DD6A34 /* RevokedCoordinator.swift */, 7A9CCCB02A96302800DD6A34 /* SafariCoordinator.swift */, - 7A9CCCA72A96302700DD6A34 /* SelectLocationCoordinator.swift */, 7A9CCCA62A96302700DD6A34 /* SetupAccountCompletedCoordinator.swift */, 7A9CCCA22A96302700DD6A34 /* TermsOfServiceCoordinator.swift */, 7A9CCCB22A96302800DD6A34 /* TunnelCoordinator.swift */, @@ -3436,6 +3432,7 @@ 7A6389D42B7E3BD6008E77E1 /* CustomListDataSourceConfiguration.swift */, 7A6389DA2B7E3BD6008E77E1 /* CustomListInteractor.swift */, 7A6389D52B7E3BD6008E77E1 /* CustomListItemIdentifier.swift */, + F050AE612B74DBAC003F4EDB /* CustomListsDataSource.swift */, 7A6389D92B7E3BD6008E77E1 /* CustomListSectionIdentifier.swift */, 7A6389E82B7F8FE2008E77E1 /* CustomListValidationError.swift */, 7A6389E62B7E42BE008E77E1 /* CustomListViewController.swift */, @@ -5000,6 +4997,7 @@ F0C2AEFD2A0BB5CC00986207 /* NotificationProviderIdentifier.swift in Sources */, 7A58699B2B482FE200640D27 /* UITableViewCell+Disable.swift in Sources */, 7A9CCCB72A96302800DD6A34 /* RevokedCoordinator.swift in Sources */, + 7A6389F82B864CDF008E77E1 /* LocationNode.swift in Sources */, 587D96742886D87C00CD8F1C /* DeviceManagementContentView.swift in Sources */, 7A11DD0B2A9495D400098CD8 /* AppRoutes.swift in Sources */, 5827B0902B0CAA0500CCBBA1 /* EditAccessMethodCoordinator.swift in Sources */, @@ -5068,7 +5066,7 @@ 58CEB30C2AFD586600E6E088 /* DynamicBackgroundConfiguration.swift in Sources */, 587B7536266528A200DEF7E9 /* NotificationManager.swift in Sources */, 5820EDA9288FE064006BF4E4 /* DeviceManagementInteractor.swift in Sources */, - 7A9CCCB92A96302800DD6A34 /* SelectLocationCoordinator.swift in Sources */, + 7A9CCCB92A96302800DD6A34 /* LocationCoordinator.swift in Sources */, 58FB865A26EA214400F188BC /* RelayCacheTrackerObserver.swift in Sources */, 581DFAEC2B1770C1005D6D1C /* AccessMethodViewModel+NavigationItem.swift in Sources */, 58ACF64D26567A5000ACE4B7 /* CustomSwitch.swift in Sources */, @@ -5078,7 +5076,6 @@ 5871FB96254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift in Sources */, F0E8E4C52A60499100ED26A3 /* AccountDeletionViewController.swift in Sources */, 7A9CCCC12A96302800DD6A34 /* AccountCoordinator.swift in Sources */, - F050AE502B70DC4F003F4EDB /* SelectLocationNodeProtocol.swift in Sources */, 58FEEB58260B662E00A621A8 /* AutomaticKeyboardResponder.swift in Sources */, 5846227326E22A160035F7C2 /* StorePaymentObserver.swift in Sources */, F0E3618B2A4ADD2F00AEEF2B /* WelcomeContentView.swift in Sources */, @@ -5104,7 +5101,7 @@ 58907D9524D17B4E00CFC3F5 /* DisconnectSplitButton.swift in Sources */, 58EE2E3B272FF814003BFF93 /* SettingsDataSourceDelegate.swift in Sources */, 5823FA5426CE49F700283BF8 /* TunnelObserver.swift in Sources */, - 5888AD87227B17950051EB06 /* SelectLocationViewController.swift in Sources */, + 5888AD87227B17950051EB06 /* LocationViewController.swift in Sources */, 58293FB3251241B4005D0BB5 /* CustomTextView.swift in Sources */, 586A950E290125F3007BAF2B /* ProductsRequestOperation.swift in Sources */, 7AF9BE902A39F26000DBFEDB /* Collection+Sorting.swift in Sources */, @@ -5174,7 +5171,7 @@ 7A1A26452A29CEF700B978AA /* RelayFilterViewController.swift in Sources */, 5862805422428EF100F5A6E1 /* TranslucentButtonBlurView.swift in Sources */, 587EB66A270EFACB00123C75 /* CharacterSet+IPAddress.swift in Sources */, - 5888AD83227B11080051EB06 /* SelectLocationCell.swift in Sources */, + 5888AD83227B11080051EB06 /* LocationCell.swift in Sources */, 5891BF1C25E3E3EB006D6FB0 /* Bundle+ProductVersion.swift in Sources */, 5878A26F2907E7E00096FC88 /* ProblemReportInteractor.swift in Sources */, 7AB4CCBB2B691BBB006037F5 /* IPOverrideInteractor.swift in Sources */, @@ -5267,9 +5264,8 @@ 584592612639B4A200EF967F /* TermsOfServiceContentView.swift in Sources */, 5875960A26F371FC00BF6711 /* Tunnel+Messaging.swift in Sources */, 586C0D912B03D8A400E7CDD7 /* AccessMethodHeaderFooterReuseIdentifier.swift in Sources */, - F050AE4C2B70D5A7003F4EDB /* SelectLocationNode.swift in Sources */, 7A2960F62A963F7500389B82 /* AlertCoordinator.swift in Sources */, - F050AE522B70DFC0003F4EDB /* SelectLocationSection.swift in Sources */, + F050AE522B70DFC0003F4EDB /* LocationSection.swift in Sources */, 063687BA28EB234F00BE7161 /* PacketTunnelTransport.swift in Sources */, A9C342C12ACC37E30045F00E /* TunnelStatusBlockObserver.swift in Sources */, 587425C12299833500CA2045 /* RootContainerViewController.swift in Sources */, diff --git a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift index 5db7d9799e5b..b77ac52ce989 100644 --- a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift @@ -65,7 +65,7 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo }() private var splitTunnelCoordinator: TunnelCoordinator? - private var splitLocationCoordinator: SelectLocationCoordinator? + private var splitLocationCoordinator: LocationCoordinator? private let tunnelManager: TunnelManager private let storePaymentManager: StorePaymentManager @@ -703,11 +703,11 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo } private func makeSelectLocationCoordinator(forModalPresentation isModalPresentation: Bool) - -> SelectLocationCoordinator { + -> LocationCoordinator { let navigationController = CustomNavigationController() navigationController.isNavigationBarHidden = !isModalPresentation - let selectLocationCoordinator = SelectLocationCoordinator( + let selectLocationCoordinator = LocationCoordinator( navigationController: navigationController, tunnelManager: tunnelManager, relayCacheTracker: relayCacheTracker diff --git a/ios/MullvadVPN/Coordinators/CustomLists/CustomListsDataSource.swift b/ios/MullvadVPN/Coordinators/CustomLists/CustomListsDataSource.swift new file mode 100644 index 000000000000..f63a8d29a2d9 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/CustomLists/CustomListsDataSource.swift @@ -0,0 +1,95 @@ +// +// CustomListsDataSource.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-02-22. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadREST +import MullvadSettings +import MullvadTypes + +class CustomListsDataSource: LocationDataSourceProtocol { + private(set) var nodes = [LocationNode]() + private(set) var repository: CustomListRepositoryProtocol + + init(repository: CustomListRepositoryProtocol) { + self.repository = repository + } + + var searchableNodes: [LocationNode] { + nodes.flatMap { $0.children } + } + + func reload(allLocationNodes: [LocationNode]) { + nodes = repository.fetchAll().map { list in + let listNode = LocationListNode( + nodeName: list.name, + nodeCode: list.name.lowercased(), + locations: list.locations, + customList: list + ) + + listNode.children = list.locations.compactMap { location in + copy(location, from: allLocationNodes, withParent: listNode) + } + + listNode.forEachDescendant { _, node in + node.nodeCode = "\(listNode.nodeCode)-\(node.nodeCode)" + } + + return listNode + } + } + + func node(by locations: [RelayLocation], for customList: CustomList) -> LocationNode? { + guard let customListNode = nodes.first(where: { $0.nodeName == customList.name }) + else { return nil } + + if locations.count > 1 { + return customListNode + } else { + return switch locations.first { + case let .country(countryCode): + customListNode.nodeFor(nodeCode: "\(customListNode.nodeCode)-\(countryCode)") + case let .city(_, cityCode): + customListNode.nodeFor(nodeCode: "\(customListNode.nodeCode)-\(cityCode)") + case let .hostname(_, _, hostCode): + customListNode.nodeFor(nodeCode: "\(customListNode.nodeCode)-\(hostCode)") + case .none: + nil + } + } + } + + func customList(by id: UUID) -> CustomList? { + repository.fetch(by: id) + } + + private func copy( + _ location: RelayLocation, + from allLocationNodes: [LocationNode], + withParent parentNode: LocationNode + ) -> LocationNode? { + let rootNode = RootNode(children: allLocationNodes) + + return switch location { + case let .country(countryCode): + rootNode + .countryFor(countryCode: countryCode)?.copy(withParent: parentNode) + + case let .city(countryCode, cityCode): + rootNode + .countryFor(countryCode: countryCode)?.copy(withParent: parentNode) + .cityFor(cityCode: cityCode) + + case let .hostname(countryCode, cityCode, hostCode): + rootNode + .countryFor(countryCode: countryCode)?.copy(withParent: parentNode) + .cityFor(cityCode: cityCode)? + .hostFor(hostCode: hostCode) + } + } +} diff --git a/ios/MullvadVPN/Coordinators/SelectLocationCoordinator.swift b/ios/MullvadVPN/Coordinators/LocationCoordinator.swift similarity index 82% rename from ios/MullvadVPN/Coordinators/SelectLocationCoordinator.swift rename to ios/MullvadVPN/Coordinators/LocationCoordinator.swift index dcaf47347d87..08459f49e8b9 100644 --- a/ios/MullvadVPN/Coordinators/SelectLocationCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/LocationCoordinator.swift @@ -1,5 +1,5 @@ // -// SelectLocationCoordinator.swift +// LocationCoordinator.swift // MullvadVPN // // Created by pronebird on 29/01/2023. @@ -13,7 +13,7 @@ import UIKit import MullvadSettings -class SelectLocationCoordinator: Coordinator, Presentable, Presenting, RelayCacheTrackerObserver { +class LocationCoordinator: Coordinator, Presentable, Presenting, RelayCacheTrackerObserver { private let tunnelManager: TunnelManager private let relayCacheTracker: RelayCacheTracker private var cachedRelays: CachedRelays? @@ -24,10 +24,10 @@ class SelectLocationCoordinator: Coordinator, Presentable, Presenting, RelayCach navigationController } - var selectLocationViewController: SelectLocationViewController? { + var selectLocationViewController: LocationViewController? { return navigationController.viewControllers.first { - $0 is SelectLocationViewController - } as? SelectLocationViewController + $0 is LocationViewController + } as? LocationViewController } var relayFilter: RelayFilter { @@ -39,7 +39,7 @@ class SelectLocationCoordinator: Coordinator, Presentable, Presenting, RelayCach } } - var didFinish: ((SelectLocationCoordinator, RelayLocation?) -> Void)? + var didFinish: ((LocationCoordinator, [RelayLocation]) -> Void)? init( navigationController: UINavigationController, @@ -52,22 +52,22 @@ class SelectLocationCoordinator: Coordinator, Presentable, Presenting, RelayCach } func start() { - let selectLocationViewController = SelectLocationViewController() + let selectLocationViewController = LocationViewController() - selectLocationViewController.didSelectRelay = { [weak self] relay in + selectLocationViewController.didSelectRelays = { [weak self] locations, customListId in guard let self else { return } var relayConstraints = tunnelManager.settings.relayConstraints relayConstraints.locations = .only(RelayLocations( - locations: [relay], - customListId: nil + locations: locations, + customListId: customListId )) tunnelManager.updateSettings([.relayConstraints(relayConstraints)]) { self.tunnelManager.startTunnel() } - didFinish?(self, relay) + didFinish?(self, locations) } selectLocationViewController.navigateToFilter = { [weak self] in @@ -91,7 +91,7 @@ class SelectLocationCoordinator: Coordinator, Presentable, Presenting, RelayCach selectLocationViewController.didFinish = { [weak self] in guard let self else { return } - didFinish?(self, nil) + didFinish?(self, []) } relayCacheTracker.addObserver(self) @@ -101,8 +101,7 @@ class SelectLocationCoordinator: Coordinator, Presentable, Presenting, RelayCach selectLocationViewController.setCachedRelays(cachedRelays, filter: relayFilter) } - selectLocationViewController.relayLocation = - tunnelManager.settings.relayConstraints.locations.value?.locations.first + selectLocationViewController.relayLocations = tunnelManager.settings.relayConstraints.locations.value navigationController.pushViewController(selectLocationViewController, animated: false) } diff --git a/ios/MullvadVPN/Coordinators/Settings/SettingsValidationErrorConfiguration.swift b/ios/MullvadVPN/Coordinators/Settings/SettingsValidationErrorConfiguration.swift new file mode 100644 index 000000000000..64b70611084d --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/SettingsValidationErrorConfiguration.swift @@ -0,0 +1,22 @@ +// +// SettingsValidationErrorConfiguration.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-02-16. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +struct SettingsValidationErrorConfiguration: UIContentConfiguration, Equatable { + var errors: [CustomListFieldValidationError] = [] + var directionalLayoutMargins: NSDirectionalEdgeInsets = UIMetrics.SettingsCell.settingsValidationErrorLayoutMargins + + func makeContentView() -> UIView & UIContentView { + return SettingsValidationErrorContentView(configuration: self) + } + + func updated(for state: UIConfigurationState) -> Self { + return self + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/SettingsValidationErrorContentView.swift b/ios/MullvadVPN/Coordinators/Settings/SettingsValidationErrorContentView.swift new file mode 100644 index 000000000000..b3708bb764dc --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/SettingsValidationErrorContentView.swift @@ -0,0 +1,90 @@ +// +// SettingsValidationErrorContentView.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-02-16. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +class SettingsValidationErrorContentView: UIView, UIContentView { + let contentView = UIStackView() + + var icon: UIImageView { + let view = UIImageView(image: UIImage(resource: .iconAlert).withTintColor(.dangerColor)) + view.heightAnchor.constraint(equalToConstant: 14).isActive = true + view.widthAnchor.constraint(equalTo: view.heightAnchor, multiplier: 1).isActive = true + return view + } + + var configuration: UIContentConfiguration { + get { + actualConfiguration + } + set { + guard let newConfiguration = newValue as? SettingsValidationErrorConfiguration else { return } + + let previousConfiguration = actualConfiguration + actualConfiguration = newConfiguration + + configureSubviews(previousConfiguration: previousConfiguration) + } + } + + private var actualConfiguration: SettingsValidationErrorConfiguration + + func supports(_ configuration: UIContentConfiguration) -> Bool { + configuration is SettingsValidationErrorConfiguration + } + + init(configuration: SettingsValidationErrorConfiguration) { + actualConfiguration = configuration + + super.init(frame: CGRect(x: 0, y: 0, width: 100, height: 44)) + + addSubviews() + configureSubviews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func addSubviews() { + contentView.axis = .vertical + contentView.spacing = 6 + + addConstrainedSubviews([contentView]) { + contentView.pinEdgesToSuperviewMargins() + } + } + + private func configureSubviews(previousConfiguration: SettingsValidationErrorConfiguration? = nil) { + guard actualConfiguration != previousConfiguration else { return } + + configureLayoutMargins() + + contentView.arrangedSubviews.forEach { view in + view.removeFromSuperview() + } + + actualConfiguration.errors.forEach { error in + let label = UILabel() + label.text = error.errorDescription + label.numberOfLines = 0 + label.font = .systemFont(ofSize: 13) + label.textColor = .white.withAlphaComponent(0.6) + + let stackView = UIStackView(arrangedSubviews: [icon, label]) + stackView.alignment = .top + stackView.spacing = 6 + + contentView.addArrangedSubview(stackView) + } + } + + private func configureLayoutMargins() { + directionalLayoutMargins = actualConfiguration.directionalLayoutMargins + } +} diff --git a/ios/MullvadVPN/UI appearance/UIColor+Palette.swift b/ios/MullvadVPN/UI appearance/UIColor+Palette.swift index 0f111024b36c..7050d0d555e1 100644 --- a/ios/MullvadVPN/UI appearance/UIColor+Palette.swift +++ b/ios/MullvadVPN/UI appearance/UIColor+Palette.swift @@ -117,6 +117,10 @@ extension UIColor { static let backgroundColor = UIColor(red: 0.13, green: 0.20, blue: 0.30, alpha: 1.0) } + enum SubSubSubCell { + static let backgroundColor = UIColor(red: 0.11, green: 0.17, blue: 0.27, alpha: 1.0) + } + enum HeaderBar { static let defaultBackgroundColor = primaryColor static let unsecuredBackgroundColor = dangerColor diff --git a/ios/MullvadVPN/UI appearance/UIMetrics.swift b/ios/MullvadVPN/UI appearance/UIMetrics.swift index 3459c5a78708..f4d306590c2d 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 selectLocationCellLayoutMargins = NSDirectionalEdgeInsets(top: 16, leading: 28, bottom: 16, trailing: 12) + static let locationCellLayoutMargins = NSDirectionalEdgeInsets(top: 16, leading: 28, 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 33bfe575935e..4279b60c6914 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/AllLocationDataSource.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/AllLocationDataSource.swift @@ -2,7 +2,7 @@ // AllLocationDataSource.swift // MullvadVPN // -// Created by Mojgan on 2024-02-07. +// Created by Jon Petersson on 2024-02-22. // Copyright © 2024 Mullvad VPN AB. All rights reserved. // @@ -11,83 +11,81 @@ import MullvadREST import MullvadTypes class AllLocationDataSource: LocationDataSourceProtocol { - var nodeByLocation = [RelayLocation: SelectLocationNode]() - private var locationList = [RelayLocation]() + private(set) var nodes = [LocationNode]() - func search(by text: String) -> [RelayLocation] { - guard !text.isEmpty else { - return locationList - } + var searchableNodes: [LocationNode] { nodes } - var filteredLocations: [RelayLocation] = [] - locationList.forEach { location in - guard let countryNode = nodeByLocation[location] else { return } - countryNode.showsChildren = false + func reload(_ response: REST.ServerRelaysResponse, relays: [REST.ServerRelay]) { + let rootNode = RootNode() - if countryNode.displayName.fuzzyMatch(text) { - filteredLocations.append(countryNode.location) - } + for relay in relays { + guard case + let .city(countryCode, cityCode) = RelayLocation(dashSeparatedString: relay.location), + let serverLocation = response.locations[relay.location] + else { continue } - countryNode.children.forEach { cityNode in - cityNode.showsChildren = false + let relayLocation = RelayLocation.hostname(countryCode, cityCode, relay.hostname) - let relaysContainSearchString = cityNode.children - .contains(where: { $0.displayName.fuzzyMatch(text) }) + for ancestorOrSelf in relayLocation.ancestors + [relayLocation] { + addLocation(ancestorOrSelf, rootNode: rootNode, serverLocation: serverLocation, relay: relay) + } + } - if cityNode.displayName.fuzzyMatch(text) || relaysContainSearchString { - if !filteredLocations.contains(countryNode.location) { - filteredLocations.append(countryNode.location) - } + nodes = rootNode.children + } - filteredLocations.append(cityNode.location) - countryNode.showsChildren = true + func node(by location: RelayLocation) -> LocationNode? { + let rootNode = RootNode(children: nodes) - if relaysContainSearchString { - filteredLocations.append(contentsOf: cityNode.children.map { $0.location }) - cityNode.showsChildren = true - } - } - } + return switch location { + case let .country(countryCode): + rootNode.nodeFor(nodeCode: countryCode) + case let .city(_, cityCode): + rootNode.nodeFor(nodeCode: cityCode) + case let .hostname(_, _, hostCode): + rootNode.nodeFor(nodeCode: hostCode) } - - return filteredLocations } - func reload( - _ response: REST.ServerRelaysResponse, - relays: [REST.ServerRelay] - ) -> [RelayLocation] { - nodeByLocation.removeAll() - let rootNode = self.makeRootNode(name: SelectLocationSection.allLocations.description) + private func addLocation( + _ location: RelayLocation, + rootNode: LocationNode, + serverLocation: REST.ServerLocation, + relay: REST.ServerRelay + ) { + switch location { + case let .country(countryCode): + let countryNode = LocationCountryNode( + nodeName: serverLocation.country, + nodeCode: countryCode, + locations: [location] + ) + + if !rootNode.children.contains(countryNode) { + rootNode.children.append(countryNode) + rootNode.children.sort() + } - for relay in relays { - guard case let .city(countryCode, cityCode) = RelayLocation(dashSeparatedString: relay.location), - let serverLocation = response.locations[relay.location] else { continue } + case let .city(countryCode, cityCode): + let cityNode = LocationCityNode(nodeName: serverLocation.city, nodeCode: cityCode, locations: [location]) - let relayLocation = RelayLocation.hostname(countryCode, cityCode, relay.hostname) + if let countryNode = rootNode.countryFor(countryCode: countryCode), + !countryNode.children.contains(cityNode) { + cityNode.parent = countryNode + countryNode.children.append(cityNode) + countryNode.children.sort() + } - for ancestorOrSelf in relayLocation.ancestors + [relayLocation] { - guard !nodeByLocation.keys.contains(ancestorOrSelf) else { - continue - } - - // Maintain the `showsChildren` state when transitioning between relay lists - let wasShowingChildren = nodeByLocation[ancestorOrSelf]?.showsChildren ?? false - - let node = createNode( - root: rootNode, - ancestorOrSelf: ancestorOrSelf, - serverLocation: serverLocation, - relay: relay, - wasShowingChildren: wasShowingChildren - ) - nodeByLocation[ancestorOrSelf] = node + case let .hostname(countryCode, cityCode, hostIdentifier): + let hostNode = LocationHostNode(nodeName: relay.hostname, nodeCode: hostIdentifier, locations: [location]) + + if let countryNode = rootNode.countryFor(countryCode: countryCode), + let cityNode = countryNode.cityFor(cityCode: cityCode), + !cityNode.children.contains(hostNode) { + hostNode.parent = cityNode + cityNode.children.append(hostNode) + cityNode.children.sort() } } - - rootNode.sortChildrenRecursive() - rootNode.computeActiveChildrenRecursive() - locationList = rootNode.flatRelayLocationList() - return locationList } } diff --git a/ios/MullvadVPN/View controllers/SelectLocation/CustomListsDataSource.swift b/ios/MullvadVPN/View controllers/SelectLocation/CustomListsDataSource.swift deleted file mode 100644 index 897e68b9c311..000000000000 --- a/ios/MullvadVPN/View controllers/SelectLocation/CustomListsDataSource.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// CustomListsDataSource.swift -// MullvadVPN -// -// Created by Mojgan on 2024-02-08. -// Copyright © 2024 Mullvad VPN AB. All rights reserved. -// - -import Foundation -import MullvadREST -import MullvadTypes - -class CustomListsDataSource: LocationDataSourceProtocol { - var nodeByLocation = [RelayLocation: SelectLocationNode]() - private var locationList = [RelayLocation]() - - func search(by text: String) -> [RelayLocation] { - [] - } - - func reload( - _ response: REST.ServerRelaysResponse, - relays: [REST.ServerRelay] - ) -> [RelayLocation] { - locationList - } -} diff --git a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationCell.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationCell.swift similarity index 97% rename from ios/MullvadVPN/View controllers/SelectLocation/SelectLocationCell.swift rename to ios/MullvadVPN/View controllers/SelectLocation/LocationCell.swift index 05a9c466646a..f4451e6dd25c 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationCell.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationCell.swift @@ -1,5 +1,5 @@ // -// SelectLocationCell.swift +// LocationCell.swift // MullvadVPN // // Created by pronebird on 02/05/2019. @@ -11,8 +11,8 @@ import UIKit private let kCollapseButtonWidth: CGFloat = 24 private let kRelayIndicatorSize: CGFloat = 16 -class SelectLocationCell: UITableViewCell { - typealias CollapseHandler = (SelectLocationCell) -> Void +class LocationCell: UITableViewCell { + typealias CollapseHandler = (LocationCell) -> Void let locationLabel = UILabel() let statusIndicator: UIView = { @@ -72,7 +72,7 @@ class SelectLocationCell: UITableViewCell { private func setLayoutMargins() { let indentation = CGFloat(indentationLevel) * indentationWidth - var contentMargins = UIMetrics.selectLocationCellLayoutMargins + var contentMargins = UIMetrics.locationCellLayoutMargins contentMargins.leading += indentation contentView.directionalLayoutMargins = contentMargins @@ -187,6 +187,8 @@ class SelectLocationCell: UITableViewCell { return UIColor.SubCell.backgroundColor case 2: return UIColor.SubSubCell.backgroundColor + case 3: + return UIColor.SubSubSubCell.backgroundColor default: return UIColor.Cell.backgroundColor } diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationCellFactory.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationCellFactory.swift index 5151752d093d..9b9c88de43c7 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationCellFactory.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationCellFactory.swift @@ -11,7 +11,6 @@ import UIKit protocol LocationCellEventHandler { func toggleCell(for item: LocationCellViewModel) - func node(for item: LocationCellViewModel) -> SelectLocationNode? } final class LocationCellFactory: CellFactoryProtocol { @@ -39,14 +38,12 @@ final class LocationCellFactory: CellFactoryProtocol { } func configureCell(_ cell: UITableViewCell, item: LocationCellViewModel, indexPath: IndexPath) { - guard let cell = cell as? SelectLocationCell, - let node = delegate?.node(for: item) else { return } - - cell.accessibilityIdentifier = node.location.stringRepresentation - cell.isDisabled = !node.isActive - cell.locationLabel.text = node.displayName - cell.showsCollapseControl = node.isCollapsible - cell.isExpanded = node.showsChildren + guard let cell = cell as? LocationCell else { return } + + cell.accessibilityIdentifier = item.node.nodeName + cell.locationLabel.text = item.node.nodeName + cell.showsCollapseControl = !item.node.children.isEmpty + cell.isExpanded = item.node.showsChildren cell.didCollapseHandler = { [weak self] _ in self?.delegate?.toggleCell(for: item) } diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationCellViewModel.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationCellViewModel.swift index 2b27f8e0ed9a..965b39ede36d 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationCellViewModel.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationCellViewModel.swift @@ -9,6 +9,6 @@ import MullvadTypes struct LocationCellViewModel: Hashable { - let group: SelectLocationSection - let location: RelayLocation + let section: LocationSection + let node: LocationNode } diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift index 1a026e54e987..836c4363c2c3 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift @@ -8,17 +8,18 @@ import Combine import MullvadREST +import MullvadSettings import MullvadTypes import UIKit -final class LocationDataSource: UITableViewDiffableDataSource { +final class LocationDataSource: UITableViewDiffableDataSource { private var currentSearchString = "" private let tableView: UITableView private let locationCellFactory: LocationCellFactory private var dataSources: [LocationDataSourceProtocol] = [] + private var selectedItem: LocationCellViewModel? - var selectedRelayLocation: LocationCellViewModel? - var didSelectRelayLocation: ((RelayLocation) -> Void)? + var didSelectRelayLocations: (([RelayLocation], UUID?) -> Void)? init( tableView: UITableView, @@ -31,7 +32,7 @@ final class LocationDataSource: UITableViewDiffableDataSource IndexPath? { - selectedRelayLocation.flatMap { - indexPath(for: $0) - } + selectedItem.flatMap { indexPath(for: $0) } } func filterRelays(by searchString: String) { currentSearchString = searchString - let list = SelectLocationSection.allCases.enumerated().map { section, group in - dataSources[section] + let list = LocationSection.allCases.enumerated().map { index, section in + dataSources[index] .search(by: searchString) - .map { LocationCellViewModel(group: group, location: $0) } + .map { LocationCellViewModel(section: section, node: $0) } } updateDataSnapshot(with: list, reloadExisting: !searchString.isEmpty) if searchString.isEmpty { - setSelectedRelayLocation(selectedRelayLocation, animated: false, completion: { + setSelectedItem(selectedItem, animated: false, completion: { self.scrollToSelectedRelay() }) } else { @@ -93,23 +107,23 @@ final class LocationDataSource: UITableViewDiffableDataSource Void)? = nil ) { - var snapshot = NSDiffableDataSourceSnapshot() + var snapshot = NSDiffableDataSourceSnapshot() + let sections = LocationSection.allCases - let sections = SelectLocationSection.allCases snapshot.appendSections(sections) for (index, section) in sections.enumerated() { snapshot.appendItems(list[index], toSection: section) } if reloadExisting { - snapshot.reloadSections(SelectLocationSection.allCases) + snapshot.reloadSections(LocationSection.allCases) } apply(snapshot, animatingDifferences: animated, completion: completion) } private func registerClasses() { - SelectLocationSection.allCases.forEach { + LocationSection.allCases.forEach { tableView.register( $0.cell.reusableViewClass, forCellReuseIdentifier: $0.cell.reuseIdentifier @@ -117,44 +131,36 @@ final class LocationDataSource: UITableViewDiffableDataSource Void)? = nil ) { - selectedRelayLocation = relayLocation - guard let selectedRelayLocation else { return } - - let group = selectedRelayLocation.group - var locationList = snapshot().itemIdentifiers(inSection: group) - guard !locationList.contains(selectedRelayLocation) else { - completion?() - return - } - let selectedLocationTree = selectedRelayLocation.location.ancestors + [selectedRelayLocation.location] + selectedItem = item + guard let selectedItem else { return } - guard let first = selectedLocationTree.first else { return } - let topLocation = LocationCellViewModel(group: group, location: first) + let topmostAncestor = selectedItem.node.topmostAncestor + guard selectedItem.node != topmostAncestor else { return } - guard let indexPath = indexPath(for: topLocation), - let topNode = node(for: topLocation) else { - return - } + var snapshotItems = snapshot().itemIdentifiers(inSection: selectedItem.section) - selectedLocationTree.forEach { location in - node(for: LocationCellViewModel(group: group, location: location))?.showsChildren = true + selectedItem.node.forEachAncestor { node in + node.showsChildren = true } - locationList.addLocations( - topNode.flatRelayLocationList().map { LocationCellViewModel(group: group, location: $0) }, - at: indexPath.row + 1 - ) + guard let indexPath = indexPath(for: LocationCellViewModel( + section: selectedItem.section, + node: topmostAncestor + )) else { return } + + let nodesToAdd = recursivelyCreateCellViewModels(for: topmostAncestor, in: selectedItem.section) + snapshotItems.insert(contentsOf: nodesToAdd, at: indexPath.row + 1) var list: [[LocationCellViewModel]] = Array(repeating: [], count: dataSources.count) for index in 0 ..< list.count { list[index] = (index == indexPath.section) - ? locationList - : snapshot().itemIdentifiers(inSection: SelectLocationSection.allCases[index]) + ? snapshotItems + : snapshot().itemIdentifiers(inSection: LocationSection.allCases[index]) } updateDataSnapshot( @@ -164,17 +170,45 @@ final class LocationDataSource: UITableViewDiffableDataSource [LocationCellViewModel] { + var viewModels = [LocationCellViewModel]() + + node.children.forEach { + $0.indentationLevel = node.indentationLevel + 1 + viewModels.append(LocationCellViewModel(section: section, node: $0)) + + if $0.showsChildren { + viewModels.append(contentsOf: recursivelyCreateCellViewModels(for: $0, in: section)) + } + } + + return viewModels + } } extension LocationDataSource: UITableViewDelegate { - func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { - guard let item = itemIdentifier(for: indexPath) else { return false } - return node(for: item)?.isActive ?? false + func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + nil + } + + func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + let section = snapshot().sectionIdentifiers[section] + + switch section { + case .customLists: + return 24 + case .allLocations: + return 0 + } } func tableView(_ tableView: UITableView, indentationLevelForRowAt indexPath: IndexPath) -> Int { guard let item = itemIdentifier(for: indexPath) else { return 0 } - return node(for: item)?.indentationLevel ?? 0 + return item.node.indentationLevel } func tableView( @@ -183,86 +217,80 @@ extension LocationDataSource: UITableViewDelegate { forRowAt indexPath: IndexPath ) { if let item = itemIdentifier(for: indexPath), - item == selectedRelayLocation { + item == selectedItem { cell.setSelected(true, animated: false) } } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - itemIdentifier(for: indexPath) - .flatMap { item in - guard item.location != selectedRelayLocation?.location else { return } - didSelectRelayLocation?(item.location) + guard + let item = itemIdentifier(for: indexPath), + item.node != selectedItem?.node + else { return } - setSelectedRelayLocation(item, animated: false) + let topmostNode = item.node.topmostAncestor as? LocationListNode + didSelectRelayLocations?(item.node.locations, topmostNode?.customList.id) - indexPathForSelectedRelay().flatMap { - let cell = tableView.cellForRow(at: $0) - cell?.setSelected(false, animated: false) - } - } + selectedItem = item + + indexPathForSelectedRelay().flatMap { + let cell = tableView.cellForRow(at: $0) + cell?.setSelected(false, animated: false) + } } } extension LocationDataSource: LocationCellEventHandler { func toggleCell(for item: LocationCellViewModel) { - indexPath(for: item).flatMap { indexPath in - guard let node = node(for: item), let cell = tableView.cellForRow(at: indexPath) else { return } - - let isExpanded = node.showsChildren - let group = SelectLocationSection.allCases[indexPath.section] - - node.showsChildren = !isExpanded - locationCellFactory.configureCell( - cell, - item: LocationCellViewModel(group: group, location: node.location), - indexPath: indexPath - ) - - var locationList = snapshot().itemIdentifiers(inSection: group) - let locationsToEdit = node.flatRelayLocationList().map { LocationCellViewModel(group: group, location: $0) } - - if !isExpanded { - locationList.addLocations(locationsToEdit, at: indexPath.row + 1) - } else { - locationsToEdit.forEach { self.node(for: $0)?.showsChildren = false } - locationList.removeLocations(locationsToEdit) - } + guard let indexPath = indexPath(for: item), + let cell = tableView.cellForRow(at: indexPath) + else { return } + + let isExpanded = item.node.showsChildren + let section = LocationSection.allCases[indexPath.section] + + item.node.showsChildren = !isExpanded + locationCellFactory.configureCell( + cell, + item: LocationCellViewModel(section: section, node: item.node), + indexPath: indexPath + ) - var list: [[LocationCellViewModel]] = Array(repeating: [], count: dataSources.count) - for index in 0 ..< list.count { - list[index] = (index == indexPath.section) - ? locationList - : snapshot().itemIdentifiers(inSection: SelectLocationSection.allCases[index]) - } + var locationList = snapshot().itemIdentifiers(inSection: section) - updateDataSnapshot(with: list, completion: { - self.scroll(to: item, animated: true) - }) + if !isExpanded { + locationList.addSubNodes(from: item.node, at: indexPath) + } else { + locationList.recursivelyRemoveSubNodes(from: item.node) } - } - func node(for item: LocationCellViewModel) -> SelectLocationNode? { - guard let sectionIndex = SelectLocationSection.allCases.firstIndex(of: item.group) else { - return nil + var list: [[LocationCellViewModel]] = Array(repeating: [], count: dataSources.count) + for index in 0 ..< list.count { + list[index] = (index == indexPath.section) + ? locationList + : snapshot().itemIdentifiers(inSection: LocationSection.allCases[index]) } - return dataSources[sectionIndex].nodeByLocation[item.location] + + updateDataSnapshot(with: list, completion: { + self.scroll(to: item, animated: true) + }) } } extension LocationDataSource { - private func scroll(to location: LocationCellViewModel, animated: Bool) { - guard let visibleIndexPaths = tableView.indexPathsForVisibleRows, - let indexPath = indexPath(for: location), - let node = node(for: location) else { return } + private func scroll(to item: LocationCellViewModel, animated: Bool) { + guard + let visibleIndexPaths = tableView.indexPathsForVisibleRows, + let indexPath = indexPath(for: item) + else { return } - if node.children.count > visibleIndexPaths.count { + if item.node.children.count > visibleIndexPaths.count { tableView.scrollToRow(at: indexPath, at: .top, animated: animated) } else { - node.children.last.flatMap { last in + if let last = item.node.children.last { if let lastInsertedIndexPath = self.indexPath(for: LocationCellViewModel( - group: SelectLocationSection.allCases[indexPath.section], - location: last.location + section: LocationSection.allCases[indexPath.section], + node: last )), let lastVisibleIndexPath = visibleIndexPaths.last, lastInsertedIndexPath >= lastVisibleIndexPath { @@ -284,17 +312,26 @@ extension LocationDataSource { } private extension [LocationCellViewModel] { - mutating func addLocations(_ locations: [LocationCellViewModel], at index: Int) { - if index < count { - insert(contentsOf: locations, at: index) + mutating func addSubNodes(from node: LocationNode, at indexPath: IndexPath) { + let section = LocationSection.allCases[indexPath.section] + let row = indexPath.row + 1 + + let locations = node.children.map { + $0.indentationLevel = node.indentationLevel + 1 + return LocationCellViewModel(section: section, node: $0) + } + + if row < count { + insert(contentsOf: locations, at: row) } else { append(contentsOf: locations) } } - mutating func removeLocations(_ locations: [LocationCellViewModel]) { - removeAll(where: { location in - locations.contains(location) - }) + mutating func recursivelyRemoveSubNodes(from node: LocationNode) { + for node in node.children { + removeAll(where: { node == $0.node }) + recursivelyRemoveSubNodes(from: node) + } } } diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSourceProtocol.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSourceProtocol.swift index 6511f4bd4462..55d89a20554f 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSourceProtocol.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSourceProtocol.swift @@ -12,57 +12,46 @@ import MullvadTypes import UIKit protocol LocationDataSourceProtocol { - var nodeByLocation: [RelayLocation: SelectLocationNode] { get } - - func search(by text: String) -> [RelayLocation] - - func reload( - _ response: REST.ServerRelaysResponse, - relays: [REST.ServerRelay] - ) -> [RelayLocation] + var nodes: [LocationNode] { get } + var searchableNodes: [LocationNode] { get } } extension LocationDataSourceProtocol { - func makeRootNode(name: String) -> SelectLocationNode { - SelectLocationNode(nodeType: .root, location: .country("#root"), displayName: name) - } + func search(by text: String) -> [LocationNode] { + guard !text.isEmpty else { + return nodes + } + + var filteredNodes: [LocationNode] = [] + searchableNodes.forEach { countryNode in + countryNode.showsChildren = false - func createNode( - root: SelectLocationNode, - ancestorOrSelf: RelayLocation, - serverLocation: REST.ServerLocation, - relay: REST.ServerRelay, - wasShowingChildren: Bool - ) -> SelectLocationNode { - let node: SelectLocationNode + if countryNode.nodeName.fuzzyMatch(text) { + filteredNodes.append(countryNode) + } - switch ancestorOrSelf { - case .country: - node = SelectLocationNode( - nodeType: .country, - location: ancestorOrSelf, - displayName: serverLocation.country, - showsChildren: wasShowingChildren - ) - root.addChild(node) - case let .city(countryCode, _): - node = SelectLocationNode( - nodeType: .city, - location: ancestorOrSelf, - displayName: serverLocation.city, - showsChildren: wasShowingChildren - ) - nodeByLocation[.country(countryCode)]!.addChild(node) + countryNode.children.forEach { cityNode in + cityNode.showsChildren = false - case let .hostname(countryCode, cityCode, _): - node = SelectLocationNode( - nodeType: .relay, - location: ancestorOrSelf, - displayName: relay.hostname, - isActive: relay.active - ) - nodeByLocation[.city(countryCode, cityCode)]!.addChild(node) + let relaysContainSearchString = cityNode.children + .contains(where: { $0.nodeName.fuzzyMatch(text) }) + + if cityNode.nodeName.fuzzyMatch(text) || relaysContainSearchString { + if !filteredNodes.contains(countryNode) { + filteredNodes.append(countryNode) + } + + filteredNodes.append(cityNode) + countryNode.showsChildren = true + + if relaysContainSearchString { + filteredNodes.append(contentsOf: cityNode.children.map { $0 }) + cityNode.showsChildren = true + } + } + } } - return node + + return filteredNodes } } diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationNode.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationNode.swift new file mode 100644 index 000000000000..943302f4ad91 --- /dev/null +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationNode.swift @@ -0,0 +1,152 @@ +// +// LocationNode.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-02-21. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import MullvadSettings +import MullvadTypes + +class LocationNode { + let nodeName: String + var nodeCode: String + var locations: [RelayLocation] + var indentationLevel: Int + var parent: LocationNode? + var children: [LocationNode] + var showsChildren: Bool + + init( + nodeName: String, + nodeCode: String, + locations: [RelayLocation] = [], + indentationLevel: Int = 0, + parent: LocationNode? = nil, + children: [LocationNode] = [], + showsChildren: Bool = false + ) { + self.nodeName = nodeName + self.nodeCode = nodeCode + self.locations = locations + self.indentationLevel = indentationLevel + self.parent = parent + self.children = children + self.showsChildren = showsChildren + } +} + +extension LocationNode { + var topmostAncestor: LocationNode { + parent?.topmostAncestor ?? self + } + + func countryFor(countryCode: String) -> LocationNode? { + nodeCode == countryCode ? self : children.first(where: { $0.nodeCode == countryCode }) + } + + func cityFor(cityCode: String) -> LocationNode? { + nodeCode == cityCode ? self : children.first(where: { $0.nodeCode == cityCode }) + } + + func hostFor(hostCode: String) -> LocationNode? { + nodeCode == hostCode ? self : children.first(where: { $0.nodeCode == hostCode }) + } + + func nodeFor(nodeCode: String) -> LocationNode? { + self.nodeCode == nodeCode ? self : children.compactMap { $0.nodeFor(nodeCode: nodeCode) }.first + } + + func forEachDescendant(do callback: (_ index: Int, _ node: LocationNode) -> Void) { + children.enumerated().forEach { index, node in + callback(index, node) + node.forEachDescendant(do: callback) + } + } + + func forEachAncestor(do callback: (LocationNode) -> Void) { + if let parent = parent { + callback(parent) + parent.forEachAncestor(do: callback) + } + } +} + +extension LocationNode { + func copy(withParent parent: LocationNode? = nil) -> LocationNode { + let node = LocationNode( + nodeName: nodeName, + nodeCode: nodeCode, + locations: locations, + indentationLevel: indentationLevel, + parent: parent, + children: [], + showsChildren: showsChildren + ) + + node.children = recursivelyCopyChildren(withParent: node) + + return node + } + + private func recursivelyCopyChildren(withParent parent: LocationNode) -> [LocationNode] { + children.map { $0.copy(withParent: parent) } + } +} + +extension LocationNode: Hashable { + func hash(into hasher: inout Hasher) { + hasher.combine(nodeCode) + } + + static func == (lhs: LocationNode, rhs: LocationNode) -> Bool { + lhs.nodeCode == rhs.nodeCode + } +} + +extension LocationNode: Comparable { + static func < (lhs: LocationNode, rhs: LocationNode) -> Bool { + lhs.nodeName < rhs.nodeName + } +} + +/// Dummy class for building and/or searching node trees. +class RootNode: LocationNode { + init(nodeName: String = "", nodeCode: String = "", children: [LocationNode] = []) { + super.init(nodeName: nodeName, nodeCode: nodeCode, children: children) + } +} + +class LocationListNode: LocationNode { + let customList: CustomList + + init( + nodeName: String, + nodeCode: String, + locations: [RelayLocation] = [], + indentationLevel: Int = 0, + parent: LocationNode? = nil, + children: [LocationNode] = [], + showsChildren: Bool = false, + customList: CustomList + ) { + self.customList = customList + + super.init( + nodeName: nodeName, + nodeCode: nodeCode, + locations: locations, + indentationLevel: indentationLevel, + parent: parent, + children: children, + showsChildren: showsChildren + ) + } +} + +class LocationCountryNode: LocationNode {} + +class LocationCityNode: LocationNode {} + +class LocationHostNode: LocationNode {} diff --git a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationSection.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationSection.swift similarity index 79% rename from ios/MullvadVPN/View controllers/SelectLocation/SelectLocationSection.swift rename to ios/MullvadVPN/View controllers/SelectLocation/LocationSection.swift index 2e0984c809d8..6ebf676adb9a 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationSection.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationSection.swift @@ -1,5 +1,5 @@ // -// SelectLocationSectionGroup.swift +// LocationSection.swift // MullvadVPN // // Created by Mojgan on 2024-02-05. @@ -7,7 +7,7 @@ // import Foundation -enum SelectLocationSection: Hashable, CustomStringConvertible, CaseIterable { +enum LocationSection: Int, Hashable, CustomStringConvertible, CaseIterable { case customLists case allLocations @@ -29,10 +29,10 @@ enum SelectLocationSection: Hashable, CustomStringConvertible, CaseIterable { } var cell: Cell { - Cell.locationCell + .locationCell } - static var allCases: [SelectLocationSection] { + static var allCases: [LocationSection] { #if DEBUG return [.customLists, .allLocations] #else @@ -41,14 +41,14 @@ enum SelectLocationSection: Hashable, CustomStringConvertible, CaseIterable { } } -extension SelectLocationSection { +extension LocationSection { enum Cell: String, CaseIterable { case locationCell var reusableViewClass: AnyClass { switch self { case .locationCell: - return SelectLocationCell.self + return LocationCell.self } } diff --git a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewController.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift similarity index 88% rename from ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewController.swift rename to ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift index 5f8f5d2145f5..6937ec82b16c 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewController.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift @@ -1,5 +1,5 @@ // -// SelectLocationViewController.swift +// LocationViewController.swift // MullvadVPN // // Created by pronebird on 02/05/2019. @@ -8,10 +8,11 @@ import MullvadLogging import MullvadREST +import MullvadSettings import MullvadTypes import UIKit -final class SelectLocationViewController: UIViewController { +final class LocationViewController: UIViewController { private let searchBar = UISearchBar() private let tableView = UITableView() private let topContentView = UIStackView() @@ -19,7 +20,7 @@ final class SelectLocationViewController: UIViewController { private var dataSource: LocationDataSource? private var cachedRelays: CachedRelays? private var filter = RelayFilter() - var relayLocation: RelayLocation? + var relayLocations: RelayLocations? override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent @@ -30,7 +31,7 @@ final class SelectLocationViewController: UIViewController { } var navigateToFilter: (() -> Void)? - var didSelectRelay: ((RelayLocation) -> Void)? + var didSelectRelays: (([RelayLocation], UUID?) -> Void)? var didUpdateFilter: ((RelayFilter) -> Void)? var didFinish: (() -> Void)? @@ -108,7 +109,7 @@ final class SelectLocationViewController: UIViewController { filterView.setFilter(filter) } - dataSource?.setRelays(cachedRelays.relays, filter: filter) + dataSource?.setRelays(cachedRelays.relays, selectedLocations: relayLocations, filter: filter) } // MARK: - Private @@ -117,19 +118,15 @@ final class SelectLocationViewController: UIViewController { dataSource = LocationDataSource( tableView: tableView, allLocations: AllLocationDataSource(), - customLists: CustomListsDataSource() + customLists: CustomListsDataSource(repository: CustomListRepository()) ) - dataSource?.didSelectRelayLocation = { [weak self] location in - self?.didSelectRelay?(location) - } - dataSource?.selectedRelayLocation = relayLocation.flatMap { LocationCellViewModel( - group: .allLocations, - location: $0 - ) } + dataSource?.didSelectRelayLocations = { [weak self] locations, customListId in + self?.didSelectRelays?(locations, customListId) + } if let cachedRelays { - dataSource?.setRelays(cachedRelays.relays, filter: filter) + dataSource?.setRelays(cachedRelays.relays, selectedLocations: relayLocations, filter: filter) } } @@ -181,7 +178,7 @@ final class SelectLocationViewController: UIViewController { } } -extension SelectLocationViewController: UISearchBarDelegate { +extension LocationViewController: UISearchBarDelegate { func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { dataSource?.filterRelays(by: searchText) } diff --git a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationNode.swift b/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationNode.swift deleted file mode 100644 index 789075d15f47..000000000000 --- a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationNode.swift +++ /dev/null @@ -1,123 +0,0 @@ -// -// SelectLocationNode.swift -// MullvadVPN -// -// Created by Mojgan on 2024-02-05. -// Copyright © 2024 Mullvad VPN AB. All rights reserved. -// - -import MullvadTypes - -enum LocationNodeType { - case root - case country - case city - case relay -} - -class SelectLocationNode: SelectLocationNodeProtocol { - var children: [SelectLocationNode] - var showsChildren: Bool - var nodeType: LocationNodeType - var location: RelayLocation - var displayName: String - var isActive: Bool - - init( - nodeType: LocationNodeType, - location: RelayLocation, - displayName: String = "", - isActive: Bool = true, - showsChildren: Bool = false, - children: [SelectLocationNode] = [] - ) { - self.showsChildren = showsChildren - self.nodeType = nodeType - self.location = location - self.displayName = displayName - self.isActive = isActive - self.children = children - } - - var isCollapsible: Bool { - switch nodeType { - case .country, .city: - return true - case .root, .relay: - return false - } - } - - var indentationLevel: Int { - switch nodeType { - case .root, .country: - return 0 - case .city: - return 1 - case .relay: - return 2 - } - } - - func addChild(_ child: SelectLocationNode) { - children.append(child) - } - - func sortChildrenRecursive() { - sortChildren() - children.forEach { node in - node.sortChildrenRecursive() - } - } - - func computeActiveChildrenRecursive() { - switch nodeType { - case .root, .country: - for node in children { - node.computeActiveChildrenRecursive() - } - fallthrough - case .city: - isActive = children.contains(where: { node -> Bool in - node.isActive - }) - case .relay: - break - } - } - - func flatRelayLocationList(includeHiddenChildren: Bool = false) -> [RelayLocation] { - children.reduce(into: []) { array, node in - Self.flatten(node: node, into: &array, includeHiddenChildren: includeHiddenChildren) - } - } - - private func sortChildren() { - switch nodeType { - case .root, .country: - children.sort { a, b -> Bool in - a.displayName.localizedCaseInsensitiveCompare(b.displayName) == .orderedAscending - } - case .city: - children.sort { a, b -> Bool in - a.location.stringRepresentation - .localizedStandardCompare(b.location.stringRepresentation) == .orderedAscending - } - case .relay: - break - } - } - - private class func flatten( - node: SelectLocationNode, - into array: inout [RelayLocation], - includeHiddenChildren: Bool - ) { - array.append(node.location) - if includeHiddenChildren || node.showsChildren { - for child in node.children { - Self.flatten(node: child, into: &array, includeHiddenChildren: includeHiddenChildren) - } - } - } -} diff --git a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationNodeProtocol.swift b/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationNodeProtocol.swift deleted file mode 100644 index 2d45f3f2d8f1..000000000000 --- a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationNodeProtocol.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// SelectLocationNodeProtocol.swift -// MullvadVPN -// -// Created by Mojgan on 2024-02-05. -// Copyright © 2024 Mullvad VPN AB. All rights reserved. -// - -import MullvadTypes - -protocol SelectLocationNodeProtocol { - var location: RelayLocation { get } - var displayName: String { get } - var showsChildren: Bool { get } - var isActive: Bool { get } - var isCollapsible: Bool { get } - var indentationLevel: Int { get } -}