diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 270ecdabc9d9..88ce35ba6199 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -539,6 +539,7 @@ 7A6F2FAB2AFD3097006D0856 /* CustomDNSCellFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6F2FAA2AFD3097006D0856 /* CustomDNSCellFactory.swift */; }; 7A6F2FAD2AFD3DA7006D0856 /* CustomDNSViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6F2FAC2AFD3DA7006D0856 /* CustomDNSViewController.swift */; }; 7A6F2FAF2AFE36E7006D0856 /* VPNSettingsInfoButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6F2FAE2AFE36E7006D0856 /* VPNSettingsInfoButtonItem.swift */; }; + 7A7907332BC0280A00B61F81 /* InterceptibleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A7907322BC0280A00B61F81 /* InterceptibleNavigationController.swift */; }; 7A7AD28D29DC677800480EF1 /* FirstTimeLaunch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A7AD28C29DC677800480EF1 /* FirstTimeLaunch.swift */; }; 7A818F1F29F0305800C7F0F4 /* RootConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A818F1E29F0305800C7F0F4 /* RootConfiguration.swift */; }; 7A83A0C62B29A750008B5CE7 /* APIAccessMethodsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A83A0C52B29A750008B5CE7 /* APIAccessMethodsTests.swift */; }; @@ -1804,6 +1805,7 @@ 7A6F2FAA2AFD3097006D0856 /* CustomDNSCellFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDNSCellFactory.swift; sourceTree = ""; }; 7A6F2FAC2AFD3DA7006D0856 /* CustomDNSViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDNSViewController.swift; sourceTree = ""; }; 7A6F2FAE2AFE36E7006D0856 /* VPNSettingsInfoButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNSettingsInfoButtonItem.swift; sourceTree = ""; }; + 7A7907322BC0280A00B61F81 /* InterceptibleNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterceptibleNavigationController.swift; sourceTree = ""; }; 7A7AD28C29DC677800480EF1 /* FirstTimeLaunch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstTimeLaunch.swift; sourceTree = ""; }; 7A818F1E29F0305800C7F0F4 /* RootConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootConfiguration.swift; sourceTree = ""; }; 7A83A0C52B29A750008B5CE7 /* APIAccessMethodsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIAccessMethodsTests.swift; sourceTree = ""; }; @@ -2695,6 +2697,7 @@ 58138E60294871C600684F0C /* DeviceDataThrottling.swift */, 7A7AD28C29DC677800480EF1 /* FirstTimeLaunch.swift */, 582AE30F2440A6CA00E6733A /* InputTextFormatter.swift */, + 7A7907322BC0280A00B61F81 /* InterceptibleNavigationController.swift */, 58DFF7D12B0256A300F864E0 /* MarkdownStylingOptions.swift */, 58CC40EE24A601900019D96E /* ObserverList.swift */, ); @@ -5463,6 +5466,7 @@ 7A42DEC92A05164100B209BE /* SettingsInputCell.swift in Sources */, 5803B4B22940A48700C23744 /* TunnelStore.swift in Sources */, 586A950F29012BEE007BAF2B /* AddressCacheTracker.swift in Sources */, + 7A7907332BC0280A00B61F81 /* InterceptibleNavigationController.swift in Sources */, F02F41A02B9723AF00625A4F /* AddLocationsViewController.swift in Sources */, 587B753D2666468F00DEF7E9 /* NotificationController.swift in Sources */, ); diff --git a/ios/MullvadVPN/Classes/InterceptibleNavigationController.swift b/ios/MullvadVPN/Classes/InterceptibleNavigationController.swift new file mode 100644 index 000000000000..2e119aa0269a --- /dev/null +++ b/ios/MullvadVPN/Classes/InterceptibleNavigationController.swift @@ -0,0 +1,35 @@ +// +// InterceptibleNavigationController.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-04-05. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +class InterceptibleNavigationController: UINavigationController { + var shouldPopViewController: ((UIViewController) -> Bool)? + var shouldPopToViewController: ((UIViewController) -> Bool)? + + // Called when popping the last view controller, eg. by pressing a nacvigation bar back button. + override func popViewController(animated: Bool) -> UIViewController? { + guard let viewController = viewControllers.last else { return nil } + + if shouldPopViewController?(viewController) == true { + return super.popViewController(animated: animated) + } else { + return nil + } + } + + // Called when popping to a specific view controller, eg. by long pressing a nacvigation bar + // back button (revealing a navigation menu) and selecting a destination view controller. + override func popToViewController(_ viewController: UIViewController, animated: Bool) -> [UIViewController]? { + if shouldPopToViewController?(viewController) == true { + return super.popToViewController(viewController, animated: animated) + } else { + return nil + } + } +} diff --git a/ios/MullvadVPN/Containers/Navigation/CustomNavigationController.swift b/ios/MullvadVPN/Containers/Navigation/CustomNavigationController.swift index 1b71603a7b72..527abcc2725f 100644 --- a/ios/MullvadVPN/Containers/Navigation/CustomNavigationController.swift +++ b/ios/MullvadVPN/Containers/Navigation/CustomNavigationController.swift @@ -9,7 +9,7 @@ import UIKit /// Custom navigation controller that applies the custom appearance to itself. -class CustomNavigationController: UINavigationController { +class CustomNavigationController: InterceptibleNavigationController { override var childForStatusBarHidden: UIViewController? { topViewController } diff --git a/ios/MullvadVPN/Coordinators/CustomLists/AddCustomListCoordinator.swift b/ios/MullvadVPN/Coordinators/CustomLists/AddCustomListCoordinator.swift index bbbf45ad54bc..6ef8d044fd6b 100644 --- a/ios/MullvadVPN/Coordinators/CustomLists/AddCustomListCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/CustomLists/AddCustomListCoordinator.swift @@ -84,17 +84,10 @@ extension AddCustomListCoordinator: CustomListViewControllerDelegate { let coordinator = AddLocationsCoordinator( navigationController: navigationController, nodes: nodes, - customList: list + subject: subject ) - coordinator.didFinish = { [weak self] locationsCoordinator, customList in - guard let self else { return } - subject.send(CustomListViewModel( - id: customList.id, - name: customList.name, - locations: customList.locations, - tableSections: subject.value.tableSections - )) + coordinator.didFinish = { locationsCoordinator in locationsCoordinator.removeFromParent() } diff --git a/ios/MullvadVPN/Coordinators/CustomLists/AddLocationsCoordinator.swift b/ios/MullvadVPN/Coordinators/CustomLists/AddLocationsCoordinator.swift index feb5bd415e5b..1634a24e8075 100644 --- a/ios/MullvadVPN/Coordinators/CustomLists/AddLocationsCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/CustomLists/AddLocationsCoordinator.swift @@ -6,6 +6,7 @@ // Copyright © 2024 Mullvad VPN AB. All rights reserved. // +import Combine import MullvadSettings import MullvadTypes import Routing @@ -14,9 +15,9 @@ import UIKit class AddLocationsCoordinator: Coordinator, Presentable, Presenting { private let navigationController: UINavigationController private let nodes: [LocationNode] - private var customList: CustomList + private var subject: CurrentValueSubject - var didFinish: ((AddLocationsCoordinator, CustomList) -> Void)? + var didFinish: ((AddLocationsCoordinator) -> Void)? var presentedViewController: UIViewController { navigationController @@ -25,17 +26,17 @@ class AddLocationsCoordinator: Coordinator, Presentable, Presenting { init( navigationController: UINavigationController, nodes: [LocationNode], - customList: CustomList + subject: CurrentValueSubject ) { self.navigationController = navigationController self.nodes = nodes - self.customList = customList + self.subject = subject } func start() { let controller = AddLocationsViewController( allLocationsNodes: nodes, - customList: customList + subject: subject ) controller.delegate = self @@ -51,11 +52,7 @@ class AddLocationsCoordinator: Coordinator, Presentable, Presenting { } extension AddLocationsCoordinator: AddLocationsViewControllerDelegate { - func didUpdateSelectedLocations(locations: [RelayLocation]) { - customList.locations = locations - } - func didBack() { - didFinish?(self, customList) + didFinish?(self) } } diff --git a/ios/MullvadVPN/Coordinators/CustomLists/AddLocationsDataSource.swift b/ios/MullvadVPN/Coordinators/CustomLists/AddLocationsDataSource.swift index 048d3e51fa1a..7f0b3f8417a6 100644 --- a/ios/MullvadVPN/Coordinators/CustomLists/AddLocationsDataSource.swift +++ b/ios/MullvadVPN/Coordinators/CustomLists/AddLocationsDataSource.swift @@ -6,6 +6,7 @@ // Copyright © 2024 Mullvad VPN AB. All rights reserved. // +import Combine import MullvadSettings import MullvadTypes import UIKit @@ -15,20 +16,21 @@ class AddLocationsDataSource: LocationDiffableDataSourceProtocol { private var customListLocationNode: CustomListLocationNode private let nodes: [LocationNode] - var didUpdateCustomList: ((CustomListLocationNode) -> Void)? + private let subject: CurrentValueSubject let tableView: UITableView let sections: [LocationSection] init( tableView: UITableView, allLocationNodes: [LocationNode], - customList: CustomList + subject: CurrentValueSubject ) { self.tableView = tableView self.nodes = allLocationNodes + self.subject = subject self.customListLocationNode = CustomListLocationNodeBuilder( - customList: customList, + customList: subject.value.customList, allLocations: self.nodes ).customListLocationNode @@ -149,7 +151,10 @@ extension AddLocationsDataSource: LocationCellDelegate { customListLocationNode.remove(selectedLocation: item.node, with: locationList) } updateDataSnapshot(with: [locationList], completion: { - self.didUpdateCustomList?(self.customListLocationNode) + let locations = self.customListLocationNode.children.reduce([]) { partialResult, locationNode in + partialResult + locationNode.locations + } + self.subject.value.locations = locations }) } } diff --git a/ios/MullvadVPN/Coordinators/CustomLists/AddLocationsViewController.swift b/ios/MullvadVPN/Coordinators/CustomLists/AddLocationsViewController.swift index c728982fdbbc..1dbd7ac7ae8d 100644 --- a/ios/MullvadVPN/Coordinators/CustomLists/AddLocationsViewController.swift +++ b/ios/MullvadVPN/Coordinators/CustomLists/AddLocationsViewController.swift @@ -6,19 +6,19 @@ // Copyright © 2024 Mullvad VPN AB. All rights reserved. // +import Combine import MullvadSettings import MullvadTypes import UIKit protocol AddLocationsViewControllerDelegate: AnyObject { - func didUpdateSelectedLocations(locations: [RelayLocation]) func didBack() } class AddLocationsViewController: UIViewController { private var dataSource: AddLocationsDataSource? private let nodes: [LocationNode] - private let customList: CustomList + private let subject: CurrentValueSubject weak var delegate: AddLocationsViewControllerDelegate? private let tableView: UITableView = { @@ -33,10 +33,10 @@ class AddLocationsViewController: UIViewController { init( allLocationsNodes: [LocationNode], - customList: CustomList + subject: CurrentValueSubject ) { self.nodes = allLocationsNodes - self.customList = customList + self.subject = subject super.init(nibName: nil, bundle: nil) } @@ -70,17 +70,8 @@ class AddLocationsViewController: UIViewController { dataSource = AddLocationsDataSource( tableView: tableView, allLocationNodes: nodes.copy(), - customList: customList + subject: subject ) - - dataSource?.didUpdateCustomList = { [weak self] customListLocationNode in - guard let self else { return } - delegate?.didUpdateSelectedLocations( - locations: customListLocationNode.children.reduce([]) { partialResult, locationNode in - partialResult + locationNode.locations - } - ) - } } } diff --git a/ios/MullvadVPN/Coordinators/CustomLists/CustomListViewController.swift b/ios/MullvadVPN/Coordinators/CustomLists/CustomListViewController.swift index 4e5891658dcd..2fe1ff59c6d6 100644 --- a/ios/MullvadVPN/Coordinators/CustomLists/CustomListViewController.swift +++ b/ios/MullvadVPN/Coordinators/CustomLists/CustomListViewController.swift @@ -27,6 +27,13 @@ class CustomListViewController: UIViewController { private let alertPresenter: AlertPresenter private var validationErrors: Set = [] + private var customListHasUnsavedChanges: Bool { + guard let customList = interactor.fetchAll().first(where: { $0.id == subject.value.id }) else { + return false + } + return customList != subject.value.customList + } + private lazy var cellConfiguration: CustomListCellConfiguration = { CustomListCellConfiguration(tableView: tableView, subject: subject) }() @@ -91,9 +98,37 @@ class CustomListViewController: UIViewController { } private func configureNavigationItem() { + if let navigationController = navigationController as? InterceptibleNavigationController { + interceptNavigation(navigationController) + } + navigationItem.rightBarButtonItem = saveBarButton } + private func interceptNavigation(_ navigationController: InterceptibleNavigationController) { + navigationController.shouldPopViewController = { [weak self] viewController in + guard + let self, + viewController is Self, + customListHasUnsavedChanges + else { return true } + + self.onUnsavedChanges() + return false + } + + navigationController.shouldPopToViewController = { [weak self] viewController in + guard + let self, + viewController is ListCustomListViewController, + customListHasUnsavedChanges + else { return true } + + self.onUnsavedChanges() + return false + } + } + private func configureTableView() { tableView.delegate = dataSourceConfiguration tableView.backgroundColor = .secondaryColor @@ -195,4 +230,51 @@ class CustomListViewController: UIViewController { alertPresenter.showAlert(presentation: presentation, animated: true) } + + @objc private func onUnsavedChanges() { + let message = NSMutableAttributedString( + markdownString: NSLocalizedString( + "CUSTOM_LISTS_UNSAVED_CHANGES_PROMPT", + tableName: "CustomLists", + value: "You have unsaved changes made to this list.", + comment: "" + ), + options: MarkdownStylingOptions(font: .preferredFont(forTextStyle: .body)) + ) + + let presentation = AlertPresentation( + id: "api-custom-lists-unsaved-changes-alert", + icon: .alert, + attributedMessage: message, + buttons: [ + AlertAction( + title: NSLocalizedString( + "CUSTOM_LISTS_DISCARD_CHANGES_BUTTON", + tableName: "CustomLists", + value: "Discard changes", + comment: "" + ), + style: .destructive, + handler: { + // Reset subject/view model to no longer having unsaved changes. + if let list = self.interactor.fetchAll().first(where: { $0.id == self.subject.value.id }) { + self.subject.value.update(with: list) + } + self.delegate?.customListDidSave(self.subject.value.customList) + } + ), + AlertAction( + title: NSLocalizedString( + "CUSTOM_LISTS_BACK_TO_EDITING_BUTTON", + tableName: "CustomLists", + value: "Back to editing", + 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 index b41d52d2f572..10b9e1359242 100644 --- a/ios/MullvadVPN/Coordinators/CustomLists/CustomListViewModel.swift +++ b/ios/MullvadVPN/Coordinators/CustomLists/CustomListViewModel.swift @@ -18,4 +18,9 @@ struct CustomListViewModel { var customList: CustomList { CustomList(id: id, name: name, locations: locations) } + + mutating func update(with list: CustomList) { + name = list.name + locations = list.locations + } } diff --git a/ios/MullvadVPN/Coordinators/CustomLists/EditCustomListCoordinator.swift b/ios/MullvadVPN/Coordinators/CustomLists/EditCustomListCoordinator.swift index 5545f1bc951b..8cc441738ae9 100644 --- a/ios/MullvadVPN/Coordinators/CustomLists/EditCustomListCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/CustomLists/EditCustomListCoordinator.swift @@ -78,17 +78,10 @@ extension EditCustomListCoordinator: CustomListViewControllerDelegate { let coordinator = EditLocationsCoordinator( navigationController: navigationController, nodes: nodes, - customList: list + subject: subject ) - coordinator.didFinish = { [weak self] locationsCoordinator, customList in - guard let self else { return } - subject.send(CustomListViewModel( - id: customList.id, - name: customList.name, - locations: customList.locations, - tableSections: subject.value.tableSections - )) + coordinator.didFinish = { locationsCoordinator in locationsCoordinator.removeFromParent() } diff --git a/ios/MullvadVPN/Coordinators/CustomLists/EditLocationsCoordinator.swift b/ios/MullvadVPN/Coordinators/CustomLists/EditLocationsCoordinator.swift index 9255a2bc29db..9ca615ea693b 100644 --- a/ios/MullvadVPN/Coordinators/CustomLists/EditLocationsCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/CustomLists/EditLocationsCoordinator.swift @@ -6,6 +6,7 @@ // Copyright © 2024 Mullvad VPN AB. All rights reserved. // +import Combine import MullvadSettings import MullvadTypes import Routing @@ -14,9 +15,9 @@ import UIKit class EditLocationsCoordinator: Coordinator, Presentable, Presenting { private let navigationController: UINavigationController private let nodes: [LocationNode] - private var customList: CustomList + private var subject: CurrentValueSubject - var didFinish: ((EditLocationsCoordinator, CustomList) -> Void)? + var didFinish: ((EditLocationsCoordinator) -> Void)? var presentedViewController: UIViewController { navigationController @@ -25,17 +26,17 @@ class EditLocationsCoordinator: Coordinator, Presentable, Presenting { init( navigationController: UINavigationController, nodes: [LocationNode], - customList: CustomList + subject: CurrentValueSubject ) { self.navigationController = navigationController self.nodes = nodes - self.customList = customList + self.subject = subject } func start() { let controller = AddLocationsViewController( allLocationsNodes: nodes, - customList: customList + subject: subject ) controller.delegate = self @@ -50,11 +51,7 @@ class EditLocationsCoordinator: Coordinator, Presentable, Presenting { } extension EditLocationsCoordinator: AddLocationsViewControllerDelegate { - func didUpdateSelectedLocations(locations: [RelayLocation]) { - customList.locations = locations - } - func didBack() { - didFinish?(self, customList) + didFinish?(self) } }