summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2023-08-03 15:01:34 +0200
committerAndrej Mihajlov <and@mullvad.net>2023-08-03 15:01:34 +0200
commit4ee496e6fa7132e5d8426229a1b4f8f69e33f33b (patch)
treecc5c245ca3ea082ecd96898a85a284b0e081935f
parentbb0d75ff7a5eef940e4987b71dbbea4aa8bd0a17 (diff)
parente5b91ab493c4e4f2fd8afb4e993936520f23ff26 (diff)
downloadmullvadvpn-4ee496e6fa7132e5d8426229a1b4f8f69e33f33b.tar.xz
mullvadvpn-4ee496e6fa7132e5d8426229a1b4f8f69e33f33b.zip
Merge branch 'implement-account-deletion-button-and-logic-ios-229'
-rw-r--r--ios/CHANGELOG.md1
-rw-r--r--ios/MullvadREST/RESTAccountsProxy.swift42
-rw-r--r--ios/MullvadREST/RESTRequestFactory.swift7
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj36
-rw-r--r--ios/MullvadVPN/Classes/AutomaticKeyboardResponder.swift76
-rw-r--r--ios/MullvadVPN/Coordinators/App/AccountCoordinator.swift35
-rw-r--r--ios/MullvadVPN/Coordinators/App/AccountDeletionCoordinator.swift47
-rw-r--r--ios/MullvadVPN/Presentation controllers/FormsheetPresentationController.swift30
-rw-r--r--ios/MullvadVPN/TunnelManager/DeleteAccountOperation.swift57
-rw-r--r--ios/MullvadVPN/TunnelManager/TunnelManager.swift84
-rw-r--r--ios/MullvadVPN/UI appearance/UIMetrics.swift12
-rw-r--r--ios/MullvadVPN/View controllers/Account/AccountContentView.swift25
-rw-r--r--ios/MullvadVPN/View controllers/Account/AccountViewController.swift7
-rw-r--r--ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionContentView.swift372
-rw-r--r--ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionInteractor.swift54
-rw-r--r--ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionViewController.swift103
-rw-r--r--ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionViewModel.swift13
-rw-r--r--ios/MullvadVPN/View controllers/Login/AccountTextField.swift36
-rw-r--r--ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherContentView.swift108
-rw-r--r--ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherViewController.swift24
20 files changed, 1052 insertions, 117 deletions
diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md
index 152f73a451..3511978865 100644
--- a/ios/CHANGELOG.md
+++ b/ios/CHANGELOG.md
@@ -24,6 +24,7 @@ Line wrap the file at 100 chars. Th
## [Unreleased]
### Added
- Allow redeeming vouchers in account view.
+- Allow deleting account in account view.
## [2023.3 - 2023-07-15]
### Added
diff --git a/ios/MullvadREST/RESTAccountsProxy.swift b/ios/MullvadREST/RESTAccountsProxy.swift
index f6eacfe65c..071616ec65 100644
--- a/ios/MullvadREST/RESTAccountsProxy.swift
+++ b/ios/MullvadREST/RESTAccountsProxy.swift
@@ -82,6 +82,48 @@ extension REST {
completionHandler: completion
)
}
+
+ public func deleteAccount(
+ accountNumber: String,
+ retryStrategy: RetryStrategy,
+ completion: @escaping CompletionHandler<Void>
+ ) -> Cancellable {
+ let requestHandler = AnyRequestHandler(createURLRequest: { endpoint, authorization in
+ var requestBuilder = try self.requestFactory.createRequestBuilder(
+ endpoint: endpoint,
+ method: .delete,
+ pathTemplate: "accounts/me"
+ )
+ requestBuilder.setAuthorization(authorization)
+ requestBuilder.addValue(accountNumber, forHTTPHeaderField: "Mullvad-Account-Number")
+
+ return requestBuilder.getRequest()
+ }, authorizationProvider: createAuthorizationProvider(accountNumber: accountNumber))
+
+ let responseHandler = AnyResponseHandler { response, data -> ResponseHandlerResult<Void> in
+ let statusCode = HTTPStatus(rawValue: response.statusCode)
+
+ switch statusCode {
+ case let statusCode where statusCode.isSuccess:
+ return .success(())
+ default:
+ return .unhandledResponse(
+ try? self.responseDecoder.decode(
+ ServerErrorResponse.self,
+ from: data
+ )
+ )
+ }
+ }
+
+ return addOperation(
+ name: "delete-my-account",
+ retryStrategy: retryStrategy,
+ requestHandler: requestHandler,
+ responseHandler: responseHandler,
+ completionHandler: completion
+ )
+ }
}
public struct NewAccountData: Decodable {
diff --git a/ios/MullvadREST/RESTRequestFactory.swift b/ios/MullvadREST/RESTRequestFactory.swift
index d1e1c06a63..40db97f4f0 100644
--- a/ios/MullvadREST/RESTRequestFactory.swift
+++ b/ios/MullvadREST/RESTRequestFactory.swift
@@ -119,6 +119,13 @@ extension REST {
)
}
+ mutating func addValue(_ value: String, forHTTPHeaderField: String) {
+ restRequest.urlRequest.addValue(
+ value,
+ forHTTPHeaderField: forHTTPHeaderField
+ )
+ }
+
func getRequest() -> REST.Request {
restRequest
}
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index 59d592d214..dd0e8795f9 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -438,12 +438,18 @@
F07C0A072A52DA64009825CA /* SetupAccountCompletedCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F07C0A062A52DA64009825CA /* SetupAccountCompletedCoordinator.swift */; };
F07CFF2029F2720E008C0343 /* RegisteredDeviceInAppNotificationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F07CFF1F29F2720E008C0343 /* RegisteredDeviceInAppNotificationProvider.swift */; };
F0C2AEFD2A0BB5CC00986207 /* NotificationProviderIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0C2AEFC2A0BB5CC00986207 /* NotificationProviderIdentifier.swift */; };
+ F0C6FA812A66E23300F521F0 /* DeleteAccountOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0C6FA802A66E23300F521F0 /* DeleteAccountOperation.swift */; };
F0E3618B2A4ADD2F00AEEF2B /* WelcomeContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E3618A2A4ADD2F00AEEF2B /* WelcomeContentView.swift */; };
F0E8CC032A4C753B007ED3B4 /* WelcomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E8CC022A4C753B007ED3B4 /* WelcomeViewController.swift */; };
F0E8CC052A4CC88F007ED3B4 /* WelcomeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E8CC042A4CC88F007ED3B4 /* WelcomeCoordinator.swift */; };
F0E8CC0A2A4EE127007ED3B4 /* SetupAccountCompletedContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E8CC092A4EE127007ED3B4 /* SetupAccountCompletedContentView.swift */; };
F0E8CC0C2A4EE672007ED3B4 /* SetupAccountCompletedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E8CC0B2A4EE672007ED3B4 /* SetupAccountCompletedViewController.swift */; };
F0E8E4BB2A56C9F100ED26A3 /* WelcomeInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E8E4BA2A56C9F100ED26A3 /* WelcomeInteractor.swift */; };
+ F0E8E4C12A602CCB00ED26A3 /* AccountDeletionContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E8E4C02A602CCB00ED26A3 /* AccountDeletionContentView.swift */; };
+ F0E8E4C32A602E0D00ED26A3 /* AccountDeletionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E8E4C22A602E0D00ED26A3 /* AccountDeletionViewModel.swift */; };
+ F0E8E4C52A60499100ED26A3 /* AccountDeletionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E8E4C42A60499100ED26A3 /* AccountDeletionViewController.swift */; };
+ F0E8E4C72A604CBE00ED26A3 /* AccountDeletionCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E8E4C62A604CBE00ED26A3 /* AccountDeletionCoordinator.swift */; };
+ F0E8E4C92A604E7400ED26A3 /* AccountDeletionInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E8E4C82A604E7400ED26A3 /* AccountDeletionInteractor.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -1213,12 +1219,18 @@
F07C0A062A52DA64009825CA /* SetupAccountCompletedCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupAccountCompletedCoordinator.swift; sourceTree = "<group>"; };
F07CFF1F29F2720E008C0343 /* RegisteredDeviceInAppNotificationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisteredDeviceInAppNotificationProvider.swift; sourceTree = "<group>"; };
F0C2AEFC2A0BB5CC00986207 /* NotificationProviderIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationProviderIdentifier.swift; sourceTree = "<group>"; };
+ F0C6FA802A66E23300F521F0 /* DeleteAccountOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteAccountOperation.swift; sourceTree = "<group>"; };
F0E3618A2A4ADD2F00AEEF2B /* WelcomeContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeContentView.swift; sourceTree = "<group>"; };
F0E8CC022A4C753B007ED3B4 /* WelcomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeViewController.swift; sourceTree = "<group>"; };
F0E8CC042A4CC88F007ED3B4 /* WelcomeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeCoordinator.swift; sourceTree = "<group>"; };
F0E8CC092A4EE127007ED3B4 /* SetupAccountCompletedContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupAccountCompletedContentView.swift; sourceTree = "<group>"; };
F0E8CC0B2A4EE672007ED3B4 /* SetupAccountCompletedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupAccountCompletedViewController.swift; sourceTree = "<group>"; };
F0E8E4BA2A56C9F100ED26A3 /* WelcomeInteractor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WelcomeInteractor.swift; sourceTree = "<group>"; };
+ F0E8E4C02A602CCB00ED26A3 /* AccountDeletionContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDeletionContentView.swift; sourceTree = "<group>"; };
+ F0E8E4C22A602E0D00ED26A3 /* AccountDeletionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDeletionViewModel.swift; sourceTree = "<group>"; };
+ F0E8E4C42A60499100ED26A3 /* AccountDeletionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDeletionViewController.swift; sourceTree = "<group>"; };
+ F0E8E4C62A604CBE00ED26A3 /* AccountDeletionCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDeletionCoordinator.swift; sourceTree = "<group>"; };
+ F0E8E4C82A604E7400ED26A3 /* AccountDeletionInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDeletionInteractor.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -1501,6 +1513,7 @@
5823FA5726CE4A4100283BF8 /* TunnelManager */ = {
isa = PBXGroup;
children = (
+ F0C6FA802A66E23300F521F0 /* DeleteAccountOperation.swift */,
588527B1276B3F0700BAA373 /* LoadTunnelConfigurationOperation.swift */,
58F2E147276A307400A79513 /* MapConnectionStatusOperation.swift */,
F07BF2612A26279100042943 /* RedeemVoucherOperation.swift */,
@@ -1547,9 +1560,10 @@
583FE01629C196E8006E85F9 /* View controllers */ = {
isa = PBXGroup;
children = (
- F0E8E4B92A55593300ED26A3 /* CreationAccount */,
583FE02029C1A0B1006E85F9 /* Account */,
+ F0E8E4BF2A602C7D00ED26A3 /* AccountDeletion */,
5878F4FA29CDA2D4003D4BE2 /* ChangeLog */,
+ F0E8E4B92A55593300ED26A3 /* CreationAccount */,
583FE01D29C197C1006E85F9 /* DeviceList */,
583FE02529C1AD0E006E85F9 /* Launch */,
583FE02129C1A0F4006E85F9 /* Login */,
@@ -1931,11 +1945,11 @@
isa = PBXGroup;
children = (
7AF0419D29E957EB00D492DD /* AccountCoordinator.swift */,
+ F0E8E4C62A604CBE00ED26A3 /* AccountDeletionCoordinator.swift */,
F07C0A042A52D4C3009825CA /* AccountRedeemingVoucherCoordinator.swift */,
58BBB39629717E0C00C8DB7C /* ApplicationCoordinator.swift */,
5893C6FB29C311E9009090D1 /* ApplicationRouter.swift */,
5878F50129CDB989003D4BE2 /* ChangeLogCoordinator.swift */,
- F07C0A062A52DA64009825CA /* SetupAccountCompletedCoordinator.swift */,
58CAF9F92983E0C600BE19F7 /* LoginCoordinator.swift */,
583FE00D29C0D586006E85F9 /* OutOfTimeCoordinator.swift */,
5847D58C29B7740F008C3808 /* RevokedCoordinator.swift */,
@@ -1943,6 +1957,7 @@
587C92FD2986E28100FB9664 /* SelectLocationCoordinator.swift */,
58C3F4FA296C3AD500D72515 /* SettingsCoordinator.swift */,
F041CD552A38B0B7001B703B /* SettingsRedeemVoucherCoordinator.swift */,
+ F07C0A062A52DA64009825CA /* SetupAccountCompletedCoordinator.swift */,
587C92FF2986E2B600FB9664 /* TermsOfServiceCoordinator.swift */,
58F185A9298A3E3E00075977 /* TunnelCoordinator.swift */,
F0E8CC042A4CC88F007ED3B4 /* WelcomeCoordinator.swift */,
@@ -2344,6 +2359,17 @@
path = CreationAccount;
sourceTree = "<group>";
};
+ F0E8E4BF2A602C7D00ED26A3 /* AccountDeletion */ = {
+ isa = PBXGroup;
+ children = (
+ F0E8E4C02A602CCB00ED26A3 /* AccountDeletionContentView.swift */,
+ F0E8E4C82A604E7400ED26A3 /* AccountDeletionInteractor.swift */,
+ F0E8E4C42A60499100ED26A3 /* AccountDeletionViewController.swift */,
+ F0E8E4C22A602E0D00ED26A3 /* AccountDeletionViewModel.swift */,
+ );
+ path = AccountDeletion;
+ sourceTree = "<group>";
+ };
/* End PBXGroup section */
/* Begin PBXHeadersBuildPhase section */
@@ -3199,6 +3225,7 @@
5846227126E229F20035F7C2 /* StoreSubscription.swift in Sources */,
58421030282D8A3C00F24E46 /* UpdateAccountDataOperation.swift in Sources */,
F0E8CC052A4CC88F007ED3B4 /* WelcomeCoordinator.swift in Sources */,
+ F0E8E4C92A604E7400ED26A3 /* AccountDeletionInteractor.swift in Sources */,
58FF2C03281BDE02009EF542 /* SettingsManager.swift in Sources */,
5803B4B02940A47300C23744 /* TunnelConfiguration.swift in Sources */,
587EB672271451E300123C75 /* PreferencesViewModel.swift in Sources */,
@@ -3247,6 +3274,7 @@
58F2E14C276A61C000A79513 /* RotateKeyOperation.swift in Sources */,
5871FB96254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift in Sources */,
068CE57029278F5300A068BB /* MigrationFromV1ToV2.swift in Sources */,
+ F0E8E4C52A60499100ED26A3 /* AccountDeletionViewController.swift in Sources */,
F028A54B2A3370FA00C0CAA3 /* RedeemVoucherContentView.swift in Sources */,
58FEEB58260B662E00A621A8 /* AutomaticKeyboardResponder.swift in Sources */,
5846227326E22A160035F7C2 /* StorePaymentObserver.swift in Sources */,
@@ -3303,6 +3331,8 @@
582BB1AF229566420055B6EF /* SettingsCell.swift in Sources */,
58F3C0A4249CB069003E76BE /* HeaderBarView.swift in Sources */,
5864AF0829C78849005B0CD9 /* CellFactoryProtocol.swift in Sources */,
+ F0C6FA812A66E23300F521F0 /* DeleteAccountOperation.swift in Sources */,
+ F0E8E4C72A604CBE00ED26A3 /* AccountDeletionCoordinator.swift in Sources */,
F07CFF2029F2720E008C0343 /* RegisteredDeviceInAppNotificationProvider.swift in Sources */,
587A01FC23F1F0BE00B68763 /* SimulatorTunnelProviderHost.swift in Sources */,
58CAF9F82983D36800BE19F7 /* Coordinator.swift in Sources */,
@@ -3344,10 +3374,12 @@
586891CD29D452E4002A8278 /* SafariCoordinator.swift in Sources */,
58ACF64F26567A7100ACE4B7 /* CustomSwitchContainer.swift in Sources */,
58EE2E3A272FF814003BFF93 /* SettingsDataSource.swift in Sources */,
+ F0E8E4C12A602CCB00ED26A3 /* AccountDeletionContentView.swift in Sources */,
58B26E1E2943514300D5980C /* InAppNotificationDescriptor.swift in Sources */,
58421032282E42B000F24E46 /* UpdateDeviceDataOperation.swift in Sources */,
06410E04292D0F7100AFC18C /* SettingsParser.swift in Sources */,
5878A27D2909657C0096FC88 /* RevokedDeviceInteractor.swift in Sources */,
+ F0E8E4C32A602E0D00ED26A3 /* AccountDeletionViewModel.swift in Sources */,
58677710290975E9006F721F /* SettingsInteractorFactory.swift in Sources */,
58B26E282943527300D5980C /* SystemNotificationProvider.swift in Sources */,
58CCA01222424D11004F3011 /* SettingsViewController.swift in Sources */,
diff --git a/ios/MullvadVPN/Classes/AutomaticKeyboardResponder.swift b/ios/MullvadVPN/Classes/AutomaticKeyboardResponder.swift
index 9b13f411b8..8447bc1c65 100644
--- a/ios/MullvadVPN/Classes/AutomaticKeyboardResponder.swift
+++ b/ios/MullvadVPN/Classes/AutomaticKeyboardResponder.swift
@@ -13,7 +13,6 @@ class AutomaticKeyboardResponder {
weak var targetView: UIView?
private let handler: (UIView, CGFloat) -> Void
- private var showsKeyboard = false
private var lastKeyboardRect: CGRect?
private let logger = Logger(label: "AutomaticKeyboardResponder")
@@ -28,58 +27,74 @@ class AutomaticKeyboardResponder {
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardWillChangeFrame(_:)),
- name: UIWindow.keyboardWillChangeFrameNotification,
+ name: UIResponder.keyboardWillChangeFrameNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardWillShow(_:)),
- name: UIWindow.keyboardWillShowNotification,
+ name: UIResponder.keyboardWillShowNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
- selector: #selector(keyboardDidHide(_:)),
- name: UIWindow.keyboardDidHideNotification,
+ selector: #selector(keyboardWillHide(_:)),
+ name: UIResponder.keyboardWillHideNotification,
object: nil
)
}
func updateContentInsets() {
guard let keyboardRect = lastKeyboardRect else { return }
-
- adjustContentInsets(keyboardRect: keyboardRect)
+ adjustContentInsets(convertedKeyboardFrameEnd: keyboardRect)
}
// MARK: - Keyboard notifications
@objc private func keyboardWillShow(_ notification: Notification) {
- showsKeyboard = true
-
addPresentationControllerObserver()
- handleKeyboardNotification(notification)
}
- @objc private func keyboardDidHide(_ notification: Notification) {
- showsKeyboard = false
+ @objc private func keyboardWillHide(_ notification: Notification) {
presentationFrameObserver = nil
}
@objc private func keyboardWillChangeFrame(_ notification: Notification) {
- guard showsKeyboard else { return }
-
handleKeyboardNotification(notification)
}
// MARK: - Private
private func handleKeyboardNotification(_ notification: Notification) {
- guard let keyboardFrameValue = notification
- .userInfo?[UIWindow.keyboardFrameEndUserInfoKey] as? NSValue else { return }
+ guard let userInfo = notification.userInfo,
+ let targetView else { return }
+ // In iOS 16.1 and later, the keyboard notification object is the screen the keyboard appears on.
+ if #available(iOS 16.1, *) {
+ guard let screen = notification.object as? UIScreen,
+ // Get the keyboard’s frame at the end of its animation.
+ let keyboardFrameEnd = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
+
+ // Use that screen to get the coordinate space to convert from.
+ let fromCoordinateSpace = screen.coordinateSpace
+
+ // Get your view's coordinate space.
+ let toCoordinateSpace: UICoordinateSpace = targetView
+
+ // Convert the keyboard's frame from the screen's coordinate space to your view's coordinate space.
+ let convertedKeyboardFrameEnd = fromCoordinateSpace.convert(keyboardFrameEnd, to: toCoordinateSpace)
- lastKeyboardRect = keyboardFrameValue.cgRectValue
+ lastKeyboardRect = convertedKeyboardFrameEnd
- adjustContentInsets(keyboardRect: keyboardFrameValue.cgRectValue)
+ adjustContentInsets(convertedKeyboardFrameEnd: convertedKeyboardFrameEnd)
+ } else {
+ guard let keyboardValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue
+ else { return }
+ let keyboardFrameEnd = keyboardValue.cgRectValue
+ let convertedKeyboardFrameEnd = targetView.convert(keyboardFrameEnd, from: targetView.window)
+ lastKeyboardRect = convertedKeyboardFrameEnd
+
+ adjustContentInsets(convertedKeyboardFrameEnd: convertedKeyboardFrameEnd)
+ }
}
private func addPresentationControllerObserver() {
@@ -100,7 +115,7 @@ class AutomaticKeyboardResponder {
guard let self,
let keyboardFrameValue = lastKeyboardRect else { return }
- adjustContentInsets(keyboardRect: keyboardFrameValue)
+ adjustContentInsets(convertedKeyboardFrameEnd: keyboardFrameValue)
}
)
}
@@ -112,7 +127,6 @@ class AutomaticKeyboardResponder {
responder = responder?.next
return responder
}
-
return iterator.first { $0 is UIViewController } as? UIViewController
}
@@ -152,16 +166,24 @@ class AutomaticKeyboardResponder {
return presentationStyle == .formSheet
}
- private func adjustContentInsets(keyboardRect: CGRect) {
- guard let targetView, let superview = targetView.superview else { return }
+ private func adjustContentInsets(convertedKeyboardFrameEnd: CGRect) {
+ guard let targetView else { return }
+
+ // Get the safe area insets when the keyboard is offscreen.
+ var bottomOffset = targetView.safeAreaInsets.bottom
- // Compute the target view frame within screen coordinates
- let screenRect = superview.convert(targetView.frame, to: nil)
+ // Get the intersection between the keyboard's frame and the view's bounds to work with the
+ // part of the keyboard that overlaps your view.
+ let viewIntersection = targetView.bounds.intersection(convertedKeyboardFrameEnd)
- // Find the intersection between the keyboard and the view
- let intersection = keyboardRect.intersection(screenRect)
+ // Check whether the keyboard intersects your view before adjusting your offset.
+ if !viewIntersection.isEmpty {
+ // Adjust the offset by the difference between the view's height and the height of the
+ // intersection rectangle.
+ bottomOffset = targetView.bounds.maxY - viewIntersection.minY
+ }
- handler(targetView, intersection.height)
+ handler(targetView, bottomOffset)
}
}
diff --git a/ios/MullvadVPN/Coordinators/App/AccountCoordinator.swift b/ios/MullvadVPN/Coordinators/App/AccountCoordinator.swift
index e83b5c96bc..934e0a7199 100644
--- a/ios/MullvadVPN/Coordinators/App/AccountCoordinator.swift
+++ b/ios/MullvadVPN/Coordinators/App/AccountCoordinator.swift
@@ -11,6 +11,7 @@ import UIKit
enum AccountDismissReason: Equatable {
case none
case userLoggedOut
+ case accountDeletion
}
enum AddedMoreCreditOption: Equatable {
@@ -70,6 +71,8 @@ final class AccountCoordinator: Coordinator, Presentable, Presenting {
logOut()
case .navigateToVoucher:
navigateToRedeemVoucher()
+ case .navigateToDeleteAccount:
+ navigateToDeleteAccount()
}
}
@@ -92,9 +95,35 @@ final class AccountCoordinator: Coordinator, Presentable, Presenting {
coordinator,
animated: true,
configuration: ModalPresentationConfiguration(
- preferredContentSize: UIMetrics.RedeemVoucher.preferredContentSize,
- modalPresentationStyle: .custom,
- transitioningDelegate: FormSheetTransitioningDelegate()
+ preferredContentSize: UIMetrics.SettingsRedeemVoucher.preferredContentSize,
+ modalPresentationStyle: .formSheet
+ )
+ )
+ }
+
+ private func navigateToDeleteAccount() {
+ let coordinator = AccountDeletionCoordinator(
+ navigationController: CustomNavigationController(),
+ interactor: AccountDeletionInteractor(tunnelManager: interactor.tunnelManager)
+ )
+
+ coordinator.start()
+ coordinator.didCancel = { accountDeletionCoordinator in
+ accountDeletionCoordinator.dismiss(animated: true)
+ }
+
+ coordinator.didFinish = { accountDeletionCoordinator in
+ accountDeletionCoordinator.dismiss(animated: true) {
+ self.didFinish?(self, .userLoggedOut)
+ }
+ }
+
+ presentChild(
+ coordinator,
+ animated: true,
+ configuration: ModalPresentationConfiguration(
+ preferredContentSize: UIMetrics.AccountDeletion.preferredContentSize,
+ modalPresentationStyle: .formSheet
)
)
}
diff --git a/ios/MullvadVPN/Coordinators/App/AccountDeletionCoordinator.swift b/ios/MullvadVPN/Coordinators/App/AccountDeletionCoordinator.swift
new file mode 100644
index 0000000000..7e0f674d57
--- /dev/null
+++ b/ios/MullvadVPN/Coordinators/App/AccountDeletionCoordinator.swift
@@ -0,0 +1,47 @@
+//
+// AccountDeletionCoordinator.swift
+// MullvadVPN
+//
+// Created by Mojgan on 2023-07-13.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import UIKit
+
+final class AccountDeletionCoordinator: Coordinator, Presentable {
+ private let navigationController: UINavigationController
+ private let interactor: AccountDeletionInteractor
+
+ var didCancel: ((AccountDeletionCoordinator) -> Void)?
+ var didFinish: ((AccountDeletionCoordinator) -> Void)?
+
+ var presentedViewController: UIViewController {
+ navigationController
+ }
+
+ init(
+ navigationController: UINavigationController,
+ interactor: AccountDeletionInteractor
+ ) {
+ self.navigationController = navigationController
+ self.interactor = interactor
+ }
+
+ func start() {
+ navigationController.navigationBar.isHidden = true
+ let viewController = AccountDeletionViewController(interactor: interactor)
+ viewController.delegate = self
+ navigationController.pushViewController(viewController, animated: true)
+ }
+}
+
+extension AccountDeletionCoordinator: AccountDeletionViewControllerDelegate {
+ func deleteAccountDidSucceed(controller: AccountDeletionViewController) {
+ didFinish?(self)
+ }
+
+ func deleteAccountDidCancel(controller: AccountDeletionViewController) {
+ didCancel?(self)
+ }
+}
diff --git a/ios/MullvadVPN/Presentation controllers/FormsheetPresentationController.swift b/ios/MullvadVPN/Presentation controllers/FormsheetPresentationController.swift
index b1a734932f..e472aa19ea 100644
--- a/ios/MullvadVPN/Presentation controllers/FormsheetPresentationController.swift
+++ b/ios/MullvadVPN/Presentation controllers/FormsheetPresentationController.swift
@@ -29,8 +29,6 @@ class FormSheetPresentationController: UIPresentationController {
*/
private var lastKnownIsInFullScreen: Bool?
- private var keyboardResponder: AutomaticKeyboardResponder?
-
private let dimmingView: UIView = {
let dimmingView = UIView()
dimmingView.backgroundColor = UIMetrics.DimmingView.backgroundColor
@@ -55,11 +53,6 @@ class FormSheetPresentationController: UIPresentationController {
traitCollection.horizontalSizeClass == .compact
}
- override init(presentedViewController: UIViewController, presenting presentingViewController: UIViewController?) {
- super.init(presentedViewController: presentedViewController, presenting: presentingViewController)
- addKeyboardResponder()
- }
-
override var frameOfPresentedViewInContainerView: CGRect {
guard let containerView else {
return super.frameOfPresentedViewInContainerView
@@ -154,29 +147,6 @@ class FormSheetPresentationController: UIPresentationController {
userInfo: [Self.isFullScreenUserInfoKey: NSNumber(booleanLiteral: currentIsInFullScreen)]
)
}
-
- private func addKeyboardResponder() {
- guard let presentedView else { return }
- keyboardResponder = AutomaticKeyboardResponder(
- targetView: presentedView,
- handler: { [weak self] view, adjustment in
- guard let self,
- let containerView,
- !isInFullScreenPresentation else { return }
- let frame = view.frame
- let bottomMarginFromKeyboard = adjustment > 0 ? UIMetrics.sectionSpacing : 0
- view.frame = CGRect(
- origin: CGPoint(
- x: frame.origin.x,
- y: containerView.bounds.midY - presentedViewController.preferredContentSize
- .height * 0.5 - adjustment - bottomMarginFromKeyboard
- ),
- size: frame.size
- )
- view.layoutIfNeeded()
- }
- )
- }
}
class FormSheetTransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate {
diff --git a/ios/MullvadVPN/TunnelManager/DeleteAccountOperation.swift b/ios/MullvadVPN/TunnelManager/DeleteAccountOperation.swift
new file mode 100644
index 0000000000..94cb537f55
--- /dev/null
+++ b/ios/MullvadVPN/TunnelManager/DeleteAccountOperation.swift
@@ -0,0 +1,57 @@
+//
+// DeleteAccountOperation.swift
+// MullvadVPN
+//
+// Created by Mojgan on 2023-07-18.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import MullvadLogging
+import MullvadREST
+import MullvadTypes
+import Operations
+
+class DeleteAccountOperation: ResultOperation<Void> {
+ private let logger = Logger(label: "\(DeleteAccountOperation.self)")
+
+ private let accountNumber: String
+ private let accountsProxy: REST.AccountsProxy
+ private var task: Cancellable?
+
+ init(
+ dispatchQueue: DispatchQueue,
+ accountsProxy: REST.AccountsProxy,
+ accountNumber: String
+ ) {
+ self.accountNumber = accountNumber
+ self.accountsProxy = accountsProxy
+ super.init(dispatchQueue: dispatchQueue)
+ }
+
+ override func main() {
+ task = accountsProxy.deleteAccount(
+ accountNumber: accountNumber,
+ retryStrategy: .default,
+ completion: { [weak self] result in
+ self?.dispatchQueue.async {
+ switch result {
+ case .success():
+ self?.finish(result: .success(()))
+ case let .failure(error):
+ self?.logger.error(
+ error: error,
+ message: "Failed to delete account."
+ )
+ self?.finish(result: .failure(error))
+ }
+ }
+ }
+ )
+ }
+
+ override func operationDidCancel() {
+ task?.cancel()
+ task = nil
+ }
+}
diff --git a/ios/MullvadVPN/TunnelManager/TunnelManager.swift b/ios/MullvadVPN/TunnelManager/TunnelManager.swift
index b950170e36..b642dbf8ef 100644
--- a/ios/MullvadVPN/TunnelManager/TunnelManager.swift
+++ b/ios/MullvadVPN/TunnelManager/TunnelManager.swift
@@ -431,6 +431,45 @@ final class TunnelManager: StorePaymentObserver {
return operation
}
+ func deleteAccount(
+ accountNumber: String,
+ completion: ((Error?) -> Void)? = nil
+ ) -> Cancellable {
+ let operation = DeleteAccountOperation(
+ dispatchQueue: internalQueue,
+ accountsProxy: accountsProxy,
+ accountNumber: accountNumber
+ )
+
+ operation.completionQueue = .main
+ operation.completionHandler = { [weak self] result in
+ switch result {
+ case .success:
+ self?.unsetTunnelConfiguration {
+ self?.operationQueue.cancelAllOperations()
+ self?.wipeAllUserData()
+ self?.setDeviceState(.loggedOut, persist: true)
+ completion?(nil)
+ }
+ case let .failure(error):
+ completion?(error)
+ }
+ }
+
+ operation.addObserver(
+ BackgroundObserver(
+ application: application,
+ name: "Delete account",
+ cancelUponExpiration: true
+ )
+ )
+
+ operation.addCondition(MutuallyExclusive(category: OperationCategory.deviceStateUpdate.category))
+
+ operationQueue.addOperation(operation)
+ return operation
+ }
+
func updateDeviceData(_ completionHandler: ((Error?) -> Void)? = nil) {
let operation = UpdateDeviceDataOperation(
dispatchQueue: internalQueue,
@@ -1083,10 +1122,47 @@ final class TunnelManager: StorePaymentObserver {
if restError.compareErrorCode(.deviceNotFound) {
setDeviceState(.revoked, persist: true)
} else if restError.compareErrorCode(.invalidAccount) {
- setDeviceState(.revoked, persist: true)
- cancelPollingTunnelStatus()
- cancelPollingKeyRotation()
- wipeAllUserData()
+ unsetTunnelConfiguration {
+ self.setDeviceState(.revoked, persist: true)
+ self.operationQueue.cancelAllOperations()
+ self.wipeAllUserData()
+ }
+ }
+ }
+
+ private func unsetTunnelConfiguration(completion: @escaping () -> Void) {
+ setSettings(TunnelSettingsV2(), persist: true)
+
+ // Tell the caller to unsubscribe from VPN status notifications.
+ prepareForVPNConfigurationDeletion()
+
+ // Reset tunnel.
+ _ = setTunnelStatus { tunnelStatus in
+ tunnelStatus = TunnelStatus()
+ tunnelStatus.state = .disconnected
+ }
+
+ // Finish immediately if tunnel provider is not set.
+ guard let tunnel else {
+ completion()
+ return
+ }
+
+ // Remove VPN configuration.
+ tunnel.removeFromPreferences { [self] error in
+ internalQueue.async { [self] in
+ // Ignore error but log it.
+ if let error {
+ logger.error(
+ error: error,
+ message: "Failed to remove VPN configuration."
+ )
+ }
+
+ setTunnel(nil, shouldRefreshTunnelState: false)
+
+ completion()
+ }
}
}
}
diff --git a/ios/MullvadVPN/UI appearance/UIMetrics.swift b/ios/MullvadVPN/UI appearance/UIMetrics.swift
index fe20da05e4..56f07102b3 100644
--- a/ios/MullvadVPN/UI appearance/UIMetrics.swift
+++ b/ios/MullvadVPN/UI appearance/UIMetrics.swift
@@ -34,12 +34,16 @@ enum UIMetrics {
static let animationOptions: UIView.AnimationOptions = [.curveEaseInOut]
}
- enum RedeemVoucher {
+ enum SettingsRedeemVoucher {
static let cornerRadius = 8.0
- static let preferredContentSize = CGSize(width: 300, height: 280)
+ static let preferredContentSize = CGSize(width: 280, height: 240)
static let contentLayoutMargins = NSDirectionalEdgeInsets(top: 16, leading: 0, bottom: 16, trailing: 0)
}
+ enum AccountDeletion {
+ static let preferredContentSize = CGSize(width: 480, height: 640)
+ }
+
enum Button {
static let barButtonSize: CGFloat = 44.0
}
@@ -118,7 +122,11 @@ extension UIMetrics {
static let headerBarBrandNameHeight: CGFloat = 18
/// Various paddings used throughout the app to visually separate elements in StackViews
+ static let padding4: CGFloat = 4
static let padding8: CGFloat = 8
static let padding16: CGFloat = 16
static let padding24: CGFloat = 24
+ static let padding32: CGFloat = 32
+ static let padding40: CGFloat = 40
+ static let padding48: CGFloat = 48
}
diff --git a/ios/MullvadVPN/View controllers/Account/AccountContentView.swift b/ios/MullvadVPN/View controllers/Account/AccountContentView.swift
index 813fa51867..623d2e52f2 100644
--- a/ios/MullvadVPN/View controllers/Account/AccountContentView.swift
+++ b/ios/MullvadVPN/View controllers/Account/AccountContentView.swift
@@ -54,6 +54,19 @@ class AccountContentView: UIView {
return button
}()
+ let deleteButton: AppButton = {
+ let button = AppButton(style: .danger)
+ button.translatesAutoresizingMaskIntoConstraints = false
+ button.accessibilityIdentifier = "DeleteButton"
+ button.setTitle(NSLocalizedString(
+ "DELETE_BUTTON_TITLE",
+ tableName: "Account",
+ value: "Delete account",
+ comment: ""
+ ), for: .normal)
+ return button
+ }()
+
let accountDeviceRow: AccountDeviceRow = {
let view = AccountDeviceRow()
view.translatesAutoresizingMaskIntoConstraints = false
@@ -81,16 +94,22 @@ class AccountContentView: UIView {
])
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
- stackView.spacing = UIMetrics.sectionSpacing
+ stackView.spacing = UIMetrics.padding8
return stackView
}()
lazy var buttonStackView: UIStackView = {
let stackView =
- UIStackView(arrangedSubviews: [redeemVoucherButton, purchaseButton, restorePurchasesButton, logoutButton])
+ UIStackView(arrangedSubviews: [
+ redeemVoucherButton,
+ purchaseButton,
+ restorePurchasesButton,
+ logoutButton,
+ deleteButton,
+ ])
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
- stackView.spacing = UIMetrics.interButtonSpacing
+ stackView.spacing = UIMetrics.padding16
stackView.setCustomSpacing(UIMetrics.interButtonSpacing, after: restorePurchasesButton)
return stackView
}()
diff --git a/ios/MullvadVPN/View controllers/Account/AccountViewController.swift b/ios/MullvadVPN/View controllers/Account/AccountViewController.swift
index 6d40d383ad..8384993285 100644
--- a/ios/MullvadVPN/View controllers/Account/AccountViewController.swift
+++ b/ios/MullvadVPN/View controllers/Account/AccountViewController.swift
@@ -18,6 +18,7 @@ enum AccountViewControllerAction {
case finish
case logOut
case navigateToVoucher
+ case navigateToDeleteAccount
}
class AccountViewController: UIViewController {
@@ -117,6 +118,8 @@ class AccountViewController: UIViewController {
)
contentView.logoutButton.addTarget(self, action: #selector(logOut), for: .touchUpInside)
+ contentView.deleteButton.addTarget(self, action: #selector(deleteAccount), for: .touchUpInside)
+
interactor.didReceiveDeviceState = { [weak self] deviceState in
self?.updateView(from: deviceState)
}
@@ -239,6 +242,10 @@ class AccountViewController: UIViewController {
actionHandler?(.navigateToVoucher)
}
+ @objc private func deleteAccount() {
+ actionHandler?(.navigateToDeleteAccount)
+ }
+
@objc private func doPurchase() {
guard case let .received(product) = productState,
let accountData = interactor.deviceState.accountData
diff --git a/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionContentView.swift b/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionContentView.swift
new file mode 100644
index 0000000000..b2436e3c9c
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionContentView.swift
@@ -0,0 +1,372 @@
+//
+// AccountDeletionContentView.swift
+// MullvadVPN
+//
+// Created by Mojgan on 2023-07-13.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import MullvadREST
+import UIKit
+
+protocol AccountDeletionContentViewDelegate: AnyObject {
+ func didTapDeleteButton(contentView: AccountDeletionContentView, button: AppButton)
+ func didTapCancelButton(contentView: AccountDeletionContentView, button: AppButton)
+}
+
+class AccountDeletionContentView: UIView {
+ enum State {
+ case initial
+ case loading
+ case failure(Error)
+ }
+
+ private enum Action: String {
+ case delete, cancel
+ }
+
+ private let scrollView: UIScrollView = {
+ let scrollView = UIScrollView()
+ return scrollView
+ }()
+
+ private let contentHolderView: UIView = {
+ let contentHolderView = UIView()
+ return contentHolderView
+ }()
+
+ private let titleLabel: UILabel = {
+ let label = UILabel()
+ label.translatesAutoresizingMaskIntoConstraints = false
+ label.font = .preferredFont(forTextStyle: .title2, weight: .bold)
+ label.numberOfLines = .zero
+ label.lineBreakMode = .byWordWrapping
+ label.textColor = .white
+ label.text = NSLocalizedString(
+ "ACCOUNT_DELETION_PAGE_TITLE",
+ tableName: "Account",
+ value: "Account deletion",
+ comment: ""
+ )
+ return label
+ }()
+
+ private let messageLabel: UILabel = {
+ let label = UILabel()
+ label.translatesAutoresizingMaskIntoConstraints = false
+ label.font = .preferredFont(forTextStyle: .body, weight: .bold)
+ label.numberOfLines = .zero
+ label.lineBreakMode = .byWordWrapping
+ label.textColor = .white
+ return label
+ }()
+
+ private let tipLabel: UILabel = {
+ let label = UILabel()
+ label.translatesAutoresizingMaskIntoConstraints = false
+ label.font = .preferredFont(forTextStyle: .footnote, weight: .bold)
+ label.numberOfLines = .zero
+ label.lineBreakMode = .byWordWrapping
+ label.textColor = .white
+ label.text = NSLocalizedString(
+ "TIP_TEXT",
+ tableName: "Account",
+ value: """
+ This logs out all devices using this account and all VPN access will be denied even if there is time left on the account. Enter the last 4 digits of the account number and hit OK if you really want to delete the account :
+ """,
+ comment: ""
+ )
+ return label
+ }()
+
+ private lazy var accountTextField: AccountTextField = {
+ let groupingStyle = AccountTextField.GroupingStyle.lastPart
+ let textField = AccountTextField(groupingStyle: groupingStyle)
+ textField.font = .preferredFont(forTextStyle: .body, weight: .bold)
+ textField.placeholder = Array(repeating: "X", count: 4).joined()
+ textField.placeholderTextColor = .lightGray
+ textField.textContentType = .username
+ textField.autocorrectionType = .no
+ textField.smartDashesType = .no
+ textField.smartInsertDeleteType = .no
+ textField.smartQuotesType = .no
+ textField.spellCheckingType = .no
+ textField.keyboardType = .numberPad
+ textField.returnKeyType = .done
+ textField.enablesReturnKeyAutomatically = false
+ textField.backgroundColor = .white
+ textField.borderStyle = .line
+ return textField
+ }()
+
+ private let deleteButton: AppButton = {
+ let button = AppButton(style: .danger)
+ button.accessibilityIdentifier = Action.delete.rawValue
+ button.setTitle(NSLocalizedString(
+ "OK_BUTTON_TITLE",
+ tableName: "Account",
+ value: "Ok",
+ comment: ""
+ ), for: .normal)
+ return button
+ }()
+
+ private let cancelButton: AppButton = {
+ let button = AppButton(style: .default)
+ button.accessibilityIdentifier = Action.cancel.rawValue
+ button.setTitle(NSLocalizedString(
+ "CANCEL_BUTTON_TITLE",
+ tableName: "Account",
+ value: "Cancel",
+ comment: ""
+ ), for: .normal)
+ return button
+ }()
+
+ private lazy var textsStack: UIStackView = {
+ let stackView = UIStackView(arrangedSubviews: [
+ titleLabel,
+ messageLabel,
+ tipLabel,
+ accountTextField,
+ statusStack,
+ ])
+ stackView.setCustomSpacing(UIMetrics.padding8, after: titleLabel)
+ stackView.setCustomSpacing(UIMetrics.padding16, after: messageLabel)
+ stackView.setCustomSpacing(UIMetrics.padding8, after: tipLabel)
+ stackView.setCustomSpacing(UIMetrics.padding4, after: accountTextField)
+ stackView.setContentHuggingPriority(.defaultLow, for: .vertical)
+ stackView.axis = .vertical
+ return stackView
+ }()
+
+ private lazy var buttonsStack: UIStackView = {
+ let stackView = UIStackView(arrangedSubviews: [deleteButton, cancelButton])
+ stackView.axis = .vertical
+ stackView.spacing = UIMetrics.padding16
+ stackView.setContentCompressionResistancePriority(.required, for: .vertical)
+ return stackView
+ }()
+
+ private let activityIndicator: SpinnerActivityIndicatorView = {
+ let activityIndicator = SpinnerActivityIndicatorView(style: .medium)
+ activityIndicator.translatesAutoresizingMaskIntoConstraints = false
+ activityIndicator.tintColor = .white
+ activityIndicator.setContentHuggingPriority(.defaultHigh, for: .horizontal)
+ activityIndicator.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
+ return activityIndicator
+ }()
+
+ private let statusLabel: UILabel = {
+ let label = UILabel()
+ label.font = .preferredFont(forTextStyle: .body)
+ label.numberOfLines = 2
+ label.lineBreakMode = .byWordWrapping
+ label.textColor = .red
+ label.setContentHuggingPriority(.defaultLow, for: .horizontal)
+ label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+ if #available(iOS 14.0, *) {
+ // See: https://stackoverflow.com/q/46200027/351305
+ label.lineBreakStrategy = []
+ }
+ return label
+ }()
+
+ private lazy var statusStack: UIStackView = {
+ let stackView = UIStackView(arrangedSubviews: [activityIndicator, statusLabel])
+ stackView.axis = .horizontal
+ stackView.spacing = UIMetrics.padding8
+ return stackView
+ }()
+
+ private var keyboardResponder: AutomaticKeyboardResponder?
+ private var bottomsOfButtonsConstraint: NSLayoutConstraint?
+
+ var state: State = .initial {
+ didSet {
+ updateUI()
+ }
+ }
+
+ var isEditing = false {
+ didSet {
+ _ = isEditing ? accountTextField.becomeFirstResponder() : accountTextField.resignFirstResponder()
+ }
+ }
+
+ var viewModel: AccountDeletionViewModel? {
+ didSet {
+ updateData()
+ }
+ }
+
+ var lastPartOfAccountNumber: String {
+ accountTextField.parsedToken
+ }
+
+ private var text: String {
+ switch state {
+ case let .failure(error):
+ return error.localizedDescription
+ case .loading:
+ return NSLocalizedString(
+ "DELETE_ACCOUNT_STATUS_WAITING",
+ tableName: "Account",
+ value: "Deleting account...",
+ comment: ""
+ )
+ default: return ""
+ }
+ }
+
+ private var isDeleteButtonEnabled: Bool {
+ switch state {
+ case .initial, .failure:
+ return true
+ case .loading:
+ return false
+ }
+ }
+
+ private var textColor: UIColor {
+ switch state {
+ case .failure:
+ return .dangerColor
+ default:
+ return .white
+ }
+ }
+
+ private var isLoading: Bool {
+ switch state {
+ case .loading:
+ return true
+ default:
+ return false
+ }
+ }
+
+ private var isAccountNumberLengthSatisfied: Bool {
+ let length = accountTextField.text?.count ?? 0
+ return length == 4
+ }
+
+ weak var delegate: AccountDeletionContentViewDelegate?
+
+ override init(frame: CGRect) {
+ super.init(frame: .zero)
+ commonInit()
+ }
+
+ required init?(coder: NSCoder) {
+ super.init(coder: coder)
+ commonInit()
+ }
+
+ private func commonInit() {
+ setupAppearance()
+ configureUI()
+ addActions()
+ updateUI()
+ addKeyboardResponder()
+ addObservers()
+ }
+
+ private func configureUI() {
+ addConstrainedSubviews([scrollView]) {
+ scrollView.pinEdgesToSuperviewMargins()
+ }
+
+ scrollView.addConstrainedSubviews([contentHolderView]) {
+ contentHolderView.pinEdgesToSuperview()
+ contentHolderView.widthAnchor.constraint(equalTo: scrollView.widthAnchor, multiplier: 1.0)
+ contentHolderView.heightAnchor.constraint(greaterThanOrEqualTo: scrollView.heightAnchor, multiplier: 1.0)
+ }
+ contentHolderView.addConstrainedSubviews([textsStack, buttonsStack]) {
+ textsStack.pinEdgesToSuperview(.all().excluding(.bottom))
+ buttonsStack.pinEdgesToSuperview(PinnableEdges([.leading(.zero), .trailing(.zero)]))
+ textsStack.bottomAnchor.constraint(
+ lessThanOrEqualTo: buttonsStack.topAnchor,
+ constant: -UIMetrics.padding16
+ )
+ }
+ bottomsOfButtonsConstraint = buttonsStack.pinEdgesToSuperview(PinnableEdges([.bottom(.zero)])).first
+ bottomsOfButtonsConstraint?.isActive = true
+ }
+
+ private func addActions() {
+ [deleteButton, cancelButton].forEach { $0.addTarget(
+ self,
+ action: #selector(didPress(button:)),
+ for: .touchUpInside
+ ) }
+ }
+
+ private func updateData() {
+ viewModel.flatMap { viewModel in
+ let text = NSLocalizedString(
+ "BODY_LABEL_TEXT",
+ tableName: "Account",
+ value: """
+ Are you sure you want to delete account **\(viewModel.accountNumber)**?
+ """,
+ comment: ""
+ )
+ messageLabel.attributedText = NSAttributedString(
+ markdownString: text,
+ options: NSAttributedString
+ .MarkdownStylingOptions(
+ font: .preferredFont(forTextStyle: .body)
+ )
+ )
+ }
+ }
+
+ private func updateUI() {
+ isLoading ? activityIndicator.startAnimating() : activityIndicator.stopAnimating()
+ deleteButton.isEnabled = isDeleteButtonEnabled && isAccountNumberLengthSatisfied
+ statusLabel.text = text
+ statusLabel.textColor = textColor
+ }
+
+ private func setupAppearance() {
+ translatesAutoresizingMaskIntoConstraints = false
+ backgroundColor = .secondaryColor
+ directionalLayoutMargins = UIMetrics.contentLayoutMargins
+ }
+
+ private func addKeyboardResponder() {
+ keyboardResponder = AutomaticKeyboardResponder(
+ targetView: self,
+ handler: { [weak self] targetView, offset in
+ guard let self else { return }
+ self.bottomsOfButtonsConstraint?.constant = self.accountTextField.isFirstResponder ? -offset : 0
+ self.layoutIfNeeded()
+ self.scrollView.flashScrollIndicators()
+ }
+ )
+ }
+
+ private func addObservers() {
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(textDidChange),
+ name: UITextField.textDidChangeNotification,
+ object: accountTextField
+ )
+ }
+
+ @objc private func didPress(button: AppButton) {
+ switch Action(rawValue: button.accessibilityIdentifier ?? "") {
+ case .delete:
+ delegate?.didTapDeleteButton(contentView: self, button: button)
+ case .cancel:
+ delegate?.didTapCancelButton(contentView: self, button: button)
+ default: return
+ }
+ }
+
+ @objc private func textDidChange() {
+ updateUI()
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionInteractor.swift b/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionInteractor.swift
new file mode 100644
index 0000000000..50bf101f4f
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionInteractor.swift
@@ -0,0 +1,54 @@
+//
+// AccountDeletionInteractor.swift
+// MullvadVPN
+//
+// Created by Mojgan on 2023-07-13.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import MullvadREST
+import MullvadTypes
+
+enum AccountDeletionError: LocalizedError {
+ case invalidInput
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidInput:
+ return NSLocalizedString(
+ "INVALID_ACCOUNT_NUMBER",
+ tableName: "Account",
+ value: "Last four digits of the account number are incorrect",
+ comment: ""
+ )
+ }
+ }
+}
+
+class AccountDeletionInteractor {
+ private let tunnelManager: TunnelManager
+ var viewModel: AccountDeletionViewModel {
+ AccountDeletionViewModel(
+ accountNumber: tunnelManager.deviceState.accountData?.number.formattedAccountNumber ?? ""
+ )
+ }
+
+ init(tunnelManager: TunnelManager) {
+ self.tunnelManager = tunnelManager
+ }
+
+ func validate(input: String) -> Result<String, Error> {
+ if let accountNumber = tunnelManager.deviceState.accountData?.number,
+ let fourLastDigits = accountNumber.split(every: 4).last,
+ fourLastDigits == input {
+ return .success(accountNumber)
+ } else {
+ return .failure(AccountDeletionError.invalidInput)
+ }
+ }
+
+ func delete(accountNumber: String, completionHandler: @escaping (Error?) -> Void) -> Cancellable {
+ return tunnelManager.deleteAccount(accountNumber: accountNumber, completion: completionHandler)
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionViewController.swift b/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionViewController.swift
new file mode 100644
index 0000000000..6469cb53ee
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionViewController.swift
@@ -0,0 +1,103 @@
+//
+// AccountDeletionViewController.swift
+// MullvadVPN
+//
+// Created by Mojgan on 2023-07-13.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import MullvadTypes
+import UIKit
+
+protocol AccountDeletionViewControllerDelegate: AnyObject {
+ func deleteAccountDidSucceed(controller: AccountDeletionViewController)
+ func deleteAccountDidCancel(controller: AccountDeletionViewController)
+}
+
+class AccountDeletionViewController: UIViewController {
+ private var task: Cancellable?
+ private lazy var contentView: AccountDeletionContentView = {
+ let view = AccountDeletionContentView()
+ view.delegate = self
+ return view
+ }()
+
+ weak var delegate: AccountDeletionViewControllerDelegate?
+ var interactor: AccountDeletionInteractor
+
+ init(interactor: AccountDeletionInteractor) {
+ self.interactor = interactor
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ configureUI()
+ contentView.viewModel = interactor.viewModel
+ }
+
+ override func viewDidAppear(_ animated: Bool) {
+ super.viewDidAppear(animated)
+ enableEditing()
+ }
+
+ override func viewWillDisappear(_ animated: Bool) {
+ super.viewWillDisappear(animated)
+ disableEditing()
+ }
+
+ override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
+ contentView.isEditing = false
+ super.viewWillTransition(to: size, with: coordinator)
+ }
+
+ private func enableEditing() {
+ guard !contentView.isEditing else { return }
+ contentView.isEditing = true
+ }
+
+ private func disableEditing() {
+ guard contentView.isEditing else { return }
+ contentView.isEditing = false
+ }
+
+ private func configureUI() {
+ view.addConstrainedSubviews([contentView]) {
+ contentView.pinEdgesToSuperview(.all())
+ }
+ }
+
+ private func submit(accountNumber: String) {
+ contentView.state = .loading
+ task = interactor.delete(accountNumber: accountNumber) { [weak self] error in
+ guard let self else { return }
+ guard let error else {
+ self.contentView.state = .initial
+ self.delegate?.deleteAccountDidSucceed(controller: self)
+ return
+ }
+ self.contentView.state = .failure(error)
+ }
+ }
+}
+
+extension AccountDeletionViewController: AccountDeletionContentViewDelegate {
+ func didTapCancelButton(contentView: AccountDeletionContentView, button: AppButton) {
+ contentView.isEditing = false
+ task?.cancel()
+ delegate?.deleteAccountDidCancel(controller: self)
+ }
+
+ func didTapDeleteButton(contentView: AccountDeletionContentView, button: AppButton) {
+ switch interactor.validate(input: contentView.lastPartOfAccountNumber) {
+ case let .success(accountNumber):
+ submit(accountNumber: accountNumber)
+ case let .failure(error):
+ contentView.state = .failure(error)
+ }
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionViewModel.swift b/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionViewModel.swift
new file mode 100644
index 0000000000..d30d0631a6
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionViewModel.swift
@@ -0,0 +1,13 @@
+//
+// AccountDeletionViewModel.swift
+// MullvadVPN
+//
+// Created by Mojgan on 2023-07-13.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+struct AccountDeletionViewModel {
+ let accountNumber: String
+}
diff --git a/ios/MullvadVPN/View controllers/Login/AccountTextField.swift b/ios/MullvadVPN/View controllers/Login/AccountTextField.swift
index aa04abd8a8..d31310a757 100644
--- a/ios/MullvadVPN/View controllers/Login/AccountTextField.swift
+++ b/ios/MullvadVPN/View controllers/Login/AccountTextField.swift
@@ -9,18 +9,46 @@
import UIKit
class AccountTextField: CustomTextField, UITextFieldDelegate {
- private let inputFormatter = InputTextFormatter(configuration: InputTextFormatter.Configuration(
+ enum GroupingStyle: Int {
+ case full
+ case lastPart
+
+ var size: UInt8 {
+ switch self {
+ case .full:
+ return 4
+ case .lastPart:
+ return 1
+ }
+ }
+ }
+
+ private var groupSize: GroupingStyle = .full
+ private lazy var inputFormatter = InputTextFormatter(configuration: InputTextFormatter.Configuration(
allowedInput: .numeric,
groupSeparator: " ",
groupSize: 4,
- maxGroups: 4
+ maxGroups: groupSize.size
))
var onReturnKey: ((AccountTextField) -> Bool)?
+ init(groupingStyle: GroupingStyle = .full) {
+ self.groupSize = groupingStyle
+ super.init(frame: .zero)
+ commonInit()
+ }
+
override init(frame: CGRect) {
super.init(frame: frame)
+ commonInit()
+ }
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ private func commonInit() {
backgroundColor = .clear
cornerRadius = 0
@@ -35,10 +63,6 @@ class AccountTextField: CustomTextField, UITextFieldDelegate {
)
}
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
var autoformattingText: String {
set {
inputFormatter.replace(with: newValue)
diff --git a/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherContentView.swift b/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherContentView.swift
index bd19d30eb8..8ea061f012 100644
--- a/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherContentView.swift
+++ b/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherContentView.swift
@@ -19,9 +19,19 @@ enum RedeemVoucherState {
final class RedeemVoucherContentView: UIView {
// MARK: - private
+ private let scrollView: UIScrollView = {
+ let scrollView = UIScrollView()
+ return scrollView
+ }()
+
+ private let contentHolderView: UIView = {
+ let contentHolderView = UIView()
+ return contentHolderView
+ }()
+
private let titleLabel: UILabel = {
let label = UILabel()
- label.font = UIFont.systemFont(ofSize: 17)
+ label.font = .preferredFont(forTextStyle: .body)
label.text = NSLocalizedString(
"REDEEM_VOUCHER_INSTRUCTION",
tableName: "RedeemVoucher",
@@ -40,7 +50,7 @@ final class RedeemVoucherContentView: UIView {
textField.placeholder = Array(repeating: "XXXX", count: 4).joined(separator: "-")
textField.placeholderTextColor = .lightGray
textField.backgroundColor = .white
- textField.cornerRadius = UIMetrics.RedeemVoucher.cornerRadius
+ textField.cornerRadius = UIMetrics.SettingsRedeemVoucher.cornerRadius
textField.keyboardType = .asciiCapable
textField.autocapitalizationType = .allCharacters
textField.returnKeyType = .done
@@ -50,16 +60,21 @@ final class RedeemVoucherContentView: UIView {
private let activityIndicator: SpinnerActivityIndicatorView = {
let activityIndicator = SpinnerActivityIndicatorView(style: .medium)
+ activityIndicator.translatesAutoresizingMaskIntoConstraints = false
activityIndicator.tintColor = .white
+ activityIndicator.setContentHuggingPriority(.defaultHigh, for: .horizontal)
+ activityIndicator.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
return activityIndicator
}()
private let statusLabel: UILabel = {
let label = UILabel()
- label.font = UIFont.systemFont(ofSize: 17)
- label.textColor = .white
- label.numberOfLines = .zero
+ label.font = .preferredFont(forTextStyle: .body)
+ label.numberOfLines = 2
label.lineBreakMode = .byWordWrapping
+ label.textColor = .red
+ label.setContentHuggingPriority(.defaultLow, for: .horizontal)
+ label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
if #available(iOS 14.0, *) {
// See: https://stackoverflow.com/q/46200027/351305
label.lineBreakStrategy = []
@@ -93,7 +108,7 @@ final class RedeemVoucherContentView: UIView {
let stackView = UIStackView(arrangedSubviews: [activityIndicator, statusLabel])
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .horizontal
- stackView.spacing = UIMetrics.interButtonSpacing
+ stackView.spacing = UIMetrics.padding8
return stackView
}()
@@ -107,14 +122,17 @@ final class RedeemVoucherContentView: UIView {
stackView.axis = .vertical
stackView.setCustomSpacing(UIMetrics.padding16, after: titleLabel)
stackView.setCustomSpacing(UIMetrics.padding8, after: textField)
+ stackView.setCustomSpacing(UIMetrics.padding16, after: statusLabel)
+ stackView.setContentHuggingPriority(.defaultLow, for: .vertical)
return stackView
}()
- private lazy var actionsStackView: UIStackView = {
+ private lazy var buttonsStackView: UIStackView = {
let stackView = UIStackView(arrangedSubviews: [redeemButton, cancelButton])
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
- stackView.spacing = UIMetrics.interButtonSpacing
+ stackView.spacing = UIMetrics.padding16
+ stackView.setContentCompressionResistancePriority(.required, for: .vertical)
return stackView
}()
@@ -163,6 +181,9 @@ final class RedeemVoucherContentView: UIView {
}
}
+ private var keyboardResponder: AutomaticKeyboardResponder?
+ private var bottomsOfButtonsConstraint: NSLayoutConstraint?
+
// MARK: - public
var redeemAction: ((String) -> Void)?
@@ -186,25 +207,21 @@ final class RedeemVoucherContentView: UIView {
init() {
super.init(frame: .zero)
- setup()
+ commonInit()
}
required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
+ super.init(coder: coder)
+ commonInit()
}
- private func setup() {
+ private func commonInit() {
setupAppearance()
configureUI()
addButtonHandlers()
- addTextFieldObserver()
updateUI()
- }
-
- private func configureUI() {
- addSubview(voucherCodeStackView)
- addSubview(actionsStackView)
- addConstraints()
+ addKeyboardResponder()
+ addObservers()
}
private func setupAppearance() {
@@ -213,26 +230,26 @@ final class RedeemVoucherContentView: UIView {
directionalLayoutMargins = UIMetrics.contentLayoutMargins
}
- private func addConstraints() {
- addConstrainedSubviews([voucherCodeStackView, actionsStackView]) {
- voucherCodeStackView
- .pinEdgesToSuperviewMargins(.all(UIMetrics.RedeemVoucher.contentLayoutMargins).excluding(.bottom))
- actionsStackView.pinEdgesToSuperviewMargins(.all().excluding(.top))
+ private func configureUI() {
+ addConstrainedSubviews([scrollView]) {
+ scrollView.pinEdgesToSuperviewMargins()
+ }
- actionsStackView.topAnchor.constraint(
- greaterThanOrEqualTo: voucherCodeStackView.bottomAnchor,
- constant: UIMetrics.interButtonSpacing
+ scrollView.addConstrainedSubviews([contentHolderView]) {
+ contentHolderView.pinEdgesToSuperview()
+ contentHolderView.widthAnchor.constraint(equalTo: scrollView.widthAnchor, multiplier: 1.0)
+ contentHolderView.heightAnchor.constraint(greaterThanOrEqualTo: scrollView.heightAnchor, multiplier: 1.0)
+ }
+ contentHolderView.addConstrainedSubviews([voucherCodeStackView, buttonsStackView]) {
+ voucherCodeStackView.pinEdgesToSuperview(.all().excluding(.bottom))
+ buttonsStackView.pinEdgesToSuperview(PinnableEdges([.leading(.zero), .trailing(.zero)]))
+ voucherCodeStackView.bottomAnchor.constraint(
+ lessThanOrEqualTo: buttonsStackView.topAnchor,
+ constant: -UIMetrics.padding16
)
}
- }
-
- private func addTextFieldObserver() {
- NotificationCenter.default.addObserver(
- self,
- selector: #selector(textDidChange),
- name: UITextField.textDidChangeNotification,
- object: textField
- )
+ bottomsOfButtonsConstraint = buttonsStackView.pinEdgesToSuperview(PinnableEdges([.bottom(.zero)])).first
+ bottomsOfButtonsConstraint?.isActive = true
}
private func addButtonHandlers() {
@@ -256,6 +273,15 @@ final class RedeemVoucherContentView: UIView {
statusLabel.textColor = textColor
}
+ private func addObservers() {
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(textDidChange),
+ name: UITextField.textDidChangeNotification,
+ object: textField
+ )
+ }
+
@objc private func cancelButtonTapped(_ sender: AppButton) {
cancelAction?()
}
@@ -270,6 +296,18 @@ final class RedeemVoucherContentView: UIView {
@objc private func textDidChange() {
updateUI()
}
+
+ private func addKeyboardResponder() {
+ keyboardResponder = AutomaticKeyboardResponder(
+ targetView: self,
+ handler: { [weak self] targetView, offset in
+ guard let self else { return }
+ guard self.textField.isFirstResponder else { return }
+ self.bottomsOfButtonsConstraint?.constant = -offset
+ self.layoutIfNeeded()
+ }
+ )
+ }
}
private extension REST.Error {
diff --git a/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherViewController.swift b/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherViewController.swift
index de9f9c97ad..1036dc5e3c 100644
--- a/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherViewController.swift
+++ b/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherViewController.swift
@@ -46,16 +46,31 @@ class RedeemVoucherViewController: UIViewController, UINavigationControllerDeleg
addActions()
}
- override func viewWillAppear(_ animated: Bool) {
- super.viewWillAppear(animated)
+ override func viewDidAppear(_ animated: Bool) {
+ super.viewDidAppear(animated)
enableEditing()
}
+ override func viewWillDisappear(_ animated: Bool) {
+ super.viewWillDisappear(animated)
+ disableEditing()
+ }
+
+ override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
+ self.contentView.isEditing = false
+ super.viewWillTransition(to: size, with: coordinator)
+ }
+
// MARK: - private functions
private func enableEditing() {
- guard !contentView.isEditing else { return }
- contentView.isEditing = true
+ guard !self.contentView.isEditing else { return }
+ self.contentView.isEditing = true
+ }
+
+ private func disableEditing() {
+ guard contentView.isEditing else { return }
+ self.contentView.isEditing = false
}
private func addActions() {
@@ -69,7 +84,6 @@ class RedeemVoucherViewController: UIViewController, UINavigationControllerDeleg
}
private func configureUI() {
- view.addSubview(contentView)
view.addConstrainedSubviews([contentView]) {
contentView.pinEdgesToSuperview(.all())
}