From 13dbec56afd0e9ec1ffad7499557ea9a11c82856 Mon Sep 17 00:00:00 2001 From: Jon Petersson Date: Thu, 15 Feb 2024 13:56:30 +0100 Subject: [PATCH] Add UI for creating and editing a custom list --- ios/MullvadSettings/CustomList.swift | 6 +- .../CustomListRepository.swift | 4 +- .../CustomListRepositoryProtocol.swift | 3 +- ios/MullvadVPN.xcodeproj/project.pbxproj | 46 ++++- .../AddCustomListCoordinator.swift | 74 ++++++++ .../CustomListCellConfiguration.swift | 74 ++++++++ .../CustomListDataSourceConfiguration.swift | 60 ++++++ .../CustomLists/CustomListInteractor.swift | 31 +++ .../CustomListItemIdentifier.swift | 52 +++++ .../CustomListSectionIdentifier.swift | 16 ++ .../CustomListViewController.swift | 179 ++++++++++++++++++ .../CustomLists/CustomListViewModel.swift | 21 ++ .../EditCustomListCoordinator.swift | 72 +++++++ .../SelectLocationCoordinator.swift | 1 + ios/MullvadVPN/UI appearance/UIMetrics.swift | 1 + .../CustomListRepositoryTests.swift | 33 ++-- 16 files changed, 652 insertions(+), 21 deletions(-) create mode 100644 ios/MullvadVPN/Coordinators/CustomLists/AddCustomListCoordinator.swift create mode 100644 ios/MullvadVPN/Coordinators/CustomLists/CustomListCellConfiguration.swift create mode 100644 ios/MullvadVPN/Coordinators/CustomLists/CustomListDataSourceConfiguration.swift create mode 100644 ios/MullvadVPN/Coordinators/CustomLists/CustomListInteractor.swift create mode 100644 ios/MullvadVPN/Coordinators/CustomLists/CustomListItemIdentifier.swift create mode 100644 ios/MullvadVPN/Coordinators/CustomLists/CustomListSectionIdentifier.swift create mode 100644 ios/MullvadVPN/Coordinators/CustomLists/CustomListViewController.swift create mode 100644 ios/MullvadVPN/Coordinators/CustomLists/CustomListViewModel.swift create mode 100644 ios/MullvadVPN/Coordinators/CustomLists/EditCustomListCoordinator.swift 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 17a4b9ae7fa4..6c28b51771e2 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -518,6 +518,15 @@ 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 */; }; 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 */; }; @@ -1727,6 +1736,15 @@ 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 = ""; }; 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 = ""; }; @@ -3003,6 +3021,8 @@ 58CAF9F22983D32200BE19F7 /* Coordinators */ = { isa = PBXGroup; children = ( + 7A6389D12B7E3BD6008E77E1 /* CustomLists */, + 58EFC76F2AFB3FA800E9F4CB /* Settings */, 7A9CCCAF2A96302800DD6A34 /* AccountCoordinator.swift */, 7A9CCCAC2A96302800DD6A34 /* AccountDeletionCoordinator.swift */, 7A9CCCA32A96302700DD6A34 /* AddCreditSucceededCoordinator.swift */, @@ -3018,7 +3038,6 @@ 7A9CCCA52A96302700DD6A34 /* RevokedCoordinator.swift */, 7A9CCCB02A96302800DD6A34 /* SafariCoordinator.swift */, 7A9CCCA72A96302700DD6A34 /* SelectLocationCoordinator.swift */, - 58EFC76F2AFB3FA800E9F4CB /* Settings */, 7A9CCCA62A96302700DD6A34 /* SetupAccountCompletedCoordinator.swift */, 7A9CCCA22A96302700DD6A34 /* TermsOfServiceCoordinator.swift */, 7A9CCCB22A96302800DD6A34 /* TunnelCoordinator.swift */, @@ -3381,6 +3400,22 @@ 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 */, + 7A6389E62B7E42BE008E77E1 /* CustomListViewController.swift */, + 7A6389D32B7E3BD6008E77E1 /* CustomListViewModel.swift */, + 7A6389E42B7E4247008E77E1 /* EditCustomListCoordinator.swift */, + ); + path = CustomLists; + sourceTree = ""; + }; 7A83C3FC2A55B39500DFB83A /* TestPlans */ = { isa = PBXGroup; children = ( @@ -4908,6 +4943,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 */, @@ -5071,6 +5107,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 */, @@ -5086,8 +5123,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 */, @@ -5107,6 +5147,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 */, @@ -5167,6 +5208,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 */, @@ -5203,6 +5245,7 @@ 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 */, @@ -5221,6 +5264,7 @@ 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..10ffeb054dae --- /dev/null +++ b/ios/MullvadVPN/Coordinators/CustomLists/CustomListCellConfiguration.swift @@ -0,0 +1,74 @@ +// +// 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) -> UITableViewCell { + let cell = tableView.dequeueReusableView(withIdentifier: itemIdentifier.cellIdentifier, for: indexPath) + + configureBackground(cell: cell, itemIdentifier: itemIdentifier) + + 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) { + guard let cell = cell as? DynamicBackgroundConfiguration else { return } + cell.setAutoAdaptingBackgroundConfiguration(.mullvadListGroupedCell(), selectionType: .dimmed) + } + + 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..da93a6b51195 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/CustomLists/CustomListDataSourceConfiguration.swift @@ -0,0 +1,60 @@ +// +// 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 didSelectItem: ((CustomListItemIdentifier) -> Void)? + + init(dataSource: UITableViewDiffableDataSource) { + self.dataSource = dataSource + } + + func updateDataSource( + sections: [CustomListSectionIdentifier], + 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) + } +} + +extension CustomListDataSourceConfiguration: UITableViewDelegate { + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + UIMetrics.SettingsCell.customListsCellHeight + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: false) + + dataSource.itemIdentifier(for: indexPath).flatMap { item in + 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..c005f2bd6502 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/CustomLists/CustomListItemIdentifier.swift @@ -0,0 +1,52 @@ +// +// 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: "") + } + } +} 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/CustomListViewController.swift b/ios/MullvadVPN/Coordinators/CustomLists/CustomListViewController.swift new file mode 100644 index 000000000000..0093ba193bfe --- /dev/null +++ b/ios/MullvadVPN/Coordinators/CustomLists/CustomListViewController.swift @@ -0,0 +1,179 @@ +// +// 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 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 { viewModel in + self.saveBarButton.isEnabled = !viewModel.name.isEmpty + }.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) + } + ) + + 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, animated: false) + } + + private func addSubviews() { + view.addConstrainedSubviews([tableView]) { + tableView.pinEdgesToSuperview() + } + } + + private func onSave() { + do { + try interactor.createCustomList(viewModel: subject.value) + delegate?.customListDidSave() + } catch { + showSaveErrorAlert() + } + } + + 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..754b25a67c46 100644 --- a/ios/MullvadVPN/Coordinators/SelectLocationCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/SelectLocationCoordinator.swift @@ -7,6 +7,7 @@ // import MullvadREST +import MullvadSettings import MullvadTypes import Routing import UIKit diff --git a/ios/MullvadVPN/UI appearance/UIMetrics.swift b/ios/MullvadVPN/UI appearance/UIMetrics.swift index 0e6ea7d00cc7..9e4b2d51a5cc 100644 --- a/ios/MullvadVPN/UI appearance/UIMetrics.swift +++ b/ios/MullvadVPN/UI appearance/UIMetrics.swift @@ -84,6 +84,7 @@ 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 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..8aa5669c1ab7 100644 --- a/ios/MullvadVPNTests/CustomListRepositoryTests.swift +++ b/ios/MullvadVPNTests/CustomListRepositoryTests.swift @@ -30,8 +30,8 @@ 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,10 +39,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")) - + var item = try XCTUnwrap(repository.create(name, locations: [ + .country("SE"), + .city("SE", "Gothenburg"), + ])) repository.update(item) let storedItem = repository.fetch(by: item.id) @@ -52,9 +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")) + var item = try XCTUnwrap(repository.create(name, locations: [ + .country("SE"), + .city("SE", "Gothenburg"), + ])) repository.update(item) let storedItem = repository.fetch(by: item.id) @@ -64,14 +65,16 @@ class CustomListRepositoryTests: XCTestCase { } func testFetchingAllCustomList() throws { - var streaming = try XCTUnwrap(repository.create("Netflix")) - streaming.list.append(.country("FR")) - streaming.list.append(.city("SE", "Gothenburg")) + var streaming = try XCTUnwrap(repository.create("Netflix", locations: [ + .country("FR"), + .city("SE", "Gothenburg"), + ])) repository.update(streaming) - var gaming = try XCTUnwrap(repository.create("PS5")) - gaming.list.append(.country("DE")) - gaming.list.append(.city("SE", "Gothenburg")) + var gaming = try XCTUnwrap(repository.create("PS5", locations: [ + .country("DE"), + .city("SE", "Gothenburg"), + ])) repository.update(streaming) XCTAssertEqual(repository.fetchAll().count, 2)