diff --git a/ios/MullvadSettings/CustomList.swift b/ios/MullvadSettings/CustomList.swift index 51066c7281b7..cc54672e1ad7 100644 --- a/ios/MullvadSettings/CustomList.swift +++ b/ios/MullvadSettings/CustomList.swift @@ -12,9 +12,11 @@ import MullvadTypes public struct CustomList: Codable, Equatable { public let id: UUID public var name: String - public var list: [RelayLocation] = [] - public init(id: UUID, name: String) { + public var locations: [RelayLocation] + + public init(id: UUID = UUID(), name: String, locations: [RelayLocation]) { self.id = id self.name = name + self.locations = locations } } diff --git a/ios/MullvadSettings/CustomListRepository.swift b/ios/MullvadSettings/CustomListRepository.swift index e900ff355c76..deb0162d160e 100644 --- a/ios/MullvadSettings/CustomListRepository.swift +++ b/ios/MullvadSettings/CustomListRepository.swift @@ -41,12 +41,12 @@ public struct CustomListRepository: CustomListRepositoryProtocol { public init() {} - public func create(_ name: String) throws -> CustomList { + public func create(_ name: String, locations: [RelayLocation]) throws -> CustomList { var lists = fetchAll() if lists.contains(where: { $0.name == name }) { throw CustomRelayListError.duplicateName } else { - let item = CustomList(id: UUID(), name: name) + let item = CustomList(id: UUID(), name: name, locations: locations) lists.append(item) try write(lists) return item diff --git a/ios/MullvadSettings/CustomListRepositoryProtocol.swift b/ios/MullvadSettings/CustomListRepositoryProtocol.swift index 42c498d45238..582111b15d33 100644 --- a/ios/MullvadSettings/CustomListRepositoryProtocol.swift +++ b/ios/MullvadSettings/CustomListRepositoryProtocol.swift @@ -28,8 +28,9 @@ public protocol CustomListRepositoryProtocol { /// Create a custom list by unique name. /// - Parameter name: a custom list name. + /// - Parameter locations: locations in a custom list. /// - Returns: a persistent custom list model upon success, otherwise throws `Error`. - func create(_ name: String) throws -> CustomList + func create(_ name: String, locations: [RelayLocation]) throws -> CustomList /// Fetch all custom list. /// - Returns: all custom list model . diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 6495711e9095..c648222b8f8d 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -501,8 +501,6 @@ 7A58699B2B482FE200640D27 /* UITableViewCell+Disable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A58699A2B482FE200640D27 /* UITableViewCell+Disable.swift */; }; 7A58699F2B50057100640D27 /* AccessMethodKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A58699D2B50057100640D27 /* AccessMethodKind.swift */; }; 7A5869A22B502EA800640D27 /* MethodSettingsSectionIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5869A12B502EA700640D27 /* MethodSettingsSectionIdentifier.swift */; }; - 7A5869A62B51405900640D27 /* MethodSettingsValidationErrorContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5869A52B51405900640D27 /* MethodSettingsValidationErrorContentConfiguration.swift */; }; - 7A5869A82B5140C200640D27 /* MethodSettingsValidationErrorContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5869A72B5140C200640D27 /* MethodSettingsValidationErrorContentView.swift */; }; 7A5869AB2B55527C00640D27 /* IPOverrideCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5869AA2B55527C00640D27 /* IPOverrideCoordinator.swift */; }; 7A5869AD2B5552E200640D27 /* IPOverrideViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5869AC2B5552E200640D27 /* IPOverrideViewController.swift */; }; 7A5869B72B56B41500640D27 /* IPOverrideTextViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5869B62B56B41500640D27 /* IPOverrideTextViewController.swift */; }; @@ -518,6 +516,18 @@ 7A6000F92B6273A4001CF0D9 /* AccessMethodViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D7B2B03BDD100E7CDD7 /* AccessMethodViewModel.swift */; }; 7A6000FC2B628DF6001CF0D9 /* ListCellContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6000FB2B628DF6001CF0D9 /* ListCellContentConfiguration.swift */; }; 7A6000FE2B628E9F001CF0D9 /* ListCellContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6000FD2B628E9F001CF0D9 /* ListCellContentView.swift */; }; + 7A6389DB2B7E3BD6008E77E1 /* CustomListCellConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6389D22B7E3BD6008E77E1 /* CustomListCellConfiguration.swift */; }; + 7A6389DC2B7E3BD6008E77E1 /* CustomListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6389D32B7E3BD6008E77E1 /* CustomListViewModel.swift */; }; + 7A6389DD2B7E3BD6008E77E1 /* CustomListDataSourceConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6389D42B7E3BD6008E77E1 /* CustomListDataSourceConfiguration.swift */; }; + 7A6389DE2B7E3BD6008E77E1 /* CustomListItemIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6389D52B7E3BD6008E77E1 /* CustomListItemIdentifier.swift */; }; + 7A6389DF2B7E3BD6008E77E1 /* AddCustomListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6389D72B7E3BD6008E77E1 /* AddCustomListCoordinator.swift */; }; + 7A6389E12B7E3BD6008E77E1 /* CustomListSectionIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6389D92B7E3BD6008E77E1 /* CustomListSectionIdentifier.swift */; }; + 7A6389E22B7E3BD6008E77E1 /* CustomListInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6389DA2B7E3BD6008E77E1 /* CustomListInteractor.swift */; }; + 7A6389E52B7E4247008E77E1 /* EditCustomListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6389E42B7E4247008E77E1 /* EditCustomListCoordinator.swift */; }; + 7A6389E72B7E42BE008E77E1 /* CustomListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6389E62B7E42BE008E77E1 /* CustomListViewController.swift */; }; + 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 */; }; 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 */; }; @@ -785,14 +795,14 @@ 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 */; }; - 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 */; }; - F050AE5C2B73797D003F4EDB /* CustomListRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE5B2B73797D003F4EDB /* CustomListRepositoryTests.swift */; }; F050AE4C2B70D5A7003F4EDB /* SelectLocationNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE4B2B70D5A7003F4EDB /* SelectLocationNode.swift */; }; F050AE4E2B70D7F8003F4EDB /* LocationCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE4D2B70D7F8003F4EDB /* LocationCellViewModel.swift */; }; F050AE502B70DC4F003F4EDB /* SelectLocationNodeProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE4F2B70DC4F003F4EDB /* SelectLocationNodeProtocol.swift */; }; F050AE522B70DFC0003F4EDB /* SelectLocationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE512B70DFC0003F4EDB /* SelectLocationSection.swift */; }; + 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 */; }; + F050AE5C2B73797D003F4EDB /* CustomListRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE5B2B73797D003F4EDB /* CustomListRepositoryTests.swift */; }; F050AE5E2B739A73003F4EDB /* LocationDataSourceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE5D2B739A73003F4EDB /* LocationDataSourceProtocol.swift */; }; F050AE602B73A41E003F4EDB /* AllLocationDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE5F2B73A41E003F4EDB /* AllLocationDataSource.swift */; }; F050AE622B74DBAC003F4EDB /* CustomListsDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE612B74DBAC003F4EDB /* CustomListsDataSource.swift */; }; @@ -1718,8 +1728,6 @@ 7A58699A2B482FE200640D27 /* UITableViewCell+Disable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableViewCell+Disable.swift"; sourceTree = ""; }; 7A58699D2B50057100640D27 /* AccessMethodKind.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccessMethodKind.swift; sourceTree = ""; }; 7A5869A12B502EA700640D27 /* MethodSettingsSectionIdentifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MethodSettingsSectionIdentifier.swift; sourceTree = ""; }; - 7A5869A52B51405900640D27 /* MethodSettingsValidationErrorContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MethodSettingsValidationErrorContentConfiguration.swift; sourceTree = ""; }; - 7A5869A72B5140C200640D27 /* MethodSettingsValidationErrorContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MethodSettingsValidationErrorContentView.swift; sourceTree = ""; }; 7A5869AA2B55527C00640D27 /* IPOverrideCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPOverrideCoordinator.swift; sourceTree = ""; }; 7A5869AC2B5552E200640D27 /* IPOverrideViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPOverrideViewController.swift; sourceTree = ""; }; 7A5869B22B5697AC00640D27 /* IPOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPOverride.swift; sourceTree = ""; }; @@ -1734,6 +1742,18 @@ 7A6000F52B60092F001CF0D9 /* AccessMethodViewModelEditing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodViewModelEditing.swift; sourceTree = ""; }; 7A6000FB2B628DF6001CF0D9 /* ListCellContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListCellContentConfiguration.swift; sourceTree = ""; }; 7A6000FD2B628E9F001CF0D9 /* ListCellContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListCellContentView.swift; sourceTree = ""; }; + 7A6389D22B7E3BD6008E77E1 /* CustomListCellConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomListCellConfiguration.swift; sourceTree = ""; }; + 7A6389D32B7E3BD6008E77E1 /* CustomListViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomListViewModel.swift; sourceTree = ""; }; + 7A6389D42B7E3BD6008E77E1 /* CustomListDataSourceConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomListDataSourceConfiguration.swift; sourceTree = ""; }; + 7A6389D52B7E3BD6008E77E1 /* CustomListItemIdentifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomListItemIdentifier.swift; sourceTree = ""; }; + 7A6389D72B7E3BD6008E77E1 /* AddCustomListCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddCustomListCoordinator.swift; sourceTree = ""; }; + 7A6389D92B7E3BD6008E77E1 /* CustomListSectionIdentifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomListSectionIdentifier.swift; sourceTree = ""; }; + 7A6389DA2B7E3BD6008E77E1 /* CustomListInteractor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomListInteractor.swift; sourceTree = ""; }; + 7A6389E42B7E4247008E77E1 /* EditCustomListCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCustomListCoordinator.swift; sourceTree = ""; }; + 7A6389E62B7E42BE008E77E1 /* CustomListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomListViewController.swift; sourceTree = ""; }; + 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 = ""; }; 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 = ""; }; @@ -1901,14 +1921,14 @@ 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 = ""; }; - 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 = ""; }; - F050AE5B2B73797D003F4EDB /* CustomListRepositoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomListRepositoryTests.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 = ""; }; + 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 = ""; }; + F050AE5B2B73797D003F4EDB /* CustomListRepositoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomListRepositoryTests.swift; sourceTree = ""; }; F050AE5D2B739A73003F4EDB /* LocationDataSourceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationDataSourceProtocol.swift; sourceTree = ""; }; F050AE5F2B73A41E003F4EDB /* AllLocationDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllLocationDataSource.swift; sourceTree = ""; }; F050AE612B74DBAC003F4EDB /* CustomListsDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomListsDataSource.swift; sourceTree = ""; }; @@ -2281,8 +2301,6 @@ 7A5869C62B5A8E4C00640D27 /* MethodSettingsDataSourceConfiguration.swift */, 5827B0952B0DB2C100CCBBA1 /* MethodSettingsItemIdentifier.swift */, 7A5869A12B502EA700640D27 /* MethodSettingsSectionIdentifier.swift */, - 7A5869A52B51405900640D27 /* MethodSettingsValidationErrorContentConfiguration.swift */, - 7A5869A72B5140C200640D27 /* MethodSettingsValidationErrorContentView.swift */, 5827B0912B0CAB2800CCBBA1 /* MethodSettingsViewController.swift */, 5827B0AD2B0F4CBE00CCBBA1 /* MethodSettingsViewControllerDelegate.swift */, 5827B0B82B14A1C700CCBBA1 /* MethodTestingStatusCellContentConfiguration.swift */, @@ -3024,6 +3042,8 @@ 58CAF9F22983D32200BE19F7 /* Coordinators */ = { isa = PBXGroup; children = ( + 7A6389D12B7E3BD6008E77E1 /* CustomLists */, + 58EFC76F2AFB3FA800E9F4CB /* Settings */, 7A9CCCAF2A96302800DD6A34 /* AccountCoordinator.swift */, 7A9CCCAC2A96302800DD6A34 /* AccountDeletionCoordinator.swift */, 7A9CCCA32A96302700DD6A34 /* AddCreditSucceededCoordinator.swift */, @@ -3039,7 +3059,6 @@ 7A9CCCA52A96302700DD6A34 /* RevokedCoordinator.swift */, 7A9CCCB02A96302800DD6A34 /* SafariCoordinator.swift */, 7A9CCCA72A96302700DD6A34 /* SelectLocationCoordinator.swift */, - 58EFC76F2AFB3FA800E9F4CB /* Settings */, 7A9CCCA62A96302700DD6A34 /* SetupAccountCompletedCoordinator.swift */, 7A9CCCA22A96302700DD6A34 /* TermsOfServiceCoordinator.swift */, 7A9CCCB22A96302800DD6A34 /* TunnelCoordinator.swift */, @@ -3320,6 +3339,8 @@ 7A5869A92B55516700640D27 /* IPOverride */, 58EFC7702AFB45E500E9F4CB /* SettingsChildCoordinator.swift */, 7A9CCCAD2A96302800DD6A34 /* SettingsCoordinator.swift */, + 7A6389EC2B7FADA1008E77E1 /* SettingsFieldValidationErrorConfiguration.swift */, + 7A6389EA2B7FAD7A008E77E1 /* SettingsFieldValidationErrorContentView.swift */, ); path = Settings; sourceTree = ""; @@ -3402,6 +3423,23 @@ path = IPOverride; sourceTree = ""; }; + 7A6389D12B7E3BD6008E77E1 /* CustomLists */ = { + isa = PBXGroup; + children = ( + 7A6389D72B7E3BD6008E77E1 /* AddCustomListCoordinator.swift */, + 7A6389D22B7E3BD6008E77E1 /* CustomListCellConfiguration.swift */, + 7A6389D42B7E3BD6008E77E1 /* CustomListDataSourceConfiguration.swift */, + 7A6389DA2B7E3BD6008E77E1 /* CustomListInteractor.swift */, + 7A6389D52B7E3BD6008E77E1 /* CustomListItemIdentifier.swift */, + 7A6389D92B7E3BD6008E77E1 /* CustomListSectionIdentifier.swift */, + 7A6389E82B7F8FE2008E77E1 /* CustomListValidationError.swift */, + 7A6389E62B7E42BE008E77E1 /* CustomListViewController.swift */, + 7A6389D32B7E3BD6008E77E1 /* CustomListViewModel.swift */, + 7A6389E42B7E4247008E77E1 /* EditCustomListCoordinator.swift */, + ); + path = CustomLists; + sourceTree = ""; + }; 7A83C3FC2A55B39500DFB83A /* TestPlans */ = { isa = PBXGroup; children = ( @@ -4929,6 +4967,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7A6389DC2B7E3BD6008E77E1 /* CustomListViewModel.swift in Sources */, 7A9CCCC42A96302800DD6A34 /* TunnelCoordinator.swift in Sources */, 5827B0A42B0F38FD00CCBBA1 /* EditAccessMethodInteractorProtocol.swift in Sources */, 586C0D852B03D31E00E7CDD7 /* SocksSectionHandler.swift in Sources */, @@ -4977,6 +5016,7 @@ 7A516C2E2B6D357500BBD33D /* URL+Scoping.swift in Sources */, 5878A27529093A310096FC88 /* StorePaymentEvent.swift in Sources */, 7A7AD28D29DC677800480EF1 /* FirstTimeLaunch.swift in Sources */, + 7A6389EB2B7FAD7A008E77E1 /* SettingsFieldValidationErrorContentView.swift in Sources */, 58B26E2A2943545A00D5980C /* NotificationManagerDelegate.swift in Sources */, 58A1AA8C23F5584C009F7EA6 /* ConnectionPanelView.swift in Sources */, 5878A27B2909649A0096FC88 /* CustomOverlayRenderer.swift in Sources */, @@ -5041,6 +5081,7 @@ 58293FB125124117005D0BB5 /* CustomTextField.swift in Sources */, F09A29822A9F8AD200EA3B6F /* RedeemVoucherInteractor.swift in Sources */, 58138E61294871C600684F0C /* DeviceDataThrottling.swift in Sources */, + 7A6389ED2B7FADA1008E77E1 /* SettingsFieldValidationErrorConfiguration.swift in Sources */, 5878A279290954790096FC88 /* TunnelViewControllerInteractor.swift in Sources */, 7A818F1F29F0305800C7F0F4 /* RootConfiguration.swift in Sources */, 7A9CCCBF2A96302800DD6A34 /* SettingsCoordinator.swift in Sources */, @@ -5074,6 +5115,7 @@ 58FF9FE02B075ABC00E4C97D /* EditAccessMethodViewController.swift in Sources */, F050AE622B74DBAC003F4EDB /* CustomListsDataSource.swift in Sources */, F0DA87472A9CB9A2006044F1 /* AccountExpiryRow.swift in Sources */, + 7A6389E92B7F8FE2008E77E1 /* CustomListValidationError.swift in Sources */, 585CA70F25F8C44600B47C62 /* UIMetrics.swift in Sources */, E1187ABD289BBB850024E748 /* OutOfTimeContentView.swift in Sources */, 58CC40EF24A601900019D96E /* ObserverList.swift in Sources */, @@ -5095,6 +5137,7 @@ 58E25F812837BBBB002CFB2C /* SceneDelegate.swift in Sources */, 7A1A26492A29D48A00B978AA /* RelayFilterCellFactory.swift in Sources */, 5867771629097C5B006F721F /* ProductState.swift in Sources */, + 7A6389DE2B7E3BD6008E77E1 /* CustomListItemIdentifier.swift in Sources */, 58C76A082A33850E00100D75 /* ApplicationTarget.swift in Sources */, 58CEB3042AFD36CE00E6E088 /* SwitchCellContentView.swift in Sources */, F07BF2622A26279100042943 /* RedeemVoucherOperation.swift in Sources */, @@ -5111,8 +5154,11 @@ 7AF9BE8E2A331C7B00DBFEDB /* RelayFilterViewModel.swift in Sources */, 58F3C0A4249CB069003E76BE /* HeaderBarView.swift in Sources */, 5864AF0829C78849005B0CD9 /* CellFactoryProtocol.swift in Sources */, + 7A6389E22B7E3BD6008E77E1 /* CustomListInteractor.swift in Sources */, F07CFF2029F2720E008C0343 /* RegisteredDeviceInAppNotificationProvider.swift in Sources */, + 7A6389E12B7E3BD6008E77E1 /* CustomListSectionIdentifier.swift in Sources */, 58CEB2F32AFD0BA100E6E088 /* TextCellContentView.swift in Sources */, + 7A6389E72B7E42BE008E77E1 /* CustomListViewController.swift in Sources */, 586C0D7C2B03BDD100E7CDD7 /* AccessMethodViewModel.swift in Sources */, 587A01FC23F1F0BE00B68763 /* SimulatorTunnelProviderHost.swift in Sources */, 7A6F2FA72AFBB9AE006D0856 /* AccountExpiry.swift in Sources */, @@ -5132,6 +5178,7 @@ 5807E2C02432038B00F5FF30 /* String+Split.swift in Sources */, 58B26E242943520C00D5980C /* NotificationProviderProtocol.swift in Sources */, 5877F94E2A0A59AA0052D9E9 /* NotificationResponse.swift in Sources */, + 7A6389E52B7E4247008E77E1 /* EditCustomListCoordinator.swift in Sources */, 58677712290976FB006F721F /* SettingsInteractor.swift in Sources */, 58EF875D2B1638BF00C098B2 /* ProxyConfigurationTesterProtocol.swift in Sources */, 58CE5E66224146200008646E /* LoginViewController.swift in Sources */, @@ -5192,6 +5239,7 @@ 581DA2752A1E283E0046ED47 /* WgKeyRotation.swift in Sources */, 5827B0BB2B14A28300CCBBA1 /* MethodTestingStatusCellContentView.swift in Sources */, 7A83C4022A57FAA800DFB83A /* SettingsDNSInfoCell.swift in Sources */, + 7A6389DF2B7E3BD6008E77E1 /* AddCustomListCoordinator.swift in Sources */, 586C0D952B03D92100E7CDD7 /* SocksItemIdentifier.swift in Sources */, F0C6A8432AB08E54000777A8 /* RedeemVoucherViewConfiguration.swift in Sources */, 7AF10EB42ADE85BC00C090B9 /* RelayFilterCoordinator.swift in Sources */, @@ -5230,11 +5278,11 @@ 584D26C4270C855B004EA533 /* PreferencesDataSource.swift in Sources */, F0D8825B2B04F53600D3EF9A /* OutgoingConnectionData.swift in Sources */, 7A6F2FAF2AFE36E7006D0856 /* PreferencesInfoButtonItem.swift in Sources */, + 7A6389DD2B7E3BD6008E77E1 /* CustomListDataSourceConfiguration.swift in Sources */, 5827B0BF2B14B37D00CCBBA1 /* Publisher+PreviousValue.swift in Sources */, 7A9CCCB62A96302800DD6A34 /* OutOfTimeCoordinator.swift in Sources */, 5827B0AA2B0F4C9100CCBBA1 /* EditAccessMethodViewControllerDelegate.swift in Sources */, F050AE5E2B739A73003F4EDB /* LocationDataSourceProtocol.swift in Sources */, - 7A5869A82B5140C200640D27 /* MethodSettingsValidationErrorContentView.swift in Sources */, A99E5EE22B762ED30033F241 /* ProblemReportViewController+ViewManagement.swift in Sources */, 7A5869A22B502EA800640D27 /* MethodSettingsSectionIdentifier.swift in Sources */, 586C0D812B03CA8400E7CDD7 /* CurrentValueSubject+UIActionBindings.swift in Sources */, @@ -5244,11 +5292,11 @@ 58B43C1925F77DB60002C8C3 /* TunnelControlView.swift in Sources */, F09A297B2A9F8A9B00EA3B6F /* LogoutDialogueView.swift in Sources */, 58CEB2FB2AFD13E600E6E088 /* UIListContentConfiguration+Extensions.swift in Sources */, - 7A5869A62B51405900640D27 /* MethodSettingsValidationErrorContentConfiguration.swift in Sources */, 5811DE50239014550011EB53 /* NEVPNStatus+Debug.swift in Sources */, 7A21DACF2A30AA3700A787A9 /* UITextField+Appearance.swift in Sources */, 585B1FF02AB09F97008AD470 /* VPNConnectionProtocol.swift in Sources */, 58C3A4B222456F1B00340BDB /* AccountInputGroupView.swift in Sources */, + 7A6389DB2B7E3BD6008E77E1 /* CustomListCellConfiguration.swift in Sources */, F09A297C2A9F8A9B00EA3B6F /* VoucherTextField.swift in Sources */, 7A5869B72B56B41500640D27 /* IPOverrideTextViewController.swift in Sources */, 58ACF64B26553C3F00ACE4B7 /* SettingsSwitchCell.swift in Sources */, diff --git a/ios/MullvadVPN/Coordinators/CustomLists/AddCustomListCoordinator.swift b/ios/MullvadVPN/Coordinators/CustomLists/AddCustomListCoordinator.swift new file mode 100644 index 000000000000..c85147f37c7c --- /dev/null +++ b/ios/MullvadVPN/Coordinators/CustomLists/AddCustomListCoordinator.swift @@ -0,0 +1,74 @@ +// +// AddCustomListCoordinator.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-02-14. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Combine +import MullvadSettings +import Routing +import UIKit + +class AddCustomListCoordinator: Coordinator, Presentable, Presenting { + let navigationController: UINavigationController + let customListInteractor: CustomListInteractorProtocol + + var presentedViewController: UIViewController { + navigationController + } + + var didFinish: (() -> Void)? + + init( + navigationController: UINavigationController, + customListInteractor: CustomListInteractorProtocol + ) { + self.navigationController = navigationController + self.customListInteractor = customListInteractor + } + + func start() { + let subject = CurrentValueSubject( + CustomListViewModel(id: UUID(), name: "", locations: [], tableSections: [.name, .addLocations]) + ) + + let controller = CustomListViewController( + interactor: customListInteractor, + subject: subject, + alertPresenter: AlertPresenter(context: self) + ) + controller.delegate = self + + controller.navigationItem.title = NSLocalizedString( + "CUSTOM_LIST_NAVIGATION_EDIT_TITLE", + tableName: "CustomLists", + value: "New custom list", + comment: "" + ) + + controller.saveBarButton.title = NSLocalizedString( + "CUSTOM_LIST_NAVIGATION_CREATE_BUTTON", + tableName: "CustomLists", + value: "Create", + comment: "" + ) + + navigationController.pushViewController(controller, animated: false) + } +} + +extension AddCustomListCoordinator: CustomListViewControllerDelegate { + func customListDidSave() { + didFinish?() + } + + func customListDidDelete() { + // No op. + } + + func showLocations() { + // TODO: Show view controller for locations. + } +} diff --git a/ios/MullvadVPN/Coordinators/CustomLists/CustomListCellConfiguration.swift b/ios/MullvadVPN/Coordinators/CustomLists/CustomListCellConfiguration.swift new file mode 100644 index 000000000000..b1db9122eb6c --- /dev/null +++ b/ios/MullvadVPN/Coordinators/CustomLists/CustomListCellConfiguration.swift @@ -0,0 +1,105 @@ +// +// CustomListCellConfiguration.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-02-14. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Combine +import UIKit + +struct CustomListCellConfiguration { + let tableView: UITableView + let subject: CurrentValueSubject + + var onDelete: (() -> Void)? + + func dequeueCell( + at indexPath: IndexPath, + for itemIdentifier: CustomListItemIdentifier, + validationErrors: Set + ) -> UITableViewCell { + let cell = tableView.dequeueReusableView(withIdentifier: itemIdentifier.cellIdentifier, for: indexPath) + + configureBackground(cell: cell, itemIdentifier: itemIdentifier, validationErrors: validationErrors) + + switch itemIdentifier { + case .name: + configureName(cell, itemIdentifier: itemIdentifier) + case .addLocations, .editLocations: + configureLocations(cell, itemIdentifier: itemIdentifier) + case .deleteList: + configureDelete(cell, itemIdentifier: itemIdentifier) + } + + return cell + } + + private func configureBackground( + cell: UITableViewCell, + itemIdentifier: CustomListItemIdentifier, + validationErrors: Set + ) { + configureErrorState( + cell: cell, + itemIdentifier: itemIdentifier, + contentValidationErrors: validationErrors + ) + + guard let cell = cell as? DynamicBackgroundConfiguration else { return } + + cell.setAutoAdaptingBackgroundConfiguration(.mullvadListGroupedCell(), selectionType: .dimmed) + } + + private func configureErrorState( + cell: UITableViewCell, + itemIdentifier: CustomListItemIdentifier, + contentValidationErrors: Set + ) { + let itemsWithErrors = CustomListItemIdentifier.fromFieldValidationErrors(contentValidationErrors) + + if itemsWithErrors.contains(itemIdentifier) { + cell.layer.cornerRadius = 10 + cell.layer.borderWidth = 1 + cell.layer.borderColor = UIColor.Cell.validationErrorBorderColor.cgColor + } else { + cell.layer.borderWidth = 0 + } + } + + private func configureName(_ cell: UITableViewCell, itemIdentifier: CustomListItemIdentifier) { + var contentConfiguration = TextCellContentConfiguration() + + contentConfiguration.text = itemIdentifier.text + contentConfiguration.setPlaceholder(type: .required) + contentConfiguration.textFieldProperties = .withSmartFeaturesDisabled() + contentConfiguration.inputText = subject.value.name + contentConfiguration.editingEvents.onChange = subject.bindTextAction(to: \.name) + + cell.contentConfiguration = contentConfiguration + } + + private func configureLocations(_ cell: UITableViewCell, itemIdentifier: CustomListItemIdentifier) { + var contentConfiguration = UIListContentConfiguration.mullvadValueCell(tableStyle: tableView.style) + + contentConfiguration.text = itemIdentifier.text + cell.contentConfiguration = contentConfiguration + + if let cell = cell as? CustomCellDisclosureHandling { + cell.disclosureType = .chevron + } + } + + private func configureDelete(_ cell: UITableViewCell, itemIdentifier: CustomListItemIdentifier) { + var contentConfiguration = ButtonCellContentConfiguration() + + contentConfiguration.style = .tableInsetGroupedDanger + contentConfiguration.text = itemIdentifier.text + contentConfiguration.primaryAction = UIAction { _ in + onDelete?() + } + + cell.contentConfiguration = contentConfiguration + } +} diff --git a/ios/MullvadVPN/Coordinators/CustomLists/CustomListDataSourceConfiguration.swift b/ios/MullvadVPN/Coordinators/CustomLists/CustomListDataSourceConfiguration.swift new file mode 100644 index 000000000000..faf4e1776408 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/CustomLists/CustomListDataSourceConfiguration.swift @@ -0,0 +1,106 @@ +// +// CustomListDataSourceConfigurationv.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-02-14. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +class CustomListDataSourceConfiguration: NSObject { + let dataSource: UITableViewDiffableDataSource + var validationErrors: Set = [] + + var didSelectItem: ((CustomListItemIdentifier) -> Void)? + + init(dataSource: UITableViewDiffableDataSource) { + self.dataSource = dataSource + } + + func updateDataSource( + sections: [CustomListSectionIdentifier], + validationErrors: Set, + animated: Bool, + completion: (() -> Void)? = nil + ) { + var snapshot = NSDiffableDataSourceSnapshot() + + sections.forEach { section in + switch section { + case .name: + snapshot.appendSections([.name]) + snapshot.appendItems([.name], toSection: .name) + case .addLocations: + snapshot.appendSections([.addLocations]) + snapshot.appendItems([.addLocations], toSection: .addLocations) + case .editLocations: + snapshot.appendSections([.editLocations]) + snapshot.appendItems([.editLocations], toSection: .editLocations) + case .deleteList: + snapshot.appendSections([.deleteList]) + snapshot.appendItems([.deleteList], toSection: .deleteList) + } + } + + dataSource.apply(snapshot, animatingDifferences: animated) + } + + func set(validationErrors: Set) { + self.validationErrors = validationErrors + + var snapshot = dataSource.snapshot() + + validationErrors.forEach { error in + switch error { + case .name: + snapshot.reloadSections([.name]) + } + } + + dataSource.apply(snapshot, animatingDifferences: false) + } +} + +extension CustomListDataSourceConfiguration: UITableViewDelegate { + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + UIMetrics.SettingsCell.customListsCellHeight + } + + func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + let snapshot = dataSource.snapshot() + + let sectionIdentifier = snapshot.sectionIdentifiers[section] + let itemsInSection = snapshot.itemIdentifiers(inSection: sectionIdentifier) + + let itemsWithErrors = CustomListItemIdentifier.fromFieldValidationErrors(validationErrors) + let errorsInSection = itemsWithErrors.filter { itemsInSection.contains($0) }.compactMap { item in + switch item { + case .name: + CustomListFieldValidationError.name + case .addLocations, .editLocations, .deleteList: + nil + } + } + + switch sectionIdentifier { + case .name: + let view = SettingsFieldValidationErrorContentView( + configuration: SettingsFieldValidationErrorConfiguration( + errors: errorsInSection.settingsFieldValidationErrors + ) + ) + return view + default: + return nil + } + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: false) + + if let item = dataSource.itemIdentifier(for: indexPath) { + didSelectItem?(item) + } + } +} diff --git a/ios/MullvadVPN/Coordinators/CustomLists/CustomListInteractor.swift b/ios/MullvadVPN/Coordinators/CustomLists/CustomListInteractor.swift new file mode 100644 index 000000000000..5f129c79cfed --- /dev/null +++ b/ios/MullvadVPN/Coordinators/CustomLists/CustomListInteractor.swift @@ -0,0 +1,31 @@ +// +// CustomListInteractor.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-02-15. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import MullvadSettings + +protocol CustomListInteractorProtocol { + func createCustomList(viewModel: CustomListViewModel) throws + func updateCustomList(viewModel: CustomListViewModel) + func deleteCustomList(id: UUID) +} + +struct CustomListInteractor: CustomListInteractorProtocol { + let repository: CustomListRepositoryProtocol + + func createCustomList(viewModel: CustomListViewModel) throws { + try _ = repository.create(viewModel.name, locations: viewModel.locations) + } + + func updateCustomList(viewModel: CustomListViewModel) { + repository.update(viewModel.customList) + } + + func deleteCustomList(id: UUID) { + repository.delete(id: id) + } +} diff --git a/ios/MullvadVPN/Coordinators/CustomLists/CustomListItemIdentifier.swift b/ios/MullvadVPN/Coordinators/CustomLists/CustomListItemIdentifier.swift new file mode 100644 index 000000000000..f2cc7726a497 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/CustomLists/CustomListItemIdentifier.swift @@ -0,0 +1,61 @@ +// +// CustomListItemIdentifier.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-02-14. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +enum CustomListItemIdentifier: Hashable, CaseIterable { + case name + case addLocations + case editLocations + case deleteList + + enum CellIdentifier: String, CellIdentifierProtocol { + case name + case locations + case delete + + var cellClass: AnyClass { + BasicCell.self + } + } + + var cellIdentifier: CellIdentifier { + switch self { + case .name: + .name + case .addLocations: + .locations + case .editLocations: + .locations + case .deleteList: + .delete + } + } + + var text: String? { + switch self { + case .name: + NSLocalizedString("NAME", tableName: "CustomLists", value: "Name", comment: "") + case .addLocations: + NSLocalizedString("ADD", tableName: "CustomLists", value: "Add locations", comment: "") + case .editLocations: + NSLocalizedString("EDIT", tableName: "CustomLists", value: "Edit locations", comment: "") + case .deleteList: + NSLocalizedString("Delete", tableName: "CustomLists", value: "Delete list", comment: "") + } + } + + static func fromFieldValidationErrors(_ errors: Set) -> [CustomListItemIdentifier] { + errors.compactMap { error in + switch error { + case .name: + .name + } + } + } +} diff --git a/ios/MullvadVPN/Coordinators/CustomLists/CustomListSectionIdentifier.swift b/ios/MullvadVPN/Coordinators/CustomLists/CustomListSectionIdentifier.swift new file mode 100644 index 000000000000..847a44a57f71 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/CustomLists/CustomListSectionIdentifier.swift @@ -0,0 +1,16 @@ +// +// CustomListSectionIdentifier.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-02-14. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +enum CustomListSectionIdentifier: Hashable, CaseIterable { + case name + case addLocations + case editLocations + case deleteList +} diff --git a/ios/MullvadVPN/Coordinators/CustomLists/CustomListValidationError.swift b/ios/MullvadVPN/Coordinators/CustomLists/CustomListValidationError.swift new file mode 100644 index 000000000000..100cff15a6f9 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/CustomLists/CustomListValidationError.swift @@ -0,0 +1,31 @@ +// +// CustomListValidationError.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-02-16. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +enum CustomListFieldValidationError: LocalizedError { + case name + + var errorDescription: String? { + switch self { + case .name: + NSLocalizedString( + "CUSTOM_LISTS_VALIDATION_ERROR_EMPTY_FIELD", + tableName: "CutstomLists", + value: "A custom list with this name exists, please choose a unique name.", + comment: "" + ) + } + } +} + +extension Collection { + var settingsFieldValidationErrors: [SettingsFieldValidationError] { + map { SettingsFieldValidationError(errorDescription: $0.errorDescription) } + } +} diff --git a/ios/MullvadVPN/Coordinators/CustomLists/CustomListViewController.swift b/ios/MullvadVPN/Coordinators/CustomLists/CustomListViewController.swift new file mode 100644 index 000000000000..e171bf66245f --- /dev/null +++ b/ios/MullvadVPN/Coordinators/CustomLists/CustomListViewController.swift @@ -0,0 +1,190 @@ +// +// CustomListViewController.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-02-15. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Combine +import MullvadSettings +import UIKit + +protocol CustomListViewControllerDelegate: AnyObject { + func customListDidSave() + func customListDidDelete() + func showLocations() +} + +class CustomListViewController: UIViewController { + typealias DataSource = UITableViewDiffableDataSource + + private let interactor: CustomListInteractorProtocol + private let tableView = UITableView(frame: .zero, style: .insetGrouped) + private let subject: CurrentValueSubject + private var cancellables = Set() + private var dataSource: DataSource? + private let alertPresenter: AlertPresenter + private var validationErrors: Set = [] + + private lazy var cellConfiguration: CustomListCellConfiguration = { + CustomListCellConfiguration(tableView: tableView, subject: subject) + }() + + private lazy var dataSourceConfiguration: CustomListDataSourceConfiguration? = { + dataSource.flatMap { dataSource in + CustomListDataSourceConfiguration(dataSource: dataSource) + } + }() + + lazy var saveBarButton: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem( + title: NSLocalizedString( + "CUSTOM_LIST_NAVIGATION_SAVE_BUTTON", + tableName: "CustomLists", + value: "Save", + comment: "" + ), + primaryAction: UIAction { _ in + self.onSave() + } + ) + barButtonItem.style = .done + + return barButtonItem + }() + + weak var delegate: CustomListViewControllerDelegate? + + init( + interactor: CustomListInteractorProtocol, + subject: CurrentValueSubject, + alertPresenter: AlertPresenter + ) { + self.subject = subject + self.interactor = interactor + self.alertPresenter = alertPresenter + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.directionalLayoutMargins = UIMetrics.contentLayoutMargins + view.backgroundColor = .secondaryColor + isModalInPresentation = true + + addSubviews() + configureNavigationItem() + configureDataSource() + configureTableView() + + subject.sink { [weak self] viewModel in + self?.saveBarButton.isEnabled = !viewModel.name.isEmpty + self?.validationErrors.removeAll() + }.store(in: &cancellables) + } + + private func configureNavigationItem() { + navigationItem.leftBarButtonItem = UIBarButtonItem( + systemItem: .cancel, + primaryAction: UIAction(handler: { _ in + self.dismiss(animated: true) + }) + ) + + navigationItem.rightBarButtonItem = saveBarButton + } + + private func configureTableView() { + tableView.delegate = dataSourceConfiguration + tableView.backgroundColor = .secondaryColor + tableView.registerReusableViews(from: CustomListItemIdentifier.CellIdentifier.self) + } + + private func configureDataSource() { + cellConfiguration.onDelete = { + self.onDelete() + } + + dataSource = DataSource( + tableView: tableView, + cellProvider: { _, indexPath, itemIdentifier in + self.cellConfiguration.dequeueCell( + at: indexPath, + for: itemIdentifier, + validationErrors: self.validationErrors + ) + } + ) + + dataSourceConfiguration?.didSelectItem = { item in + self.view.endEditing(false) + + switch item { + case .name, .deleteList: + break + case .addLocations, .editLocations: + self.delegate?.showLocations() + } + } + + dataSourceConfiguration?.updateDataSource( + sections: subject.value.tableSections, + validationErrors: validationErrors, + animated: false + ) + } + + private func addSubviews() { + view.addConstrainedSubviews([tableView]) { + tableView.pinEdgesToSuperview() + } + } + + private func onSave() { + do { + try interactor.createCustomList(viewModel: subject.value) + delegate?.customListDidSave() + } catch { + validationErrors.insert(.name) + dataSourceConfiguration?.set(validationErrors: validationErrors) + } + } + + private func onDelete() { + // TODO: Show error dialog. + delegate?.customListDidDelete() + } + + private func showSaveErrorAlert() { + let presentation = AlertPresentation( + id: "api-custom-lists-save-list-alert", + icon: .alert, + message: NSLocalizedString( + "CUSTOM_LISTS_SAVE_ERROR_PROMPT", + tableName: "APIAccess", + value: "List name is already taken.", + comment: "" + ), + buttons: [ + AlertAction( + title: NSLocalizedString( + "CUSTOM_LISTS_OK_BUTTON", + tableName: "APIAccess", + value: "Got it!", + comment: "" + ), + style: .default + ), + ] + ) + + alertPresenter.showAlert(presentation: presentation, animated: true) + } +} diff --git a/ios/MullvadVPN/Coordinators/CustomLists/CustomListViewModel.swift b/ios/MullvadVPN/Coordinators/CustomLists/CustomListViewModel.swift new file mode 100644 index 000000000000..b41d52d2f572 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/CustomLists/CustomListViewModel.swift @@ -0,0 +1,21 @@ +// +// CustomListViewModel.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-02-14. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import MullvadSettings +import MullvadTypes + +struct CustomListViewModel { + var id: UUID + var name: String + var locations: [RelayLocation] + let tableSections: [CustomListSectionIdentifier] + + var customList: CustomList { + CustomList(id: id, name: name, locations: locations) + } +} diff --git a/ios/MullvadVPN/Coordinators/CustomLists/EditCustomListCoordinator.swift b/ios/MullvadVPN/Coordinators/CustomLists/EditCustomListCoordinator.swift new file mode 100644 index 000000000000..2da41754b873 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/CustomLists/EditCustomListCoordinator.swift @@ -0,0 +1,72 @@ +// +// EditCustomListCoordinator.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-02-15. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Combine +import MullvadSettings +import Routing +import UIKit + +class EditCustomListCoordinator: Coordinator, Presentable, Presenting { + let navigationController: UINavigationController + let customListInteractor: CustomListInteractorProtocol + + var presentedViewController: UIViewController { + navigationController + } + + var didFinish: (() -> Void)? + + init( + navigationController: UINavigationController, + customListInteractor: CustomListInteractorProtocol + ) { + self.navigationController = navigationController + self.customListInteractor = customListInteractor + } + + func start() { + let subject = CurrentValueSubject( + CustomListViewModel( + id: UUID(), + name: "A list", + locations: [], + tableSections: [.name, .editLocations, .deleteList] + ) + ) + + let controller = CustomListViewController( + interactor: customListInteractor, + subject: subject, + alertPresenter: AlertPresenter(context: self) + ) + controller.delegate = self + + controller.navigationItem.title = NSLocalizedString( + "CUSTOM_LIST_NAVIGATION_TITLE", + tableName: "CustomLists", + value: subject.value.name, + comment: "" + ) + + navigationController.pushViewController(controller, animated: false) + } +} + +extension EditCustomListCoordinator: CustomListViewControllerDelegate { + func customListDidSave() { + didFinish?() + } + + func customListDidDelete() { + didFinish?() + } + + func showLocations() { + // TODO: Show view controller for locations. + } +} diff --git a/ios/MullvadVPN/Coordinators/SelectLocationCoordinator.swift b/ios/MullvadVPN/Coordinators/SelectLocationCoordinator.swift index 67e892e3afe7..aa4def6c2e43 100644 --- a/ios/MullvadVPN/Coordinators/SelectLocationCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/SelectLocationCoordinator.swift @@ -11,6 +11,8 @@ import MullvadTypes import Routing import UIKit +import MullvadSettings + class SelectLocationCoordinator: Coordinator, Presentable, Presenting, RelayCacheTrackerObserver { private let tunnelManager: TunnelManager private let relayCacheTracker: RelayCacheTracker @@ -68,9 +70,19 @@ class SelectLocationCoordinator: Coordinator, Presentable, Presenting, RelayCach selectLocationViewController.navigateToFilter = { [weak self] in guard let self else { return } - let coordinator = makeRelayFilterCoordinator(forModalPresentation: true) +// let coordinator = makeRelayFilterCoordinator(forModalPresentation: true) +// coordinator.start() + + let coordinator = AddCustomListCoordinator( + navigationController: CustomNavigationController(), + customListInteractor: CustomListInteractor(repository: CustomListRepository()) + ) coordinator.start() + coordinator.didFinish = { + coordinator.dismiss(animated: true) + } + presentChild(coordinator, animated: true) } diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsCellConfiguration.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsCellConfiguration.swift index c9b69ffd20bd..ac712ef6fac0 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsCellConfiguration.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsCellConfiguration.swift @@ -134,8 +134,8 @@ class MethodSettingsCellConfiguration { itemIdentifier: MethodSettingsItemIdentifier, contentValidationErrors: [AccessMethodFieldValidationError] ) { - var contentConfiguration = MethodSettingsValidationErrorContentConfiguration() - contentConfiguration.fieldErrors = contentValidationErrors + var contentConfiguration = SettingsFieldValidationErrorConfiguration() + contentConfiguration.errors = contentValidationErrors.settingsFieldValidationErrors cell.contentConfiguration = contentConfiguration } diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsValidationErrorContentConfiguration.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsValidationErrorContentConfiguration.swift deleted file mode 100644 index 74aa8f87a4c5..000000000000 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsValidationErrorContentConfiguration.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// MethodSettingsValidationErrorContentConfiguration.swift -// MullvadVPN -// -// Created by Jon Petersson on 2024-01-12. -// Copyright © 2024 Mullvad VPN AB. All rights reserved. -// - -import UIKit - -/// Content configuration for presenting the access method testing progress. -struct MethodSettingsValidationErrorContentConfiguration: UIContentConfiguration, Equatable { - /// Field validation errors. - var fieldErrors: [AccessMethodFieldValidationError] = [] - - /// Layout margins. - var directionalLayoutMargins: NSDirectionalEdgeInsets = UIMetrics.SettingsCell.apiAccessInsetLayoutMargins - - func makeContentView() -> UIView & UIContentView { - return MethodSettingsValidationErrorContentView(configuration: self) - } - - func updated(for state: UIConfigurationState) -> Self { - return self - } -} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodValidationError.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodValidationError.swift index d2b41f095e90..6bd2bd327448 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodValidationError.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodValidationError.swift @@ -94,3 +94,9 @@ struct AccessMethodFieldValidationError: LocalizedError, Equatable { } } } + +extension Collection { + var settingsFieldValidationErrors: [SettingsFieldValidationError] { + map { SettingsFieldValidationError(errorDescription: $0.errorDescription) } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/SettingsFieldValidationErrorConfiguration.swift b/ios/MullvadVPN/Coordinators/Settings/SettingsFieldValidationErrorConfiguration.swift new file mode 100644 index 000000000000..2e3ff5fbffbd --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/SettingsFieldValidationErrorConfiguration.swift @@ -0,0 +1,26 @@ +// +// SettingsFieldValidationErrorConfiguration.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-02-16. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +struct SettingsFieldValidationError: LocalizedError, Equatable { + var errorDescription: String? +} + +struct SettingsFieldValidationErrorConfiguration: UIContentConfiguration, Equatable { + var errors: [SettingsFieldValidationError] = [] + var directionalLayoutMargins: NSDirectionalEdgeInsets = UIMetrics.SettingsCell.settingsValidationErrorLayoutMargins + + func makeContentView() -> UIView & UIContentView { + return SettingsFieldValidationErrorContentView(configuration: self) + } + + func updated(for state: UIConfigurationState) -> Self { + return self + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsValidationErrorContentView.swift b/ios/MullvadVPN/Coordinators/Settings/SettingsFieldValidationErrorContentView.swift similarity index 73% rename from ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsValidationErrorContentView.swift rename to ios/MullvadVPN/Coordinators/Settings/SettingsFieldValidationErrorContentView.swift index 38b3f0cd3a4e..1c10dec6810c 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsValidationErrorContentView.swift +++ b/ios/MullvadVPN/Coordinators/Settings/SettingsFieldValidationErrorContentView.swift @@ -1,15 +1,14 @@ // -// MethodSettingsValidationErrorContentView.swift +// SettingsFieldValidationErrorContentView.swift // MullvadVPN // -// Created by Jon Petersson on 2024-01-12. +// Created by Jon Petersson on 2024-02-16. // Copyright © 2024 Mullvad VPN AB. All rights reserved. // import UIKit -/// Content view presenting the access method validation errors. -class MethodSettingsValidationErrorContentView: UIView, UIContentView { +class SettingsFieldValidationErrorContentView: UIView, UIContentView { let contentView = UIStackView() var icon: UIImageView { @@ -24,7 +23,7 @@ class MethodSettingsValidationErrorContentView: UIView, UIContentView { actualConfiguration } set { - guard let newConfiguration = newValue as? MethodSettingsValidationErrorContentConfiguration else { return } + guard let newConfiguration = newValue as? SettingsFieldValidationErrorConfiguration else { return } let previousConfiguration = actualConfiguration actualConfiguration = newConfiguration @@ -33,13 +32,13 @@ class MethodSettingsValidationErrorContentView: UIView, UIContentView { } } - private var actualConfiguration: MethodSettingsValidationErrorContentConfiguration + private var actualConfiguration: SettingsFieldValidationErrorConfiguration func supports(_ configuration: UIContentConfiguration) -> Bool { - configuration is MethodSettingsValidationErrorContentConfiguration + configuration is SettingsFieldValidationErrorConfiguration } - init(configuration: MethodSettingsValidationErrorContentConfiguration) { + init(configuration: SettingsFieldValidationErrorConfiguration) { actualConfiguration = configuration super.init(frame: CGRect(x: 0, y: 0, width: 100, height: 44)) @@ -61,7 +60,7 @@ class MethodSettingsValidationErrorContentView: UIView, UIContentView { } } - private func configureSubviews(previousConfiguration: MethodSettingsValidationErrorContentConfiguration? = nil) { + private func configureSubviews(previousConfiguration: SettingsFieldValidationErrorConfiguration? = nil) { guard actualConfiguration != previousConfiguration else { return } configureLayoutMargins() @@ -70,7 +69,7 @@ class MethodSettingsValidationErrorContentView: UIView, UIContentView { view.removeFromSuperview() } - actualConfiguration.fieldErrors.forEach { error in + actualConfiguration.errors.forEach { error in let label = UILabel() label.text = error.errorDescription label.numberOfLines = 0 diff --git a/ios/MullvadVPN/UI appearance/UIMetrics.swift b/ios/MullvadVPN/UI appearance/UIMetrics.swift index 0e6ea7d00cc7..3459c5a78708 100644 --- a/ios/MullvadVPN/UI appearance/UIMetrics.swift +++ b/ios/MullvadVPN/UI appearance/UIMetrics.swift @@ -83,7 +83,14 @@ enum UIMetrics { static let apiAccessLayoutMargins = NSDirectionalEdgeInsets(top: 20, leading: 16, bottom: 20, trailing: 16) static let apiAccessInsetLayoutMargins = NSDirectionalEdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16) + static let settingsValidationErrorLayoutMargins = NSDirectionalEdgeInsets( + top: 8, + leading: 16, + bottom: 8, + trailing: 16 + ) static let apiAccessCellHeight: CGFloat = 44 + static let customListsCellHeight: CGFloat = 44 static let apiAccessSwitchCellTrailingMargin: CGFloat = apiAccessInsetLayoutMargins.trailing - 4 static let apiAccessPickerListContentInsetTop: CGFloat = 16 } diff --git a/ios/MullvadVPNTests/CustomListRepositoryTests.swift b/ios/MullvadVPNTests/CustomListRepositoryTests.swift index d7b80a63742b..be7bffc24e4f 100644 --- a/ios/MullvadVPNTests/CustomListRepositoryTests.swift +++ b/ios/MullvadVPNTests/CustomListRepositoryTests.swift @@ -30,8 +30,9 @@ class CustomListRepositoryTests: XCTestCase { func testFailedAddingDuplicateCustomList() throws { let name = "Netflix" - let item = try XCTUnwrap(repository.create(name)) - XCTAssertThrowsError(try repository.create(item.name)) { error in + let item = try XCTUnwrap(repository.create(name, locations: [])) + + XCTAssertThrowsError(try repository.create(item.name, locations: [])) { error in XCTAssertEqual(error as? CustomRelayListError, CustomRelayListError.duplicateName) } } @@ -39,11 +40,10 @@ class CustomListRepositoryTests: XCTestCase { func testAddingCustomList() throws { let name = "Netflix" - var item = try XCTUnwrap(repository.create(name)) - item.list.append(.country("SE")) - item.list.append(.city("SE", "Gothenburg")) - - repository.update(item) + let item = try XCTUnwrap(repository.create(name, locations: [ + .country("SE"), + .city("SE", "Gothenburg"), + ])) let storedItem = repository.fetch(by: item.id) XCTAssertEqual(storedItem, item) @@ -52,10 +52,10 @@ class CustomListRepositoryTests: XCTestCase { func testDeletingCustomList() throws { let name = "Netflix" - var item = try XCTUnwrap(repository.create(name)) - item.list.append(.country("SE")) - item.list.append(.city("SE", "Gothenburg")) - repository.update(item) + let item = try XCTUnwrap(repository.create(name, locations: [ + .country("SE"), + .city("SE", "Gothenburg"), + ])) let storedItem = repository.fetch(by: item.id) repository.delete(id: try XCTUnwrap(storedItem?.id)) @@ -64,15 +64,15 @@ class CustomListRepositoryTests: XCTestCase { } func testFetchingAllCustomList() throws { - var streaming = try XCTUnwrap(repository.create("Netflix")) - streaming.list.append(.country("FR")) - streaming.list.append(.city("SE", "Gothenburg")) - repository.update(streaming) - - var gaming = try XCTUnwrap(repository.create("PS5")) - gaming.list.append(.country("DE")) - gaming.list.append(.city("SE", "Gothenburg")) - repository.update(streaming) + _ = try XCTUnwrap(repository.create("Netflix", locations: [ + .country("FR"), + .city("SE", "Gothenburg"), + ])) + + _ = try XCTUnwrap(repository.create("PS5", locations: [ + .country("DE"), + .city("SE", "Gothenburg"), + ])) XCTAssertEqual(repository.fetchAll().count, 2) }