diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2023-08-03 15:01:34 +0200 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2023-08-03 15:01:34 +0200 |
| commit | 4ee496e6fa7132e5d8426229a1b4f8f69e33f33b (patch) | |
| tree | cc5c245ca3ea082ecd96898a85a284b0e081935f | |
| parent | bb0d75ff7a5eef940e4987b71dbbea4aa8bd0a17 (diff) | |
| parent | e5b91ab493c4e4f2fd8afb4e993936520f23ff26 (diff) | |
| download | mullvadvpn-4ee496e6fa7132e5d8426229a1b4f8f69e33f33b.tar.xz mullvadvpn-4ee496e6fa7132e5d8426229a1b4f8f69e33f33b.zip | |
Merge branch 'implement-account-deletion-button-and-logic-ios-229'
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()) } |
