Skip to content

Commit 2ac9458

Browse files
author
Jon Petersson
committedApr 10, 2024
Intercept back button when leaving an unsaved custom list
1 parent b2c94cc commit 2ac9458

15 files changed

+224
-78
lines changed
 

‎ios/MullvadVPN.xcodeproj/project.pbxproj

+4
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,7 @@
539539
7A6F2FAB2AFD3097006D0856 /* CustomDNSCellFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6F2FAA2AFD3097006D0856 /* CustomDNSCellFactory.swift */; };
540540
7A6F2FAD2AFD3DA7006D0856 /* CustomDNSViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6F2FAC2AFD3DA7006D0856 /* CustomDNSViewController.swift */; };
541541
7A6F2FAF2AFE36E7006D0856 /* VPNSettingsInfoButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6F2FAE2AFE36E7006D0856 /* VPNSettingsInfoButtonItem.swift */; };
542+
7A7907332BC0280A00B61F81 /* InterceptibleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A7907322BC0280A00B61F81 /* InterceptibleNavigationController.swift */; };
542543
7A7AD28D29DC677800480EF1 /* FirstTimeLaunch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A7AD28C29DC677800480EF1 /* FirstTimeLaunch.swift */; };
543544
7A818F1F29F0305800C7F0F4 /* RootConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A818F1E29F0305800C7F0F4 /* RootConfiguration.swift */; };
544545
7A83A0C62B29A750008B5CE7 /* APIAccessMethodsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A83A0C52B29A750008B5CE7 /* APIAccessMethodsTests.swift */; };
@@ -1803,6 +1804,7 @@
18031804
7A6F2FAA2AFD3097006D0856 /* CustomDNSCellFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDNSCellFactory.swift; sourceTree = "<group>"; };
18041805
7A6F2FAC2AFD3DA7006D0856 /* CustomDNSViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDNSViewController.swift; sourceTree = "<group>"; };
18051806
7A6F2FAE2AFE36E7006D0856 /* VPNSettingsInfoButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNSettingsInfoButtonItem.swift; sourceTree = "<group>"; };
1807+
7A7907322BC0280A00B61F81 /* InterceptibleNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterceptibleNavigationController.swift; sourceTree = "<group>"; };
18061808
7A7AD28C29DC677800480EF1 /* FirstTimeLaunch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstTimeLaunch.swift; sourceTree = "<group>"; };
18071809
7A818F1E29F0305800C7F0F4 /* RootConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootConfiguration.swift; sourceTree = "<group>"; };
18081810
7A83A0C52B29A750008B5CE7 /* APIAccessMethodsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIAccessMethodsTests.swift; sourceTree = "<group>"; };
@@ -2692,6 +2694,7 @@
26922694
58138E60294871C600684F0C /* DeviceDataThrottling.swift */,
26932695
7A7AD28C29DC677800480EF1 /* FirstTimeLaunch.swift */,
26942696
582AE30F2440A6CA00E6733A /* InputTextFormatter.swift */,
2697+
7A7907322BC0280A00B61F81 /* InterceptibleNavigationController.swift */,
26952698
58DFF7D12B0256A300F864E0 /* MarkdownStylingOptions.swift */,
26962699
58CC40EE24A601900019D96E /* ObserverList.swift */,
26972700
);
@@ -5459,6 +5462,7 @@
54595462
7A42DEC92A05164100B209BE /* SettingsInputCell.swift in Sources */,
54605463
5803B4B22940A48700C23744 /* TunnelStore.swift in Sources */,
54615464
586A950F29012BEE007BAF2B /* AddressCacheTracker.swift in Sources */,
5465+
7A7907332BC0280A00B61F81 /* InterceptibleNavigationController.swift in Sources */,
54625466
F02F41A02B9723AF00625A4F /* AddLocationsViewController.swift in Sources */,
54635467
587B753D2666468F00DEF7E9 /* NotificationController.swift in Sources */,
54645468
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
//
2+
// InterceptibleNavigationController.swift
3+
// MullvadVPN
4+
//
5+
// Created by Jon Petersson on 2024-04-05.
6+
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
7+
//
8+
9+
import UIKit
10+
11+
class InterceptibleNavigationController: CustomNavigationController {
12+
var shouldPopViewController: ((UIViewController) -> Bool)?
13+
var shouldPopToViewController: ((UIViewController) -> Bool)?
14+
15+
// Called when popping the last view controller, eg. by pressing a navigation bar back button.
16+
override func popViewController(animated: Bool) -> UIViewController? {
17+
guard let viewController = viewControllers.last else { return nil }
18+
19+
if shouldPopViewController?(viewController) ?? true {
20+
return super.popViewController(animated: animated)
21+
} else {
22+
return nil
23+
}
24+
}
25+
26+
// Called when popping to a specific view controller, eg. by long pressing a navigation bar
27+
// back button (revealing a navigation menu) and selecting a destination view controller.
28+
override func popToViewController(_ viewController: UIViewController, animated: Bool) -> [UIViewController]? {
29+
if shouldPopToViewController?(viewController) ?? true {
30+
return super.popToViewController(viewController, animated: animated)
31+
} else {
32+
return nil
33+
}
34+
}
35+
}

‎ios/MullvadVPN/Coordinators/CustomLists/AddCustomListCoordinator.swift

+2-9
Original file line numberDiff line numberDiff line change
@@ -84,17 +84,10 @@ extension AddCustomListCoordinator: CustomListViewControllerDelegate {
8484
let coordinator = AddLocationsCoordinator(
8585
navigationController: navigationController,
8686
nodes: nodes,
87-
customList: list
87+
subject: subject
8888
)
8989

90-
coordinator.didFinish = { [weak self] locationsCoordinator, customList in
91-
guard let self else { return }
92-
subject.send(CustomListViewModel(
93-
id: customList.id,
94-
name: customList.name,
95-
locations: customList.locations,
96-
tableSections: subject.value.tableSections
97-
))
90+
coordinator.didFinish = { locationsCoordinator in
9891
locationsCoordinator.removeFromParent()
9992
}
10093

‎ios/MullvadVPN/Coordinators/CustomLists/AddLocationsCoordinator.swift

+7-10
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
77
//
88

9+
import Combine
910
import MullvadSettings
1011
import MullvadTypes
1112
import Routing
@@ -14,9 +15,9 @@ import UIKit
1415
class AddLocationsCoordinator: Coordinator, Presentable, Presenting {
1516
private let navigationController: UINavigationController
1617
private let nodes: [LocationNode]
17-
private var customList: CustomList
18+
private var subject: CurrentValueSubject<CustomListViewModel, Never>
1819

19-
var didFinish: ((AddLocationsCoordinator, CustomList) -> Void)?
20+
var didFinish: ((AddLocationsCoordinator) -> Void)?
2021

2122
var presentedViewController: UIViewController {
2223
navigationController
@@ -25,17 +26,17 @@ class AddLocationsCoordinator: Coordinator, Presentable, Presenting {
2526
init(
2627
navigationController: UINavigationController,
2728
nodes: [LocationNode],
28-
customList: CustomList
29+
subject: CurrentValueSubject<CustomListViewModel, Never>
2930
) {
3031
self.navigationController = navigationController
3132
self.nodes = nodes
32-
self.customList = customList
33+
self.subject = subject
3334
}
3435

3536
func start() {
3637
let controller = AddLocationsViewController(
3738
allLocationsNodes: nodes,
38-
customList: customList
39+
subject: subject
3940
)
4041
controller.delegate = self
4142

@@ -51,11 +52,7 @@ class AddLocationsCoordinator: Coordinator, Presentable, Presenting {
5152
}
5253

5354
extension AddLocationsCoordinator: AddLocationsViewControllerDelegate {
54-
func didUpdateSelectedLocations(locations: [RelayLocation]) {
55-
customList.locations = locations
56-
}
57-
5855
func didBack() {
59-
didFinish?(self, customList)
56+
didFinish?(self)
6057
}
6158
}

‎ios/MullvadVPN/Coordinators/CustomLists/AddLocationsDataSource.swift

+11-4
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
77
//
88

9+
import Combine
910
import MullvadSettings
1011
import MullvadTypes
1112
import UIKit
@@ -15,20 +16,21 @@ class AddLocationsDataSource:
1516
LocationDiffableDataSourceProtocol {
1617
private var customListLocationNode: CustomListLocationNode
1718
private let nodes: [LocationNode]
18-
var didUpdateCustomList: ((CustomListLocationNode) -> Void)?
19+
private let subject: CurrentValueSubject<CustomListViewModel, Never>
1920
let tableView: UITableView
2021
let sections: [LocationSection]
2122

2223
init(
2324
tableView: UITableView,
2425
allLocationNodes: [LocationNode],
25-
customList: CustomList
26+
subject: CurrentValueSubject<CustomListViewModel, Never>
2627
) {
2728
self.tableView = tableView
2829
self.nodes = allLocationNodes
30+
self.subject = subject
2931

3032
self.customListLocationNode = CustomListLocationNodeBuilder(
31-
customList: customList,
33+
customList: subject.value.customList,
3234
allLocations: self.nodes
3335
).customListLocationNode
3436

@@ -51,10 +53,12 @@ class AddLocationsDataSource:
5153
reloadWithSelectedLocations()
5254
}
5355

56+
// Called from `LocationDiffableDataSourceProtocol`.
5457
func nodeShowsChildren(_ node: LocationNode) -> Bool {
5558
isLocationInCustomList(node: node)
5659
}
5760

61+
// Called from `LocationDiffableDataSourceProtocol`.
5862
func nodeShouldBeSelected(_ node: LocationNode) -> Bool {
5963
customListLocationNode.children.contains(node)
6064
}
@@ -149,7 +153,10 @@ extension AddLocationsDataSource: LocationCellDelegate {
149153
customListLocationNode.remove(selectedLocation: item.node, with: locationList)
150154
}
151155
updateDataSnapshot(with: [locationList], completion: {
152-
self.didUpdateCustomList?(self.customListLocationNode)
156+
let locations = self.customListLocationNode.children.reduce([]) { partialResult, locationNode in
157+
partialResult + locationNode.locations
158+
}
159+
self.subject.value.locations = locations
153160
})
154161
}
155162
}

‎ios/MullvadVPN/Coordinators/CustomLists/AddLocationsViewController.swift

+5-14
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,19 @@
66
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
77
//
88

9+
import Combine
910
import MullvadSettings
1011
import MullvadTypes
1112
import UIKit
1213

1314
protocol AddLocationsViewControllerDelegate: AnyObject {
14-
func didUpdateSelectedLocations(locations: [RelayLocation])
1515
func didBack()
1616
}
1717

1818
class AddLocationsViewController: UIViewController {
1919
private var dataSource: AddLocationsDataSource?
2020
private let nodes: [LocationNode]
21-
private let customList: CustomList
21+
private let subject: CurrentValueSubject<CustomListViewModel, Never>
2222

2323
weak var delegate: AddLocationsViewControllerDelegate?
2424
private let tableView: UITableView = {
@@ -33,10 +33,10 @@ class AddLocationsViewController: UIViewController {
3333

3434
init(
3535
allLocationsNodes: [LocationNode],
36-
customList: CustomList
36+
subject: CurrentValueSubject<CustomListViewModel, Never>
3737
) {
3838
self.nodes = allLocationsNodes
39-
self.customList = customList
39+
self.subject = subject
4040
super.init(nibName: nil, bundle: nil)
4141
}
4242

@@ -70,17 +70,8 @@ class AddLocationsViewController: UIViewController {
7070
dataSource = AddLocationsDataSource(
7171
tableView: tableView,
7272
allLocationNodes: nodes.copy(),
73-
customList: customList
73+
subject: subject
7474
)
75-
76-
dataSource?.didUpdateCustomList = { [weak self] customListLocationNode in
77-
guard let self else { return }
78-
delegate?.didUpdateSelectedLocations(
79-
locations: customListLocationNode.children.reduce([]) { partialResult, locationNode in
80-
partialResult + locationNode.locations
81-
}
82-
)
83-
}
8475
}
8576
}
8677

‎ios/MullvadVPN/Coordinators/CustomLists/CustomListViewController.swift

+83
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ class CustomListViewController: UIViewController {
2727
private let alertPresenter: AlertPresenter
2828
private var validationErrors: Set<CustomListFieldValidationError> = []
2929

30+
private var persistedCustomList: CustomList? {
31+
return interactor.fetchAll().first(where: { $0.id == subject.value.id })
32+
}
33+
34+
private var customListHasUnsavedChanges: Bool {
35+
return persistedCustomList != subject.value.customList
36+
}
37+
3038
private lazy var cellConfiguration: CustomListCellConfiguration = {
3139
CustomListCellConfiguration(tableView: tableView, subject: subject)
3240
}()
@@ -91,9 +99,37 @@ class CustomListViewController: UIViewController {
9199
}
92100

93101
private func configureNavigationItem() {
102+
if let navigationController = navigationController as? InterceptibleNavigationController {
103+
interceptNavigation(navigationController)
104+
}
105+
94106
navigationItem.rightBarButtonItem = saveBarButton
95107
}
96108

109+
private func interceptNavigation(_ navigationController: InterceptibleNavigationController) {
110+
navigationController.shouldPopViewController = { [weak self] viewController in
111+
guard
112+
let self,
113+
viewController is Self,
114+
customListHasUnsavedChanges
115+
else { return true }
116+
117+
self.onUnsavedChanges()
118+
return false
119+
}
120+
121+
navigationController.shouldPopToViewController = { [weak self] viewController in
122+
guard
123+
let self,
124+
viewController is ListCustomListViewController,
125+
customListHasUnsavedChanges
126+
else { return true }
127+
128+
self.onUnsavedChanges()
129+
return false
130+
}
131+
}
132+
97133
private func configureTableView() {
98134
tableView.delegate = dataSourceConfiguration
99135
tableView.backgroundColor = .secondaryColor
@@ -195,4 +231,51 @@ class CustomListViewController: UIViewController {
195231

196232
alertPresenter.showAlert(presentation: presentation, animated: true)
197233
}
234+
235+
@objc private func onUnsavedChanges() {
236+
let message = NSMutableAttributedString(
237+
markdownString: NSLocalizedString(
238+
"CUSTOM_LISTS_UNSAVED_CHANGES_PROMPT",
239+
tableName: "CustomLists",
240+
value: "You have unsaved changes.",
241+
comment: ""
242+
),
243+
options: MarkdownStylingOptions(font: .preferredFont(forTextStyle: .body))
244+
)
245+
246+
let presentation = AlertPresentation(
247+
id: "api-custom-lists-unsaved-changes-alert",
248+
icon: .alert,
249+
attributedMessage: message,
250+
buttons: [
251+
AlertAction(
252+
title: NSLocalizedString(
253+
"CUSTOM_LISTS_DISCARD_CHANGES_BUTTON",
254+
tableName: "CustomLists",
255+
value: "Discard changes",
256+
comment: ""
257+
),
258+
style: .destructive,
259+
handler: {
260+
// Reset subject/view model to no longer having unsaved changes.
261+
if let persistedCustomList = self.persistedCustomList {
262+
self.subject.value.update(with: persistedCustomList)
263+
}
264+
self.delegate?.customListDidSave(self.subject.value.customList)
265+
}
266+
),
267+
AlertAction(
268+
title: NSLocalizedString(
269+
"CUSTOM_LISTS_BACK_TO_EDITING_BUTTON",
270+
tableName: "CustomLists",
271+
value: "Back to editing",
272+
comment: ""
273+
),
274+
style: .default
275+
),
276+
]
277+
)
278+
279+
alertPresenter.showAlert(presentation: presentation, animated: true)
280+
}
198281
}

‎ios/MullvadVPN/Coordinators/CustomLists/CustomListViewModel.swift

+5
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,9 @@ struct CustomListViewModel {
1818
var customList: CustomList {
1919
CustomList(id: id, name: name, locations: locations)
2020
}
21+
22+
mutating func update(with list: CustomList) {
23+
name = list.name
24+
locations = list.locations
25+
}
2126
}

‎ios/MullvadVPN/Coordinators/CustomLists/EditCustomListCoordinator.swift

+2-9
Original file line numberDiff line numberDiff line change
@@ -78,17 +78,10 @@ extension EditCustomListCoordinator: CustomListViewControllerDelegate {
7878
let coordinator = EditLocationsCoordinator(
7979
navigationController: navigationController,
8080
nodes: nodes,
81-
customList: list
81+
subject: subject
8282
)
8383

84-
coordinator.didFinish = { [weak self] locationsCoordinator, customList in
85-
guard let self else { return }
86-
subject.send(CustomListViewModel(
87-
id: customList.id,
88-
name: customList.name,
89-
locations: customList.locations,
90-
tableSections: subject.value.tableSections
91-
))
84+
coordinator.didFinish = { locationsCoordinator in
9285
locationsCoordinator.removeFromParent()
9386
}
9487

0 commit comments

Comments
 (0)
Failed to load comments.