Skip to content

Commit

Permalink
Intercept back button when leaving an unsaved custom list
Browse files Browse the repository at this point in the history
  • Loading branch information
Jon Petersson committed Apr 7, 2024
1 parent e859f00 commit b66b111
Show file tree
Hide file tree
Showing 12 changed files with 162 additions and 61 deletions.
4 changes: 4 additions & 0 deletions ios/MullvadVPN.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -1804,6 +1805,7 @@
7A6F2FAA2AFD3097006D0856 /* CustomDNSCellFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDNSCellFactory.swift; sourceTree = "<group>"; };
7A6F2FAC2AFD3DA7006D0856 /* CustomDNSViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDNSViewController.swift; sourceTree = "<group>"; };
7A6F2FAE2AFE36E7006D0856 /* VPNSettingsInfoButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNSettingsInfoButtonItem.swift; sourceTree = "<group>"; };
7A7907322BC0280A00B61F81 /* InterceptibleNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterceptibleNavigationController.swift; sourceTree = "<group>"; };
7A7AD28C29DC677800480EF1 /* FirstTimeLaunch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstTimeLaunch.swift; sourceTree = "<group>"; };
7A818F1E29F0305800C7F0F4 /* RootConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootConfiguration.swift; sourceTree = "<group>"; };
7A83A0C52B29A750008B5CE7 /* APIAccessMethodsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIAccessMethodsTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2695,6 +2697,7 @@
58138E60294871C600684F0C /* DeviceDataThrottling.swift */,
7A7AD28C29DC677800480EF1 /* FirstTimeLaunch.swift */,
582AE30F2440A6CA00E6733A /* InputTextFormatter.swift */,
7A7907322BC0280A00B61F81 /* InterceptibleNavigationController.swift */,
58DFF7D12B0256A300F864E0 /* MarkdownStylingOptions.swift */,
58CC40EE24A601900019D96E /* ObserverList.swift */,
);
Expand Down Expand Up @@ -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 */,
);
Expand Down
35 changes: 35 additions & 0 deletions ios/MullvadVPN/Classes/InterceptibleNavigationController.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
//

import Combine
import MullvadSettings
import MullvadTypes
import Routing
Expand All @@ -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<CustomListViewModel, Never>

var didFinish: ((AddLocationsCoordinator, CustomList) -> Void)?
var didFinish: ((AddLocationsCoordinator) -> Void)?

var presentedViewController: UIViewController {
navigationController
Expand All @@ -25,17 +26,17 @@ class AddLocationsCoordinator: Coordinator, Presentable, Presenting {
init(
navigationController: UINavigationController,
nodes: [LocationNode],
customList: CustomList
subject: CurrentValueSubject<CustomListViewModel, Never>
) {
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

Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
//

import Combine
import MullvadSettings
import MullvadTypes
import UIKit
Expand All @@ -15,20 +16,21 @@ class AddLocationsDataSource:
LocationDiffableDataSourceProtocol {
private var customListLocationNode: CustomListLocationNode
private let nodes: [LocationNode]
var didUpdateCustomList: ((CustomListLocationNode) -> Void)?
private let subject: CurrentValueSubject<CustomListViewModel, Never>
let tableView: UITableView
let sections: [LocationSection]

init(
tableView: UITableView,
allLocationNodes: [LocationNode],
customList: CustomList
subject: CurrentValueSubject<CustomListViewModel, Never>
) {
self.tableView = tableView
self.nodes = allLocationNodes
self.subject = subject

self.customListLocationNode = CustomListLocationNodeBuilder(
customList: customList,
customList: subject.value.customList,
allLocations: self.nodes
).customListLocationNode

Expand Down Expand Up @@ -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
})
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<CustomListViewModel, Never>

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

init(
allLocationsNodes: [LocationNode],
customList: CustomList
subject: CurrentValueSubject<CustomListViewModel, Never>
) {
self.nodes = allLocationsNodes
self.customList = customList
self.subject = subject
super.init(nibName: nil, bundle: nil)
}

Expand Down Expand Up @@ -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
}
)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ class CustomListViewController: UIViewController {
private let alertPresenter: AlertPresenter
private var validationErrors: Set<CustomListFieldValidationError> = []

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)
}()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.",
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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down
Loading

0 comments on commit b66b111

Please sign in to comment.